Merge pull request #3994 from atmire/refactor-menu-resolvers-9.0

Refactor menu resolvers 9.0
This commit is contained in:
Tim Donohue
2025-04-01 14:53:28 -05:00
committed by GitHub
118 changed files with 6056 additions and 2703 deletions

View File

@@ -7,7 +7,7 @@ describe('Item Statistics Page', () => {
it('should load if you click on "Statistics" from an Item/Entity page', () => {
cy.visit('/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')));
cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click();
cy.location('pathname').should('eq', ITEMSTATISTICSPAGE);
cy.location('pathname').should('eq', '/statistics/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')));
});
it('should contain element ds-item-statistics-page when navigating to an item statistics page', () => {

View File

@@ -3,7 +3,7 @@
[ngClass]="{ disabled: isDisabled }"
role="menuitem"
[attr.aria-disabled]="isDisabled"
[attr.aria-labelledby]="adminMenuSectionTitleId(section.id)"
[attr.aria-labelledby]="adminMenuSectionTitleAccessibilityHandle(section)"
[routerLink]="itemModel.link"
(keyup.space)="navigate($event)"
(keyup.enter)="navigate($event)"
@@ -14,7 +14,7 @@
</div>
<div class="sidebar-collapsible-element-outer-wrapper">
<div class="sidebar-collapsible-element-inner-wrapper sidebar-item">
<span [id]="adminMenuSectionTitleId(section.id)" [attr.data-test]="adminMenuSectionTitleId(section.id) | dsBrowserOnly">
<span [id]="adminMenuSectionTitleAccessibilityHandle(section)" [attr.data-test]="adminMenuSectionTitleAccessibilityHandle(section) | dsBrowserOnly">
{{itemModel.text | translate}}
</span>
</div>

View File

@@ -16,7 +16,7 @@ import { MenuService } from '../../../shared/menu/menu.service';
import { MenuID } from '../../../shared/menu/menu-id.model';
import { LinkMenuItemModel } from '../../../shared/menu/menu-item/models/link.model';
import { MenuSection } from '../../../shared/menu/menu-section.model';
import { MenuSectionComponent } from '../../../shared/menu/menu-section/menu-section.component';
import { AbstractMenuSectionComponent } from '../../../shared/menu/menu-section/abstract-menu-section.component';
import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe';
/**
@@ -30,7 +30,7 @@ import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe';
imports: [NgClass, RouterLink, TranslateModule, BrowserOnlyPipe],
})
export class AdminSidebarSectionComponent extends MenuSectionComponent implements OnInit {
export class AdminSidebarSectionComponent extends AbstractMenuSectionComponent implements OnInit {
/**
* This section resides in the Admin Sidebar
@@ -44,16 +44,17 @@ export class AdminSidebarSectionComponent extends MenuSectionComponent implement
isDisabled: boolean;
constructor(
@Inject('sectionDataProvider') menuSection: MenuSection,
@Inject('sectionDataProvider') protected section: MenuSection,
protected menuService: MenuService,
protected injector: Injector,
protected router: Router,
) {
super(menuSection, menuService, injector);
this.itemModel = menuSection.model as LinkMenuItemModel;
super(menuService, injector);
this.itemModel = section.model as LinkMenuItemModel;
}
ngOnInit(): void {
// todo: should support all menu entries?
this.isDisabled = this.itemModel?.disabled || isEmpty(this.itemModel?.link);
super.ngOnInit();
}
@@ -65,11 +66,13 @@ export class AdminSidebarSectionComponent extends MenuSectionComponent implement
}
}
adminMenuSectionId(sectionId: string) {
return `admin-menu-section-${sectionId}`;
adminMenuSectionId(section: MenuSection) {
const accessibilityHandle = section.accessibilityHandle ?? section.id;
return `admin-menu-section-${accessibilityHandle}`;
}
adminMenuSectionTitleId(sectionId: string) {
return `admin-menu-section-${sectionId}-title`;
adminMenuSectionTitleAccessibilityHandle(section: MenuSection) {
const accessibilityHandle = section.accessibilityHandle ?? section.id;
return `admin-menu-section-${accessibilityHandle}-title`;
}
}

View File

@@ -1,4 +1,6 @@
<div [ngClass]="{'expanded': (isExpanded$ | async)}"
@if (hasSubSections$ | async) {
<div
[ngClass]="{'expanded': (isExpanded$ | async)}"
[@bgColor]="{
value: ((isExpanded$ | async) ? 'endBackground' : 'startBackground'),
params: {endColor: (sidebarActiveBg$ | async)}
@@ -6,7 +8,7 @@
<a class="sidebar-section-wrapper"
role="menuitem" tabindex="0"
aria-haspopup="menu"
[attr.aria-controls]="adminMenuSectionId(section.id)"
[attr.aria-controls]="adminMenuSectionId(section)"
[attr.aria-expanded]="isExpanded$ | async"
[attr.aria-label]="('menu.section.toggle.' + section.id) | translate"
[class.disabled]="section.model?.disabled"
@@ -15,11 +17,11 @@
href="javascript:void(0);"
>
<div class="sidebar-fixed-element-wrapper" data-test="sidebar-section-icon" aria-hidden="true">
<i class="fas fa-{{section.icon}} fa-fw"></i>
<i class="fas fa-{{section.icon ?? 'notdef'}} fa-fw"></i>
</div>
<div class="sidebar-collapsible-element-outer-wrapper">
<div class="sidebar-collapsible-element-inner-wrapper sidebar-item toggler-wrapper">
<span [id]="adminMenuSectionTitleId(section.id)" [attr.data-test]="adminMenuSectionTitleId(section.id) | dsBrowserOnly">
<span [id]="adminMenuSectionTitleAccessibilityHandle(section)" [attr.data-test]="adminMenuSectionTitleAccessibilityHandle(section) | dsBrowserOnly">
<ng-container
*ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
</span>
@@ -34,7 +36,7 @@
<div class="sidebar-fixed-element-wrapper"></div>
<div class="sidebar-collapsible-element-outer-wrapper">
<div class="sidebar-collapsible-element-inner-wrapper">
<div class="sidebar-sub-level-item-list" role="menu" [id]="adminMenuSectionId(section.id)" [attr.aria-label]="('menu.section.' + section.id) | translate">
<div class="sidebar-sub-level-item-list" role="menu" [id]="adminMenuSectionId(section)" [attr.aria-label]="('menu.section.' + section.id) | translate">
@for (subSection of (subSections$ | async); track subSection) {
<div class="sidebar-item">
<ng-container
@@ -47,3 +49,4 @@
</div>
}
</div>
}

View File

@@ -11,6 +11,7 @@ import { TranslateModule } from '@ngx-translate/core';
import { of as observableOf } from 'rxjs';
import { MenuService } from '../../../shared/menu/menu.service';
import { MenuItemModels } from '../../../shared/menu/menu-section.model';
import { CSSVariableService } from '../../../shared/sass-helper/css-variable.service';
import { CSSVariableServiceStub } from '../../../shared/testing/css-variable-service.stub';
import { MenuServiceStub } from '../../../shared/testing/menu-service.stub';
@@ -22,6 +23,9 @@ describe('ExpandableAdminSidebarSectionComponent', () => {
let fixture: ComponentFixture<ExpandableAdminSidebarSectionComponent>;
const menuService = new MenuServiceStub();
const iconString = 'test';
describe('when there are subsections', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [NoopAnimationsModule, TranslateModule.forRoot(), ExpandableAdminSidebarSectionComponent, TestComponent],
@@ -35,7 +39,11 @@ describe('ExpandableAdminSidebarSectionComponent', () => {
}));
beforeEach(() => {
spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([]));
spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([{
id: 'test',
visible: true,
model: {} as MenuItemModels,
}]));
fixture = TestBed.createComponent(ExpandableAdminSidebarSectionComponent);
component = fixture.componentInstance;
spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent);
@@ -67,6 +75,41 @@ describe('ExpandableAdminSidebarSectionComponent', () => {
});
});
describe('when there are no subsections', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [NoopAnimationsModule, TranslateModule.forRoot(), ExpandableAdminSidebarSectionComponent, TestComponent],
providers: [
{ provide: 'sectionDataProvider', useValue: { icon: iconString, model: {} } },
{ provide: MenuService, useValue: menuService },
{ provide: CSSVariableService, useClass: CSSVariableServiceStub },
{ provide: Router, useValue: new RouterStub() },
],
}).compileComponents();
}));
beforeEach(() => {
spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([]));
fixture = TestBed.createComponent(ExpandableAdminSidebarSectionComponent);
component = fixture.componentInstance;
spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should not contain a section', () => {
const icon = fixture.debugElement.query(By.css('.shortcut-icon'));
expect(icon).toBeNull();
const sidebarToggler = fixture.debugElement.query(By.css('.sidebar-section'));
expect(sidebarToggler).toBeNull();
});
});
});
// declare a test component
@Component({
selector: 'ds-test-cmp',

View File

@@ -20,8 +20,10 @@ import { map } from 'rxjs/operators';
import { bgColor } from '../../../shared/animations/bgColor';
import { rotate } from '../../../shared/animations/rotate';
import { slide } from '../../../shared/animations/slide';
import { isNotEmpty } from '../../../shared/empty.util';
import { MenuService } from '../../../shared/menu/menu.service';
import { MenuID } from '../../../shared/menu/menu-id.model';
import { MenuSection } from '../../../shared/menu/menu-section.model';
import { CSSVariableService } from '../../../shared/sass-helper/css-variable.service';
import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe';
import { AdminSidebarSectionComponent } from '../admin-sidebar-section/admin-sidebar-section.component';
@@ -65,14 +67,20 @@ export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionC
*/
isExpanded$: Observable<boolean>;
/**
* Emits true when the top section has subsections, else emits false
*/
hasSubSections$: Observable<boolean>;
constructor(
@Inject('sectionDataProvider') menuSection,
@Inject('sectionDataProvider') protected section: MenuSection,
protected menuService: MenuService,
private variableService: CSSVariableService,
protected injector: Injector,
protected router: Router,
) {
super(menuSection, menuService, injector, router);
super(section, menuService, injector, router);
}
/**
@@ -80,6 +88,9 @@ export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionC
*/
ngOnInit(): void {
super.ngOnInit();
this.hasSubSections$ = this.subSections$.pipe(
map((subSections) => isNotEmpty(subSections)),
);
this.sidebarActiveBg$ = this.variableService.getVariable('--ds-admin-sidebar-active-bg');
this.isSidebarCollapsed$ = this.menuService.isMenuCollapsed(this.menuID);
this.isSidebarPreviewCollapsed$ = this.menuService.isMenuPreviewCollapsed(this.menuID);

View File

@@ -34,7 +34,6 @@ import { forgotPasswordCheckGuard } from './core/rest-property/forgot-password-c
import { ServerCheckGuard } from './core/server-check/server-check.guard';
import { ThemedForbiddenComponent } from './forbidden/themed-forbidden.component';
import { ITEM_MODULE_PATH } from './item-page/item-page-routing-paths';
import { menuResolver } from './menuResolver';
import { provideSuggestionNotificationsState } from './notifications/provide-suggestion-notifications-state';
import { ThemedPageErrorComponent } from './page-error/themed-page-error.component';
import { ThemedPageInternalServerErrorComponent } from './page-internal-server-error/themed-page-internal-server-error.component';
@@ -50,7 +49,6 @@ export const APP_ROUTES: Route[] = [
path: '',
canActivate: [authBlockingGuard],
canActivateChild: [ServerCheckGuard],
resolve: [menuResolver],
children: [
{ path: '', redirectTo: '/home', pathMatch: 'full' },
{

View File

@@ -39,6 +39,7 @@ import { StoreDevModules } from '../config/store/devtools';
import { environment } from '../environments/environment';
import { EagerThemesModule } from '../themes/eager-themes.module';
import { appEffects } from './app.effects';
import { MENUS } from './app.menus';
import {
appMetaReducers,
debugMetaReducers,
@@ -159,6 +160,10 @@ export const commonAppConfig: ApplicationConfig = {
},
// register the dynamic matcher used by form. MUST be provided by the app module
...DYNAMIC_MATCHER_PROVIDERS,
// DI-composable menus
...MENUS,
provideCore(),
],
};

101
src/app/app.menus.ts Normal file
View File

@@ -0,0 +1,101 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { buildMenuStructure } from './shared/menu/menu.structure';
import { MenuID } from './shared/menu/menu-id.model';
import { MenuRoute } from './shared/menu/menu-route.model';
import { AccessControlMenuProvider } from './shared/menu/providers/access-control.menu';
import { AdminSearchMenuProvider } from './shared/menu/providers/admin-search.menu';
import { BrowseMenuProvider } from './shared/menu/providers/browse.menu';
import { CoarNotifyMenuProvider } from './shared/menu/providers/coar-notify.menu';
import { SubscribeMenuProvider } from './shared/menu/providers/comcol-subscribe.menu';
import { CommunityListMenuProvider } from './shared/menu/providers/community-list.menu';
import { CreateReportMenuProvider } from './shared/menu/providers/create-report.menu';
import { CurationMenuProvider } from './shared/menu/providers/curation.menu';
import { DSpaceObjectEditMenuProvider } from './shared/menu/providers/dso-edit.menu';
import { DsoOptionMenuProvider } from './shared/menu/providers/dso-option.menu';
import { EditMenuProvider } from './shared/menu/providers/edit.menu';
import { ExportMenuProvider } from './shared/menu/providers/export.menu';
import { HealthMenuProvider } from './shared/menu/providers/health.menu';
import { ImportMenuProvider } from './shared/menu/providers/import.menu';
import { ClaimMenuProvider } from './shared/menu/providers/item-claim.menu';
import { OrcidMenuProvider } from './shared/menu/providers/item-orcid.menu';
import { VersioningMenuProvider } from './shared/menu/providers/item-versioning.menu';
import { NewMenuProvider } from './shared/menu/providers/new.menu';
import { NotificationsMenuProvider } from './shared/menu/providers/notifications.menu';
import { ProcessesMenuProvider } from './shared/menu/providers/processes.menu';
import { RegistriesMenuProvider } from './shared/menu/providers/registries.menu';
import { StatisticsMenuProvider } from './shared/menu/providers/statistics.menu';
import { SystemWideAlertMenuProvider } from './shared/menu/providers/system-wide-alert.menu';
import { WithdrawnReinstateItemMenuProvider } from './shared/menu/providers/withdrawn-reinstate-item.menu';
import { WorkflowMenuProvider } from './shared/menu/providers/workflow.menu';
/**
* Represents and builds the menu structure for the three available menus (public navbar, admin sidebar and the dso edit
* menus).
* The structure consists of a list of menu IDs with each of them having a list of providers that will create the
* sections to be part of the menu matching the ID.
*
* The following menu groups are present in this structure:
* - `MenuID.PUBLIC`: Defines menus accessible by the public in the navigation bar.
* - `MenuID.ADMIN`: Defines menus for administrative users in the sidebar.
* - `MenuID.DSO_EDIT`: Defines dynamic menu options for DSpace Objects that will be present on the DSpace Object's page.
*
* To add more menu sections to a menu (public navbar, admin sidebar or the dso edit menus),
* a new provider can be added to the list with the corresponding menu ID.
*
* The configuration supports route-specific menu providers and hierarchically structured menu options.
*/
export const MENUS = buildMenuStructure({
[MenuID.PUBLIC]: [
CommunityListMenuProvider,
BrowseMenuProvider,
StatisticsMenuProvider,
],
[MenuID.ADMIN]: [
NewMenuProvider,
EditMenuProvider,
ImportMenuProvider,
ExportMenuProvider,
NotificationsMenuProvider,
AccessControlMenuProvider,
AdminSearchMenuProvider,
CreateReportMenuProvider,
RegistriesMenuProvider,
CurationMenuProvider,
ProcessesMenuProvider,
WorkflowMenuProvider,
HealthMenuProvider,
SystemWideAlertMenuProvider,
CoarNotifyMenuProvider,
],
[MenuID.DSO_EDIT]: [
DsoOptionMenuProvider.withSubs([
SubscribeMenuProvider.onRoute(
MenuRoute.COMMUNITY_PAGE,
MenuRoute.COLLECTION_PAGE,
),
DSpaceObjectEditMenuProvider.onRoute(
MenuRoute.COMMUNITY_PAGE,
MenuRoute.COLLECTION_PAGE,
MenuRoute.ITEM_PAGE,
),
WithdrawnReinstateItemMenuProvider.onRoute(
MenuRoute.ITEM_PAGE,
),
VersioningMenuProvider.onRoute(
MenuRoute.ITEM_PAGE,
),
OrcidMenuProvider.onRoute(
MenuRoute.ITEM_PAGE,
),
ClaimMenuProvider.onRoute(
MenuRoute.ITEM_PAGE,
),
]),
],
});

View File

@@ -8,9 +8,7 @@ import { communityBreadcrumbResolver } from '../core/breadcrumbs/community-bread
import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
import { ComcolBrowseByComponent } from '../shared/comcol/sections/comcol-browse-by/comcol-browse-by.component';
import { ComcolSearchSectionComponent } from '../shared/comcol/sections/comcol-search-section/comcol-search-section.component';
import { dsoEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
import { MenuItemType } from '../shared/menu/menu-item-type.model';
import { MenuRoute } from '../shared/menu/menu-route.model';
import { collectionPageResolver } from './collection-page.resolver';
import { collectionPageAdministratorGuard } from './collection-page-administrator.guard';
import {
@@ -82,8 +80,8 @@ export const ROUTES: Route[] = [
{
path: '',
component: ThemedCollectionPageComponent,
resolve: {
menu: dsoEditMenuResolver,
data: {
menuRoute: MenuRoute.COLLECTION_PAGE,
},
children: [
{
@@ -108,25 +106,13 @@ export const ROUTES: Route[] = [
resolve: {
breadcrumb: browseByI18nBreadcrumbResolver,
},
data: { breadcrumbKey: 'browse.metadata' },
},
],
},
],
data: {
menu: {
public: [{
id: 'statistics_collection_:id',
active: true,
visible: true,
index: 2,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics',
link: 'statistics/collections/:id/',
} as LinkMenuItemModel,
}],
breadcrumbKey: 'browse.metadata',
menuRoute: MenuRoute.COLLECTION_PAGE,
},
},
],
},
],
},
];

View File

@@ -7,9 +7,7 @@ import { communityBreadcrumbResolver } from '../core/breadcrumbs/community-bread
import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
import { ComcolBrowseByComponent } from '../shared/comcol/sections/comcol-browse-by/comcol-browse-by.component';
import { ComcolSearchSectionComponent } from '../shared/comcol/sections/comcol-search-section/comcol-search-section.component';
import { dsoEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
import { MenuItemType } from '../shared/menu/menu-item-type.model';
import { MenuRoute } from '../shared/menu/menu-route.model';
import { communityPageResolver } from './community-page.resolver';
import { communityPageAdministratorGuard } from './community-page-administrator.guard';
import {
@@ -69,8 +67,8 @@ export const ROUTES: Route[] = [
{
path: '',
component: ThemedCommunityPageComponent,
resolve: {
menu: dsoEditMenuResolver,
data: {
menuRoute: MenuRoute.COMMUNITY_PAGE,
},
children: [
{
@@ -94,7 +92,10 @@ export const ROUTES: Route[] = [
resolve: {
breadcrumb: i18nBreadcrumbResolver,
},
data: { breadcrumbKey: 'community.subcoms-cols' },
data: {
breadcrumbKey: 'community.subcoms-cols',
menuRoute: MenuRoute.COMMUNITY_PAGE,
},
},
{
path: 'browse/:id',
@@ -104,25 +105,13 @@ export const ROUTES: Route[] = [
resolve: {
breadcrumb: browseByI18nBreadcrumbResolver,
},
data: { breadcrumbKey: 'browse.metadata' },
},
],
},
],
data: {
menu: {
public: [{
id: 'statistics_community_:id',
active: true,
visible: true,
index: 2,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics',
link: 'statistics/communities/:id/',
} as LinkMenuItemModel,
}],
breadcrumbKey: 'browse.metadata',
menuRoute: MenuRoute.COMMUNITY_PAGE,
},
},
],
},
],
},
];

View File

@@ -42,10 +42,10 @@ import { HeadTagService } from './core/metadata/head-tag.service';
import { CorrelationIdService } from './correlation-id/correlation-id.service';
import { dsDynamicFormControlMapFn } from './shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-map-fn';
import { MenuService } from './shared/menu/menu.service';
import { MenuProviderService } from './shared/menu/menu-provider.service';
import { ThemeService } from './shared/theme-support/theme.service';
import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider';
/**
* Performs the initialization of the app.
*
@@ -74,6 +74,7 @@ export abstract class InitService {
protected breadcrumbsService: BreadcrumbsService,
protected themeService: ThemeService,
protected menuService: MenuService,
protected menuProviderService: MenuProviderService,
) {
}
@@ -216,7 +217,6 @@ export abstract class InitService {
this.headTagService.listenForRouteChange();
this.breadcrumbsService.listenForRouteChanges();
this.themeService.listenForRouteChanges();
this.menuService.listenForRouteChanges();
}
/**

View File

@@ -4,9 +4,7 @@ import { REQUEST_COPY_MODULE_PATH } from '../app-routing-paths';
import { accessTokenResolver } from '../core/auth/access-token.resolver';
import { authenticatedGuard } from '../core/auth/authenticated.guard';
import { itemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.resolver';
import { dsoEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
import { MenuItemType } from '../shared/menu/menu-item-type.model';
import { MenuRoute } from '../shared/menu/menu-route.model';
import { BitstreamRequestACopyPageComponent } from './bitstreams/request-a-copy/bitstream-request-a-copy-page.component';
import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component';
import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component';
@@ -37,16 +35,18 @@ export const ROUTES: Route[] = [
path: '',
component: ThemedItemPageComponent,
pathMatch: 'full',
resolve: {
menu: dsoEditMenuResolver,
data: {
menuRoute: MenuRoute.ITEM_PAGE,
},
},
{
path: 'full',
component: ThemedFullItemPageComponent,
resolve: {
menu: dsoEditMenuResolver,
data: {
menuRoute: MenuRoute.ITEM_PAGE,
},
},
{
path: ITEM_EDIT_PATH,
@@ -75,21 +75,6 @@ export const ROUTES: Route[] = [
},
},
],
data: {
menu: {
public: [{
id: 'statistics_item_:id',
active: true,
visible: true,
index: 2,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics',
link: 'statistics/items/:id/',
} as LinkMenuItemModel,
}],
},
},
},
{
path: 'version',

View File

@@ -8,6 +8,8 @@ export function getItemModuleRoute() {
return `/${ITEM_MODULE_PATH}`;
}
export const ENTITY_MODULE_PATH = 'entities';
/**
* Get the route to an item's page
* Depending on the item's entity type, the route will either start with /items or /entities

View File

@@ -1,879 +0,0 @@
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
RouterStateSnapshot,
} from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import {
combineLatest,
combineLatest as observableCombineLatest,
mergeMap,
Observable,
of as observableOf,
} from 'rxjs';
import {
filter,
find,
map,
take,
} from 'rxjs/operators';
import { environment } from '../environments/environment';
import { PUBLICATION_CLAIMS_PATH } from './admin/admin-notifications/admin-notifications-routing-paths';
import { AuthService } from './core/auth/auth.service';
import { BrowseService } from './core/browse/browse.service';
import { ConfigurationDataService } from './core/data/configuration-data.service';
import { AuthorizationDataService } from './core/data/feature-authorization/authorization-data.service';
import { FeatureID } from './core/data/feature-authorization/feature-id';
import { PaginatedList } from './core/data/paginated-list.model';
import {
METADATA_EXPORT_SCRIPT_NAME,
METADATA_IMPORT_SCRIPT_NAME,
ScriptDataService,
} from './core/data/processes/script-data.service';
import { RemoteData } from './core/data/remote-data';
import { BrowseDefinition } from './core/shared/browse-definition.model';
import { ConfigurationProperty } from './core/shared/configuration-property.model';
import { getFirstCompletedRemoteData } from './core/shared/operators';
import { ThemedCreateCollectionParentSelectorComponent } from './shared/dso-selector/modal-wrappers/create-collection-parent-selector/themed-create-collection-parent-selector.component';
import { ThemedCreateCommunityParentSelectorComponent } from './shared/dso-selector/modal-wrappers/create-community-parent-selector/themed-create-community-parent-selector.component';
import { ThemedCreateItemParentSelectorComponent } from './shared/dso-selector/modal-wrappers/create-item-parent-selector/themed-create-item-parent-selector.component';
import { ThemedEditCollectionSelectorComponent } from './shared/dso-selector/modal-wrappers/edit-collection-selector/themed-edit-collection-selector.component';
import { ThemedEditCommunitySelectorComponent } from './shared/dso-selector/modal-wrappers/edit-community-selector/themed-edit-community-selector.component';
import { ThemedEditItemSelectorComponent } from './shared/dso-selector/modal-wrappers/edit-item-selector/themed-edit-item-selector.component';
import { ExportBatchSelectorComponent } from './shared/dso-selector/modal-wrappers/export-batch-selector/export-batch-selector.component';
import { ExportMetadataSelectorComponent } from './shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component';
import { hasValue } from './shared/empty.util';
import { MenuService } from './shared/menu/menu.service';
import { MenuID } from './shared/menu/menu-id.model';
import { LinkMenuItemModel } from './shared/menu/menu-item/models/link.model';
import { OnClickMenuItemModel } from './shared/menu/menu-item/models/onclick.model';
import { TextMenuItemModel } from './shared/menu/menu-item/models/text.model';
import { MenuItemType } from './shared/menu/menu-item-type.model';
import { MenuState } from './shared/menu/menu-state.model';
/**
* Creates all of the app's menus
*/
@Injectable({
providedIn: 'root',
})
export class MenuResolverService {
constructor(
protected menuService: MenuService,
protected browseService: BrowseService,
protected authorizationService: AuthorizationDataService,
protected modalService: NgbModal,
protected scriptDataService: ScriptDataService,
protected configurationDataService: ConfigurationDataService,
protected authService: AuthService,
) {
}
/**
* Initialize all menus
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
return combineLatest([
this.createPublicMenu$(),
this.createAdminMenuIfLoggedIn$(),
]).pipe(
map((menusDone: boolean[]) => menusDone.every(Boolean)),
);
}
/**
* 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<boolean> {
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<boolean> {
const menuList: any[] = [
/* Communities & Collections tree */
{
id: `browse_global_communities_and_collections`,
active: false,
visible: true,
index: 0,
model: {
type: MenuItemType.LINK,
text: `menu.section.browse_global_communities_and_collections`,
link: `/community-list`,
} as LinkMenuItemModel,
},
];
// Read the different Browse-By types from config and add them to the browse menu
this.browseService.getBrowseDefinitions()
.pipe(getFirstCompletedRemoteData<PaginatedList<BrowseDefinition>>())
.subscribe((browseDefListRD: RemoteData<PaginatedList<BrowseDefinition>>) => {
if (browseDefListRD.hasSucceeded) {
browseDefListRD.payload.page.forEach((browseDef: BrowseDefinition) => {
menuList.push({
id: `browse_global_by_${browseDef.id}`,
parentID: 'browse_global',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: `menu.section.browse_global_by_${browseDef.id}`,
link: `/browse/${browseDef.id}`,
} as LinkMenuItemModel,
});
});
menuList.push(
/* Browse */
{
id: 'browse_global',
active: false,
visible: true,
index: 1,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.browse_global',
} as TextMenuItemModel,
},
);
}
/* Add "Browse by Geolocation" map if enabled in configuration, with index = length to put it at the end of the list */
if (environment.geospatialMapViewer.enableBrowseMap) {
menuList.push(
{
id: `browse_global_geospatial_map`,
parentID: 'browse_global',
active: false,
visible: true,
index: menuList.length,
model: {
type: MenuItemType.LINK,
text: `menu.section.browse_global_geospatial_map`,
link: `/browse/map`,
disabled: false,
} as LinkMenuItemModel,
},
);
}
menuList.forEach((menuSection) => this.menuService.addSection(MenuID.PUBLIC, Object.assign(menuSection, {
shouldPersistOnRouteChange: true,
})));
});
return this.waitForMenu$(MenuID.PUBLIC);
}
/**
* Initialize all menu sections and items for {@link MenuID.ADMIN}, only if the user is logged in.
*/
createAdminMenuIfLoggedIn$() {
return this.authService.isAuthenticated().pipe(
mergeMap((isAuthenticated) => isAuthenticated ? this.createAdminMenu$() : observableOf(true)),
);
}
/**
* Initialize all menu sections and items for {@link MenuID.ADMIN}
*/
createAdminMenu$() {
this.createMainMenuSections();
this.createSiteAdministratorMenuSections();
this.createExportMenuSections();
this.createImportMenuSections();
this.createAccessControlMenuSections();
this.createReportMenuSections();
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),
this.authorizationService.isAuthorized(FeatureID.CanSubmit),
this.authorizationService.isAuthorized(FeatureID.CanEditItem),
this.authorizationService.isAuthorized(FeatureID.CanSeeQA),
this.authorizationService.isAuthorized(FeatureID.CoarNotifyEnabled),
]).subscribe(([isCollectionAdmin, isCommunityAdmin, isSiteAdmin, canSubmit, canEditItem, canSeeQa, isCoarNotifyEnabled]) => {
const newSubMenuList = [
{
id: 'new_community',
parentID: 'new',
active: false,
visible: isCommunityAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.new_community',
function: () => {
this.modalService.open(ThemedCreateCommunityParentSelectorComponent);
},
} as OnClickMenuItemModel,
},
{
id: 'new_collection',
parentID: 'new',
active: false,
visible: isCommunityAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.new_collection',
function: () => {
this.modalService.open(ThemedCreateCollectionParentSelectorComponent);
},
} as OnClickMenuItemModel,
},
{
id: 'new_item',
parentID: 'new',
active: false,
visible: canSubmit,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.new_item',
function: () => {
this.modalService.open(ThemedCreateItemParentSelectorComponent);
},
} as OnClickMenuItemModel,
},
{
id: 'new_process',
parentID: 'new',
active: false,
visible: isSiteAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.new_process',
link: '/processes/new',
} as LinkMenuItemModel,
},/* ldn_services */
{
id: 'ldn_services_new',
parentID: 'new',
active: false,
visible: isSiteAdmin && isCoarNotifyEnabled,
model: {
type: MenuItemType.LINK,
text: 'menu.section.services_new',
link: '/admin/ldn/services/new',
} as LinkMenuItemModel,
icon: '',
},
];
const editSubMenuList = [
/* Edit */
{
id: 'edit_community',
parentID: 'edit',
active: false,
visible: isCommunityAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_community',
function: () => {
this.modalService.open(ThemedEditCommunitySelectorComponent);
},
} as OnClickMenuItemModel,
},
{
id: 'edit_collection',
parentID: 'edit',
active: false,
visible: isCollectionAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_collection',
function: () => {
this.modalService.open(ThemedEditCollectionSelectorComponent);
},
} as OnClickMenuItemModel,
},
{
id: 'edit_item',
parentID: 'edit',
active: false,
visible: canEditItem,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_item',
function: () => {
this.modalService.open(ThemedEditItemSelectorComponent);
},
} as OnClickMenuItemModel,
},
];
const newSubMenu = {
id: 'new',
active: false,
visible: newSubMenuList.some(subMenu => subMenu.visible),
model: {
type: MenuItemType.TEXT,
text: 'menu.section.new',
} as TextMenuItemModel,
icon: 'plus',
index: 0,
};
const editSubMenu = {
id: 'edit',
active: false,
visible: editSubMenuList.some(subMenu => subMenu.visible),
model: {
type: MenuItemType.TEXT,
text: 'menu.section.edit',
} as TextMenuItemModel,
icon: 'pencil-alt',
index: 1,
};
const menuList = [
...newSubMenuList,
newSubMenu,
...editSubMenuList,
editSubMenu,
// 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,
// },
/* 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,
},
/* COAR Notify section */
{
id: 'coar_notify',
active: false,
visible: isSiteAdmin && isCoarNotifyEnabled,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.coar_notify',
} as TextMenuItemModel,
icon: 'inbox',
index: 13,
},
{
id: 'notify_dashboard',
active: false,
parentID: 'coar_notify',
visible: isSiteAdmin && isCoarNotifyEnabled,
model: {
type: MenuItemType.LINK,
text: 'menu.section.notify_dashboard',
link: '/admin/notify-dashboard',
} as LinkMenuItemModel,
},
/* LDN Services */
{
id: 'ldn_services',
active: false,
parentID: 'coar_notify',
visible: isSiteAdmin && isCoarNotifyEnabled,
model: {
type: MenuItemType.LINK,
text: 'menu.section.services',
link: '/admin/ldn/services',
} as LinkMenuItemModel,
},
{
id: 'health',
active: false,
visible: isSiteAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.health',
link: '/health',
} as LinkMenuItemModel,
icon: 'heartbeat',
index: 11,
},
/* Notifications */
{
id: 'notifications',
active: false,
visible: canSeeQa || isSiteAdmin,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.notifications',
} as TextMenuItemModel,
icon: 'bell',
index: 4,
},
{
id: 'notifications_quality-assurance',
parentID: 'notifications',
active: false,
visible: canSeeQa,
model: {
type: MenuItemType.LINK,
text: 'menu.section.quality-assurance',
link: '/notifications/quality-assurance',
} as LinkMenuItemModel,
},
{
id: 'notifications_publication-claim',
parentID: 'notifications',
active: false,
visible: isSiteAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.notifications_publication-claim',
link: '/admin/notifications/' + PUBLICATION_CLAIMS_PATH,
} as LinkMenuItemModel,
},
/* Admin Search */
];
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 = [
// 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(
filter(([authorized, metadataExportScriptExists]: boolean[]) => authorized && metadataExportScriptExists),
take(1),
).subscribe(() => {
// Hides the export menu for unauthorised people
// If in the future more sub-menus are added,
// it should be reviewed if they need to be in this subscribe
this.menuService.addSection(MenuID.ADMIN, {
id: 'export',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.export',
} as TextMenuItemModel,
icon: 'file-export',
index: 3,
shouldPersistOnRouteChange: true,
});
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,
});
this.menuService.addSection(MenuID.ADMIN, {
id: 'export_batch',
parentID: 'export',
active: false,
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.export_batch',
function: () => {
this.modalService.open(ExportBatchSelectorComponent);
},
} 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 = [];
menuList.forEach((menuSection) => this.menuService.addSection(MenuID.ADMIN, menuSection));
observableCombineLatest([
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
this.scriptDataService.scriptWithNameExistsAndCanExecute(METADATA_IMPORT_SCRIPT_NAME),
]).pipe(
filter(([authorized, metadataImportScriptExists]: boolean[]) => authorized && metadataImportScriptExists),
take(1),
).subscribe(() => {
// Hides the import menu for unauthorised people
// If in the future more sub-menus are added,
// it should be reviewed if they need to be in this subscribe
this.menuService.addSection(MenuID.ADMIN, {
id: 'import',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.import',
} as TextMenuItemModel,
icon: 'file-import',
index: 2,
shouldPersistOnRouteChange: true,
});
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,
});
this.menuService.addSection(MenuID.ADMIN, {
id: 'import_batch',
parentID: 'import',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.import_batch',
link: '/admin/batch-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 = [
{
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,
},
{
id: 'system_wide_alert',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.system-wide-alert',
link: '/admin/system-wide-alert',
} as LinkMenuItemModel,
icon: 'exclamation-circle',
index: 12,
},
];
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,
},
{
id: 'access_control_bulk',
parentID: 'access_control',
active: false,
visible: isSiteAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_bulk',
link: '/access-control/bulk-access',
} 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,
})));
});
}
/**
* Create menu sections dependent on whether or not the current user is a site administrator
*/
createReportMenuSections() {
observableCombineLatest([
this.configurationDataService.findByPropertyName('contentreport.enable').pipe(
getFirstCompletedRemoteData(),
map((res: RemoteData<ConfigurationProperty>) => res.hasSucceeded && res.payload && res.payload.values[0] === 'true'),
),
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
]).subscribe(([isSiteAdmin]) => {
const menuList = [
{
id: 'reports',
active: false,
visible: isSiteAdmin,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.reports',
} as TextMenuItemModel,
icon: 'file-alt',
index: 5,
},
/* Collections Report */
{
id: 'reports_collections',
parentID: 'reports',
active: false,
visible: isSiteAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.reports.collections',
link: '/admin/reports/collections',
} as LinkMenuItemModel,
icon: 'user-check',
},
/* Queries Report */
{
id: 'reports_queries',
parentID: 'reports',
active: false,
visible: isSiteAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.reports.queries',
link: '/admin/reports/queries',
} as LinkMenuItemModel,
icon: 'user-check',
},
];
menuList.forEach((menuSection) => this.menuService.addSection(MenuID.ADMIN, Object.assign(menuSection, {
shouldPersistOnRouteChange: true,
})));
});
}
}

View File

@@ -1,440 +0,0 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import {
TestBed,
waitForAsync,
} from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { RouterTestingModule } from '@angular/router/testing';
import {
NgbModal,
NgbModalRef,
} from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core';
import { cold } from 'jasmine-marbles';
import { of as observableOf } from 'rxjs';
import { AdminSidebarComponent } from './admin/admin-sidebar/admin-sidebar.component';
import { BrowseService } from './core/browse/browse.service';
import { ConfigurationDataService } from './core/data/configuration-data.service';
import { AuthorizationDataService } from './core/data/feature-authorization/authorization-data.service';
import { FeatureID } from './core/data/feature-authorization/feature-id';
import { ScriptDataService } from './core/data/processes/script-data.service';
import { MenuService } from './shared/menu/menu.service';
import { MenuID } from './shared/menu/menu-id.model';
import { createSuccessfulRemoteDataObject$ } from './shared/remote-data.utils';
import { ConfigurationDataServiceStub } from './shared/testing/configuration-data.service.stub';
import { MenuServiceStub } from './shared/testing/menu-service.stub';
import { createPaginatedList } from './shared/testing/utils.test';
import createSpy = jasmine.createSpy;
import { AuthService } from './core/auth/auth.service';
import { MenuResolverService } from './menu-resolver.service';
import { AuthServiceStub } from './shared/testing/auth-service.stub';
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: MenuResolverService;
let menuService;
let browseService;
let authorizationService;
let scriptService;
let mockNgbModal;
let configurationDataService;
beforeEach(waitForAsync(() => {
menuService = new MenuServiceStub();
spyOn(menuService, 'getMenu').and.returnValue(observableOf(MENU_STATE));
spyOn(menuService, 'addSection');
browseService = jasmine.createSpyObj('browseService', {
getBrowseDefinitions: createSuccessfulRemoteDataObject$(createPaginatedList(BROWSE_DEFINITIONS)),
});
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true),
});
scriptService = jasmine.createSpyObj('scriptService', {
scriptWithNameExistsAndCanExecute: observableOf(true),
});
mockNgbModal = {
open: jasmine.createSpy('open').and.returnValue(
{ componentInstance: {}, closed: observableOf({}) } as NgbModalRef,
),
};
configurationDataService = new ConfigurationDataServiceStub();
spyOn(configurationDataService, 'findByPropertyName').and.returnValue(observableOf(true));
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), NoopAnimationsModule, RouterTestingModule, AdminSidebarComponent],
providers: [
{ provide: MenuService, useValue: menuService },
{ provide: BrowseService, useValue: browseService },
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: ScriptDataService, useValue: scriptService },
{ provide: ConfigurationDataService, useValue: configurationDataService },
{ provide: NgbModal, useValue: mockNgbModal },
{ provide: AuthService, useValue: AuthServiceStub },
MenuResolverService,
],
schemas: [NO_ERRORS_SCHEMA],
});
resolver = TestBed.inject(MenuResolverService);
}));
it('should be created', () => {
expect(resolver).toBeTruthy();
});
describe('resolve', () => {
it('should create all menus', (done) => {
spyOn(resolver, 'createPublicMenu$').and.returnValue(observableOf(true));
spyOn(resolver, 'createAdminMenuIfLoggedIn$').and.returnValue(observableOf(true));
resolver.resolve(null, null).subscribe(resolved => {
expect(resolved).toBeTrue();
expect(resolver.createPublicMenu$).toHaveBeenCalled();
expect(resolver.createAdminMenuIfLoggedIn$).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, 'createAdminMenuIfLoggedIn$').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$', () => {
const dontShowAdminSections = () => {
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 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,
}));
});
// We check that the menu section has not been called with visible set to true
// The reason why we don't check if it has been called with visible set to false
// Is because the function does not get called unless a user is authorised
it('should not show the import section', () => {
expect(menuService.addSection).not.toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'import', visible: true,
}));
});
// We check that the menu section has not been called with visible set to true
// The reason why we don't check if it has been called with visible set to false
// Is because the function does not get called unless a user is authorised
it('should not show the export section', () => {
expect(menuService.addSection).not.toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'export', visible: true,
}));
});
};
const dontShowNewSection = () => {
it('should not show the "New" section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'new_community', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'new_collection', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'new_item', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'new', visible: false,
}));
});
};
const dontShowEditSection = () => {
it('should not show the "Edit" section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'edit_community', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'edit_collection', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'edit_item', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'edit', visible: false,
}));
});
};
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((featureID) => {
return observableOf(false);
});
});
beforeEach((done) => {
resolver.createAdminMenu$().subscribe((_) => {
done();
});
});
dontShowAdminSections();
dontShowNewSection();
dontShowEditSection();
});
describe('regular user who can submit', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized')
.and.callFake((featureID: FeatureID) => {
return observableOf(featureID === FeatureID.CanSubmit);
});
});
beforeEach((done) => {
resolver.createAdminMenu$().subscribe((_) => {
done();
});
});
it('should show "New Item" section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'new_item', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'new', visible: true,
}));
});
dontShowAdminSections();
dontShowEditSection();
});
describe('regular user who can edit items', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized')
.and.callFake((featureID: FeatureID) => {
return observableOf(featureID === FeatureID.CanEditItem);
});
});
beforeEach((done) => {
resolver.createAdminMenu$().subscribe((_) => {
done();
});
});
it('should show "Edit Item" section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'edit_item', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'edit', visible: true,
}));
});
dontShowAdminSections();
dontShowNewSection();
});
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 show new_process', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'new_process', visible: true,
}));
});
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,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'workflow', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'import', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'import_batch', parentID: 'import', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'export', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'export_batch', parentID: 'export', 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,
}));
});
});
});
});

View File

@@ -1,21 +0,0 @@
import { inject } from '@angular/core';
import {
ActivatedRouteSnapshot,
ResolveFn,
RouterStateSnapshot,
} from '@angular/router';
import { Observable } from 'rxjs';
import { MenuResolverService } from './menu-resolver.service';
/**
* Initialize all menus
*/
export const menuResolver: ResolveFn<boolean> = (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
menuResolverService: MenuResolverService = inject(MenuResolverService),
): Observable<boolean> => {
return menuResolverService.resolve(route, state);
};

View File

@@ -1,3 +1,4 @@
@if (hasSubSections$ | async) {
<div class="ds-menu-item-wrapper text-md-center"
[id]="'expandable-navbar-section-' + section.id"
(mouseenter)="onMouseEnter($event)"
@@ -39,3 +40,4 @@
</div>
}
</div>
}

View File

@@ -16,7 +16,10 @@ import { of as observableOf } from 'rxjs';
import { HostWindowService } from '../../shared/host-window.service';
import { MenuService } from '../../shared/menu/menu.service';
import { LinkMenuItemModel } from '../../shared/menu/menu-item/models/link.model';
import { MenuSection } from '../../shared/menu/menu-section.model';
import {
MenuItemModels,
MenuSection,
} from '../../shared/menu/menu-section.model';
import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub';
import { MenuServiceStub } from '../../shared/testing/menu-service.stub';
import { HoverOutsideDirective } from '../../shared/utils/hover-outside.directive';
@@ -46,7 +49,7 @@ describe('ExpandableNavbarSectionComponent', () => {
}));
beforeEach(() => {
spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([]));
spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([{ id: 'test', visible: true, model: {} as MenuItemModels }]));
fixture = TestBed.createComponent(ExpandableNavbarSectionComponent);
component = fixture.componentInstance;
@@ -258,21 +261,23 @@ describe('ExpandableNavbarSectionComponent', () => {
beforeEach(fakeAsync(() => {
jasmine.getEnv().allowRespy(true);
spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([
Object.assign(new MenuSection(), {
{
id: 'subSection1',
model: Object.assign(new LinkMenuItemModel(), {
type: 'TEST_LINK',
}),
parentId: component.section.id,
}),
Object.assign(new MenuSection(), {
visible: true,
parentID: component.section.id,
},
{
id: 'subSection2',
model: Object.assign(new LinkMenuItemModel(), {
type: 'TEST_LINK',
}),
parentId: component.section.id,
}),
]));
visible: true,
parentID: component.section.id,
},
] as MenuSection[]));
component.ngOnInit();
flush();
fixture.detectChanges();
@@ -321,7 +326,7 @@ describe('ExpandableNavbarSectionComponent', () => {
}));
beforeEach(() => {
spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([]));
spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([{ id: 'test', visible: true, model: {} as MenuItemModels }]));
fixture = TestBed.createComponent(ExpandableNavbarSectionComponent);
component = fixture.componentInstance;

View File

@@ -13,9 +13,13 @@ import {
} from '@angular/core';
import { RouterLinkActive } from '@angular/router';
import { Observable } from 'rxjs';
import { first } from 'rxjs/operators';
import {
first,
map,
} from 'rxjs/operators';
import { slide } from '../../shared/animations/slide';
import { isNotEmpty } from '../../shared/empty.util';
import { HostWindowService } from '../../shared/host-window.service';
import { MenuService } from '../../shared/menu/menu.service';
import { MenuID } from '../../shared/menu/menu-id.model';
@@ -78,6 +82,11 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
*/
private dropdownItems: NodeListOf<HTMLElement>;
/**
* Emits true when the top section has subsections, else emits false
*/
hasSubSections$: Observable<boolean>;
@HostListener('window:resize', ['$event'])
onResize() {
this.isMobile$.pipe(
@@ -104,6 +113,9 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
ngOnInit() {
super.ngOnInit();
this.hasSubSections$ = this.subSections$.pipe(
map((subSections) => isNotEmpty(subSections)),
);
this.subs.push(this.active$.subscribe((active: boolean) => {
if (active === true) {
this.addArrowEventListeners = true;

View File

@@ -11,7 +11,8 @@ import {
import { MenuService } from '../../shared/menu/menu.service';
import { MenuID } from '../../shared/menu/menu-id.model';
import { MenuSectionComponent } from '../../shared/menu/menu-section/menu-section.component';
import { MenuSection } from '../../shared/menu/menu-section.model';
import { AbstractMenuSectionComponent } from '../../shared/menu/menu-section/abstract-menu-section.component';
/**
* Represents a non-expandable section in the navbar
@@ -23,17 +24,18 @@ import { MenuSectionComponent } from '../../shared/menu/menu-section/menu-sectio
standalone: true,
imports: [NgComponentOutlet, AsyncPipe],
})
export class NavbarSectionComponent extends MenuSectionComponent implements OnInit {
export class NavbarSectionComponent extends AbstractMenuSectionComponent implements OnInit {
/**
* This section resides in the Public Navbar
*/
menuID = MenuID.PUBLIC;
constructor(@Inject('sectionDataProvider') menuSection,
constructor(
@Inject('sectionDataProvider') protected section: MenuSection,
protected menuService: MenuService,
protected injector: Injector,
) {
super(menuSection, menuService, injector);
super(menuService, injector);
}
ngOnInit() {

View File

@@ -1,324 +0,0 @@
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
RouterStateSnapshot,
} from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { TranslateService } from '@ngx-translate/core';
import {
combineLatest,
Observable,
of as observableOf,
} from 'rxjs';
import {
map,
switchMap,
} from 'rxjs/operators';
import { getDSORoute } from '../../app-routing-paths';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { ResearcherProfileDataService } from '../../core/profile/researcher-profile-data.service';
import { Collection } from '../../core/shared/collection.model';
import { Community } from '../../core/shared/community.model';
import { Item } from '../../core/shared/item.model';
import {
getFirstCompletedRemoteData,
getRemoteDataPayload,
} from '../../core/shared/operators';
import { CorrectionTypeDataService } from '../../core/submission/correctiontype-data.service';
import { URLCombiner } from '../../core/url-combiner/url-combiner';
import {
hasNoValue,
hasValue,
isNotEmpty,
} from '../empty.util';
import { MenuService } from '../menu/menu.service';
import { MenuID } from '../menu/menu-id.model';
import { LinkMenuItemModel } from '../menu/menu-item/models/link.model';
import { OnClickMenuItemModel } from '../menu/menu-item/models/onclick.model';
import { MenuItemType } from '../menu/menu-item-type.model';
import { MenuSection } from '../menu/menu-section.model';
import { NotificationsService } from '../notifications/notifications.service';
import { SubscriptionModalComponent } from '../subscriptions/subscription-modal/subscription-modal.component';
import { DsoVersioningModalService } from './dso-versioning-modal-service/dso-versioning-modal.service';
import {
DsoWithdrawnReinstateModalService,
REQUEST_REINSTATE,
REQUEST_WITHDRAWN,
} from './dso-withdrawn-reinstate-service/dso-withdrawn-reinstate-modal.service';
/**
* Creates the menus for the dspace object pages
*/
@Injectable({
providedIn: 'root',
})
export class DSOEditMenuResolverService {
constructor(
protected dSpaceObjectDataService: DSpaceObjectDataService,
protected menuService: MenuService,
protected authorizationService: AuthorizationDataService,
protected modalService: NgbModal,
protected dsoVersioningModalService: DsoVersioningModalService,
protected researcherProfileService: ResearcherProfileDataService,
protected notificationsService: NotificationsService,
protected translate: TranslateService,
protected dsoWithdrawnReinstateModalService: DsoWithdrawnReinstateModalService,
private correctionTypeDataService: CorrectionTypeDataService,
) {
}
/**
* Initialise all dspace object related menus
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<{ [key: string]: MenuSection[] }> {
let id = route.params.id;
if (hasNoValue(id) && hasValue(route.queryParams.scope)) {
id = route.queryParams.scope;
}
if (hasNoValue(id)) {
// If there's no ID, we're not on a DSO homepage, so pass on any pre-existing menu route data
return observableOf({ ...route.data?.menu });
} else {
return this.dSpaceObjectDataService.findById(id, true, false).pipe(
getFirstCompletedRemoteData(),
switchMap((dsoRD) => {
if (dsoRD.hasSucceeded) {
const dso = dsoRD.payload;
return combineLatest(this.getDsoMenus(dso, route, state)).pipe(
// Menu sections are retrieved as an array of arrays and flattened into a single array
map((combinedMenus) => [].concat.apply([], combinedMenus)),
map((menus) => this.addDsoUuidToMenuIDs(menus, dso)),
map((menus) => {
return {
...route.data?.menu,
[MenuID.DSO_EDIT]: menus,
};
}),
);
} else {
return observableOf({ ...route.data?.menu });
}
}),
);
}
}
/**
* Return all the menus for a dso based on the route and state
*/
getDsoMenus(dso, route, state): Observable<MenuSection[]>[] {
return [
this.getItemMenu(dso),
this.getComColMenu(dso),
this.getCommonMenu(dso, state),
];
}
/**
* Get the common menus between all dspace objects
*/
protected getCommonMenu(dso, state): Observable<MenuSection[]> {
return combineLatest([
this.authorizationService.isAuthorized(FeatureID.CanEditMetadata, dso.self),
]).pipe(
map(([canEditItem]) => {
return [
{
id: 'edit-dso',
active: false,
visible: canEditItem,
model: {
type: MenuItemType.LINK,
text: this.getDsoType(dso) + '.page.edit',
link: new URLCombiner(getDSORoute(dso), 'edit', 'metadata').toString(),
} as LinkMenuItemModel,
icon: 'pencil-alt',
index: 2,
},
];
}),
);
}
/**
* Get item specific menus
*/
protected getItemMenu(dso): Observable<MenuSection[]> {
if (dso instanceof Item) {
return combineLatest([
this.authorizationService.isAuthorized(FeatureID.CanCreateVersion, dso.self),
this.dsoVersioningModalService.isNewVersionButtonDisabled(dso),
this.dsoVersioningModalService.getVersioningTooltipMessage(dso, 'item.page.version.hasDraft', 'item.page.version.create'),
this.authorizationService.isAuthorized(FeatureID.CanSynchronizeWithORCID, dso.self),
this.authorizationService.isAuthorized(FeatureID.CanClaimItem, dso.self),
this.correctionTypeDataService.findByItem(dso.uuid, true).pipe(
getFirstCompletedRemoteData(),
getRemoteDataPayload()),
]).pipe(
map(([canCreateVersion, disableVersioning, versionTooltip, canSynchronizeWithOrcid, canClaimItem, correction]) => {
const isPerson = this.getDsoType(dso) === 'person';
return [
{
id: 'orcid-dso',
active: false,
visible: isPerson && canSynchronizeWithOrcid,
model: {
type: MenuItemType.LINK,
text: 'item.page.orcid.tooltip',
link: new URLCombiner(getDSORoute(dso), 'orcid').toString(),
} as LinkMenuItemModel,
icon: 'orcid fab fa-lg',
index: 0,
},
{
id: 'version-dso',
active: false,
visible: canCreateVersion,
model: {
type: MenuItemType.ONCLICK,
text: versionTooltip,
disabled: disableVersioning,
function: () => {
this.dsoVersioningModalService.openCreateVersionModal(dso);
},
} as OnClickMenuItemModel,
icon: 'code-branch',
index: 1,
},
{
id: 'claim-dso',
active: false,
visible: isPerson && canClaimItem,
model: {
type: MenuItemType.ONCLICK,
text: 'item.page.claim.button',
function: () => {
this.claimResearcher(dso);
},
} as OnClickMenuItemModel,
icon: 'hand-paper',
index: 3,
},
{
id: 'withdrawn-item',
active: false,
visible: dso.isArchived && correction?.page.some((c) => c.topic === REQUEST_WITHDRAWN),
model: {
type: MenuItemType.ONCLICK,
text:'item.page.withdrawn',
function: () => {
this.dsoWithdrawnReinstateModalService.openCreateWithdrawnReinstateModal(dso, 'request-withdrawn', dso.isArchived);
},
} as OnClickMenuItemModel,
icon: 'eye-slash',
index: 4,
},
{
id: 'reinstate-item',
active: false,
visible: dso.isWithdrawn && correction?.page.some((c) => c.topic === REQUEST_REINSTATE),
model: {
type: MenuItemType.ONCLICK,
text:'item.page.reinstate',
function: () => {
this.dsoWithdrawnReinstateModalService.openCreateWithdrawnReinstateModal(dso, 'request-reinstate', dso.isArchived);
},
} as OnClickMenuItemModel,
icon: 'eye',
index: 5,
},
];
}),
);
} else {
return observableOf([]);
}
}
/**
* Get Community/Collection-specific menus
*/
protected getComColMenu(dso): Observable<MenuSection[]> {
if (dso instanceof Community || dso instanceof Collection) {
return combineLatest([
this.authorizationService.isAuthorized(FeatureID.CanSubscribe, dso.self),
]).pipe(
map(([canSubscribe]) => {
return [
{
id: 'subscribe',
active: false,
visible: canSubscribe,
model: {
type: MenuItemType.ONCLICK,
text: 'subscriptions.tooltip',
function: () => {
const modalRef = this.modalService.open(SubscriptionModalComponent);
modalRef.componentInstance.dso = dso;
},
} as OnClickMenuItemModel,
icon: 'bell',
index: 4,
},
];
}),
);
} else {
return observableOf([]);
}
}
/**
* Claim a researcher by creating a profile
* Shows notifications and/or hides the menu section on success/error
*/
protected claimResearcher(dso) {
this.researcherProfileService.createFromExternalSourceAndReturnRelatedItemId(dso.self)
.subscribe((id: string) => {
if (isNotEmpty(id)) {
this.notificationsService.success(this.translate.get('researcherprofile.success.claim.title'),
this.translate.get('researcherprofile.success.claim.body'));
this.authorizationService.invalidateAuthorizationsRequestCache();
this.menuService.hideMenuSection(MenuID.DSO_EDIT, 'claim-dso-' + dso.uuid);
} else {
this.notificationsService.error(
this.translate.get('researcherprofile.error.claim.title'),
this.translate.get('researcherprofile.error.claim.body'));
}
});
}
/**
* Retrieve the dso or entity type for an object to be used in generic messages
*/
protected getDsoType(dso) {
const renderType = dso.getRenderTypes()[0];
if (typeof renderType === 'string' || renderType instanceof String) {
return renderType.toLowerCase();
} else {
return dso.type.toString().toLowerCase();
}
}
/**
* Add the dso uuid to all provided menu ids and parent ids
*/
protected addDsoUuidToMenuIDs(menus, dso) {
return menus.map((menu) => {
Object.assign(menu, {
id: menu.id + '-' + dso.uuid,
});
if (hasValue(menu.parentID)) {
Object.assign(menu, {
parentID: menu.parentID + '-' + dso.uuid,
});
}
return menu;
});
}
}

View File

@@ -1,436 +0,0 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import {
TestBed,
waitForAsync,
} from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { RouterTestingModule } from '@angular/router/testing';
import {
NgbModal,
NgbModalRef,
} from '@ng-bootstrap/ng-bootstrap';
import {
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import flatten from 'lodash/flatten';
import {
combineLatest,
map,
of as observableOf,
} from 'rxjs';
import { CorrectionTypeDataService } from 'src/app/core/submission/correctiontype-data.service';
import { AdminSidebarComponent } from '../../admin/admin-sidebar/admin-sidebar.component';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { ResearcherProfileDataService } from '../../core/profile/researcher-profile-data.service';
import { Collection } from '../../core/shared/collection.model';
import { Community } from '../../core/shared/community.model';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { Item } from '../../core/shared/item.model';
import { MenuService } from '../menu/menu.service';
import { MenuID } from '../menu/menu-id.model';
import { LinkMenuItemModel } from '../menu/menu-item/models/link.model';
import { MenuItemType } from '../menu/menu-item-type.model';
import { NotificationsService } from '../notifications/notifications.service';
import {
createFailedRemoteDataObject$,
createSuccessfulRemoteDataObject$,
} from '../remote-data.utils';
import { MenuServiceStub } from '../testing/menu-service.stub';
import { createPaginatedList } from '../testing/utils.test';
import { DSOEditMenuResolverService } from './dso-edit-menu-resolver.service';
import { DsoVersioningModalService } from './dso-versioning-modal-service/dso-versioning-modal.service';
import { DsoWithdrawnReinstateModalService } from './dso-withdrawn-reinstate-service/dso-withdrawn-reinstate-modal.service';
describe('dsoEditMenuResolver', () => {
const MENU_STATE = {
id: 'some menu',
};
let resolver: DSOEditMenuResolverService;
let dSpaceObjectDataService;
let menuService;
let authorizationService;
let dsoVersioningModalService;
let researcherProfileService;
let notificationsService;
let translate;
let mockNgbModal;
let dsoWithdrawnReinstateModalService;
let correctionsDataService;
const dsoRoute = (dso: DSpaceObject) => {
return {
data: {
menu: {
'statistics': [{
id: 'statistics-dummy-1',
active: false,
visible: true,
model: null,
}],
},
},
params: { id: dso.uuid },
};
};
const state = {
url: 'test-url',
};
const testCommunity: Community = Object.assign(new Community(), {
uuid: 'test-community-uuid',
type: 'community',
_links: { self: { href: 'self-link' } },
});
const testCollection: Collection = Object.assign(new Collection(), {
uuid: 'test-collection-uuid',
type: 'collection',
_links: { self: { href: 'self-link' } },
});
const testItem: Item = Object.assign(new Item(), {
uuid: 'test-item-uuid',
type: 'item',
_links: { self: { href: 'self-link' } },
});
let testObject: DSpaceObject;
let route;
const dummySections1 = [{
id: 'dummy-1',
active: false,
visible: true,
model: null,
},
{
id: 'dummy-2',
active: false,
visible: true,
model: null,
}];
const dummySections2 = [{
id: 'dummy-3',
active: false,
visible: true,
model: null,
},
{
id: 'dummy-4',
active: false,
visible: true,
model: null,
},
{
id: 'dummy-5',
active: false,
visible: true,
model: null,
}];
beforeEach(waitForAsync(() => {
// test with Items unless specified otherwise
testObject = testItem;
route = dsoRoute(testItem);
menuService = new MenuServiceStub();
spyOn(menuService, 'getMenu').and.returnValue(observableOf(MENU_STATE));
dSpaceObjectDataService = jasmine.createSpyObj('dSpaceObjectDataService', {
findById: createSuccessfulRemoteDataObject$(testObject),
});
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true),
});
dsoVersioningModalService = jasmine.createSpyObj('dsoVersioningModalService', {
isNewVersionButtonDisabled: observableOf(false),
getVersioningTooltipMessage: observableOf('message'),
openCreateVersionModal: {},
});
researcherProfileService = jasmine.createSpyObj('researcherProfileService', {
createFromExternalSourceAndReturnRelatedItemId: observableOf('mock-id'),
});
translate = jasmine.createSpyObj('translate', {
get: observableOf('translated-message'),
});
notificationsService = jasmine.createSpyObj('notificationsService', {
success: {},
error: {},
});
mockNgbModal = {
open: jasmine.createSpy('open').and.returnValue(
{ componentInstance: {}, closed: observableOf({}) } as NgbModalRef,
),
};
dsoWithdrawnReinstateModalService = jasmine.createSpyObj('dsoWithdrawnReinstateModalService', {
openCreateWithdrawnReinstateModal: {},
});
correctionsDataService = jasmine.createSpyObj('correctionsDataService', {
findByItem: createSuccessfulRemoteDataObject$(createPaginatedList([])),
});
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), NoopAnimationsModule, RouterTestingModule, AdminSidebarComponent],
providers: [
{ provide: DSpaceObjectDataService, useValue: dSpaceObjectDataService },
{ provide: MenuService, useValue: menuService },
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: DsoVersioningModalService, useValue: dsoVersioningModalService },
{ provide: ResearcherProfileDataService, useValue: researcherProfileService },
{ provide: TranslateService, useValue: translate },
{ provide: NotificationsService, useValue: notificationsService },
{ provide: DsoWithdrawnReinstateModalService, useValue: dsoWithdrawnReinstateModalService },
{ provide: CorrectionTypeDataService, useValue: correctionsDataService },
{ provide: NgbModal, useValue: mockNgbModal },
DSOEditMenuResolverService,
],
schemas: [NO_ERRORS_SCHEMA],
});
resolver = TestBed.inject(DSOEditMenuResolverService);
spyOn(menuService, 'addSection');
}));
it('should be created', () => {
expect(resolver).toBeTruthy();
});
describe('resolve', () => {
it('should create all menus when a dso is found based on the route id param', (done) => {
spyOn(resolver, 'getDsoMenus').and.returnValue(
[observableOf(dummySections1), observableOf(dummySections2)],
);
resolver.resolve(route as any, null).subscribe(resolved => {
expect(resolved).toEqual(
{
...route.data.menu,
[MenuID.DSO_EDIT]: [
...dummySections1.map((menu) => Object.assign(menu, { id: menu.id + '-test-item-uuid' })),
...dummySections2.map((menu) => Object.assign(menu, { id: menu.id + '-test-item-uuid' })),
],
},
);
expect(dSpaceObjectDataService.findById).toHaveBeenCalledWith('test-item-uuid', true, false);
expect(resolver.getDsoMenus).toHaveBeenCalled();
done();
});
});
it('should create all menus when a dso is found based on the route scope query param when no id param is present', (done) => {
spyOn(resolver, 'getDsoMenus').and.returnValue(
[observableOf(dummySections1), observableOf(dummySections2)],
);
const routeWithScope = {
data: {
menu: {
'statistics': [{
id: 'statistics-dummy-1',
active: false,
visible: true,
model: null,
}],
},
},
params: {},
queryParams: { scope: 'test-scope-uuid' },
};
resolver.resolve(routeWithScope as any, null).subscribe(resolved => {
expect(resolved).toEqual(
{
...route.data.menu,
[MenuID.DSO_EDIT]: [
...dummySections1.map((menu) => Object.assign(menu, { id: menu.id + '-test-scope-uuid' })),
...dummySections2.map((menu) => Object.assign(menu, { id: menu.id + '-test-scope-uuid' })),
],
},
);
expect(dSpaceObjectDataService.findById).toHaveBeenCalledWith('test-scope-uuid', true, false);
expect(resolver.getDsoMenus).toHaveBeenCalled();
done();
});
});
it('should return the statistics menu when no dso is found', (done) => {
(dSpaceObjectDataService.findById as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$());
resolver.resolve(route as any, null).subscribe(resolved => {
expect(resolved).toEqual(
{
...route.data.menu,
},
);
done();
});
});
});
describe('getDsoMenus', () => {
describe('for Communities', () => {
beforeEach(() => {
testObject = testCommunity;
dSpaceObjectDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(testCommunity));
route = dsoRoute(testCommunity);
});
it('should not return Item-specific entries', (done) => {
const result = resolver.getDsoMenus(testObject, route, state);
combineLatest(result).pipe(map(flatten)).subscribe((menu) => {
const orcidEntry = menu.find(entry => entry.id === 'orcid-dso');
expect(orcidEntry).toBeFalsy();
const versionEntry = menu.find(entry => entry.id === 'version-dso');
expect(versionEntry).toBeFalsy();
const claimEntry = menu.find(entry => entry.id === 'claim-dso');
expect(claimEntry).toBeFalsy();
done();
});
});
it('should return Community/Collection-specific entries', (done) => {
const result = resolver.getDsoMenus(testObject, route, state);
combineLatest(result).pipe(map(flatten)).subscribe((menu) => {
const subscribeEntry = menu.find(entry => entry.id === 'subscribe');
expect(subscribeEntry).toBeTruthy();
expect(subscribeEntry.active).toBeFalse();
expect(subscribeEntry.visible).toBeTrue();
expect(subscribeEntry.model.type).toEqual(MenuItemType.ONCLICK);
done();
});
});
it('should return as third part the common list ', (done) => {
const result = resolver.getDsoMenus(testObject, route, state);
combineLatest(result).pipe(map(flatten)).subscribe((menu) => {
const editEntry = menu.find(entry => entry.id === 'edit-dso');
expect(editEntry).toBeTruthy();
expect(editEntry.active).toBeFalse();
expect(editEntry.visible).toBeTrue();
expect(editEntry.model.type).toEqual(MenuItemType.LINK);
expect((editEntry.model as LinkMenuItemModel).link).toEqual(
'/communities/test-community-uuid/edit/metadata',
);
done();
});
});
});
describe('for Collections', () => {
beforeEach(() => {
testObject = testCollection;
dSpaceObjectDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(testCollection));
route = dsoRoute(testCollection);
});
it('should not return Item-specific entries', (done) => {
const result = resolver.getDsoMenus(testObject, route, state);
combineLatest(result).pipe(map(flatten)).subscribe((menu) => {
const orcidEntry = menu.find(entry => entry.id === 'orcid-dso');
expect(orcidEntry).toBeFalsy();
const versionEntry = menu.find(entry => entry.id === 'version-dso');
expect(versionEntry).toBeFalsy();
const claimEntry = menu.find(entry => entry.id === 'claim-dso');
expect(claimEntry).toBeFalsy();
done();
});
});
it('should return Community/Collection-specific entries', (done) => {
const result = resolver.getDsoMenus(testObject, route, state);
combineLatest(result).pipe(map(flatten)).subscribe((menu) => {
const subscribeEntry = menu.find(entry => entry.id === 'subscribe');
expect(subscribeEntry).toBeTruthy();
expect(subscribeEntry.active).toBeFalse();
expect(subscribeEntry.visible).toBeTrue();
expect(subscribeEntry.model.type).toEqual(MenuItemType.ONCLICK);
done();
});
});
it('should return as third part the common list ', (done) => {
const result = resolver.getDsoMenus(testObject, route, state);
combineLatest(result).pipe(map(flatten)).subscribe((menu) => {
const editEntry = menu.find(entry => entry.id === 'edit-dso');
expect(editEntry).toBeTruthy();
expect(editEntry.active).toBeFalse();
expect(editEntry.visible).toBeTrue();
expect(editEntry.model.type).toEqual(MenuItemType.LINK);
expect((editEntry.model as LinkMenuItemModel).link).toEqual(
'/collections/test-collection-uuid/edit/metadata',
);
done();
});
});
});
describe('for Items', () => {
beforeEach(() => {
testObject = testItem;
dSpaceObjectDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(testItem));
route = dsoRoute(testItem);
});
it('should return Item-specific entries', (done: DoneFn) => {
const result = resolver.getDsoMenus(testObject, route, state);
combineLatest(result).pipe(map(flatten)).subscribe((menu) => {
const orcidEntry = menu.find(entry => entry.id === 'orcid-dso');
expect(orcidEntry).toBeTruthy();
expect(orcidEntry.active).toBeFalse();
expect(orcidEntry.visible).toBeFalse();
expect(orcidEntry.model.type).toEqual(MenuItemType.LINK);
const versionEntry = menu.find(entry => entry.id === 'version-dso');
expect(versionEntry).toBeTruthy();
expect(versionEntry.active).toBeFalse();
expect(versionEntry.visible).toBeTrue();
expect(versionEntry.model.type).toEqual(MenuItemType.ONCLICK);
expect(versionEntry.model.disabled).toBeFalse();
const claimEntry = menu.find(entry => entry.id === 'claim-dso');
expect(claimEntry).toBeTruthy();
expect(claimEntry.active).toBeFalse();
expect(claimEntry.visible).toBeFalse();
expect(claimEntry.model.type).toEqual(MenuItemType.ONCLICK);
done();
});
});
it('should not return Community/Collection-specific entries', (done: DoneFn) => {
const result = resolver.getDsoMenus(testObject, route, state);
combineLatest(result).pipe(map(flatten)).subscribe((menu) => {
const subscribeEntry = menu.find(entry => entry.id === 'subscribe');
expect(subscribeEntry).toBeFalsy();
done();
});
});
it('should return as third part the common list ', (done: DoneFn) => {
const result = resolver.getDsoMenus(testObject, route, state);
combineLatest(result).pipe(map(flatten)).subscribe((menu) => {
const editEntry = menu.find(entry => entry.id === 'edit-dso');
expect(editEntry).toBeTruthy();
expect(editEntry.active).toBeFalse();
expect(editEntry.visible).toBeTrue();
expect(editEntry.model.type).toEqual(MenuItemType.LINK);
expect((editEntry.model as LinkMenuItemModel).link).toEqual(
'/items/test-item-uuid/edit/metadata',
);
done();
});
});
});
});
});

View File

@@ -1,21 +0,0 @@
import { inject } from '@angular/core';
import {
ActivatedRouteSnapshot,
ResolveFn,
RouterStateSnapshot,
} from '@angular/router';
import { Observable } from 'rxjs';
import { MenuSection } from '../menu/menu-section.model';
import { DSOEditMenuResolverService } from './dso-edit-menu-resolver.service';
/**
* Initialise all dspace object related menus
*/
export const dsoEditMenuResolver: ResolveFn<{ [key: string]: MenuSection[] }> = (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
menuResolverService: DSOEditMenuResolverService = inject(DSOEditMenuResolverService),
): Observable<{ [key: string]: MenuSection[] }> => {
return menuResolverService.resolve(route, state);
};

View File

@@ -1,18 +1,19 @@
@if (hasSubSections$ | async) {
<div class="dso-button-menu mb-1" ngbDropdown container="body" placement="bottom-right">
<div class="d-flex flex-row flex-nowrap"
[ngbTooltip]="itemModel.text | translate" container="body">
<button [attr.aria-label]="itemModel.text | translate" [title]="itemModel.text | translate" class="btn btn-dark btn-sm" ngbDropdownToggle [dsBtnDisabled]="section.model?.disabled">
<button [attr.aria-label]="itemModel.text | translate" [title]="itemModel.text | translate" class="btn btn-dark btn-sm" ngbDropdownToggle [dsBtnDisabled]="section.model?.disabled" role="menuitem" tabindex="0">
<i class="fas fa-{{section.icon}} fa-fw"></i>
</button>
<ul ngbDropdownMenu class="dso-edit-menu-dropdown">
<ul ngbDropdownMenu class="dso-edit-menu-dropdown p-1" role="menu">
@for (subSection of (subSections$ | async); track subSection) {
<li class="nav-item nav-link d-flex flex-row">
<li class="nav-item nav-link d-flex flex-row p-2" role="presentation">
@if (renderIcons$ | async) {
<div class="me-2">
@if (subSection.icon) {
<i class="fas fa-{{subSection.icon}} fa-fw"></i>
<i class="fas fa-{{subSection.icon}} fa-fw" aria-hidden="true"></i>
} @else {
<i class="fas fa-fw"></i>
<i class="fas fa-fw" aria-hidden="true"></i>
}
</div>
}
@@ -24,6 +25,7 @@
</ul>
</div>
</div>
}

View File

@@ -4,8 +4,19 @@
.dso-button-menu {
.dropdown-toggle::after {
display: none;
content: '';
width: 0;
height: 0;
border-style: solid;
border-width: 12px 12px 0 0;
border-color: transparent #627a91 transparent transparent;
border-bottom-right-radius: var(--bs-btn-border-radius-sm);
right: 0;
bottom: 0;
position: absolute;
overflow: hidden;
}
overflow: hidden;
}
ul.dropdown-menu {

View File

@@ -8,9 +8,10 @@ import { By } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { of as observableOf } from 'rxjs';
import { MenuItemType } from 'src/app/shared/menu/menu-item-type.model';
import { MenuService } from '../../../menu/menu.service';
import { MenuItemType } from '../../../menu/menu-item-type.model';
import { MenuItemModels } from '../../../menu/menu-section.model';
import { CSSVariableService } from '../../../sass-helper/css-variable.service';
import { CSSVariableServiceStub } from '../../../testing/css-variable-service.stub';
import { MenuServiceStub } from '../../../testing/menu-service.stub';
@@ -35,6 +36,42 @@ describe('DsoEditMenuExpandableSectionComponent', () => {
icon: iconString,
};
describe('when there are subsections', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), DsoEditMenuExpandableSectionComponent, TestComponent],
providers: [
{ provide: 'sectionDataProvider', useValue: dummySection },
{ provide: MenuService, useValue: menuService },
{ provide: CSSVariableService, useClass: CSSVariableServiceStub },
{ provide: Router, useValue: new RouterStub() },
],
}).compileComponents();
}));
beforeEach(() => {
spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([{
id: 'test',
visible: true,
model: {} as MenuItemModels,
}]));
fixture = TestBed.createComponent(DsoEditMenuExpandableSectionComponent);
component = fixture.componentInstance;
spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should show a button with the icon', () => {
const button = fixture.debugElement.query(By.css('.btn-dark'));
expect(button.nativeElement.innerHTML).toContain('fa-' + iconString);
});
});
describe('when there are no subsections', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), DsoEditMenuExpandableSectionComponent, TestComponent],
@@ -59,9 +96,10 @@ describe('DsoEditMenuExpandableSectionComponent', () => {
expect(component).toBeTruthy();
});
it('should show a button with the icon', () => {
it('should now show a button', () => {
const button = fixture.debugElement.query(By.css('.btn-dark'));
expect(button.nativeElement.innerHTML).toContain('fa-' + iconString);
expect(button).toBeNull();
});
});
});

View File

@@ -18,10 +18,13 @@ import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { MenuID } from 'src/app/shared/menu/menu-id.model';
import { MenuSection } from 'src/app/shared/menu/menu-section.model';
import { MenuSectionComponent } from 'src/app/shared/menu/menu-section/menu-section.component';
import { AbstractMenuSectionComponent } from 'src/app/shared/menu/menu-section/abstract-menu-section.component';
import { BtnDisabledDirective } from '../../../btn-disabled.directive';
import { hasValue } from '../../../empty.util';
import {
hasValue,
isNotEmpty,
} from '../../../empty.util';
import { MenuService } from '../../../menu/menu.service';
/**
@@ -34,21 +37,37 @@ import { MenuService } from '../../../menu/menu.service';
standalone: true,
imports: [NgbDropdownModule, NgbTooltipModule, NgComponentOutlet, TranslateModule, AsyncPipe, BtnDisabledDirective],
})
export class DsoEditMenuExpandableSectionComponent extends MenuSectionComponent implements OnInit {
export class DsoEditMenuExpandableSectionComponent extends AbstractMenuSectionComponent implements OnInit {
/**
* This section resides in the DSO edit menu
*/
menuID: MenuID = MenuID.DSO_EDIT;
/**
* The MenuItemModel of the top section
*/
itemModel;
/**
* Emits whether one of the subsections contains an icon
*/
renderIcons$: Observable<boolean>;
/**
* Emits true when the top section has subsections, else emits false
*/
hasSubSections$: Observable<boolean>;
constructor(
@Inject('sectionDataProvider') menuSection: MenuSection,
@Inject('sectionDataProvider') protected section: MenuSection,
protected menuService: MenuService,
protected injector: Injector,
protected router: Router,
) {
super(menuSection, menuService, injector);
this.itemModel = menuSection.model;
super(menuService, injector);
this.itemModel = section.model;
}
ngOnInit(): void {
@@ -60,5 +79,10 @@ export class DsoEditMenuExpandableSectionComponent extends MenuSectionComponent
return sections.some(section => hasValue(section.icon));
}),
);
this.hasSubSections$ = this.subSections$.pipe(
map((subSections) => isNotEmpty(subSections)),
);
}
}

View File

@@ -142,17 +142,17 @@ describe('DsoEditMenuSectionComponent', () => {
stopPropagation: jasmine.createSpy('stopPropagation'),
});
it('should call the item model function when not disabled', () => {
spyOn(component.section.model as OnClickMenuItemModel, 'function');
spyOn((component as any).section.model as OnClickMenuItemModel, 'function');
component.activate(mockEvent);
expect((component.section.model as OnClickMenuItemModel).function).toHaveBeenCalled();
expect(((component as any).section.model as OnClickMenuItemModel).function).toHaveBeenCalled();
});
it('should call not the item model function when disabled', () => {
spyOn(component.section.model as OnClickMenuItemModel, 'function');
spyOn((component as any).section.model as OnClickMenuItemModel, 'function');
component.itemModel.disabled = true;
component.activate(mockEvent);
expect((component.section.model as OnClickMenuItemModel).function).not.toHaveBeenCalled();
expect(((component as any).section.model as OnClickMenuItemModel).function).not.toHaveBeenCalled();
component.itemModel.disabled = false;
});
});

View File

@@ -8,7 +8,7 @@ import {
import { RouterLink } from '@angular/router';
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core';
import { MenuSectionComponent } from 'src/app/shared/menu/menu-section/menu-section.component';
import { AbstractMenuSectionComponent } from 'src/app/shared/menu/menu-section/abstract-menu-section.component';
import { BtnDisabledDirective } from '../../../btn-disabled.directive';
import { isNotEmpty } from '../../../empty.util';
@@ -26,7 +26,7 @@ import { MenuSection } from '../../../menu/menu-section.model';
standalone: true,
imports: [NgbTooltipModule, RouterLink, TranslateModule, BtnDisabledDirective],
})
export class DsoEditMenuSectionComponent extends MenuSectionComponent implements OnInit {
export class DsoEditMenuSectionComponent extends AbstractMenuSectionComponent implements OnInit {
menuID: MenuID = MenuID.DSO_EDIT;
itemModel;
@@ -34,12 +34,12 @@ export class DsoEditMenuSectionComponent extends MenuSectionComponent implements
canActivate: boolean;
constructor(
@Inject('sectionDataProvider') menuSection: MenuSection,
@Inject('sectionDataProvider') protected section: MenuSection,
protected menuService: MenuService,
protected injector: Injector,
) {
super(menuSection, menuService, injector);
this.itemModel = menuSection.model;
super(menuService, injector);
this.itemModel = section.model;
}
ngOnInit(): void {

View File

@@ -1,4 +1,4 @@
<div class="dso-edit-menu d-flex">
<div class="dso-edit-menu d-flex" role="menubar">
@for (section of (sections | async); track section) {
<div class="ms-1">
<ng-container

View File

@@ -15,7 +15,7 @@ import { of as observableOf } from 'rxjs';
import { AuthService } from '../../../core/auth/auth.service';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { MenuService } from '../../menu/menu.service';
import { MenuItemModel } from '../../menu/menu-item/models/menu-item.model';
import { TextMenuItemModel } from '../../menu/menu-item/models/text.model';
import { getMockThemeService } from '../../mocks/theme-service.mock';
import { AuthServiceStub } from '../../testing/auth-service.stub';
import { MenuServiceStub } from '../../testing/menu-service.stub';
@@ -37,9 +37,10 @@ describe('DsoEditMenuComponent', () => {
active: false,
visible: true,
model: {
text: 'section-text',
type: null,
disabled: false,
} as MenuItemModel,
} as TextMenuItemModel,
icon: 'pencil-alt',
index: 1,
};

View File

@@ -28,7 +28,7 @@ export const initialMenusState: MenusState = {
id: MenuID.DSO_EDIT,
collapsed: true,
previewCollapsed: true,
visible: false,
visible: true,
sections: {},
sectionToSubsectionIndex: {},
},

View File

@@ -6,6 +6,6 @@ import { MenuItemModel } from './menu-item.model';
*/
export class AltmetricMenuItemModel implements MenuItemModel {
type = MenuItemType.ALTMETRIC;
disabled: boolean;
disabled?: boolean;
url: string;
}

View File

@@ -6,7 +6,7 @@ import { MenuItemModel } from './menu-item.model';
*/
export class ExternalLinkMenuItemModel implements MenuItemModel {
type = MenuItemType.EXTERNAL;
disabled: boolean;
disabled?: boolean;
text: string;
href: string;
}

View File

@@ -8,7 +8,7 @@ import { MenuItemModel } from './menu-item.model';
*/
export class LinkMenuItemModel implements MenuItemModel {
type = MenuItemType.LINK;
disabled: boolean;
disabled?: boolean;
text: string;
link: string;
queryParams?: Params | null;

View File

@@ -5,5 +5,5 @@ import { MenuItemType } from '../../menu-item-type.model';
*/
export interface MenuItemModel {
type: MenuItemType;
disabled: boolean;
disabled?: boolean;
}

View File

@@ -6,7 +6,7 @@ import { MenuItemModel } from './menu-item.model';
*/
export class OnClickMenuItemModel implements MenuItemModel {
type = MenuItemType.ONCLICK;
disabled: boolean;
disabled?: boolean;
text: string;
function: () => void;
}

View File

@@ -6,7 +6,7 @@ import { MenuItemModel } from './menu-item.model';
*/
export class SearchMenuItemModel implements MenuItemModel {
type = MenuItemType.SEARCH;
disabled: boolean;
disabled?: boolean;
placeholder: string;
action: string;
}

View File

@@ -6,6 +6,6 @@ import { MenuItemModel } from './menu-item.model';
*/
export class TextMenuItemModel implements MenuItemModel {
type = MenuItemType.TEXT;
disabled: boolean;
disabled?: boolean;
text: string;
}

View File

@@ -0,0 +1,162 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
/* eslint-disable max-classes-per-file */
import { Type } from '@angular/core';
import {
ActivatedRouteSnapshot,
RouterStateSnapshot,
} from '@angular/router';
import { Observable } from 'rxjs';
import { MenuID } from './menu-id.model';
import { MenuRoute } from './menu-route.model';
import { MenuItemModels } from './menu-section.model';
/**
* Partial menu section
* This object acts like a menu section but with certain properties being optional
*/
export interface PartialMenuSection {
id?: string;
accessibilityHandle?: string;
visible: boolean;
model: MenuItemModels;
parentID?: string;
active?: boolean;
shouldPersistOnRouteChange?: boolean;
icon?: string;
alwaysRenderExpandable?: boolean;
}
/**
* Interface to represent a menu provider
* Implementations of this provider will contain sections to be added to the menus
*/
export interface MenuProvider {
shouldPersistOnRouteChange?: boolean,
menuID?: MenuID;
index?: number;
/**
* Retrieve the sections from the provider. These sections can be route dependent.
* @param route - The route on which the menu sections possibly depend
* @param state - The router snapshot on which the sections possibly depend
*/
getSections(route?: ActivatedRouteSnapshot, state?: RouterStateSnapshot): Observable<PartialMenuSection[]>;
}
/**
* Class to represent a Menu Provider together with additional information added through the static methods on
* AbstractMenuProvider. This additional information is either the paths on which the sections of this provider should
* be present or a list of child providers
*/
export class MenuProviderTypeWithOptions {
providerType: Type<MenuProvider>;
paths?: MenuRoute[];
childProviderTypes?: (Type<MenuProvider> | MenuProviderTypeWithOptions)[];
}
/**
* Abstract class to be extended when creating menu providers
*/
export abstract class AbstractMenuProvider implements MenuProvider {
/**
* ID of the menu this provider is part of
* This will be set to the menu ID of the menu in which it is present in the app.menus.ts file
*/
menuID?: MenuID;
/**
* Whether the sections of this menu should be set on the
*/
shouldPersistOnRouteChange = true;
/**
* The ID of the menu provider.
* This will be automatically set based on the menu and the index of the provider in the list
*/
menuProviderId?: string;
/**
* The index of the menu provider
* This will be automatically set based on the index of the provider in the list
*/
index?: number;
/**
* The paths on which the sections of this provider will be active
* This will be automatically set based on the paths added based on the paths provided through the 'onRoute' static
* method in the app.menus.ts file
*/
activePaths?: MenuRoute[];
/**
* The ID of the parent provider of this provider.
* This will be automatically set based on the provider that calls the 'withSubs' static method with this provider
* in the list of arguments
*/
parentID?: string;
/**
* When true, the sections added by this provider will be assumed to be parent sections with children
* The sections will not be rendered when they have no visible children
* This can be overwritten on the level of sections
*/
alwaysRenderExpandable? = false;
/**
* When true, this provider will only add its sections on Browser Side Rendering.
*/
renderBrowserOnly? = false;
/**
* Static method to be called from the app.menus.ts file to define paths on which this provider should the active
* @param paths - The paths on which the sections of this provider should be active
*/
public static onRoute(...paths: MenuRoute[]): MenuProviderTypeWithOptions {
if (!AbstractMenuProvider.isPrototypeOf(this)) {
throw new Error(
'onRoute should only be called from concrete subclasses of AbstractMenuProvider',
);
}
const providerType = this as unknown as Type<AbstractMenuProvider>;
return { providerType: providerType, paths: paths };
}
/**
* Static method to be called from the app.menus.ts file to add sub menu providers to this top provider
* @param childProviders - the list of sub providers that will provide subsections for this provider
*/
public static withSubs(childProviders: (Type<MenuProvider> | MenuProviderTypeWithOptions)[]): MenuProviderTypeWithOptions {
if (!AbstractMenuProvider.isPrototypeOf(this)) {
throw new Error(
'withSubs should only be called from concrete subclasses of AbstractMenuProvider',
);
}
const providerType = this as unknown as Type<AbstractMenuProvider>;
return { providerType: providerType, childProviderTypes: childProviders };
}
/**
* Retrieve the sections from the provider. These sections can be route dependent.
* @param route - The route on which the menu sections possibly depend
* @param state - The router snapshot on which the sections possibly depend
*/
abstract getSections(route?: ActivatedRouteSnapshot, state?: RouterStateSnapshot): Observable<PartialMenuSection[]>;
protected getAutomatedSectionId(indexOfSectionInProvider: number): string {
return `${this.menuProviderId}_${indexOfSectionInProvider}`;
}
}

View File

@@ -0,0 +1,292 @@
import { waitForAsync } from '@angular/core/testing';
import {
ActivatedRouteSnapshot,
ResolveEnd,
RouterStateSnapshot,
UrlSegment,
} from '@angular/router';
import { of as observableOf } from 'rxjs';
import { COMMUNITY_MODULE_PATH } from '../../community-page/community-page-routing-paths';
import { MenuService } from './menu.service';
import { MenuID } from './menu-id.model';
import { MenuItemType } from './menu-item-type.model';
import {
AbstractMenuProvider,
PartialMenuSection,
} from './menu-provider.model';
import { MenuProviderService } from './menu-provider.service';
import { MenuRoute } from './menu-route.model';
describe('MenuProviderService', () => {
class TestMenuProvider extends AbstractMenuProvider {
constructor(
public menuID: MenuID,
public shouldPersistOnRouteChange: boolean,
public menuProviderId: string,
public index: number,
public activePaths: MenuRoute[],
public parentID: string,
public alwaysRenderExpandable: boolean,
public sections: PartialMenuSection[],
public renderBrowserOnly: boolean = false,
) {
super();
}
getSections(route?: ActivatedRouteSnapshot, state?: RouterStateSnapshot) {
return observableOf(this.sections);
}
}
let menuProviderService: MenuProviderService;
let menuService: MenuService;
const router = {
events: observableOf(new ResolveEnd(1, 'test-url', 'test-url-after-redirect', {
url: 'test-url',
root: {
url: [new UrlSegment('test-url', {})], data: {},
},
data: {},
} as any)),
};
const section = {
visible: true, model: {
type: MenuItemType.TEXT,
text: `test1`,
},
};
const sectionToBeRemoved = {
id: 'sectionToBeRemoved',
visible: true, model: {
type: MenuItemType.TEXT,
text: `test1`,
},
};
const persistentProvider1 = new TestMenuProvider(MenuID.PUBLIC, true, 'provider1', 0, undefined, undefined, false, [section]);
const persistentProvider2 = new TestMenuProvider(MenuID.PUBLIC, true, 'provider2', 1, undefined, 'provider1', false, [section, section]);
const nonPersistentProvider3 = new TestMenuProvider(MenuID.PUBLIC, false, 'provider3', 2, undefined, undefined, false, [section]);
const nonPersistentProvider4 = new TestMenuProvider(MenuID.PUBLIC, false, 'provider4', 3, undefined, 'provider3', false, [section]);
const nonPersistentProvider5WithRoutes = new TestMenuProvider(MenuID.PUBLIC, false, 'provider5', 4, [MenuRoute.COMMUNITY_PAGE, MenuRoute.COLLECTION_PAGE], undefined, false, [section]);
const nonPersistentProvider6WithRoutesAndBrowserOnlyRendering = new TestMenuProvider(MenuID.PUBLIC, false, 'provider6', 5, [MenuRoute.COMMUNITY_PAGE, MenuRoute.COLLECTION_PAGE], undefined, false, [section], true);
const persistentProvider7WithBrowserOnlyRendering = new TestMenuProvider(MenuID.PUBLIC, true, 'provider7', 6, undefined, undefined, false, [section], true);
const nonPersistentProvider8WithBrowserOnlyRendering = new TestMenuProvider(MenuID.PUBLIC, false, 'provider8', 3, undefined, undefined, false, [section], true);
const listOfProvider = [persistentProvider1, persistentProvider2, nonPersistentProvider3, nonPersistentProvider4, nonPersistentProvider5WithRoutes, nonPersistentProvider6WithRoutesAndBrowserOnlyRendering, persistentProvider7WithBrowserOnlyRendering, nonPersistentProvider8WithBrowserOnlyRendering];
const expectedSection1 = generateAddedSection(persistentProvider1, section);
const expectedSection21 = generateAddedSection(persistentProvider2, section);
const expectedSection22 = generateAddedSection(persistentProvider2, section, 1);
const expectedSection3 = generateAddedSection(nonPersistentProvider3, section);
const expectedSection4 = generateAddedSection(nonPersistentProvider4, section);
const expectedSection5 = generateAddedSection(nonPersistentProvider5WithRoutes, section);
const expectedSection6 = generateAddedSection(nonPersistentProvider6WithRoutesAndBrowserOnlyRendering, section);
const expectedSection7 = generateAddedSection(persistentProvider7WithBrowserOnlyRendering, section);
const expectedSection8 = generateAddedSection(nonPersistentProvider8WithBrowserOnlyRendering, section);
function generateAddedSection(provider, sectionToAdd, index = 0) {
return {
...sectionToAdd,
id: sectionToAdd.id ?? `${provider.menuProviderId}_${index}`,
parentID: sectionToAdd.parentID ?? provider.parentID,
index: sectionToAdd.index ?? provider.index,
active: false,
shouldPersistOnRouteChange: sectionToAdd.shouldPersistOnRouteChange ?? provider.shouldPersistOnRouteChange,
alwaysRenderExpandable: sectionToAdd.alwaysRenderExpandable ?? provider.alwaysRenderExpandable,
};
}
beforeEach(waitForAsync(() => {
menuService = jasmine.createSpyObj('MenuService',
{
addSection: {},
removeSection: {},
getMenu: observableOf({ id: MenuID.PUBLIC }),
getNonPersistentMenuSections: observableOf([sectionToBeRemoved]),
});
menuProviderService = new MenuProviderService(listOfProvider, menuService, router as any);
}));
describe('initPersistentMenus', () => {
describe('when server side rendering', () => {
it('should initialise the menu sections from the persistent providers while skipping the ones that should not be rendered on SSR', () => {
menuProviderService.initPersistentMenus(true);
expect(menuService.addSection).toHaveBeenCalledWith(persistentProvider1.menuID, expectedSection1);
expect(menuService.addSection).toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection21);
expect(menuService.addSection).toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection22);
expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider3.menuID, expectedSection3);
expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider4.menuID, expectedSection4);
expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider5WithRoutes.menuID, expectedSection5);
expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider6WithRoutesAndBrowserOnlyRendering.menuID, expectedSection6);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider7WithBrowserOnlyRendering.menuID, expectedSection7);
expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider8WithBrowserOnlyRendering.menuID, expectedSection8);
});
});
describe('when browser side rendering', () => {
it('should initialise the menu sections from the persistent providers without skipping the ones that should not be rendered on SSR', () => {
menuProviderService.initPersistentMenus(false);
expect(menuService.addSection).toHaveBeenCalledWith(persistentProvider1.menuID, expectedSection1);
expect(menuService.addSection).toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection21);
expect(menuService.addSection).toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection22);
expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider3.menuID, expectedSection3);
expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider4.menuID, expectedSection4);
expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider5WithRoutes.menuID, expectedSection5);
expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider6WithRoutesAndBrowserOnlyRendering.menuID, expectedSection6);
expect(menuService.addSection).toHaveBeenCalledWith(persistentProvider7WithBrowserOnlyRendering.menuID, expectedSection7);
expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider8WithBrowserOnlyRendering.menuID, expectedSection8);
});
});
});
describe('resolveRouteMenus with no matching path specific providers', () => {
describe('when browser side rendering', () => {
it('should remove the current non persistent menus and add the general non persistent menus', () => {
const route = { data: {} };
const state = { url: 'test-url' };
menuProviderService.resolveRouteMenus(route as any, state as any, false).subscribe();
expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.PUBLIC, sectionToBeRemoved.id);
expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.ADMIN, sectionToBeRemoved.id);
expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.DSO_EDIT, sectionToBeRemoved.id);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider1.menuID, expectedSection1);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection21);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection22);
expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider3.menuID, expectedSection3);
expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider4.menuID, expectedSection4);
expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider5WithRoutes.menuID, expectedSection5);
expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider6WithRoutesAndBrowserOnlyRendering.menuID, expectedSection6);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider7WithBrowserOnlyRendering.menuID, expectedSection7);
expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider8WithBrowserOnlyRendering.menuID, expectedSection8);
});
});
describe('when server side rendering', () => {
it('should remove the current non persistent menus and add the general non persistent menus', () => {
const route = { data: {} };
const state = { url: 'test-url' };
menuProviderService.resolveRouteMenus(route as any, state as any, true).subscribe();
expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.PUBLIC, sectionToBeRemoved.id);
expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.ADMIN, sectionToBeRemoved.id);
expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.DSO_EDIT, sectionToBeRemoved.id);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider1.menuID, expectedSection1);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection21);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection22);
expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider3.menuID, expectedSection3);
expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider4.menuID, expectedSection4);
expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider5WithRoutes.menuID, expectedSection5);
expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider6WithRoutesAndBrowserOnlyRendering.menuID, expectedSection6);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider7WithBrowserOnlyRendering.menuID, expectedSection7);
expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider8WithBrowserOnlyRendering.menuID, expectedSection8);
});
});
});
describe('resolveRouteMenus with a matching path specific provider', () => {
describe('when browser side rendering', () => {
it('should remove the current non persistent menus and add the general non persistent menus', () => {
const route = { data: { menuRoute: MenuRoute.COMMUNITY_PAGE } };
const state = { url: `xxxx/${COMMUNITY_MODULE_PATH}/xxxxxx` };
menuProviderService.resolveRouteMenus(route as any, state as any, false).subscribe();
expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.PUBLIC, sectionToBeRemoved.id);
expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.ADMIN, sectionToBeRemoved.id);
expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.DSO_EDIT, sectionToBeRemoved.id);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider1.menuID, expectedSection1);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection21);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection22);
expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider3.menuID, expectedSection3);
expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider4.menuID, expectedSection4);
expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider5WithRoutes.menuID, expectedSection5);
expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider6WithRoutesAndBrowserOnlyRendering.menuID, expectedSection6);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider7WithBrowserOnlyRendering.menuID, expectedSection7);
expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider8WithBrowserOnlyRendering.menuID, expectedSection8);
});
});
describe('when server side rendering', () => {
it('should remove the current non persistent menus and add the general non persistent menus', () => {
const route = { data: { menuRoute: MenuRoute.COMMUNITY_PAGE } };
const state = { url: `xxxx/${COMMUNITY_MODULE_PATH}/xxxxxx` };
menuProviderService.resolveRouteMenus(route as any, state as any, true).subscribe();
expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.PUBLIC, sectionToBeRemoved.id);
expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.ADMIN, sectionToBeRemoved.id);
expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.DSO_EDIT, sectionToBeRemoved.id);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider1.menuID, expectedSection1);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection21);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection22);
expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider3.menuID, expectedSection3);
expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider4.menuID, expectedSection4);
expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider5WithRoutes.menuID, expectedSection5);
expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider6WithRoutesAndBrowserOnlyRendering.menuID, expectedSection6);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider7WithBrowserOnlyRendering.menuID, expectedSection7);
expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider8WithBrowserOnlyRendering.menuID, expectedSection8);
});
});
});
describe('listenForRouteChanges ', () => {
describe('when browser side rendering', () => {
it('should listen to the route changes and update the menu sections based on the retrieved state and route', () => {
menuProviderService.listenForRouteChanges(false);
expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.PUBLIC, sectionToBeRemoved.id);
expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.ADMIN, sectionToBeRemoved.id);
expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.DSO_EDIT, sectionToBeRemoved.id);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider1.menuID, expectedSection1);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection21);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection22);
expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider3.menuID, expectedSection3);
expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider4.menuID, expectedSection4);
expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider5WithRoutes.menuID, expectedSection5);
expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider6WithRoutesAndBrowserOnlyRendering.menuID, expectedSection6);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider7WithBrowserOnlyRendering.menuID, expectedSection7);
expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider8WithBrowserOnlyRendering.menuID, expectedSection8);
});
});
describe('when server side rendering', () => {
it('should listen to the route changes and update the menu sections based on the retrieved state and route', () => {
menuProviderService.listenForRouteChanges(true);
expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.PUBLIC, sectionToBeRemoved.id);
expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.ADMIN, sectionToBeRemoved.id);
expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.DSO_EDIT, sectionToBeRemoved.id);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider1.menuID, expectedSection1);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection21);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection22);
expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider3.menuID, expectedSection3);
expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider4.menuID, expectedSection4);
expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider5WithRoutes.menuID, expectedSection5);
expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider6WithRoutesAndBrowserOnlyRendering.menuID, expectedSection6);
expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider7WithBrowserOnlyRendering.menuID, expectedSection7);
expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider8WithBrowserOnlyRendering.menuID, expectedSection8);
});
});
});
});

View File

@@ -0,0 +1,227 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import {
Inject,
Injectable,
Optional,
} from '@angular/core';
import {
ActivatedRouteSnapshot,
ResolveEnd,
Router,
RouterStateSnapshot,
} from '@angular/router';
import {
combineLatest,
map,
Observable,
} from 'rxjs';
import {
filter,
find,
switchMap,
take,
} from 'rxjs/operators';
import {
hasValue,
isNotEmpty,
} from '../empty.util';
import { MenuService } from './menu.service';
import { MENU_PROVIDER } from './menu.structure';
import { MenuID } from './menu-id.model';
import {
AbstractMenuProvider,
PartialMenuSection,
} from './menu-provider.model';
import { MenuRoute } from './menu-route.model';
import { MenuState } from './menu-state.model';
/**
* Service that is responsible for adding and removing the menu sections created by the providers, both for
* persistent and non-persistent menu sections
*/
@Injectable({
providedIn: 'root',
})
export class MenuProviderService {
constructor(
@Inject(MENU_PROVIDER) @Optional() protected providers: ReadonlyArray<AbstractMenuProvider>,
protected menuService: MenuService,
protected router: Router,
) {
}
/**
* 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<boolean> {
return this.menuService.getMenu(id).pipe(
find((menu: MenuState) => hasValue(menu)),
map(() => true),
);
}
/**
* Listen for route changes and resolve the route dependent menu sections on route change
*/
listenForRouteChanges(isServerRendering) {
this.router.events.pipe(
filter(event => event instanceof ResolveEnd),
switchMap((event: ResolveEnd) => {
const currentRoute = this.getCurrentRoute(event.state.root);
return this.resolveRouteMenus(currentRoute, event.state, isServerRendering);
}),
).subscribe();
}
/**
* Get the full current route
*/
private getCurrentRoute(route: ActivatedRouteSnapshot): ActivatedRouteSnapshot {
while (route.firstChild) {
route = route.firstChild;
}
return route;
}
/**
* Initialise the persistent menu sections
*/
public initPersistentMenus(isServerRendering) {
combineLatest([
...this.providers
.map((provider) => {
return provider;
})
.filter(provider => !(isServerRendering && provider.renderBrowserOnly))
.filter(provider => provider.shouldPersistOnRouteChange)
.map(provider => provider.getSections()
.pipe(
map((sections) => {
return { provider: provider, sections: sections };
}),
),
)])
.pipe(
switchMap((providerWithSections: { provider: AbstractMenuProvider, sections: PartialMenuSection[] }[]) => {
const waitForMenus = providerWithSections.map((providerWithSection: {
provider: AbstractMenuProvider,
sections: PartialMenuSection[]
}, sectionIndex) => {
providerWithSection.sections.forEach((section, index) => {
this.addSection(providerWithSection.provider, section, index);
});
return this.waitForMenu$(providerWithSection.provider.menuID);
});
return [waitForMenus];
}),
map(done => done.every(Boolean)),
take(1),
).subscribe();
}
/**
* Resolve the non-persistent route based menu sections
* @param route - the current route
* @param state - the current router state
* @param isServerRendering - whether server side rendering is true
*/
public resolveRouteMenus(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
isServerRendering,
): Observable<boolean> {
const currentNonPersistentMenuSections$ = combineLatest([
...Object.values(MenuID).map((menuID) => {
return this.menuService.getNonPersistentMenuSections(menuID).pipe(
take(1),
map((sections) => {
return { menuId: menuID, sections: sections };
}));
})]);
const routeDependentMenuSections$ = combineLatest([
...this.providers
.filter(provider => !(isServerRendering && provider.renderBrowserOnly))
.filter(provider => {
let shouldUpdate = false;
if (!provider.shouldPersistOnRouteChange && isNotEmpty(provider.activePaths)) {
provider.activePaths.forEach((path: MenuRoute) => {
if (route.data.menuRoute === path) {
shouldUpdate = true;
}
});
} else if (!provider.shouldPersistOnRouteChange) {
shouldUpdate = true;
}
return shouldUpdate;
})
.map(provider => provider.getSections(route, state)
.pipe(
map((sections) => {
return { provider: provider, sections: sections };
}),
),
),
]);
return combineLatest([
currentNonPersistentMenuSections$,
routeDependentMenuSections$,
]).pipe(
switchMap(([currentMenusWithSections, providerWithSections]) => {
this.removeNonPersistentSections(currentMenusWithSections);
const waitForMenus = providerWithSections.map((providerWithSection: {
provider: AbstractMenuProvider,
sections: PartialMenuSection[]
}) => {
providerWithSection.sections.forEach((section, index) => {
this.addSection(providerWithSection.provider, section, index);
});
return this.waitForMenu$(providerWithSection.provider.menuID);
});
return [waitForMenus];
}),
map(done => done.every(Boolean)),
);
}
/**
* Add the provided section combined with information from the menu provider to the menus
* @param provider - The provider of the section which will be used to provide extra data to the section
* @param section - The partial section to be added to the menus
*/
private addSection(provider: AbstractMenuProvider, section: PartialMenuSection, index: number) {
this.menuService.addSection(provider.menuID, {
...section,
id: section.id ?? `${provider.menuProviderId}_${index}`,
parentID: section.parentID ?? provider.parentID,
index: provider.index,
active: section.active ?? false,
shouldPersistOnRouteChange: section.shouldPersistOnRouteChange ?? provider.shouldPersistOnRouteChange,
alwaysRenderExpandable: section.alwaysRenderExpandable ?? provider.alwaysRenderExpandable,
});
}
/**
* Remove all non-persistent sections from the menus
* @param menuWithSections - The menu with its sections to be removed
*/
private removeNonPersistentSections(menuWithSections) {
menuWithSections.forEach((menu) => {
menu.sections.forEach((section) => {
this.menuService.removeSection(menu.menuId, section.id);
});
});
}
}

View File

@@ -0,0 +1,8 @@
/**
* The menu route IDs that can be used for route resolvers
*/
export enum MenuRoute {
COMMUNITY_PAGE = 'community-page',
COLLECTION_PAGE = 'collection-page',
ITEM_PAGE = 'item-page',
}

View File

@@ -1,15 +1,72 @@
import { MenuItemModel } from './menu-item/models/menu-item.model';
import { AltmetricMenuItemModel } from './menu-item/models/altmetric.model';
import { ExternalLinkMenuItemModel } from './menu-item/models/external-link.model';
import { LinkMenuItemModel } from './menu-item/models/link.model';
import { OnClickMenuItemModel } from './menu-item/models/onclick.model';
import { SearchMenuItemModel } from './menu-item/models/search.model';
import { TextMenuItemModel } from './menu-item/models/text.model';
export type MenuItemModels =
LinkMenuItemModel
| AltmetricMenuItemModel
| ExternalLinkMenuItemModel
| OnClickMenuItemModel
| SearchMenuItemModel
| TextMenuItemModel;
export interface MenuSection {
/**
* The identifier for this section
*/
id: string;
/**
* Represents the state of a single menu section in the store
* Accessibility handle that can be used to find a specific menu in the html
*/
accessibilityHandle?: string;
/**
* Whether this section should be visible.
*/
export class MenuSection {
id: string;
parentID?: string;
visible: boolean;
active: boolean;
model: MenuItemModel;
/**
*
*/
model: MenuItemModels;
/**
* The identifier of this section's parent section (optional).
*/
parentID?: string;
/**
* The index of this section in its menu.
*/
index?: number;
/**
* Whether this section is currently active.
* Newly created sections are inactive until toggled.
*/
active?: boolean;
/**
* Whether this section is independent of the route (default: true).
* This value should be set explicitly for route-dependent sections.
*/
shouldPersistOnRouteChange?: boolean;
/**
* An optional icon for this section.
* Must correspond to a FontAwesome icon class save for the `.fa-` prefix.
* Note that not all menus may render icons.
*/
icon?: string;
shouldPersistOnRouteChange? = false;
/**
* When true, the current section will be assumed to be a parent section with children
* This section will not be rendered when it has no visible children
*/
alwaysRenderExpandable?: boolean;
}

View File

@@ -34,7 +34,8 @@ import { MenuSection } from '../menu-section.model';
template: '',
standalone: true,
})
export class MenuSectionComponent implements OnInit, OnDestroy {
export abstract class AbstractMenuSectionComponent implements OnInit, OnDestroy {
protected abstract section: MenuSection;
/**
* {@link BehaviorSubject} containing the current state to whether this section is currently active
@@ -56,7 +57,7 @@ export class MenuSectionComponent implements OnInit, OnDestroy {
*/
sectionMap$: BehaviorSubject<Map<string, {
injector: Injector,
component: GenericConstructor<MenuSectionComponent>
component: GenericConstructor<AbstractMenuSectionComponent>
}>> = new BehaviorSubject(new Map());
/**
@@ -65,7 +66,10 @@ export class MenuSectionComponent implements OnInit, OnDestroy {
*/
subs: Subscription[] = [];
constructor(public section: MenuSection, protected menuService: MenuService, protected injector: Injector) {
protected constructor(
protected menuService: MenuService,
protected injector: Injector,
) {
}
/**

View File

@@ -1,5 +1,7 @@
import {
ChangeDetectionStrategy,
Component,
Inject,
Injector,
NO_ERRORS_SCHEMA,
} from '@angular/core';
@@ -16,11 +18,26 @@ import { MenuServiceStub } from '../../testing/menu-service.stub';
import { MenuService } from '../menu.service';
import { LinkMenuItemComponent } from '../menu-item/link-menu-item.component';
import { MenuSection } from '../menu-section.model';
import { MenuSectionComponent } from './menu-section.component';
import { AbstractMenuSectionComponent } from './abstract-menu-section.component';
@Component({
selector: 'ds-some-menu-section',
template: '',
standalone: true,
})
class SomeMenuSectionComponent extends AbstractMenuSectionComponent {
constructor(
@Inject('sectionDataProvider') protected section: MenuSection,
protected menuService: MenuService,
protected injector: Injector,
) {
super(menuService, injector);
}
}
describe('MenuSectionComponent', () => {
let comp: MenuSectionComponent;
let fixture: ComponentFixture<MenuSectionComponent>;
let comp: AbstractMenuSectionComponent;
let fixture: ComponentFixture<AbstractMenuSectionComponent>;
let menuService: MenuService;
let dummySection;
@@ -31,20 +48,20 @@ describe('MenuSectionComponent', () => {
active: false,
} as any;
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), NoopAnimationsModule, MenuSectionComponent],
imports: [TranslateModule.forRoot(), NoopAnimationsModule, SomeMenuSectionComponent, AbstractMenuSectionComponent],
providers: [
{ provide: Injector, useValue: {} },
{ provide: MenuService, useClass: MenuServiceStub },
{ provide: MenuSection, useValue: dummySection },
{ provide: 'sectionDataProvider', useValue: dummySection },
],
schemas: [NO_ERRORS_SCHEMA],
}).overrideComponent(MenuSectionComponent, {
}).overrideComponent(SomeMenuSectionComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default },
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(MenuSectionComponent);
fixture = TestBed.createComponent(SomeMenuSectionComponent);
comp = fixture.componentInstance;
menuService = (comp as any).menuService;
spyOn(comp as any, 'getMenuItemComponent').and.returnValue(LinkMenuItemComponent);

View File

@@ -1,5 +1,5 @@
import { MenuID } from './menu-id.model';
import { MenuSectionIndex } from './menu-section-Index.model';
import { MenuSectionIndex } from './menu-section-index.model';
import { MenuSections } from './menu-sections.model';
/**

View File

@@ -43,6 +43,7 @@ import { MenuComponent } from './menu.component';
import { MenuService } from './menu.service';
import { MenuID } from './menu-id.model';
import { LinkMenuItemModel } from './menu-item/models/link.model';
import { TextMenuItemModel } from './menu-item/models/text.model';
import { MenuItemType } from './menu-item-type.model';
import { rendersSectionForMenu } from './menu-section.decorator';
import { MenuSection } from './menu-section.model';
@@ -53,6 +54,7 @@ const mockMenuID = 'mock-menuID' as MenuID;
// eslint-disable-next-line @angular-eslint/component-selector
selector: '',
template: '',
standalone: true,
})
@rendersSectionForMenu(mockMenuID, true)
class TestExpandableMenuComponent {
@@ -62,6 +64,7 @@ class TestExpandableMenuComponent {
// eslint-disable-next-line @angular-eslint/component-selector
selector: '',
template: '',
standalone: true,
})
@rendersSectionForMenu(mockMenuID, false)
class TestMenuComponent {
@@ -72,6 +75,17 @@ describe('MenuComponent', () => {
let fixture: ComponentFixture<MenuComponent>;
let menuService: MenuService;
let store: MockStore;
let router: any;
const menuSection: MenuSection = {
id: 'browse',
model: {
type: MenuItemType.TEXT,
text: 'menu.section.browse_global',
} as TextMenuItemModel,
icon: 'globe',
visible: true,
};
const mockStatisticSection = { 'id': 'statistics_site', 'active': true, 'visible': true, 'index': 2, 'type': 'statistics', 'model': { 'type': 1, 'text': 'menu.section.statistics', 'link': 'statistics' } };
@@ -111,6 +125,7 @@ describe('MenuComponent', () => {
id: 'section1',
active: false,
visible: true,
alwaysRenderExpandable: false,
model: {
type: MenuItemType.LINK,
text: 'test',
@@ -130,7 +145,7 @@ describe('MenuComponent', () => {
});
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), NoopAnimationsModule, RouterTestingModule, MenuComponent, StoreModule.forRoot(authReducer, storeModuleConfig)],
imports: [TranslateModule.forRoot(), NoopAnimationsModule, RouterTestingModule, MenuComponent, StoreModule.forRoot(authReducer, storeModuleConfig), TestExpandableMenuComponent, TestMenuComponent],
providers: [
Injector,
{ provide: ThemeService, useValue: getMockThemeService() },
@@ -138,8 +153,6 @@ describe('MenuComponent', () => {
provideMockStore({ initialState }),
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: ActivatedRoute, useValue: routeStub },
TestExpandableMenuComponent,
TestMenuComponent,
],
schemas: [NO_ERRORS_SCHEMA],
}).overrideComponent(MenuComponent, {
@@ -153,7 +166,7 @@ describe('MenuComponent', () => {
comp.menuID = mockMenuID;
menuService = TestBed.inject(MenuService);
store = TestBed.inject(Store) as MockStore<AppState>;
spyOn(comp as any, 'getSectionDataInjector').and.returnValue(MenuSection);
spyOn(comp as any, 'getSectionDataInjector').and.returnValue(menuSection);
fixture.detectChanges();
});
@@ -178,6 +191,7 @@ describe('MenuComponent', () => {
id: 'section1',
active: false,
visible: true,
alwaysRenderExpandable: false,
model: {
type: MenuItemType.LINK,
text: 'test',
@@ -189,6 +203,7 @@ describe('MenuComponent', () => {
parentID: 'section1',
active: false,
visible: true,
alwaysRenderExpandable: false,
model: {
type: MenuItemType.LINK,
text: 'test',
@@ -258,35 +273,4 @@ describe('MenuComponent', () => {
expect(menuService.collapseMenuPreview).toHaveBeenCalledWith(comp.menuID);
}));
});
describe('when unauthorized statistics', () => {
beforeEach(() => {
(authorizationService as any).isAuthorized.and.returnValue(observableOf(false));
fixture.detectChanges();
});
it('should return observable of empty object', done => {
comp.getAuthorizedStatistics(mockStatisticSection).subscribe((res) => {
expect(res).toEqual({});
done();
});
});
});
describe('get authorized statistics', () => {
beforeEach(() => {
(authorizationService as any).isAuthorized.and.returnValue(observableOf(true));
fixture.detectChanges();
});
it('should return observable of statistics section menu', done => {
comp.getAuthorizedStatistics(mockStatisticSection).subscribe((res) => {
expect(res).toEqual(mockStatisticSection);
done();
});
});
});
});

View File

@@ -9,18 +9,15 @@ import { ActivatedRoute } from '@angular/router';
import {
BehaviorSubject,
Observable,
of as observableOf,
Subscription,
} from 'rxjs';
import {
distinctUntilChanged,
map,
mergeMap,
switchMap,
} from 'rxjs/operators';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { GenericConstructor } from '../../core/shared/generic-constructor';
import {
hasValue,
@@ -31,7 +28,7 @@ import { MenuService } from './menu.service';
import { MenuID } from './menu-id.model';
import { getComponentForMenu } from './menu-section.decorator';
import { MenuSection } from './menu-section.model';
import { MenuSectionComponent } from './menu-section/menu-section.component';
import { AbstractMenuSectionComponent } from './menu-section/abstract-menu-section.component';
/**
* A basic implementation of a MenuComponent
@@ -72,7 +69,7 @@ export class MenuComponent implements OnInit, OnDestroy {
*/
sectionMap$: BehaviorSubject<Map<string, {
injector: Injector,
component: GenericConstructor<MenuSectionComponent>
component: GenericConstructor<AbstractMenuSectionComponent>
}>> = new BehaviorSubject(new Map());
/**
@@ -93,8 +90,12 @@ export class MenuComponent implements OnInit, OnDestroy {
private activatedRouteLastChild: ActivatedRoute;
constructor(protected menuService: MenuService, protected injector: Injector, public authorizationService: AuthorizationDataService,
public route: ActivatedRoute, protected themeService: ThemeService,
constructor(
protected menuService: MenuService,
protected injector: Injector,
public authorizationService: AuthorizationDataService,
public route: ActivatedRoute,
protected themeService: ThemeService,
) {
}
@@ -113,15 +114,9 @@ export class MenuComponent implements OnInit, OnDestroy {
// if you return an array from a switchMap it will emit each element as a separate event.
// So this switchMap is equivalent to a subscribe with a forEach inside
switchMap((sections: MenuSection[]) => sections),
mergeMap((section: MenuSection) => {
if (section.id.includes('statistics')) {
return this.getAuthorizedStatistics(section);
}
return observableOf(section);
}),
isNotEmptyOperator(),
switchMap((section: MenuSection) => this.getSectionComponent(section).pipe(
map((component: GenericConstructor<MenuSectionComponent>) => ({ section, component })),
map((component: GenericConstructor<AbstractMenuSectionComponent>) => ({ section, component })),
)),
distinctUntilChanged((x, y) => x.section.id === y.section.id && x.component.prototype === y.component.prototype),
).subscribe(({ section, component }) => {
@@ -146,32 +141,6 @@ export class MenuComponent implements OnInit, OnDestroy {
}
}
/**
* Get section of statistics after checking authorization
*/
getAuthorizedStatistics(section) {
return this.activatedRouteLastChild.data.pipe(
switchMap((data) => {
return this.authorizationService.isAuthorized(FeatureID.CanViewUsageStatistics, this.getObjectUrl(data)).pipe(
map((canViewUsageStatistics: boolean) => {
if (!canViewUsageStatistics) {
return {};
} else {
return section;
}
}));
}),
);
}
/**
* Get statistics route dso data
*/
getObjectUrl(data) {
const object = data.site ? data.site : data.dso?.payload;
return object?._links?.self?.href;
}
/**
* Collapse this menu when it's currently expanded, expand it when its currently collapsed
* @param {Event} event The user event that triggered this method
@@ -233,12 +202,12 @@ export class MenuComponent implements OnInit, OnDestroy {
/**
* Retrieve the component for a given MenuSection object
* @param {MenuSection} section The given MenuSection
* @returns {Observable<GenericConstructor<MenuSectionComponent>>} Emits the constructor of the Component that should be used to render this object
* @returns {Observable<GenericConstructor<AbstractMenuSectionComponent>>} Emits the constructor of the Component that should be used to render this object
*/
private getSectionComponent(section: MenuSection): Observable<GenericConstructor<MenuSectionComponent>> {
private getSectionComponent(section: MenuSection): Observable<GenericConstructor<AbstractMenuSectionComponent>> {
return this.menuService.hasSubSections(this.menuID, section.id).pipe(
map((expandable: boolean) => {
return getComponentForMenu(this.menuID, expandable, this.themeService.getThemeName());
return getComponentForMenu(this.menuID, expandable || section.alwaysRenderExpandable, this.themeService.getThemeName());
},
),
);

View File

@@ -21,7 +21,7 @@ import {
} from './menu.actions';
import { menusReducer } from './menu.reducer';
import { MenuID } from './menu-id.model';
import { MenuSectionIndex } from './menu-section-Index.model';
import { MenuSectionIndex } from './menu-section-index.model';
let visibleSection1;
let dummyState;

View File

@@ -14,7 +14,7 @@ import {
} from './menu.actions';
import { MenuID } from './menu-id.model';
import { MenuSection } from './menu-section.model';
import { MenuSectionIndex } from './menu-section-Index.model';
import { MenuSectionIndex } from './menu-section-index.model';
import { MenuSections } from './menu-sections.model';
import { MenuState } from './menu-state.model';
import { MenusState } from './menus-state.model';

View File

@@ -44,7 +44,6 @@ describe('MenuService', () => {
let topSections;
let initialState;
let routeDataMenuSection: MenuSection;
let routeDataMenuSectionResolved: MenuSection;
let routeDataMenuChildSection: MenuSection;
let routeDataMenuOverwrittenChildSection: MenuSection;
let toBeRemovedMenuSection: MenuSection;
@@ -112,16 +111,6 @@ describe('MenuService', () => {
link: 'path/:linkparam',
} as LinkMenuItemModel,
};
routeDataMenuSectionResolved = {
id: 'mockSection_id_param_resolved',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.mockSection',
link: 'path/link_param_resolved',
} as LinkMenuItemModel,
};
routeDataMenuChildSection = {
id: 'mockChildSection',
parentID: 'mockSection',
@@ -144,16 +133,6 @@ describe('MenuService', () => {
link: '',
} as LinkMenuItemModel,
};
toBeRemovedMenuSection = {
id: 'toBeRemovedSection',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.toBeRemovedSection',
link: '',
} as LinkMenuItemModel,
};
alreadyPresentMenuSection = {
id: 'alreadyPresentSection',
active: false,
@@ -566,70 +545,4 @@ describe('MenuService', () => {
expect(store.dispatch).toHaveBeenCalledWith(new DeactivateMenuSectionAction(MenuID.ADMIN, 'fakeID'));
});
});
describe('buildRouteMenuSections', () => {
it('should add and remove menu sections depending on the current route and overwrite menu sections when they have the same ID with the child route version', () => {
spyOn(service, 'addSection');
spyOn(service, 'removeSection');
spyOn(service, 'getNonPersistentMenuSections').and.returnValue(observableOf([toBeRemovedMenuSection, alreadyPresentMenuSection]));
service.buildRouteMenuSections(MenuID.PUBLIC);
expect(service.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, routeDataMenuSectionResolved);
expect(service.addSection).not.toHaveBeenCalledWith(MenuID.PUBLIC, routeDataMenuChildSection);
expect(service.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, routeDataMenuOverwrittenChildSection);
expect(service.addSection).not.toHaveBeenCalledWith(MenuID.PUBLIC, alreadyPresentMenuSection);
expect(service.removeSection).toHaveBeenCalledWith(MenuID.PUBLIC, toBeRemovedMenuSection.id);
});
});
describe('listenForRouteChanges', () => {
it('should build the menu sections on NavigationEnd event', () => {
spyOn(service, 'buildRouteMenuSections');
service.listenForRouteChanges();
expect(service.buildRouteMenuSections).toHaveBeenCalledWith(MenuID.ADMIN);
expect(service.buildRouteMenuSections).toHaveBeenCalledWith(MenuID.PUBLIC);
});
});
describe(`resolveSubstitutions`, () => {
let linkPrefix;
let link;
let uuid;
beforeEach(() => {
linkPrefix = 'statistics_collection_';
link = `${linkPrefix}:id`;
uuid = 'f7cc3ca4-3c2c-464d-8af8-add9f84f711c';
});
it(`shouldn't do anything when there are no params`, () => {
let result = (service as any).resolveSubstitutions(link, undefined);
expect(result).toEqual(link);
result = (service as any).resolveSubstitutions(link, null);
expect(result).toEqual(link);
result = (service as any).resolveSubstitutions(link, {});
expect(result).toEqual(link);
});
it(`should replace link params that are also route params`, () => {
const result = (service as any).resolveSubstitutions(link,{ 'id': uuid });
expect(result).toEqual(linkPrefix + uuid);
});
it(`should not replace link params that aren't route params`, () => {
const result = (service as any).resolveSubstitutions(link,{ 'something': 'else' });
expect(result).toEqual(link);
});
it(`should gracefully deal with routes that contain the name of the route param`, () => {
const selfReferentialParam = `:id:something`;
const result = (service as any).resolveSubstitutions(link,{ 'id': selfReferentialParam });
expect(result).toEqual(linkPrefix + selfReferentialParam);
});
});
});

View File

@@ -1,7 +1,6 @@
import { Injectable } from '@angular/core';
import {
ActivatedRoute,
NavigationEnd,
Router,
} from '@angular/router';
import {
@@ -16,10 +15,8 @@ import {
} from 'rxjs';
import {
distinctUntilChanged,
filter,
map,
switchMap,
take,
} from 'rxjs/operators';
import {
@@ -31,7 +28,6 @@ import {
hasNoValue,
hasValue,
hasValueOperator,
isEmpty,
isNotEmpty,
} from '../empty.util';
import {
@@ -377,95 +373,4 @@ export class MenuService {
return this.getMenuSection(menuID, id).pipe(map((section) => section.visible));
}
listenForRouteChanges(): void {
this.router.events.pipe(
filter(event => event instanceof NavigationEnd),
).subscribe(() => {
Object.values(MenuID).forEach((menuID) => {
this.buildRouteMenuSections(menuID);
});
});
}
/**
* Build menu sections depending on the current route
* - Adds sections found in the current route data that aren't active yet
* - Removes sections that are active, but not present in the current route data
* @param menuID The menu to add/remove sections to/from
*/
buildRouteMenuSections(menuID: MenuID) {
this.getNonPersistentMenuSections(menuID).pipe(
map((sections) => sections.map((section) => section.id)),
take(1),
).subscribe((shouldNotPersistIDs: string[]) => {
const resolvedSections = this.resolveRouteMenuSections(this.route.root, menuID);
resolvedSections.forEach((section) => {
const index = shouldNotPersistIDs.indexOf(section.id);
if (index > -1) {
shouldNotPersistIDs.splice(index, 1);
} else {
this.addSection(menuID, section);
}
});
shouldNotPersistIDs.forEach((id) => {
this.removeSection(menuID, id);
});
});
}
/**
* Resolve menu sections defined in the current route data (including parent routes)
* @param route The route to resolve data for
* @param menuID The menu to resolve data for
*/
resolveRouteMenuSections(route: ActivatedRoute, menuID: MenuID): MenuSection[] {
const data = route.snapshot.data;
const params = route.snapshot.params;
const last: boolean = hasNoValue(route.firstChild);
if (hasValue(data) && hasValue(data.menu) && hasValue(data.menu[menuID])) {
let menuSections: MenuSection[] | MenuSection = data.menu[menuID];
menuSections = this.resolveSubstitutions(menuSections, params);
if (!Array.isArray(menuSections)) {
menuSections = [menuSections];
}
if (!last) {
const childMenuSections = this.resolveRouteMenuSections(route.firstChild, menuID);
return [...menuSections.filter(menu => !(childMenuSections).map(childMenu => childMenu.id).includes(menu.id)), ...childMenuSections];
} else {
return [...menuSections];
}
}
return !last ? this.resolveRouteMenuSections(route.firstChild, menuID) : [];
}
protected resolveSubstitutions(object, params) {
let resolved;
if (isEmpty(params)) {
resolved = object;
} else if (typeof object === 'string') {
resolved = object;
Object.entries(params).forEach(([key, value]: [string, string]) =>
resolved = resolved.replaceAll(`:${key}`, value),
);
} else if (Array.isArray(object)) {
resolved = [];
object.forEach((entry, index) => {
resolved[index] = this.resolveSubstitutions(object[index], params);
});
} else if (typeof object === 'object') {
resolved = {};
Object.keys(object).forEach((key) => {
resolved[key] = this.resolveSubstitutions(object[key], params);
});
} else {
resolved = object;
}
return resolved;
}
}

View File

@@ -0,0 +1,112 @@
import { buildMenuStructure } from './menu.structure';
import { MenuID } from './menu-id.model';
import { MenuRoute } from './menu-route.model';
import { AccessControlMenuProvider } from './providers/access-control.menu';
import { AdminSearchMenuProvider } from './providers/admin-search.menu';
import { BrowseMenuProvider } from './providers/browse.menu';
import { SubscribeMenuProvider } from './providers/comcol-subscribe.menu';
import { CommunityListMenuProvider } from './providers/community-list.menu';
import { CurationMenuProvider } from './providers/curation.menu';
import { DSpaceObjectEditMenuProvider } from './providers/dso-edit.menu';
import { DsoOptionMenuProvider } from './providers/dso-option.menu';
import { EditMenuProvider } from './providers/edit.menu';
import { ExportMenuProvider } from './providers/export.menu';
import { HealthMenuProvider } from './providers/health.menu';
import { ImportMenuProvider } from './providers/import.menu';
import { ClaimMenuProvider } from './providers/item-claim.menu';
import { OrcidMenuProvider } from './providers/item-orcid.menu';
import { VersioningMenuProvider } from './providers/item-versioning.menu';
import { NewMenuProvider } from './providers/new.menu';
import { ProcessesMenuProvider } from './providers/processes.menu';
import { RegistriesMenuProvider } from './providers/registries.menu';
import { StatisticsMenuProvider } from './providers/statistics.menu';
import { SystemWideAlertMenuProvider } from './providers/system-wide-alert.menu';
import { WorkflowMenuProvider } from './providers/workflow.menu';
describe('buildMenuStructure', () => {
const providerStructure =
{
[MenuID.PUBLIC]: [
CommunityListMenuProvider,
BrowseMenuProvider,
StatisticsMenuProvider,
],
[MenuID.ADMIN]: [
NewMenuProvider,
EditMenuProvider,
ImportMenuProvider,
ExportMenuProvider,
AccessControlMenuProvider,
AdminSearchMenuProvider,
RegistriesMenuProvider,
CurationMenuProvider,
ProcessesMenuProvider,
WorkflowMenuProvider,
HealthMenuProvider,
SystemWideAlertMenuProvider,
],
[MenuID.DSO_EDIT]: [
DsoOptionMenuProvider.withSubs([
SubscribeMenuProvider.onRoute(
MenuRoute.COMMUNITY_PAGE,
MenuRoute.COLLECTION_PAGE,
),
DSpaceObjectEditMenuProvider.onRoute(
MenuRoute.COMMUNITY_PAGE,
MenuRoute.COLLECTION_PAGE,
MenuRoute.ITEM_PAGE,
),
VersioningMenuProvider.onRoute(
MenuRoute.ITEM_PAGE,
),
OrcidMenuProvider.onRoute(
MenuRoute.ITEM_PAGE,
),
ClaimMenuProvider.onRoute(
MenuRoute.ITEM_PAGE,
MenuRoute.COLLECTION_PAGE,
),
]),
],
};
const orderedProviderTypeList =
[
CommunityListMenuProvider,
BrowseMenuProvider,
StatisticsMenuProvider,
NewMenuProvider,
EditMenuProvider,
ImportMenuProvider,
ExportMenuProvider,
AccessControlMenuProvider,
AdminSearchMenuProvider,
RegistriesMenuProvider,
CurationMenuProvider,
ProcessesMenuProvider,
WorkflowMenuProvider,
HealthMenuProvider,
SystemWideAlertMenuProvider,
SubscribeMenuProvider,
DSpaceObjectEditMenuProvider,
VersioningMenuProvider,
OrcidMenuProvider,
ClaimMenuProvider,
DsoOptionMenuProvider,
];
it('should have a double amount of objects after the processing', () => {
const result = buildMenuStructure(providerStructure);
expect(result.length).toEqual(orderedProviderTypeList.length * 2);
});
it('should return a list with a resolved provider and provider type for each provider in the provided structure', () => {
const result = buildMenuStructure(providerStructure);
orderedProviderTypeList.forEach((provider, index) => {
expect((result[index * 2] as any).deps).toEqual([provider]);
expect(result[index * 2 + 1]).toEqual(provider);
});
});
});

View File

@@ -0,0 +1,118 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import {
InjectionToken,
Provider,
Type,
} from '@angular/core';
import {
hasValue,
isNotEmpty,
} from '../empty.util';
import { MenuID } from './menu-id.model';
import {
AbstractMenuProvider,
MenuProviderTypeWithOptions,
} from './menu-provider.model';
import { MenuRoute } from './menu-route.model';
export const MENU_PROVIDER = new InjectionToken<AbstractMenuProvider>('MENU_PROVIDER');
type MenuStructure = {
[key in MenuID]: (Type<AbstractMenuProvider> | MenuProviderTypeWithOptions)[];
};
/**
* Builds the menu structure by converting the provider types into resolved providers
* @param structure - The app menus structure
*/
export function buildMenuStructure(structure: MenuStructure): Provider[] {
const providers: Provider[] = [
];
Object.entries(structure).forEach(([menuID, providerTypes]) => {
for (const [index, providerType] of providerTypes.entries()) {
processProviderType(providers, menuID, providerType, index);
}
});
return providers;
}
/**
* Process a single provider type and add it to the list of providers
* When the provider type contains paths, the paths will be added to resolved provider
* When the provider type has sub provider, the sub providers will be processed with the current provider type as parent
* @param providers - The list of providers
* @param providerType - The provider to resolve and add to the list
* @param menuID - The ID of the menu to which the provider belongs
* @param index - The index of the provider
* @param parentID - The ID of the parent provider if relevant
* @param hasSubProviders - Whether this provider has sub providers
*/
function processProviderType(providers: Provider[], menuID: string, providerType: Type<AbstractMenuProvider> | MenuProviderTypeWithOptions, index: number, parentID?: string, hasSubProviders?: boolean) {
if (providerType.hasOwnProperty('providerType') && providerType.hasOwnProperty('childProviderTypes')) {
const providerPart = (providerType as any).providerType;
const childProviderTypes = (providerType as any).childProviderTypes;
childProviderTypes.forEach((childProviderType, childIndex: number) => {
processProviderType(providers, menuID, childProviderType, childIndex, `${menuID}_${index}_0`, hasSubProviders);
});
processProviderType(providers, menuID, providerPart, index, parentID, true);
} else if (providerType.hasOwnProperty('providerType') && providerType.hasOwnProperty('paths')) {
const providerPart = (providerType as any).providerType;
const paths = (providerType as any).paths;
addProviderToList(providers, providerPart, menuID, index, parentID, hasSubProviders, paths);
} else {
addProviderToList(providers, providerType as Type<AbstractMenuProvider>, menuID, index, parentID, hasSubProviders);
}
}
/**
* Resolves and adds a provider to a list of providers
* @param providers - The list of providers
* @param providerType - The provider to resolve and add to the list
* @param menuID - The ID of the menu to which the provider belongs
* @param index - The index of the provider
* @param parentID - The ID of the parent provider if relevant
* @param hasSubProviders - Whether this provider has sub providers
* @param paths - The paths this provider should be active on if relevant
*/
function addProviderToList(providers: Provider[], providerType: Type<AbstractMenuProvider>, menuID: string, index: number, parentID?: string, hasSubProviders?: boolean, paths?: MenuRoute[]) {
const resolvedProvider = {
provide: MENU_PROVIDER,
multi: true,
useFactory(provider: AbstractMenuProvider): AbstractMenuProvider {
provider.menuID = menuID as MenuID;
provider.index = provider.index ?? index;
if (hasValue(parentID)) {
provider.menuProviderId = provider.menuProviderId ?? `${parentID}_${index}`;
let providerParentID = provider.parentID;
if (hasValue(providerParentID)) {
providerParentID = `${providerParentID}_0`;
}
provider.parentID = providerParentID ?? parentID;
} else {
provider.menuProviderId = provider.menuProviderId ?? `${menuID}_${index}`;
}
if (isNotEmpty(paths)) {
provider.activePaths = paths;
provider.shouldPersistOnRouteChange = false;
}
if (hasSubProviders) {
provider.shouldPersistOnRouteChange = false;
}
return provider;
},
deps: [providerType],
};
providers.push(resolvedProvider);
providers.push(providerType);
}

View File

@@ -0,0 +1,97 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { TestBed } from '@angular/core/testing';
import { of as observableOf } from 'rxjs';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { ScriptDataService } from '../../../core/data/processes/script-data.service';
import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub';
import { ScriptServiceStub } from '../../testing/script-service.stub';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { AccessControlMenuProvider } from './access-control.menu';
describe('AccessControlMenuProvider', () => {
const expectedTopSection: PartialMenuSection = {
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.access_control',
},
icon: 'key',
};
const expectedSubSections: PartialMenuSection[] = [
{
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_people',
link: '/access-control/epeople',
},
},
{
visible: false,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_groups',
link: '/access-control/groups',
},
},
{
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_bulk',
link: '/access-control/bulk-access',
},
},
];
let provider: AccessControlMenuProvider;
let authorizationServiceStub = new AuthorizationDataServiceStub();
beforeEach(() => {
spyOn(authorizationServiceStub, 'isAuthorized').and.callFake((id: FeatureID) => {
if (id === FeatureID.CanManageGroups) {
return observableOf(false);
} else {
return observableOf(true);
}
});
TestBed.configureTestingModule({
providers: [
AccessControlMenuProvider,
{ provide: AuthorizationDataService, useValue: authorizationServiceStub },
{ provide: ScriptDataService, useClass: ScriptServiceStub },
],
});
provider = TestBed.inject(AccessControlMenuProvider);
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
it('getTopSection should return expected menu section', (done) => {
provider.getTopSection().subscribe((section) => {
expect(section).toEqual(expectedTopSection);
done();
});
});
it('getSubSections should return expected menu sections', (done) => {
provider.getSubSections().subscribe((sections) => {
expect(sections).toEqual(expectedSubSections);
done();
});
});
});

View File

@@ -0,0 +1,85 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Injectable } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import {
combineLatest as observableCombineLatest,
map,
Observable,
of as observableOf,
} from 'rxjs';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { ScriptDataService } from '../../../core/data/processes/script-data.service';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { AbstractExpandableMenuProvider } from './helper-providers/expandable-menu-provider';
/**
* Menu provider to create the "Access Control" menu (and subsections) in the admin sidebar
*/
@Injectable()
export class AccessControlMenuProvider extends AbstractExpandableMenuProvider {
constructor(
protected authorizationService: AuthorizationDataService,
protected scriptDataService: ScriptDataService,
protected modalService: NgbModal,
) {
super();
}
public getTopSection(): Observable<PartialMenuSection> {
return observableOf({
model: {
type: MenuItemType.TEXT,
text: 'menu.section.access_control',
},
icon: 'key',
visible: true,
});
}
public getSubSections(): Observable<PartialMenuSection[]> {
return observableCombineLatest([
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
this.authorizationService.isAuthorized(FeatureID.CanManageGroups),
]).pipe(
map(([isSiteAdmin, canManageGroups]: [boolean, boolean]) => {
return [
{
visible: isSiteAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_people',
link: '/access-control/epeople',
},
},
{
visible: canManageGroups,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_groups',
link: '/access-control/groups',
},
},
{
visible: isSiteAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_bulk',
link: '/access-control/bulk-access',
},
},
];
}),
);
}
}

View File

@@ -0,0 +1,60 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { TestBed } from '@angular/core/testing';
import { of as observableOf } from 'rxjs';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { AdminSearchMenuProvider } from './admin-search.menu';
describe('AdminSearchMenuProvider', () => {
const expectedSections: PartialMenuSection[] = [
{
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.admin_search',
link: '/admin/search',
},
icon: 'search',
},
];
let provider: AdminSearchMenuProvider;
let authorizationServiceStub = new AuthorizationDataServiceStub();
beforeEach(() => {
spyOn(authorizationServiceStub, 'isAuthorized').and.returnValue(
observableOf(true),
);
TestBed.configureTestingModule({
providers: [
AdminSearchMenuProvider,
{ provide: AuthorizationDataService, useValue: authorizationServiceStub },
],
});
provider = TestBed.inject(AdminSearchMenuProvider);
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
it('getSections should return expected menu sections', (done) => {
provider.getSections().subscribe((sections) => {
expect(sections).toEqual(expectedSections);
done();
});
});
});

View File

@@ -0,0 +1,51 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Injectable } from '@angular/core';
import {
map,
Observable,
} from 'rxjs';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { MenuItemType } from '../menu-item-type.model';
import {
AbstractMenuProvider,
PartialMenuSection,
} from '../menu-provider.model';
/**
* Menu provider to create the "Admin Search" menu in the admin sidebar
*/
@Injectable()
export class AdminSearchMenuProvider extends AbstractMenuProvider {
constructor(
protected authorizationService: AuthorizationDataService,
) {
super();
}
public getSections(): Observable<PartialMenuSection[]> {
return this.authorizationService.isAuthorized(FeatureID.AdministratorOf).pipe(
map((isSiteAdmin) => {
return [
{
visible: isSiteAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.admin_search',
link: '/admin/search',
},
icon: 'search',
},
];
}),
);
}
}

View File

@@ -0,0 +1,112 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { TestBed } from '@angular/core/testing';
import {
APP_CONFIG,
AppConfig,
} from '../../../../config/app-config.interface';
import { BrowseService } from '../../../core/browse/browse.service';
import { ObjectCacheService } from '../../../core/cache/object-cache.service';
import { BrowseDefinition } from '../../../core/shared/browse-definition.model';
import { getMockObjectCacheService } from '../../mocks/object-cache.service.mock';
import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils';
import { BrowseServiceStub } from '../../testing/browse-service.stub';
import { createPaginatedList } from '../../testing/utils.test';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { BrowseMenuProvider } from './browse.menu';
describe('BrowseMenuProvider', () => {
const expectedTopSection: PartialMenuSection = {
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.browse_global',
},
icon: 'globe',
};
const expectedSubSections: (enableMapLink: boolean) => PartialMenuSection[] = (enableMapLink) => [
{
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.browse_global_by_author',
link: '/browse/author',
},
},
{
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.browse_global_by_subject',
link: '/browse/subject',
},
},
{
visible: enableMapLink,
model: {
type: MenuItemType.LINK,
text: `menu.section.browse_global_geospatial_map`,
link: `/browse/map`,
},
},
];
let provider: BrowseMenuProvider;
let browseServiceStub = BrowseServiceStub;
beforeEach(() => {
spyOn(browseServiceStub, 'getBrowseDefinitions').and.returnValue(
createSuccessfulRemoteDataObject$(createPaginatedList([
{ id: 'author' } as BrowseDefinition,
{ id: 'subject' } as BrowseDefinition,
])),
);
TestBed.configureTestingModule({
providers: [
BrowseMenuProvider,
{ provide: BrowseService, useValue: browseServiceStub },
{ provide: ObjectCacheService, useValue: getMockObjectCacheService() },
{ provide: APP_CONFIG, useValue: { geospatialMapViewer: { enableBrowseMap: true } } as AppConfig },
],
});
provider = TestBed.inject(BrowseMenuProvider);
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
it('getTopSection should return expected menu section', (done) => {
provider.getTopSection().subscribe((section) => {
expect(section).toEqual(expectedTopSection);
done();
});
});
it('getSubSections should return expected menu sections', (done) => {
provider.getSubSections().subscribe((sections) => {
expect(sections).toEqual(expectedSubSections(true));
done();
});
});
it('getSubSections should return expected menu sections', (done) => {
TestBed.inject(APP_CONFIG).geospatialMapViewer.enableBrowseMap = false;
provider.getSubSections().subscribe((sections) => {
expect(sections).toEqual(expectedSubSections(false));
done();
});
});
});

View File

@@ -0,0 +1,88 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import {
Inject,
Injectable,
} from '@angular/core';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { map } from 'rxjs/operators';
import {
APP_CONFIG,
AppConfig,
} from '../../../../config/app-config.interface';
import { BrowseService } from '../../../core/browse/browse.service';
import { PaginatedList } from '../../../core/data/paginated-list.model';
import { RemoteData } from '../../../core/data/remote-data';
import { BrowseDefinition } from '../../../core/shared/browse-definition.model';
import { getFirstSucceededRemoteData } from '../../../core/shared/operators';
import { TextMenuItemModel } from '../menu-item/models/text.model';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { AbstractExpandableMenuProvider } from './helper-providers/expandable-menu-provider';
/**
* Menu provider to create the "All of DSpace" browse menu sections in the public navbar
*/
@Injectable()
export class BrowseMenuProvider extends AbstractExpandableMenuProvider {
constructor(
@Inject(APP_CONFIG) protected appConfig: AppConfig,
protected browseService: BrowseService,
) {
super();
}
getTopSection(): Observable<PartialMenuSection> {
return observableOf(
{
model: {
type: MenuItemType.TEXT,
text: 'menu.section.browse_global',
} as TextMenuItemModel,
icon: 'globe',
visible: true,
},
);
}
/**
* Retrieves subsections by fetching the browse definitions from the backend and mapping them to partial menu sections.
*/
getSubSections(): Observable<PartialMenuSection[]> {
return this.browseService.getBrowseDefinitions().pipe(
getFirstSucceededRemoteData(),
map((rd: RemoteData<PaginatedList<BrowseDefinition>>) => {
return [
...rd.payload.page.map((browseDef) => {
return {
visible: true,
model: {
type: MenuItemType.LINK,
text: `menu.section.browse_global_by_${browseDef.id}`,
link: `/browse/${browseDef.id}`,
},
};
}),
{
visible: this.appConfig.geospatialMapViewer.enableBrowseMap,
model: {
type: MenuItemType.LINK,
text: `menu.section.browse_global_geospatial_map`,
link: `/browse/map`,
},
},
];
}),
);
}
}

View File

@@ -0,0 +1,83 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { TestBed } from '@angular/core/testing';
import { of as observableOf } from 'rxjs';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub';
import { LinkMenuItemModel } from '../menu-item/models/link.model';
import { TextMenuItemModel } from '../menu-item/models/text.model';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { CoarNotifyMenuProvider } from './coar-notify.menu';
describe('CoarNotifyMenuProvider', () => {
const expectedTopSection: PartialMenuSection = {
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.coar_notify',
} as TextMenuItemModel,
icon: 'inbox',
};
const expectedSubSections: PartialMenuSection[] = [
{
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.notify_dashboard',
link: '/admin/notify-dashboard',
} as LinkMenuItemModel,
},
{
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.services',
link: '/admin/ldn/services',
} as LinkMenuItemModel,
},
];
let provider: CoarNotifyMenuProvider;
let authorizationServiceStub = new AuthorizationDataServiceStub();
beforeEach(() => {
spyOn(authorizationServiceStub, 'isAuthorized').and.returnValue(
observableOf(true),
);
TestBed.configureTestingModule({
providers: [
CoarNotifyMenuProvider,
{ provide: AuthorizationDataService, useValue: authorizationServiceStub },
],
});
provider = TestBed.inject(CoarNotifyMenuProvider);
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
it('getTopSection should return expected menu section', (done) => {
provider.getTopSection().subscribe((section) => {
expect(section).toEqual(expectedTopSection);
done();
});
});
it('getSubSections should return expected menu sections', (done) => {
provider.getSubSections().subscribe((sections) => {
expect(sections).toEqual(expectedSubSections);
done();
});
});
});

View File

@@ -0,0 +1,77 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Injectable } from '@angular/core';
import {
combineLatest as observableCombineLatest,
Observable,
} from 'rxjs';
import { map } from 'rxjs/operators';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { LinkMenuItemModel } from '../menu-item/models/link.model';
import { TextMenuItemModel } from '../menu-item/models/text.model';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { AbstractExpandableMenuProvider } from './helper-providers/expandable-menu-provider';
/**
* Menu provider to create the "COAR Notify" menu (and subsections) in the admin sidebar
*/
@Injectable()
export class CoarNotifyMenuProvider extends AbstractExpandableMenuProvider {
constructor(
protected authorizationService: AuthorizationDataService,
) {
super();
}
getSubSections(): Observable<PartialMenuSection[]> {
return observableCombineLatest([
this.authorizationService.isAuthorized(FeatureID.CoarNotifyEnabled),
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
]).pipe(
map(([isCoarNotifyEnabled, isSiteAdmin]: [boolean, boolean]) => {
return [{
visible: isSiteAdmin && isCoarNotifyEnabled,
model: {
type: MenuItemType.LINK,
text: 'menu.section.notify_dashboard',
link: '/admin/notify-dashboard',
} as LinkMenuItemModel,
},
/* LDN Services */
{
visible: isSiteAdmin && isCoarNotifyEnabled,
model: {
type: MenuItemType.LINK,
text: 'menu.section.services',
link: '/admin/ldn/services',
} as LinkMenuItemModel,
}];
}));
}
getTopSection(): Observable<PartialMenuSection> {
return observableCombineLatest([
this.authorizationService.isAuthorized(FeatureID.CoarNotifyEnabled),
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
]).pipe(
map(([isCoarNotifyEnabled, isSiteAdmin]: [boolean, boolean]) => {
return {
visible: isSiteAdmin && isCoarNotifyEnabled,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.coar_notify',
} as TextMenuItemModel,
icon: 'inbox',
};
}));
}
}

View File

@@ -0,0 +1,65 @@
import { TestBed } from '@angular/core/testing';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { of as observableOf } from 'rxjs';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { Collection } from '../../../core/shared/collection.model';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { SubscribeMenuProvider } from './comcol-subscribe.menu';
describe('SubscribeMenuProvider', () => {
const expectedSections: PartialMenuSection[] = [
{
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'subscriptions.tooltip',
function: jasmine.any(Function) as any,
},
icon: 'bell',
},
];
let provider: SubscribeMenuProvider;
const dso: Collection = Object.assign(new Collection(), { _links: { self: { href: 'self-link' } } });
let authorizationService;
let modalService;
beforeEach(() => {
authorizationService = jasmine.createSpyObj('authorizationService', {
'isAuthorized': observableOf(true),
});
modalService = jasmine.createSpyObj('modalService', ['open']);
TestBed.configureTestingModule({
providers: [
SubscribeMenuProvider,
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: NgbModal, useValue: modalService },
],
});
provider = TestBed.inject(SubscribeMenuProvider);
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
describe('getSectionsForContext', () => {
it('should return the expected sections', (done) => {
provider.getSectionsForContext(dso).subscribe((sections) => {
expect(sections).toEqual(expectedSections);
done();
});
});
});
});

View File

@@ -0,0 +1,59 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Injectable } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import {
combineLatest,
Observable,
} from 'rxjs';
import { map } from 'rxjs/operators';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { SubscriptionModalComponent } from '../../subscriptions/subscription-modal/subscription-modal.component';
import { OnClickMenuItemModel } from '../menu-item/models/onclick.model';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { DSpaceObjectPageMenuProvider } from './helper-providers/dso.menu';
/**
* Menu provider to create the "Subscribe" option in the DSO edit menu
*/
@Injectable()
export class SubscribeMenuProvider extends DSpaceObjectPageMenuProvider {
constructor(
protected authorizationService: AuthorizationDataService,
protected modalService: NgbModal,
) {
super();
}
public getSectionsForContext(dso: DSpaceObject): Observable<PartialMenuSection[]> {
return combineLatest([
this.authorizationService.isAuthorized(FeatureID.CanSubscribe, dso.self),
]).pipe(
map(([canSubscribe]) => {
return [
{
visible: canSubscribe,
model: {
type: MenuItemType.ONCLICK,
text: 'subscriptions.tooltip',
function: () => {
const modalRef = this.modalService.open(SubscriptionModalComponent);
modalRef.componentInstance.dso = dso;
},
} as OnClickMenuItemModel,
icon: 'bell',
},
] as PartialMenuSection[];
}),
);
}
}

View File

@@ -0,0 +1,49 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { TestBed } from '@angular/core/testing';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { CommunityListMenuProvider } from './community-list.menu';
describe('CommunityListMenuProvider', () => {
const expectedSections: PartialMenuSection[] = [
{
visible: true,
model: {
type: MenuItemType.LINK,
text: `menu.section.browse_global_communities_and_collections`,
link: `/community-list`,
},
icon: 'diagram-project',
},
];
let provider: CommunityListMenuProvider;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
CommunityListMenuProvider,
],
});
provider = TestBed.inject(CommunityListMenuProvider);
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
it('getSections should return expected menu sections', (done) => {
provider.getSections().subscribe((sections) => {
expect(sections).toEqual(expectedSections);
done();
});
});
});

View File

@@ -0,0 +1,39 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Injectable } from '@angular/core';
import {
Observable,
of,
} from 'rxjs';
import { MenuItemType } from '../menu-item-type.model';
import {
AbstractMenuProvider,
PartialMenuSection,
} from '../menu-provider.model';
/**
* Menu provider to create the "Communities & Collections" menu section in the public navbar
*/
@Injectable()
export class CommunityListMenuProvider extends AbstractMenuProvider {
public getSections(): Observable<PartialMenuSection[]> {
return of([
{
visible: true,
model: {
type: MenuItemType.LINK,
text: `menu.section.browse_global_communities_and_collections`,
link: `/community-list`,
},
icon: 'diagram-project',
},
] as PartialMenuSection[]);
}
}

View File

@@ -0,0 +1,100 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { TestBed } from '@angular/core/testing';
import { of as observableOf } from 'rxjs';
import { ConfigurationDataService } from '../../../core/data/configuration-data.service';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { ConfigurationProperty } from '../../../core/shared/configuration-property.model';
import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils';
import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub';
import { ConfigurationDataServiceStub } from '../../testing/configuration-data.service.stub';
import { LinkMenuItemModel } from '../menu-item/models/link.model';
import { TextMenuItemModel } from '../menu-item/models/text.model';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { CreateReportMenuProvider } from './create-report.menu';
describe('CreateReportMenuProvider', () => {
const expectedTopSection: PartialMenuSection = {
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.reports',
} as TextMenuItemModel,
icon: 'file-alt',
};
const expectedSubSections: PartialMenuSection[] = [
{
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.reports.collections',
link: '/admin/reports/collections',
} as LinkMenuItemModel,
icon: 'user-check',
},
{
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.reports.queries',
link: '/admin/reports/queries',
} as LinkMenuItemModel,
icon: 'user-check',
},
];
let provider: CreateReportMenuProvider;
let authorizationServiceStub = new AuthorizationDataServiceStub();
let configurationDataService = new ConfigurationDataServiceStub();
beforeEach(() => {
spyOn(authorizationServiceStub, 'isAuthorized').and.callFake((id: FeatureID) => {
if (id === FeatureID.AdministratorOf) {
return observableOf(true);
} else {
return observableOf(false);
}
});
spyOn(configurationDataService, 'findByPropertyName').and.callFake((property: string) => {
return createSuccessfulRemoteDataObject$(Object.assign({}, new ConfigurationProperty(), { values: ['true'] }));
});
TestBed.configureTestingModule({
providers: [
CreateReportMenuProvider,
{ provide: AuthorizationDataService, useValue: authorizationServiceStub },
{ provide: ConfigurationDataService, useValue: configurationDataService },
],
});
provider = TestBed.inject(CreateReportMenuProvider);
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
it('getTopSection should return expected menu section', (done) => {
provider.getTopSection().subscribe((section) => {
expect(section).toEqual(expectedTopSection);
done();
});
});
it('getSubSections should return expected menu sections', (done) => {
provider.getSubSections().subscribe((sections) => {
expect(sections).toEqual(expectedSubSections);
done();
});
});
});

View File

@@ -0,0 +1,93 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Injectable } from '@angular/core';
import {
combineLatest as observableCombineLatest,
Observable,
} from 'rxjs';
import { map } from 'rxjs/operators';
import { ConfigurationDataService } from '../../../core/data/configuration-data.service';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { RemoteData } from '../../../core/data/remote-data';
import { ConfigurationProperty } from '../../../core/shared/configuration-property.model';
import { getFirstCompletedRemoteData } from '../../../core/shared/operators';
import { LinkMenuItemModel } from '../menu-item/models/link.model';
import { TextMenuItemModel } from '../menu-item/models/text.model';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { AbstractExpandableMenuProvider } from './helper-providers/expandable-menu-provider';
/**
* Menu provider to create the "Reports" menu (and subsections) in the admin sidebar
*/
@Injectable()
export class CreateReportMenuProvider extends AbstractExpandableMenuProvider {
constructor(
protected authorizationService: AuthorizationDataService,
protected configurationDataService: ConfigurationDataService,
) {
super();
}
getSubSections(): Observable<PartialMenuSection[]> {
return observableCombineLatest([
this.configurationDataService.findByPropertyName('contentreport.enable').pipe(
getFirstCompletedRemoteData(),
map((res: RemoteData<ConfigurationProperty>) => res.hasSucceeded && res.payload && res.payload.values[0] === 'true'),
),
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
]).pipe(
map(([reportEnabled, isSiteAdmin]: [boolean, boolean]) => {
return [
/* Collections Report */
{
visible: isSiteAdmin && reportEnabled,
model: {
type: MenuItemType.LINK,
text: 'menu.section.reports.collections',
link: '/admin/reports/collections',
} as LinkMenuItemModel,
icon: 'user-check',
},
/* Queries Report */
{
visible: isSiteAdmin && reportEnabled,
model: {
type: MenuItemType.LINK,
text: 'menu.section.reports.queries',
link: '/admin/reports/queries',
} as LinkMenuItemModel,
icon: 'user-check',
},
];
}));
}
getTopSection(): Observable<PartialMenuSection> {
return observableCombineLatest([
this.configurationDataService.findByPropertyName('contentreport.enable').pipe(
getFirstCompletedRemoteData(),
map((res: RemoteData<ConfigurationProperty>) => res.hasSucceeded && res.payload && res.payload.values[0] === 'true'),
),
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
]).pipe(
map(([reportEnabled, isSiteAdmin]: [boolean, boolean]) => {
return {
visible: isSiteAdmin && reportEnabled,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.reports',
} as TextMenuItemModel,
icon: 'file-alt',
};
}));
}
}

View File

@@ -0,0 +1,58 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { TestBed } from '@angular/core/testing';
import { of as observableOf } from 'rxjs';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { CurationMenuProvider } from './curation.menu';
describe('CurationMenuProvider', () => {
const expectedSections: PartialMenuSection[] = [
{
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.curation_task',
link: 'admin/curation-tasks',
},
icon: 'filter',
},
];
let provider: CurationMenuProvider;
let authorizationServiceStub = new AuthorizationDataServiceStub();
beforeEach(() => {
spyOn(authorizationServiceStub, 'isAuthorized').and.returnValue(
observableOf(true),
);
TestBed.configureTestingModule({
providers: [
CurationMenuProvider,
{ provide: AuthorizationDataService, useValue: authorizationServiceStub },
],
});
provider = TestBed.inject(CurationMenuProvider);
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
it('getSections should return expected menu sections', (done) => {
provider.getSections().subscribe((sections) => {
expect(sections).toEqual(expectedSections);
done();
});
});
});

View File

@@ -0,0 +1,55 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Injectable } from '@angular/core';
import {
combineLatest,
map,
Observable,
} from 'rxjs';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { LinkMenuItemModel } from '../menu-item/models/link.model';
import { MenuItemType } from '../menu-item-type.model';
import {
AbstractMenuProvider,
PartialMenuSection,
} from '../menu-provider.model';
/**
* Menu provider to create the "Curation Task" menu in the admin sidebar
*/
@Injectable()
export class CurationMenuProvider extends AbstractMenuProvider {
constructor(
protected authorizationService: AuthorizationDataService,
) {
super();
}
public getSections(): Observable<PartialMenuSection[]> {
return combineLatest([
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
]).pipe(
map(([isSiteAdmin]) => {
return [
{
visible: isSiteAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.curation_task',
link: 'admin/curation-tasks',
} as LinkMenuItemModel,
icon: 'filter',
},
] as PartialMenuSection[];
}),
);
}
}

View File

@@ -0,0 +1,66 @@
import { TestBed } from '@angular/core/testing';
import { of as observableOf } from 'rxjs';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { Collection } from '../../../core/shared/collection.model';
import { COLLECTION } from '../../../core/shared/collection.resource-type';
import { URLCombiner } from '../../../core/url-combiner/url-combiner';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { DSpaceObjectEditMenuProvider } from './dso-edit.menu';
describe('DSpaceObjectEditMenuProvider', () => {
const expectedSections: PartialMenuSection[] = [
{
visible: true,
model: {
type: MenuItemType.LINK,
text: 'collection.page.edit',
link: new URLCombiner('/collections/test-uuid', 'edit', 'metadata').toString(),
},
icon: 'pencil-alt',
},
];
let provider: DSpaceObjectEditMenuProvider;
const dso: Collection = Object.assign(new Collection(), {
type: COLLECTION.value,
uuid: 'test-uuid',
_links: { self: { href: 'self-link' } },
});
let authorizationService;
beforeEach(() => {
authorizationService = jasmine.createSpyObj('authorizationService', {
'isAuthorized': observableOf(true),
});
TestBed.configureTestingModule({
providers: [
DSpaceObjectEditMenuProvider,
{ provide: AuthorizationDataService, useValue: authorizationService },
],
});
provider = TestBed.inject(DSpaceObjectEditMenuProvider);
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
describe('getSectionsForContext', () => {
it('should return the expected sections', (done) => {
provider.getSectionsForContext(dso).subscribe((sections) => {
expect(sections).toEqual(expectedSections);
done();
});
});
});
});

View File

@@ -0,0 +1,55 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Injectable } from '@angular/core';
import {
combineLatest,
Observable,
} from 'rxjs';
import { map } from 'rxjs/operators';
import { getDSORoute } from '../../../app-routing-paths';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { URLCombiner } from '../../../core/url-combiner/url-combiner';
import { LinkMenuItemModel } from '../menu-item/models/link.model';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { DSpaceObjectPageMenuProvider } from './helper-providers/dso.menu';
/**
* Menu provider to create the "Edit" option in the DSO edit menu
*/
@Injectable()
export class DSpaceObjectEditMenuProvider extends DSpaceObjectPageMenuProvider {
constructor(
protected authorizationDataService: AuthorizationDataService,
) {
super();
}
public getSectionsForContext(dso: DSpaceObject): Observable<PartialMenuSection[]> {
return combineLatest([
this.authorizationDataService.isAuthorized(FeatureID.CanEditMetadata, dso.self),
]).pipe(
map(([canEditItem]) => {
return [
{
visible: canEditItem,
model: {
type: MenuItemType.LINK,
text: this.getDsoType(dso) + '.page.edit',
link: new URLCombiner(getDSORoute(dso), 'edit', 'metadata').toString(),
} as LinkMenuItemModel,
icon: 'pencil-alt',
},
] as PartialMenuSection[];
}),
);
}
}

View File

@@ -0,0 +1,54 @@
import { TestBed } from '@angular/core/testing';
import { Collection } from '../../../core/shared/collection.model';
import { COLLECTION } from '../../../core/shared/collection.resource-type';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { DsoOptionMenuProvider } from './dso-option.menu';
describe('DsoOptionMenuProvider', () => {
const expectedSections: PartialMenuSection[] = [
{
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'collection.page.options',
},
icon: 'ellipsis-vertical',
},
];
let provider: DsoOptionMenuProvider;
const dso: Collection = Object.assign(new Collection(), {
type: COLLECTION.value,
uuid: 'test-uuid',
_links: { self: { href: 'self-link' } },
});
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
DsoOptionMenuProvider,
],
});
provider = TestBed.inject(DsoOptionMenuProvider);
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
describe('getSectionsForContext', () => {
it('should return the expected sections', (done) => {
provider.getSectionsForContext(dso).subscribe((sections) => {
expect(sections).toEqual(expectedSections);
done();
});
});
});
});

View File

@@ -0,0 +1,41 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Injectable } from '@angular/core';
import {
Observable,
of,
} from 'rxjs';
import { DSpaceObject } from 'src/app/core/shared/dspace-object.model';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { DSpaceObjectPageMenuProvider } from './helper-providers/dso.menu';
/**
* Menu provider to create the "Options" menu wrapper on the DSO pages.
* This section will be rendered as a button on the DSO pages if sub providers have been added.
*/
@Injectable()
export class DsoOptionMenuProvider extends DSpaceObjectPageMenuProvider {
alwaysRenderExpandable = true;
getSectionsForContext(dso: DSpaceObject): Observable<PartialMenuSection[]> {
return of([
{
visible: true,
model: {
type: MenuItemType.TEXT,
text: this.getDsoType(dso) + '.page.options',
},
icon: 'ellipsis-vertical',
},
] as PartialMenuSection[]);
}
}

View File

@@ -0,0 +1,96 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { TestBed } from '@angular/core/testing';
import { of as observableOf } from 'rxjs';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { EditMenuProvider } from './edit.menu';
describe('EditMenuProvider', () => {
const expectedTopSection: PartialMenuSection = {
accessibilityHandle: 'edit',
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.edit',
},
icon: 'pencil',
};
const expectedSubSections: PartialMenuSection[] = [
{
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_community',
function: jasmine.any(Function) as any,
},
},
{
visible: false,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_collection',
function: jasmine.any(Function) as any,
},
},
{
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_item',
function: jasmine.any(Function) as any,
},
},
];
let provider: EditMenuProvider;
let authorizationServiceStub = new AuthorizationDataServiceStub();
beforeEach(() => {
spyOn(authorizationServiceStub, 'isAuthorized').and.callFake((id: FeatureID) => {
if (id === FeatureID.IsCollectionAdmin) {
return observableOf(false);
} else {
return observableOf(true);
}
});
TestBed.configureTestingModule({
providers: [
EditMenuProvider,
{ provide: AuthorizationDataService, useValue: authorizationServiceStub },
],
});
provider = TestBed.inject(EditMenuProvider);
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
it('getTopSection should return expected menu section', (done) => {
provider.getTopSection().subscribe((section) => {
expect(section).toEqual(expectedTopSection);
done();
});
});
it('getSubSections should return expected menu sections', (done) => {
provider.getSubSections().subscribe((sections) => {
expect(sections).toEqual(expectedSubSections);
done();
});
});
});

View File

@@ -0,0 +1,95 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Injectable } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import {
combineLatest,
map,
Observable,
of as observableOf,
} from 'rxjs';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { ThemedEditCollectionSelectorComponent } from '../../dso-selector/modal-wrappers/edit-collection-selector/themed-edit-collection-selector.component';
import { ThemedEditCommunitySelectorComponent } from '../../dso-selector/modal-wrappers/edit-community-selector/themed-edit-community-selector.component';
import { ThemedEditItemSelectorComponent } from '../../dso-selector/modal-wrappers/edit-item-selector/themed-edit-item-selector.component';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { AbstractExpandableMenuProvider } from './helper-providers/expandable-menu-provider';
/**
* Menu provider to create the "Edit" menu (and subsections) in the admin sidebar
*/
@Injectable()
export class EditMenuProvider extends AbstractExpandableMenuProvider {
constructor(
protected authorizationService: AuthorizationDataService,
protected modalService: NgbModal,
) {
super();
}
public getTopSection(): Observable<PartialMenuSection> {
return observableOf(
{
accessibilityHandle: 'edit',
model: {
type: MenuItemType.TEXT,
text: 'menu.section.edit',
},
icon: 'pencil',
visible: true,
},
);
}
public getSubSections(): Observable<PartialMenuSection[]> {
return combineLatest([
this.authorizationService.isAuthorized(FeatureID.IsCollectionAdmin),
this.authorizationService.isAuthorized(FeatureID.IsCommunityAdmin),
this.authorizationService.isAuthorized(FeatureID.CanEditItem),
]).pipe(
map(([isCollectionAdmin, isCommunityAdmin, canEditItem]: [boolean, boolean, boolean]) => {
return [
{
visible: isCommunityAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_community',
function: () => {
this.modalService.open(ThemedEditCommunitySelectorComponent);
},
},
},
{
visible: isCollectionAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_collection',
function: () => {
this.modalService.open(ThemedEditCollectionSelectorComponent);
},
},
},
{
visible: canEditItem,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_item',
function: () => {
this.modalService.open(ThemedEditItemSelectorComponent);
},
},
},
];
}),
);
}
}

View File

@@ -0,0 +1,85 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { TestBed } from '@angular/core/testing';
import { of as observableOf } from 'rxjs';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { ScriptDataService } from '../../../core/data/processes/script-data.service';
import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub';
import { ScriptServiceStub } from '../../testing/script-service.stub';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { ExportMenuProvider } from './export.menu';
describe('ExportMenuProvider', () => {
const expectedTopSection: PartialMenuSection = {
accessibilityHandle: 'export',
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.export',
},
icon: 'file-export',
};
const expectedSubSections: PartialMenuSection[] = [
{
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.export_metadata',
function: jasmine.any(Function) as any,
},
},
{
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.export_batch',
function: jasmine.any(Function) as any,
},
},
];
let provider: ExportMenuProvider;
let authorizationServiceStub = new AuthorizationDataServiceStub();
beforeEach(() => {
spyOn(authorizationServiceStub, 'isAuthorized').and.returnValue(
observableOf(true),
);
TestBed.configureTestingModule({
providers: [
ExportMenuProvider,
{ provide: AuthorizationDataService, useValue: authorizationServiceStub },
{ provide: ScriptDataService, useClass: ScriptServiceStub },
],
});
provider = TestBed.inject(ExportMenuProvider);
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
it('getTopSection should return expected menu section', (done) => {
provider.getTopSection().subscribe((section) => {
expect(section).toEqual(expectedTopSection);
done();
});
});
it('getSubSections should return expected menu sections', (done) => {
provider.getSubSections().subscribe((sections) => {
expect(sections).toEqual(expectedSubSections);
done();
});
});
});

View File

@@ -0,0 +1,88 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Injectable } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import {
combineLatest as observableCombineLatest,
map,
Observable,
of as observableOf,
} from 'rxjs';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import {
METADATA_EXPORT_SCRIPT_NAME,
ScriptDataService,
} from '../../../core/data/processes/script-data.service';
import { ExportBatchSelectorComponent } from '../../dso-selector/modal-wrappers/export-batch-selector/export-batch-selector.component';
import { ExportMetadataSelectorComponent } from '../../dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { AbstractExpandableMenuProvider } from './helper-providers/expandable-menu-provider';
/**
* Menu provider to create the "Export" menu (and subsections) in the admin sidebar
*/
@Injectable()
export class ExportMenuProvider extends AbstractExpandableMenuProvider {
constructor(
protected authorizationService: AuthorizationDataService,
protected scriptDataService: ScriptDataService,
protected modalService: NgbModal,
) {
super();
}
public getTopSection(): Observable<PartialMenuSection> {
return observableOf(
{
accessibilityHandle: 'export',
model: {
type: MenuItemType.TEXT,
text: 'menu.section.export',
},
icon: 'file-export',
visible: true,
},
);
}
public getSubSections(): Observable<PartialMenuSection[]> {
return observableCombineLatest([
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
this.scriptDataService.scriptWithNameExistsAndCanExecute(METADATA_EXPORT_SCRIPT_NAME),
]).pipe(
map(([authorized, metadataExportScriptExists]: [boolean, boolean]) => {
return [
{
visible: authorized && metadataExportScriptExists,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.export_metadata',
function: () => {
this.modalService.open(ExportMetadataSelectorComponent);
},
},
},
{
visible: authorized && metadataExportScriptExists,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.export_batch',
function: () => {
this.modalService.open(ExportBatchSelectorComponent);
},
},
},
];
}),
);
}
}

View File

@@ -0,0 +1,58 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { TestBed } from '@angular/core/testing';
import { of as observableOf } from 'rxjs';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { HealthMenuProvider } from './health.menu';
describe('HealthMenuProvider', () => {
const expectedSections: PartialMenuSection[] = [
{
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.health',
link: '/health',
},
icon: 'heartbeat',
},
];
let provider: HealthMenuProvider;
let authorizationServiceStub = new AuthorizationDataServiceStub();
beforeEach(() => {
spyOn(authorizationServiceStub, 'isAuthorized').and.returnValue(
observableOf(true),
);
TestBed.configureTestingModule({
providers: [
HealthMenuProvider,
{ provide: AuthorizationDataService, useValue: authorizationServiceStub },
],
});
provider = TestBed.inject(HealthMenuProvider);
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
it('getSections should return expected menu sections', (done) => {
provider.getSections().subscribe((sections) => {
expect(sections).toEqual(expectedSections);
done();
});
});
});

View File

@@ -0,0 +1,54 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Injectable } from '@angular/core';
import {
combineLatest,
map,
Observable,
} from 'rxjs';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { MenuItemType } from '../menu-item-type.model';
import {
AbstractMenuProvider,
PartialMenuSection,
} from '../menu-provider.model';
/**
* Menu provider to create the "Health" menu in the admin sidebar
*/
@Injectable()
export class HealthMenuProvider extends AbstractMenuProvider {
constructor(
protected authorizationService: AuthorizationDataService,
) {
super();
}
public getSections(): Observable<PartialMenuSection[]> {
return combineLatest([
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
]).pipe(
map(([isSiteAdmin]) => {
return [
{
visible: isSiteAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.health',
link: '/health',
},
icon: 'heartbeat',
},
] as PartialMenuSection[];
}),
);
}
}

View File

@@ -0,0 +1,139 @@
import { TestBed } from '@angular/core/testing';
import { Collection } from '../../../../core/shared/collection.model';
import { COLLECTION } from '../../../../core/shared/collection.resource-type';
import { Item } from '../../../../core/shared/item.model';
import { ITEM } from '../../../../core/shared/item.resource-type';
import { createSuccessfulRemoteDataObject } from '../../../remote-data.utils';
import { DSpaceObjectPageMenuProvider } from './dso.menu';
describe('DSpaceObjectPageMenuProvider', () => {
let provider: DSpaceObjectPageMenuProvider;
const item: Item = Object.assign(new Item(), {
uuid: 'test-item-uuid',
type: ITEM.value,
_links: { self: { href: 'self-link' } },
metadata: {
'dc.title': [{
'value': 'Untyped Item',
}],
},
});
const item2: Item = Object.assign(new Item(), {
uuid: 'test-item2-uuid',
type: ITEM.value,
_links: { self: { href: 'self-link' } },
metadata: {
'dc.title': [{
'value': 'Untyped Item 2',
}],
},
});
const person: Item = Object.assign(new Item(), {
uuid: 'test-uuid',
type: ITEM.value,
_links: { self: { href: 'self-link' } },
metadata: {
'dc.title': [{
'value': 'Person Entity',
}],
'dspace.entity.type': [{
'value': 'Person',
}],
},
});
const collection: Collection = Object.assign(new Collection(), {
uuid: 'test-collection-uuid',
type: COLLECTION.value,
_links: { self: { href: 'self-link' } },
metadata: {
'dc.title': [{
'value': 'Collection',
}],
},
});
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
DSpaceObjectPageMenuProvider,
],
});
provider = TestBed.inject(DSpaceObjectPageMenuProvider);
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
describe('getRouteContext', () => {
it('should get the dso from the route', (done) => {
const route = { data: { dso: createSuccessfulRemoteDataObject(item) } } as any;
provider.getRouteContext(route, undefined).subscribe((dso) => {
expect(dso).toEqual(item);
done();
});
});
it('return the first parent DSO when no DSO is present on the current route', (done) => {
const route = {
data: {},
parent: {
data: {},
parent: {
data: { dso: createSuccessfulRemoteDataObject(item) },
parent: { data: { dso: createSuccessfulRemoteDataObject(item2) } },
},
},
} as any;
provider.getRouteContext(route, undefined).subscribe((dso) => {
expect(dso).toEqual(item);
done();
});
});
it('should return undefined when no dso is found in the route', (done) => {
const route = { data: {}, parent: { data: {}, parent: { data: {}, parent: { data: {} } } } } as any;
provider.getRouteContext(route, undefined).subscribe((dso) => {
expect(dso).toBeUndefined();
done();
});
});
});
describe('getDsoType', () => {
it('should return the object type for an untyped item', () => {
const dsoType = (provider as any).getDsoType(item);
expect(dsoType).toEqual('item');
});
it('should return the entity type for an entity item', () => {
const dsoType = (provider as any).getDsoType(person);
expect(dsoType).toEqual('person');
});
it('should return the object type for a colletion', () => {
const dsoType = (provider as any).getDsoType(collection);
expect(dsoType).toEqual('collection');
});
});
describe('isApplicable', () => {
it('should return true when a dso is provided', () => {
const isApplicable = (provider as any).isApplicable(collection);
expect(isApplicable).toBeTrue();
});
it('should return false when no dso is provided', () => {
const isApplicable = (provider as any).isApplicable(undefined);
expect(isApplicable).toBeFalse();
});
});
});

View File

@@ -0,0 +1,63 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import {
ActivatedRouteSnapshot,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of,
} from 'rxjs';
import { RemoteData } from '../../../../core/data/remote-data';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import {
hasNoValue,
hasValue,
} from '../../../empty.util';
import { AbstractRouteContextMenuProvider } from './route-context.menu';
/**
* Helper provider for DSpace object page based menus
*/
export abstract class DSpaceObjectPageMenuProvider extends AbstractRouteContextMenuProvider<DSpaceObject> {
/**
* Retrieve the dso from the current route data
*/
public getRouteContext(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<DSpaceObject | undefined> {
let dsoRD: RemoteData<DSpaceObject> = route.data.dso;
// Check if one of the parent routes has a DSO
while (hasValue(route.parent) && hasNoValue(dsoRD)) {
route = route.parent;
dsoRD = route.data.dso;
}
if (hasValue(dsoRD) && dsoRD.hasSucceeded && hasValue(dsoRD.payload)) {
return of(dsoRD.payload);
} else {
return of(undefined);
}
}
/**
* Retrieve the dso or entity type for an object to be used in section messages
*/
protected getDsoType(dso: DSpaceObject) {
const renderType = dso.getRenderTypes()[0];
if (typeof renderType === 'string' || renderType instanceof String) {
return renderType.toLowerCase();
} else {
return dso.type.toString().toLowerCase();
}
}
protected isApplicable(dso: DSpaceObject): boolean {
return hasValue(dso);
}
}

View File

@@ -0,0 +1,116 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { TestBed } from '@angular/core/testing';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { MenuID } from '../../menu-id.model';
import { MenuItemType } from '../../menu-item-type.model';
import { PartialMenuSection } from '../../menu-provider.model';
import { AbstractExpandableMenuProvider } from './expandable-menu-provider';
describe('AbstractExpandableMenuProvider', () => {
const topSection: PartialMenuSection = {
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'top.section.test',
},
icon: 'file-import',
};
const subSections: PartialMenuSection[] = [
{
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'sub.section.test.1',
},
},
{
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'sub.section.test.2',
},
},
];
class TestClass extends AbstractExpandableMenuProvider {
getTopSection(): Observable<PartialMenuSection> {
return observableOf(topSection);
}
getSubSections(): Observable<PartialMenuSection[]> {
return observableOf(subSections);
}
}
const expectedSections: PartialMenuSection[] = [
{
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'sub.section.test.1',
},
id: `${MenuID.ADMIN}_1_0_0`,
parentID: `${MenuID.ADMIN}_1_0`,
alwaysRenderExpandable: false,
},
{
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'sub.section.test.2',
},
id: `${MenuID.ADMIN}_1_0_1`,
parentID: `${MenuID.ADMIN}_1_0`,
alwaysRenderExpandable: false,
},
{
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'top.section.test',
},
icon: 'file-import',
id: `${MenuID.ADMIN}_1_0`,
alwaysRenderExpandable: true,
},
];
let provider: AbstractExpandableMenuProvider;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
TestClass,
],
});
provider = TestBed.inject(TestClass);
provider.menuProviderId = `${MenuID.ADMIN}_1`;
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
it('getSections should return a combination of top and sub sections', (done) => {
provider.getSections().subscribe((section) => {
expect(section).toEqual(expectedSections);
done();
});
});
});

View File

@@ -0,0 +1,77 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import {
combineLatest,
Observable,
} from 'rxjs';
import { map } from 'rxjs/operators';
import {
AbstractMenuProvider,
PartialMenuSection,
} from '../../menu-provider.model';
/**
* Helper provider for basic expandable menus
*/
export abstract class AbstractExpandableMenuProvider extends AbstractMenuProvider {
alwaysRenderExpandable = true;
/**
* Get the top section for this expandable menu
*/
abstract getTopSection(): Observable<PartialMenuSection>;
/**
* Get the subsections for this expandable menu
*/
abstract getSubSections(): Observable<PartialMenuSection[]>;
/**
* Retrieve all sections
* This method will combine both the top section and subsections
*/
getSections(): Observable<PartialMenuSection[]> {
return combineLatest([
this.getTopSection(),
this.getSubSections(),
]).pipe(
map((
[partialTopSection, partialSubSections]: [PartialMenuSection, PartialMenuSection[]],
) => {
const parentID = partialTopSection.id ?? this.getAutomatedSectionIdForTopSection();
const subSections = partialSubSections.map((partialSub, index) => {
return {
...partialSub,
id: partialSub.id ?? this.getAutomatedSectionIdForSubsection(index),
parentID: parentID,
alwaysRenderExpandable: false,
};
});
return [
...subSections,
{
...partialTopSection,
id: parentID,
alwaysRenderExpandable: this.alwaysRenderExpandable,
},
];
}),
);
}
protected getAutomatedSectionIdForTopSection(): string {
return this.getAutomatedSectionId(0);
}
protected getAutomatedSectionIdForSubsection(indexOfSubSectionInProvider: number): string {
return `${this.getAutomatedSectionIdForTopSection()}_${indexOfSubSectionInProvider}`;
}
}

View File

@@ -0,0 +1,102 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { TestBed } from '@angular/core/testing';
import {
ActivatedRouteSnapshot,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { CacheableObject } from '../../../../core/cache/cacheable-object.model';
import { MenuID } from '../../menu-id.model';
import { MenuItemType } from '../../menu-item-type.model';
import { PartialMenuSection } from '../../menu-provider.model';
import { AbstractRouteContextMenuProvider } from './route-context.menu';
describe('AbstractRouteContextMenuProvider', () => {
class TestClass extends AbstractRouteContextMenuProvider<CacheableObject> {
getRouteContext(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<CacheableObject> {
return observableOf(object);
}
getSectionsForContext(routeContext: CacheableObject): Observable<PartialMenuSection[]> {
return observableOf(expectedSections);
}
}
const object = Object.assign(new CacheableObject());
const expectedSections: PartialMenuSection[] = [
{
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'sub.section.test.1',
},
id: `${MenuID.ADMIN}_1_0`,
parentID: `${MenuID.ADMIN}_1`,
alwaysRenderExpandable: false,
},
{
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'sub.section.test.2',
},
id: `${MenuID.ADMIN}_1_1`,
parentID: `${MenuID.ADMIN}_1`,
alwaysRenderExpandable: false,
},
{
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'top.section.test',
},
icon: 'file-import',
id: `${MenuID.ADMIN}_1`,
alwaysRenderExpandable: true,
},
];
let provider: AbstractRouteContextMenuProvider<CacheableObject>;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
TestClass,
],
});
provider = TestBed.inject(TestClass);
provider.menuProviderId = `${MenuID.ADMIN}_1`;
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
it('getSections should return the sections based on the retrieved route context and sections for that context', (done) => {
spyOn(provider, 'getRouteContext').and.callThrough();
spyOn(provider, 'getSectionsForContext').and.callThrough();
provider.getSections(undefined, undefined).subscribe((sections) => {
expect(sections).toEqual(expectedSections);
expect(provider.getRouteContext).toHaveBeenCalled();
expect(provider.getSectionsForContext).toHaveBeenCalledWith(object);
done();
});
});
});

View File

@@ -0,0 +1,49 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import {
ActivatedRouteSnapshot,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { switchMap } from 'rxjs/operators';
import {
AbstractMenuProvider,
PartialMenuSection,
} from '../../menu-provider.model';
/**
* Helper provider for route dependent menus
*/
export abstract class AbstractRouteContextMenuProvider<T> extends AbstractMenuProvider {
shouldPersistOnRouteChange = false;
abstract getRouteContext(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<T | undefined>;
abstract getSectionsForContext(routeContext: T): Observable<PartialMenuSection[]>;
getSections(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<PartialMenuSection[]> {
return this.getRouteContext(route, state).pipe(
switchMap((routeContext: T) => {
if (this.isApplicable(routeContext)) {
return this.getSectionsForContext(routeContext);
} else {
return observableOf([]);
}
}),
);
}
protected isApplicable(routeContext: T): boolean {
return true;
}
}

View File

@@ -0,0 +1,84 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { TestBed } from '@angular/core/testing';
import { of as observableOf } from 'rxjs';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { ScriptDataService } from '../../../core/data/processes/script-data.service';
import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub';
import { ScriptServiceStub } from '../../testing/script-service.stub';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { ImportMenuProvider } from './import.menu';
describe('ImportMenuProvider', () => {
const expectedTopSection: PartialMenuSection = {
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.import',
},
icon: 'file-import',
};
const expectedSubSections: PartialMenuSection[] = [
{
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.import_metadata',
link: '/admin/metadata-import',
},
},
{
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.import_batch',
link: '/admin/batch-import',
},
},
];
let provider: ImportMenuProvider;
let authorizationServiceStub = new AuthorizationDataServiceStub();
beforeEach(() => {
spyOn(authorizationServiceStub, 'isAuthorized').and.returnValue(
observableOf(true),
);
TestBed.configureTestingModule({
providers: [
ImportMenuProvider,
{ provide: AuthorizationDataService, useValue: authorizationServiceStub },
{ provide: ScriptDataService, useClass: ScriptServiceStub },
],
});
provider = TestBed.inject(ImportMenuProvider);
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
it('getTopSection should return expected menu section', (done) => {
provider.getTopSection().subscribe((section) => {
expect(section).toEqual(expectedTopSection);
done();
});
});
it('getSubSections should return expected menu sections', (done) => {
provider.getSubSections().subscribe((sections) => {
expect(sections).toEqual(expectedSubSections);
done();
});
});
});

View File

@@ -0,0 +1,81 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Injectable } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import {
combineLatest as observableCombineLatest,
map,
Observable,
of as observableOf,
} from 'rxjs';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import {
METADATA_IMPORT_SCRIPT_NAME,
ScriptDataService,
} from '../../../core/data/processes/script-data.service';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { AbstractExpandableMenuProvider } from './helper-providers/expandable-menu-provider';
/**
* Menu provider to create the "Import" menu (and subsections) in the admin sidebar
*/
@Injectable()
export class ImportMenuProvider extends AbstractExpandableMenuProvider {
constructor(
protected authorizationService: AuthorizationDataService,
protected scriptDataService: ScriptDataService,
protected modalService: NgbModal,
) {
super();
}
public getTopSection(): Observable<PartialMenuSection> {
return observableOf(
{
model: {
type: MenuItemType.TEXT,
text: 'menu.section.import',
},
icon: 'file-import',
visible: true,
},
);
}
public getSubSections(): Observable<PartialMenuSection[]> {
return observableCombineLatest([
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
this.scriptDataService.scriptWithNameExistsAndCanExecute(METADATA_IMPORT_SCRIPT_NAME),
]).pipe(
map(([authorized, metadataImportScriptExists]) => {
return [
{
visible: authorized && metadataImportScriptExists,
model: {
type: MenuItemType.LINK,
text: 'menu.section.import_metadata',
link: '/admin/metadata-import',
},
},
{
visible: authorized && metadataImportScriptExists,
model: {
type: MenuItemType.LINK,
text: 'menu.section.import_batch',
link: '/admin/batch-import',
},
},
];
}),
);
}
}

View File

@@ -0,0 +1,135 @@
import { TestBed } from '@angular/core/testing';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core';
import { of as observableOf } from 'rxjs';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { ResearcherProfileDataService } from '../../../core/profile/researcher-profile-data.service';
import { Item } from '../../../core/shared/item.model';
import { ITEM } from '../../../core/shared/item.resource-type';
import { NotificationsService } from '../../notifications/notifications.service';
import { NotificationsServiceStub } from '../../testing/notifications-service.stub';
import { MenuService } from '../menu.service';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { ClaimMenuProvider } from './item-claim.menu';
describe('ClaimMenuProvider', () => {
const expectedSections: PartialMenuSection[] = [
{
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'item.page.claim.button',
function: jasmine.any(Function) as any,
},
icon: 'hand-paper',
},
];
let provider: ClaimMenuProvider;
const item: Item = Object.assign(new Item(), {
type: ITEM.value,
_links: { self: { href: 'self-link' } },
metadata: {
'dc.title': [{
'value': 'Untyped Item',
}],
},
});
const person: Item = Object.assign(new Item(), {
type: ITEM.value,
_links: { self: { href: 'self-link' } },
metadata: {
'dc.title': [{
'value': 'Person Entity',
}],
'dspace.entity.type': [{
'value': 'Person',
}],
},
});
let authorizationService;
let menuService;
let notificationsService;
let researcherProfileService;
let modalService;
beforeEach(() => {
authorizationService = jasmine.createSpyObj('authorizationService', {
'isAuthorized': observableOf(true),
'invalidateAuthorizationsRequestCache': {},
});
menuService = jasmine.createSpyObj('menuService', ['hideMenuSection']);
notificationsService = new NotificationsServiceStub();
researcherProfileService = jasmine.createSpyObj('authorizationService', {
'createFromExternalSourceAndReturnRelatedItemId': observableOf('profile-id'),
});
modalService = jasmine.createSpyObj('modalService', ['open']);
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
providers: [
ClaimMenuProvider,
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: MenuService, useValue: menuService },
{ provide: NotificationsService, useValue: notificationsService },
{ provide: ResearcherProfileDataService, useValue: researcherProfileService },
{ provide: NgbModal, useValue: modalService },
],
});
provider = TestBed.inject(ClaimMenuProvider);
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
describe('getSectionsForContext', () => {
it('should return the expected sections', (done) => {
provider.getSectionsForContext(person).subscribe((sections) => {
expect(sections).toEqual(expectedSections);
done();
});
});
});
describe('isApplicable', () => {
it('should return true whe the provided dspace object is a person entity', () => {
const result = (provider as any).isApplicable(person);
expect(result).toBeTrue();
});
it('should return true whe the provided dspace object is not a person entity', () => {
const result = (provider as any).isApplicable(item);
expect(result).toBeFalse();
});
});
describe('claimResearcher', () => {
it('should show a success notification and hide the menu when an id is returned by the researcher profile service', () => {
(provider as any).claimResearcher(person);
expect(notificationsService.success).toHaveBeenCalled();
expect(authorizationService.invalidateAuthorizationsRequestCache).toHaveBeenCalled();
expect(menuService.hideMenuSection).toHaveBeenCalled();
});
it('should show an error notification when no id is returned by the researcher profile service', () => {
(researcherProfileService.createFromExternalSourceAndReturnRelatedItemId as jasmine.Spy).and.returnValue(observableOf(null));
(provider as any).claimResearcher(person);
expect(notificationsService.error).toHaveBeenCalled();
expect(authorizationService.invalidateAuthorizationsRequestCache).not.toHaveBeenCalled();
expect(menuService.hideMenuSection).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,98 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Injectable } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { TranslateService } from '@ngx-translate/core';
import {
combineLatest,
Observable,
} from 'rxjs';
import { map } from 'rxjs/operators';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { ResearcherProfileDataService } from '../../../core/profile/researcher-profile-data.service';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { Item } from '../../../core/shared/item.model';
import { isNotEmpty } from '../../empty.util';
import { NotificationsService } from '../../notifications/notifications.service';
import { MenuService } from '../menu.service';
import { MenuID } from '../menu-id.model';
import { OnClickMenuItemModel } from '../menu-item/models/onclick.model';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { DSpaceObjectPageMenuProvider } from './helper-providers/dso.menu';
/**
* Menu provider to create the "Claim" option in the DSO edit menu on person entity pages.
* This option allows to claim a researcher by creating a profile.
*/
@Injectable()
export class ClaimMenuProvider extends DSpaceObjectPageMenuProvider {
constructor(
protected authorizationService: AuthorizationDataService,
protected menuService: MenuService,
protected translate: TranslateService,
protected notificationsService: NotificationsService,
protected researcherProfileService: ResearcherProfileDataService,
protected modalService: NgbModal,
) {
super();
}
public getSectionsForContext(item: Item): Observable<PartialMenuSection[]> {
return combineLatest([
this.authorizationService.isAuthorized(FeatureID.CanClaimItem, item.self),
]).pipe(
map(([canClaimItem]) => {
return [
{
visible: canClaimItem,
model: {
type: MenuItemType.ONCLICK,
text: 'item.page.claim.button',
function: () => {
this.claimResearcher(item);
},
} as OnClickMenuItemModel,
icon: 'hand-paper',
},
] as PartialMenuSection[];
}),
);
}
protected isApplicable(item: DSpaceObject): boolean {
if (item instanceof Item) {
return this.getDsoType(item) === 'person';
}
return false;
}
/**
* Claim a researcher by creating a profile
* Shows notifications and/or hides the menu section on success/error
*/
protected claimResearcher(item: Item) {
this.researcherProfileService.createFromExternalSourceAndReturnRelatedItemId(item.self).subscribe((id: string) => {
if (isNotEmpty(id)) {
this.notificationsService.success(
this.translate.get('researcherprofile.success.claim.title'),
this.translate.get('researcherprofile.success.claim.body'),
);
this.authorizationService.invalidateAuthorizationsRequestCache();
this.menuService.hideMenuSection(MenuID.DSO_EDIT, this.getAutomatedSectionId(0));
} else {
this.notificationsService.error(
this.translate.get('researcherprofile.error.claim.title'),
this.translate.get('researcherprofile.error.claim.body'),
);
}
});
}
}

View File

@@ -0,0 +1,95 @@
import { TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { of as observableOf } from 'rxjs';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { Item } from '../../../core/shared/item.model';
import { ITEM } from '../../../core/shared/item.resource-type';
import { URLCombiner } from '../../../core/url-combiner/url-combiner';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { OrcidMenuProvider } from './item-orcid.menu';
describe('OrcidMenuProvider', () => {
const expectedSections: PartialMenuSection[] = [
{
visible: true,
model: {
type: MenuItemType.LINK,
text: 'item.page.orcid.tooltip',
link: new URLCombiner('/entities/person/test-uuid', 'orcid').toString(),
},
icon: 'orcid fab fa-lg',
},
];
let provider: OrcidMenuProvider;
const item: Item = Object.assign(new Item(), {
type: ITEM.value,
_links: { self: { href: 'self-link' } },
metadata: {
'dc.title': [{
'value': 'Untyped Item',
}],
},
});
const person: Item = Object.assign(new Item(), {
uuid: 'test-uuid',
type: ITEM.value,
_links: { self: { href: 'self-link' } },
metadata: {
'dc.title': [{
'value': 'Person Entity',
}],
'dspace.entity.type': [{
'value': 'Person',
}],
},
});
let authorizationService;
beforeEach(() => {
authorizationService = jasmine.createSpyObj('authorizationService', {
'isAuthorized': observableOf(true),
});
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
providers: [
OrcidMenuProvider,
{ provide: AuthorizationDataService, useValue: authorizationService },
],
});
provider = TestBed.inject(OrcidMenuProvider);
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
describe('getSectionsForContext', () => {
it('should return the expected sections', (done) => {
provider.getSectionsForContext(person).subscribe((sections) => {
expect(sections).toEqual(expectedSections);
done();
});
});
});
describe('isApplicable', () => {
it('should return true whe the provided dspace object is a person entity', () => {
const result = (provider as any).isApplicable(person);
expect(result).toBeTrue();
});
it('should return true whe the provided dspace object is not a person entity', () => {
const result = (provider as any).isApplicable(item);
expect(result).toBeFalse();
});
});
});

View File

@@ -0,0 +1,62 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Injectable } from '@angular/core';
import {
combineLatest,
Observable,
} from 'rxjs';
import { map } from 'rxjs/operators';
import { getDSORoute } from '../../../app-routing-paths';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { Item } from '../../../core/shared/item.model';
import { URLCombiner } from '../../../core/url-combiner/url-combiner';
import { LinkMenuItemModel } from '../menu-item/models/link.model';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { DSpaceObjectPageMenuProvider } from './helper-providers/dso.menu';
/**
* Menu provider to create the Orcid settings related option in the DSO edit menu on person entity pages
*/
@Injectable()
export class OrcidMenuProvider extends DSpaceObjectPageMenuProvider {
constructor(
protected authorizationService: AuthorizationDataService,
) {
super();
}
public getSectionsForContext(item: Item): Observable<PartialMenuSection[]> {
return combineLatest([
this.authorizationService.isAuthorized(FeatureID.CanSynchronizeWithORCID, item.self),
]).pipe(
map(([canSynchronizeWithOrcid]) => {
return [
{
visible: canSynchronizeWithOrcid,
model: {
type: MenuItemType.LINK,
text: 'item.page.orcid.tooltip',
link: new URLCombiner(getDSORoute(item), 'orcid').toString(),
} as LinkMenuItemModel,
icon: 'orcid fab fa-lg',
},
] as PartialMenuSection[];
}),
);
}
protected isApplicable(item: Item): boolean {
if (item instanceof Item) {
return this.getDsoType(item) === 'person';
}
return false;
}
}

View File

@@ -0,0 +1,99 @@
import { TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { of as observableOf } from 'rxjs';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { Item } from '../../../core/shared/item.model';
import { ITEM } from '../../../core/shared/item.resource-type';
import { DsoVersioningModalService } from '../../dso-page/dso-versioning-modal-service/dso-versioning-modal.service';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { VersioningMenuProvider } from './item-versioning.menu';
describe('VersioningMenuProvider', () => {
const expectedSectionsWhenVersionNotPresent: PartialMenuSection[] = [
{
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'item.page.version.create',
disabled: false,
function: jasmine.any(Function) as any,
},
icon: 'code-branch',
},
];
const expectedSectionsWhenVersionPresent: PartialMenuSection[] = [
{
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'item.page.version.hasDraft',
disabled: true,
function: jasmine.any(Function) as any,
},
icon: 'code-branch',
},
];
let provider: VersioningMenuProvider;
const item: Item = Object.assign(new Item(), {
type: ITEM.value,
_links: { self: { href: 'self-link' } },
metadata: {
'dc.title': [{
'value': 'Untyped Item',
}],
},
});
let authorizationService;
let dsoVersioningModalService;
beforeEach(() => {
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true),
});
dsoVersioningModalService = jasmine.createSpyObj('dsoVersioningModalService', {
isNewVersionButtonDisabled: observableOf(false),
getVersioningTooltipMessage: observableOf('item.page.version.create'),
});
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
providers: [
VersioningMenuProvider,
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: DsoVersioningModalService, useValue: dsoVersioningModalService },
],
});
provider = TestBed.inject(VersioningMenuProvider);
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
describe('getSectionsForContext', () => {
it('should return the section to create a new version when no version draft is present yet', (done) => {
provider.getSectionsForContext(item).subscribe((sections) => {
expect(sections).toEqual(expectedSectionsWhenVersionNotPresent);
done();
});
});
it('should return the section to that a version is present when a version draft is present', (done) => {
(dsoVersioningModalService.isNewVersionButtonDisabled as jasmine.Spy).and.returnValue(observableOf(true));
(dsoVersioningModalService.getVersioningTooltipMessage as jasmine.Spy).and.returnValue(observableOf('item.page.version.hasDraft'));
provider.getSectionsForContext(item).subscribe((sections) => {
expect(sections).toEqual(expectedSectionsWhenVersionPresent);
done();
});
});
});
});

View File

@@ -0,0 +1,63 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Injectable } from '@angular/core';
import {
combineLatest,
Observable,
} from 'rxjs';
import { map } from 'rxjs/operators';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { Item } from '../../../core/shared/item.model';
import { DsoVersioningModalService } from '../../dso-page/dso-versioning-modal-service/dso-versioning-modal.service';
import { OnClickMenuItemModel } from '../menu-item/models/onclick.model';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { DSpaceObjectPageMenuProvider } from './helper-providers/dso.menu';
/**
* Menu provider to create the version related options in the DSO edit menu
*/
@Injectable()
export class VersioningMenuProvider extends DSpaceObjectPageMenuProvider {
constructor(
protected authorizationService: AuthorizationDataService,
protected dsoVersioningModalService: DsoVersioningModalService,
) {
super();
}
public getSectionsForContext(item: Item): Observable<PartialMenuSection[]> {
return combineLatest([
this.authorizationService.isAuthorized(FeatureID.CanCreateVersion, item.self),
this.dsoVersioningModalService.isNewVersionButtonDisabled(item),
this.dsoVersioningModalService.getVersioningTooltipMessage(item, 'item.page.version.hasDraft', 'item.page.version.create'),
]).pipe(
map(([canCreateVersion, disableVersioning, versionTooltip]) => {
return [
{
visible: canCreateVersion,
model: {
type: MenuItemType.ONCLICK,
text: versionTooltip,
disabled: disableVersioning,
function: () => {
this.dsoVersioningModalService.openCreateVersionModal(item);
},
} as OnClickMenuItemModel,
icon: 'code-branch',
},
] as PartialMenuSection[];
}),
);
}
}

View File

@@ -0,0 +1,113 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { TestBed } from '@angular/core/testing';
import { of as observableOf } from 'rxjs';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub';
import { LinkMenuItemModel } from '../menu-item/models/link.model';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { NewMenuProvider } from './new.menu';
describe('NewMenuProvider', () => {
const expectedTopSection: PartialMenuSection = {
accessibilityHandle: 'new',
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.new',
},
icon: 'plus',
};
const expectedSubSections: PartialMenuSection[] = [
{
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.new_community',
function: jasmine.any(Function) as any,
},
},
{
visible: false,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.new_collection',
function: jasmine.any(Function) as any,
},
},
{
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.new_item',
function: jasmine.any(Function) as any,
},
},
{
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.new_process',
link: '/processes/new',
},
},
{
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.services_new',
link: '/admin/ldn/services/new',
} as LinkMenuItemModel,
icon: '',
},
];
let provider: NewMenuProvider;
let authorizationServiceStub = new AuthorizationDataServiceStub();
beforeEach(() => {
spyOn(authorizationServiceStub, 'isAuthorized').and.callFake((id: FeatureID) => {
if (id === FeatureID.IsCollectionAdmin) {
return observableOf(false);
} else {
return observableOf(true);
}
});
TestBed.configureTestingModule({
providers: [
NewMenuProvider,
{ provide: AuthorizationDataService, useValue: authorizationServiceStub },
],
});
provider = TestBed.inject(NewMenuProvider);
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
it('getTopSection should return expected menu section', (done) => {
provider.getTopSection().subscribe((section) => {
expect(section).toEqual(expectedTopSection);
done();
});
});
it('getSubSections should return expected menu sections', (done) => {
provider.getSubSections().subscribe((sections) => {
expect(sections).toEqual(expectedSubSections);
done();
});
});
});

View File

@@ -0,0 +1,115 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Injectable } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import {
combineLatest,
map,
Observable,
of as observableOf,
} from 'rxjs';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { ThemedCreateCollectionParentSelectorComponent } from '../../dso-selector/modal-wrappers/create-collection-parent-selector/themed-create-collection-parent-selector.component';
import { ThemedCreateCommunityParentSelectorComponent } from '../../dso-selector/modal-wrappers/create-community-parent-selector/themed-create-community-parent-selector.component';
import { ThemedCreateItemParentSelectorComponent } from '../../dso-selector/modal-wrappers/create-item-parent-selector/themed-create-item-parent-selector.component';
import { LinkMenuItemModel } from '../menu-item/models/link.model';
import { TextMenuItemModel } from '../menu-item/models/text.model';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { AbstractExpandableMenuProvider } from './helper-providers/expandable-menu-provider';
/**
* Menu provider to create the "New" menu (and subsections) in the admin sidebar
*/
@Injectable()
export class NewMenuProvider extends AbstractExpandableMenuProvider {
constructor(
protected authorizationService: AuthorizationDataService,
protected modalService: NgbModal,
) {
super();
}
public getTopSection(): Observable<PartialMenuSection> {
return observableOf(
{
accessibilityHandle: 'new',
model: {
type: MenuItemType.TEXT,
text: 'menu.section.new',
} as TextMenuItemModel,
icon: 'plus',
visible: true,
},
);
}
public getSubSections(): Observable<PartialMenuSection[]> {
return combineLatest([
this.authorizationService.isAuthorized(FeatureID.IsCollectionAdmin),
this.authorizationService.isAuthorized(FeatureID.IsCommunityAdmin),
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
this.authorizationService.isAuthorized(FeatureID.CanSubmit),
this.authorizationService.isAuthorized(FeatureID.CoarNotifyEnabled),
]).pipe(map(([isCollectionAdmin, isCommunityAdmin, isSiteAdmin, canSubmit, isCoarNotifyEnabled]: [boolean, boolean, boolean, boolean, boolean]) => {
return [
{
visible: isCommunityAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.new_community',
function: () => {
this.modalService.open(ThemedCreateCommunityParentSelectorComponent);
},
},
},
{
visible: isCollectionAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.new_collection',
function: () => {
this.modalService.open(ThemedCreateCollectionParentSelectorComponent);
},
},
},
{
visible: canSubmit,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.new_item',
function: () => {
this.modalService.open(ThemedCreateItemParentSelectorComponent);
},
},
},
{
visible: isSiteAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.new_process',
link: '/processes/new',
},
},
{
visible: isSiteAdmin && isCoarNotifyEnabled,
model: {
type: MenuItemType.LINK,
text: 'menu.section.services_new',
link: '/admin/ldn/services/new',
} as LinkMenuItemModel,
icon: '',
},
];
}));
}
}

View File

@@ -0,0 +1,89 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { TestBed } from '@angular/core/testing';
import { of as observableOf } from 'rxjs';
import { PUBLICATION_CLAIMS_PATH } from '../../../admin/admin-notifications/admin-notifications-routing-paths';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub';
import { LinkMenuItemModel } from '../menu-item/models/link.model';
import { TextMenuItemModel } from '../menu-item/models/text.model';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider.model';
import { NotificationsMenuProvider } from './notifications.menu';
describe('NotificationsMenuProvider', () => {
const expectedTopSection: PartialMenuSection = {
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.notifications',
} as TextMenuItemModel,
icon: 'bell',
};
const expectedSubSections: PartialMenuSection[] = [
{
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.quality-assurance',
link: '/notifications/quality-assurance',
} as LinkMenuItemModel,
},
{
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.notifications_publication-claim',
link: '/admin/notifications/' + PUBLICATION_CLAIMS_PATH,
} as LinkMenuItemModel,
},
];
let provider: NotificationsMenuProvider;
let authorizationServiceStub = new AuthorizationDataServiceStub();
beforeEach(() => {
spyOn(authorizationServiceStub, 'isAuthorized').and.callFake((id: FeatureID) => {
if (id === FeatureID.CanSeeQA || id === FeatureID.AdministratorOf) {
return observableOf(true);
} else {
return observableOf(false);
}
});
TestBed.configureTestingModule({
providers: [
NotificationsMenuProvider,
{ provide: AuthorizationDataService, useValue: authorizationServiceStub },
],
});
provider = TestBed.inject(NotificationsMenuProvider);
});
it('should be created', () => {
expect(provider).toBeTruthy();
});
it('getTopSection should return expected menu section', (done) => {
provider.getTopSection().subscribe((section) => {
expect(section).toEqual(expectedTopSection);
done();
});
});
it('getSubSections should return expected menu sections', (done) => {
provider.getSubSections().subscribe((sections) => {
expect(sections).toEqual(expectedSubSections);
done();
});
});
});

Some files were not shown because too many files have changed in this diff Show More