Proof-of-concept: independent menu section providers

- Replace god-class resolvers with a service that populates the menus from lists of injectable providers
- Static menu sections are resolved at the root route ~ `resolveStatic`
- Route-dependent menu sections can be declared in the same structure, but are resolved on-demand ~ `resolveRoute`
- More and more easily customizable
  - Parts can be moved between menus, removed, replaced or extended individually
  - The dependencies of each provider are independent of each other
  - Order of providers determines the order of each menu → single source of truth for the order
This commit is contained in:
Yury Bondarenko
2023-12-28 13:46:39 +01:00
parent 404ccd9b0e
commit 0291942613
60 changed files with 2281 additions and 227 deletions

View File

@@ -1,5 +1,5 @@
import { Component, Inject, Injector, OnInit } from '@angular/core'; import { Component, Inject, Injector, OnInit } from '@angular/core';
import { MenuSectionComponent } from '../../../shared/menu/menu-section/menu-section.component'; import { AbstractMenuSectionComponent } from '../../../shared/menu/menu-section/abstract-menu-section.component';
import { MenuService } from '../../../shared/menu/menu.service'; import { MenuService } from '../../../shared/menu/menu.service';
import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator'; import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator';
import { LinkMenuItemModel } from '../../../shared/menu/menu-item/models/link.model'; import { LinkMenuItemModel } from '../../../shared/menu/menu-item/models/link.model';
@@ -19,7 +19,7 @@ import { Router } from '@angular/router';
}) })
@rendersSectionForMenu(MenuID.ADMIN, false) @rendersSectionForMenu(MenuID.ADMIN, false)
export class AdminSidebarSectionComponent extends MenuSectionComponent implements OnInit { export class AdminSidebarSectionComponent extends AbstractMenuSectionComponent implements OnInit {
/** /**
* This section resides in the Admin Sidebar * This section resides in the Admin Sidebar
@@ -33,16 +33,17 @@ export class AdminSidebarSectionComponent extends MenuSectionComponent implement
isDisabled: boolean; isDisabled: boolean;
constructor( constructor(
@Inject('sectionDataProvider') menuSection: MenuSection, @Inject('sectionDataProvider') protected section: MenuSection,
protected menuService: MenuService, protected menuService: MenuService,
protected injector: Injector, protected injector: Injector,
protected router: Router, protected router: Router,
) { ) {
super(menuSection, menuService, injector); super(menuService, injector);
this.itemModel = menuSection.model as LinkMenuItemModel; this.itemModel = section.model as LinkMenuItemModel;
} }
ngOnInit(): void { ngOnInit(): void {
// todo: should support all menu entries?
this.isDisabled = this.itemModel?.disabled || isEmpty(this.itemModel?.link); this.isDisabled = this.itemModel?.disabled || isEmpty(this.itemModel?.link);
super.ngOnInit(); super.ngOnInit();
} }

View File

@@ -13,7 +13,7 @@
(keyup.enter)="toggleSection($event)" (keyup.enter)="toggleSection($event)"
> >
<div class="shortcut-icon h-100"> <div class="shortcut-icon h-100">
<i class="fas fa-{{section.icon}} fa-fw"></i> <i class="fas fa-{{section.icon ?? 'notdef'}} fa-fw"></i>
</div> </div>
<div class="sidebar-collapsible"> <div class="sidebar-collapsible">
<div class="toggle"> <div class="toggle">

View File

@@ -1,5 +1,6 @@
import { Component, Inject, Injector, OnInit } from '@angular/core'; import { Component, Inject, Injector, OnInit } from '@angular/core';
import { rotate } from '../../../shared/animations/rotate'; import { rotate } from '../../../shared/animations/rotate';
import { MenuSection } from '../../../shared/menu/menu-section.model';
import { AdminSidebarSectionComponent } from '../admin-sidebar-section/admin-sidebar-section.component'; import { AdminSidebarSectionComponent } from '../admin-sidebar-section/admin-sidebar-section.component';
import { slide } from '../../../shared/animations/slide'; import { slide } from '../../../shared/animations/slide';
import { CSSVariableService } from '../../../shared/sass-helper/css-variable.service'; import { CSSVariableService } from '../../../shared/sass-helper/css-variable.service';
@@ -51,13 +52,13 @@ export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionC
expanded: Observable<boolean>; expanded: Observable<boolean>;
constructor( constructor(
@Inject('sectionDataProvider') menuSection, @Inject('sectionDataProvider') protected section: MenuSection,
protected menuService: MenuService, protected menuService: MenuService,
private variableService: CSSVariableService, private variableService: CSSVariableService,
protected injector: Injector, protected injector: Injector,
protected router: Router, protected router: Router,
) { ) {
super(menuSection, menuService, injector, router); super(section, menuService, injector, router);
} }
/** /**

View File

@@ -1,11 +1,8 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule, NoPreloading } from '@angular/router';
import { AuthBlockingGuard } from './core/auth/auth-blocking.guard';
import { AuthenticatedGuard } from './core/auth/authenticated.guard';
import { import {
SiteAdministratorGuard NoPreloading,
} from './core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; RouterModule,
} from '@angular/router';
import { import {
ACCESS_CONTROL_MODULE_PATH, ACCESS_CONTROL_MODULE_PATH,
ADMIN_MODULE_PATH, ADMIN_MODULE_PATH,
@@ -24,22 +21,24 @@ import {
} from './app-routing-paths'; } from './app-routing-paths';
import { COLLECTION_MODULE_PATH } from './collection-page/collection-page-routing-paths'; import { COLLECTION_MODULE_PATH } from './collection-page/collection-page-routing-paths';
import { COMMUNITY_MODULE_PATH } from './community-page/community-page-routing-paths'; import { COMMUNITY_MODULE_PATH } from './community-page/community-page-routing-paths';
import { ITEM_MODULE_PATH } from './item-page/item-page-routing-paths'; import { AuthBlockingGuard } from './core/auth/auth-blocking.guard';
import { PROCESS_MODULE_PATH } from './process-page/process-page-routing.paths';
import { ReloadGuard } from './core/reload/reload.guard'; import { AuthenticatedGuard } from './core/auth/authenticated.guard';
import { EndUserAgreementCurrentUserGuard } from './core/end-user-agreement/end-user-agreement-current-user.guard'; import { GroupAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/group-administrator.guard';
import { SiteAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
import { SiteRegisterGuard } from './core/data/feature-authorization/feature-authorization-guard/site-register.guard'; import { SiteRegisterGuard } from './core/data/feature-authorization/feature-authorization-guard/site-register.guard';
import { ThemedPageNotFoundComponent } from './pagenotfound/themed-pagenotfound.component'; import { EndUserAgreementCurrentUserGuard } from './core/end-user-agreement/end-user-agreement-current-user.guard';
import { ThemedForbiddenComponent } from './forbidden/themed-forbidden.component'; import { ReloadGuard } from './core/reload/reload.guard';
import {
GroupAdministratorGuard
} from './core/data/feature-authorization/feature-authorization-guard/group-administrator.guard';
import {
ThemedPageInternalServerErrorComponent
} from './page-internal-server-error/themed-page-internal-server-error.component';
import { ServerCheckGuard } from './core/server-check/server-check.guard'; 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 './menu.resolver'; import { MenuResolver } from './menu.resolver';
import { ThemedPageErrorComponent } from './page-error/themed-page-error.component'; import { ThemedPageErrorComponent } from './page-error/themed-page-error.component';
import { ThemedPageInternalServerErrorComponent } from './page-internal-server-error/themed-page-internal-server-error.component';
import { ThemedPageNotFoundComponent } from './pagenotfound/themed-pagenotfound.component';
import { PROCESS_MODULE_PATH } from './process-page/process-page-routing.paths';
import { MenuProviderService } from './shared/menu/menu-provider.service';
import { resolveStaticMenus } from './shared/menu/menu.resolver';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -50,7 +49,10 @@ import { ThemedPageErrorComponent } from './page-error/themed-page-error.compone
path: '', path: '',
canActivate: [AuthBlockingGuard], canActivate: [AuthBlockingGuard],
canActivateChild: [ServerCheckGuard], canActivateChild: [ServerCheckGuard],
resolve: [MenuResolver], resolve: [
resolveStaticMenus(),
// MenuResolver,
],
children: [ children: [
{ path: '', redirectTo: '/home', pathMatch: 'full' }, { path: '', redirectTo: '/home', pathMatch: 'full' },
{ {

58
src/app/app.menus.ts Normal file
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 { MenuID } from './shared/menu/menu-id.model';
import { buildMenuStructure } from './shared/menu/menu.structure';
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 { SubscribeMenuProvider } from './shared/menu/providers/comcol-subscribe.menu';
import { CommunityListMenuProvider } from './shared/menu/providers/community-list.menu';
import { CurationMenuProvider } from './shared/menu/providers/curation.menu';
import { DSpaceObjectEditMenuProvider } from './shared/menu/providers/dso-edit.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 { 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 { WorkflowMenuProvider } from './shared/menu/providers/workflow.menu';
export const MENUS = buildMenuStructure({
[MenuID.PUBLIC]: [
CommunityListMenuProvider,
BrowseMenuProvider,
StatisticsMenuProvider,
],
[MenuID.ADMIN]: [
NewMenuProvider,
EditMenuProvider,
ImportMenuProvider,
ExportMenuProvider,
AccessControlMenuProvider,
AdminSearchMenuProvider,
RegistriesMenuProvider,
CurationMenuProvider,
ProcessesMenuProvider,
WorkflowMenuProvider,
HealthMenuProvider,
SystemWideAlertMenuProvider,
],
[MenuID.DSO_EDIT]: [
DSpaceObjectEditMenuProvider,
VersioningMenuProvider,
OrcidMenuProvider,
ClaimMenuProvider,
SubscribeMenuProvider,
],
});

View File

@@ -1,35 +1,60 @@
import { APP_BASE_HREF, CommonModule, DOCUMENT } from '@angular/common'; import {
import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http'; APP_BASE_HREF,
CommonModule,
DOCUMENT,
} from '@angular/common';
import {
HTTP_INTERCEPTORS,
HttpClientModule,
} from '@angular/common/http';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule } from '@angular/platform-browser';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { DYNAMIC_MATCHER_PROVIDERS } from '@ng-dynamic-forms/core';
import { EffectsModule } from '@ngrx/effects'; import { EffectsModule } from '@ngrx/effects';
import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store'; import {
import { MetaReducer, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store'; RouterStateSerializer,
StoreRouterConnectingModule,
} from '@ngrx/router-store';
import {
MetaReducer,
StoreModule,
USER_PROVIDED_META_REDUCERS,
} from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to'; import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to';
import { DYNAMIC_MATCHER_PROVIDERS } from '@ng-dynamic-forms/core'; import {
APP_CONFIG,
AppConfig,
} from '../config/app-config.interface';
import { StoreDevModules } from '../config/store/devtools';
import { environment } from '../environments/environment';
import { EagerThemesModule } from '../themes/eager-themes.module';
import { AppRoutingModule } from './app-routing.module'; import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
import { appEffects } from './app.effects'; import { appEffects } from './app.effects';
import { appMetaReducers, debugMetaReducers } from './app.metareducers'; import { MENUS } from './app.menus';
import { appReducers, AppState, storeModuleConfig } from './app.reducer'; import {
appMetaReducers,
debugMetaReducers,
} from './app.metareducers';
import {
appReducers,
AppState,
storeModuleConfig,
} from './app.reducer';
import { AuthInterceptor } from './core/auth/auth.interceptor';
import { CoreModule } from './core/core.module'; import { CoreModule } from './core/core.module';
import { LocaleInterceptor } from './core/locale/locale.interceptor';
import { LogInterceptor } from './core/log/log.interceptor';
import { ClientCookieService } from './core/services/client-cookie.service'; import { ClientCookieService } from './core/services/client-cookie.service';
import { XsrfInterceptor } from './core/xsrf/xsrf.interceptor';
import { NavbarModule } from './navbar/navbar.module'; import { NavbarModule } from './navbar/navbar.module';
import { RootModule } from './root.module';
import { DSpaceRouterStateSerializer } from './shared/ngrx/dspace-router-state-serializer'; import { DSpaceRouterStateSerializer } from './shared/ngrx/dspace-router-state-serializer';
import { SharedModule } from './shared/shared.module'; import { SharedModule } from './shared/shared.module';
import { environment } from '../environments/environment';
import { AuthInterceptor } from './core/auth/auth.interceptor';
import { LocaleInterceptor } from './core/locale/locale.interceptor';
import { XsrfInterceptor } from './core/xsrf/xsrf.interceptor';
import { LogInterceptor } from './core/log/log.interceptor';
import { EagerThemesModule } from '../themes/eager-themes.module';
import { APP_CONFIG, AppConfig } from '../config/app-config.interface';
import { StoreDevModules } from '../config/store/devtools';
import { RootModule } from './root.module';
export function getConfig() { export function getConfig() {
return environment; return environment;
@@ -105,6 +130,9 @@ const PROVIDERS = [
}, },
// register the dynamic matcher used by form. MUST be provided by the app module // register the dynamic matcher used by form. MUST be provided by the app module
...DYNAMIC_MATCHER_PROVIDERS, ...DYNAMIC_MATCHER_PROVIDERS,
// DI-composable menus
...MENUS,
]; ];
const DECLARATIONS = [ const DECLARATIONS = [

View File

@@ -1,27 +1,28 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
import { CollectionBreadcrumbResolver } from '../core/breadcrumbs/collection-breadcrumb.resolver';
import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service';
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
import { LinkService } from '../core/cache/builders/link.service';
import { resolveRouteMenus } from '../shared/menu/menu.resolver';
import { SubscribeMenuProvider } from '../shared/menu/providers/comcol-subscribe.menu';
import { DSpaceObjectEditMenuProvider } from '../shared/menu/providers/dso-edit.menu';
import { StatisticsMenuProvider } from '../shared/menu/providers/statistics.menu';
import { CollectionPageAdministratorGuard } from './collection-page-administrator.guard';
import {
COLLECTION_CREATE_PATH,
COLLECTION_EDIT_PATH,
ITEMTEMPLATE_PATH,
} from './collection-page-routing-paths';
import { CollectionPageResolver } from './collection-page.resolver'; import { CollectionPageResolver } from './collection-page.resolver';
import { CreateCollectionPageComponent } from './create-collection-page/create-collection-page.component'; import { CreateCollectionPageComponent } from './create-collection-page/create-collection-page.component';
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
import { CreateCollectionPageGuard } from './create-collection-page/create-collection-page.guard'; import { CreateCollectionPageGuard } from './create-collection-page/create-collection-page.guard';
import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component'; import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component';
import { ThemedEditItemTemplatePageComponent } from './edit-item-template-page/themed-edit-item-template-page.component';
import { ItemTemplatePageResolver } from './edit-item-template-page/item-template-page.resolver'; import { ItemTemplatePageResolver } from './edit-item-template-page/item-template-page.resolver';
import { CollectionBreadcrumbResolver } from '../core/breadcrumbs/collection-breadcrumb.resolver'; import { ThemedEditItemTemplatePageComponent } from './edit-item-template-page/themed-edit-item-template-page.component';
import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service';
import { LinkService } from '../core/cache/builders/link.service';
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
import {
ITEMTEMPLATE_PATH,
COLLECTION_EDIT_PATH,
COLLECTION_CREATE_PATH
} from './collection-page-routing-paths';
import { CollectionPageAdministratorGuard } from './collection-page-administrator.guard';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
import { ThemedCollectionPageComponent } from './themed-collection-page.component'; import { ThemedCollectionPageComponent } from './themed-collection-page.component';
import { MenuItemType } from '../shared/menu/menu-item-type.model';
import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -36,7 +37,11 @@ import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
resolve: { resolve: {
dso: CollectionPageResolver, dso: CollectionPageResolver,
breadcrumb: CollectionBreadcrumbResolver, breadcrumb: CollectionBreadcrumbResolver,
menu: DSOEditMenuResolver menu: resolveRouteMenus(
StatisticsMenuProvider,
DSpaceObjectEditMenuProvider,
SubscribeMenuProvider,
),
}, },
runGuardsAndResolvers: 'always', runGuardsAndResolvers: 'always',
children: [ children: [
@@ -68,21 +73,6 @@ import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
pathMatch: 'full', pathMatch: 'full',
} }
], ],
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,
}],
},
},
}, },
]) ])
], ],

View File

@@ -1,20 +1,25 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { CommunityPageResolver } from './community-page.resolver';
import { CreateCommunityPageComponent } from './create-community-page/create-community-page.component';
import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
import { CreateCommunityPageGuard } from './create-community-page/create-community-page.guard';
import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component';
import { CommunityBreadcrumbResolver } from '../core/breadcrumbs/community-breadcrumb.resolver'; import { CommunityBreadcrumbResolver } from '../core/breadcrumbs/community-breadcrumb.resolver';
import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service';
import { LinkService } from '../core/cache/builders/link.service'; import { LinkService } from '../core/cache/builders/link.service';
import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-routing-paths';
import { CommunityPageAdministratorGuard } from './community-page-administrator.guard';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
import { ThemedCommunityPageComponent } from './themed-community-page.component';
import { MenuItemType } from '../shared/menu/menu-item-type.model';
import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
import { resolveRouteMenus } from '../shared/menu/menu.resolver';
import { SubscribeMenuProvider } from '../shared/menu/providers/comcol-subscribe.menu';
import { DSpaceObjectEditMenuProvider } from '../shared/menu/providers/dso-edit.menu';
import { StatisticsMenuProvider } from '../shared/menu/providers/statistics.menu';
import { CommunityPageAdministratorGuard } from './community-page-administrator.guard';
import {
COMMUNITY_CREATE_PATH,
COMMUNITY_EDIT_PATH,
} from './community-page-routing-paths';
import { CommunityPageResolver } from './community-page.resolver';
import { CreateCommunityPageComponent } from './create-community-page/create-community-page.component';
import { CreateCommunityPageGuard } from './create-community-page/create-community-page.guard';
import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component';
import { ThemedCommunityPageComponent } from './themed-community-page.component';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -29,7 +34,12 @@ import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
resolve: { resolve: {
dso: CommunityPageResolver, dso: CommunityPageResolver,
breadcrumb: CommunityBreadcrumbResolver, breadcrumb: CommunityBreadcrumbResolver,
menu: DSOEditMenuResolver // menu: DSOEditMenuResolver,
menu: resolveRouteMenus(
StatisticsMenuProvider,
DSpaceObjectEditMenuProvider,
SubscribeMenuProvider,
),
}, },
runGuardsAndResolvers: 'always', runGuardsAndResolvers: 'always',
children: [ children: [
@@ -51,21 +61,6 @@ import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
pathMatch: 'full', pathMatch: 'full',
} }
], ],
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,
}],
},
},
}, },
]) ])
], ],

View File

@@ -1,5 +1,8 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { MenuProviderService } from '../shared/menu/menu-provider.service';
import { resolveRouteMenus } from '../shared/menu/menu.resolver';
import { StatisticsMenuProvider } from '../shared/menu/providers/statistics.menu';
import { HomePageResolver } from './home-page.resolver'; import { HomePageResolver } from './home-page.resolver';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
@@ -15,22 +18,10 @@ import { MenuItemType } from '../shared/menu/menu-item-type.model';
pathMatch: 'full', pathMatch: 'full',
data: { data: {
title: 'home.title', title: 'home.title',
menu: {
public: [{
id: 'statistics_site',
active: true,
visible: true,
index: 2,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics',
link: 'statistics',
} as LinkMenuItemModel,
}],
},
}, },
resolve: { resolve: {
site: HomePageResolver site: HomePageResolver,
menu: resolveRouteMenus(StatisticsMenuProvider), // todo: sometimes this doesn't show up!
} }
} }
]) ])

View File

@@ -1,24 +1,31 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { ItemPageResolver } from './item-page.resolver';
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
import { ItemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.resolver';
import { VersionResolver } from './version-page/version.resolver';
import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service';
import { LinkService } from '../core/cache/builders/link.service';
import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component';
import { ITEM_EDIT_PATH, ORCID_PATH, UPLOAD_BITSTREAM_PATH } from './item-page-routing-paths';
import { ItemPageAdministratorGuard } from './item-page-administrator.guard';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
import { ThemedItemPageComponent } from './simple/themed-item-page.component';
import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component';
import { MenuItemType } from '../shared/menu/menu-item-type.model';
import { VersionPageComponent } from './version-page/version-page/version-page.component';
import { BitstreamRequestACopyPageComponent } from './bitstreams/request-a-copy/bitstream-request-a-copy-page.component';
import { REQUEST_COPY_MODULE_PATH } from '../app-routing-paths'; import { REQUEST_COPY_MODULE_PATH } from '../app-routing-paths';
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service';
import { ItemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.resolver';
import { LinkService } from '../core/cache/builders/link.service';
import { resolveRouteMenus } from '../shared/menu/menu.resolver';
import { DSpaceObjectEditMenuProvider } from '../shared/menu/providers/dso-edit.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 { StatisticsMenuProvider } from '../shared/menu/providers/statistics.menu';
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';
import { ItemPageAdministratorGuard } from './item-page-administrator.guard';
import {
ITEM_EDIT_PATH,
ORCID_PATH,
UPLOAD_BITSTREAM_PATH,
} from './item-page-routing-paths';
import { ItemPageResolver } from './item-page.resolver';
import { OrcidPageComponent } from './orcid-page/orcid-page.component'; import { OrcidPageComponent } from './orcid-page/orcid-page.component';
import { OrcidPageGuard } from './orcid-page/orcid-page.guard'; import { OrcidPageGuard } from './orcid-page/orcid-page.guard';
import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; import { ThemedItemPageComponent } from './simple/themed-item-page.component';
import { VersionPageComponent } from './version-page/version-page/version-page.component';
import { VersionResolver } from './version-page/version.resolver';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -28,7 +35,13 @@ import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
resolve: { resolve: {
dso: ItemPageResolver, dso: ItemPageResolver,
breadcrumb: ItemBreadcrumbResolver, breadcrumb: ItemBreadcrumbResolver,
menu: DSOEditMenuResolver menu: resolveRouteMenus(
StatisticsMenuProvider,
OrcidMenuProvider,
DSpaceObjectEditMenuProvider,
ClaimMenuProvider,
VersioningMenuProvider,
),
}, },
runGuardsAndResolvers: 'always', runGuardsAndResolvers: 'always',
children: [ children: [
@@ -61,21 +74,6 @@ import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
canActivate: [AuthenticatedGuard, OrcidPageGuard] canActivate: [AuthenticatedGuard, OrcidPageGuard]
} }
], ],
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', path: 'version',

View File

@@ -14,7 +14,9 @@ import { ItemResolver } from './item.resolver';
* This class represents a resolver that requests a specific item before the route is activated and will redirect to the * This class represents a resolver that requests a specific item before the route is activated and will redirect to the
* entity page * entity page
*/ */
@Injectable() @Injectable({
providedIn: 'root',
})
export class ItemPageResolver extends ItemResolver { export class ItemPageResolver extends ItemResolver {
constructor( constructor(
protected itemService: ItemDataService, protected itemService: ItemDataService,

View File

@@ -1,4 +1,5 @@
import { Component, Inject, Injector, OnInit } from '@angular/core'; import { Component, Inject, Injector, OnInit } from '@angular/core';
import { MenuSection } from '../../shared/menu/menu-section.model';
import { NavbarSectionComponent } from '../navbar-section/navbar-section.component'; import { NavbarSectionComponent } from '../navbar-section/navbar-section.component';
import { MenuService } from '../../shared/menu/menu.service'; import { MenuService } from '../../shared/menu/menu.service';
import { slide } from '../../shared/animations/slide'; import { slide } from '../../shared/animations/slide';
@@ -23,12 +24,13 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp
*/ */
menuID = MenuID.PUBLIC; menuID = MenuID.PUBLIC;
constructor(@Inject('sectionDataProvider') menuSection, constructor(
@Inject('sectionDataProvider') protected section: MenuSection,
protected menuService: MenuService, protected menuService: MenuService,
protected injector: Injector, protected injector: Injector,
private windowService: HostWindowService private windowService: HostWindowService,
) { ) {
super(menuSection, menuService, injector); super(section, menuService, injector);
} }
ngOnInit() { ngOnInit() {

View File

@@ -1,5 +1,6 @@
import { Component, Inject, Injector, OnInit } from '@angular/core'; import { Component, Inject, Injector, OnInit } from '@angular/core';
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';
import { MenuService } from '../../shared/menu/menu.service'; import { MenuService } from '../../shared/menu/menu.service';
import { rendersSectionForMenu } from '../../shared/menu/menu-section.decorator'; import { rendersSectionForMenu } from '../../shared/menu/menu-section.decorator';
import { MenuID } from '../../shared/menu/menu-id.model'; import { MenuID } from '../../shared/menu/menu-id.model';
@@ -14,17 +15,18 @@ import { MenuID } from '../../shared/menu/menu-id.model';
styleUrls: ['./navbar-section.component.scss'] styleUrls: ['./navbar-section.component.scss']
}) })
@rendersSectionForMenu(MenuID.PUBLIC, false) @rendersSectionForMenu(MenuID.PUBLIC, false)
export class NavbarSectionComponent extends MenuSectionComponent implements OnInit { export class NavbarSectionComponent extends AbstractMenuSectionComponent implements OnInit {
/** /**
* This section resides in the Public Navbar * This section resides in the Public Navbar
*/ */
menuID = MenuID.PUBLIC; menuID = MenuID.PUBLIC;
constructor(@Inject('sectionDataProvider') menuSection, constructor(
@Inject('sectionDataProvider') protected section: MenuSection,
protected menuService: MenuService, protected menuService: MenuService,
protected injector: Injector protected injector: Injector,
) { ) {
super(menuSection, menuService, injector); super(menuService, injector);
} }
ngOnInit() { ngOnInit() {

View File

@@ -3,9 +3,21 @@
} }
.dso-button-menu { .dso-button-menu {
// todo: random thought to make dso page dropdown buttons clear
.dropdown-toggle::after { .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 { ul.dropdown-menu {

View File

@@ -1,6 +1,6 @@
import { Component, Inject, Injector } from '@angular/core'; import { Component, Inject, Injector } from '@angular/core';
import { rendersSectionForMenu } from 'src/app/shared/menu/menu-section.decorator'; import { rendersSectionForMenu } from 'src/app/shared/menu/menu-section.decorator';
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 { MenuService } from '../../../menu/menu.service'; import { MenuService } from '../../../menu/menu.service';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { MenuID } from 'src/app/shared/menu/menu-id.model'; import { MenuID } from 'src/app/shared/menu/menu-id.model';
@@ -19,7 +19,7 @@ import { hasValue } from '../../../empty.util';
styleUrls: ['./dso-edit-menu-expandable-section.component.scss'], styleUrls: ['./dso-edit-menu-expandable-section.component.scss'],
}) })
@rendersSectionForMenu(MenuID.DSO_EDIT, true) @rendersSectionForMenu(MenuID.DSO_EDIT, true)
export class DsoEditMenuExpandableSectionComponent extends MenuSectionComponent { export class DsoEditMenuExpandableSectionComponent extends AbstractMenuSectionComponent {
menuID: MenuID = MenuID.DSO_EDIT; menuID: MenuID = MenuID.DSO_EDIT;
itemModel; itemModel;
@@ -27,13 +27,13 @@ export class DsoEditMenuExpandableSectionComponent extends MenuSectionComponent
renderIcons$: Observable<boolean>; renderIcons$: Observable<boolean>;
constructor( constructor(
@Inject('sectionDataProvider') menuSection: MenuSection, @Inject('sectionDataProvider') protected section: MenuSection,
protected menuService: MenuService, protected menuService: MenuService,
protected injector: Injector, protected injector: Injector,
protected router: Router, protected router: Router,
) { ) {
super(menuSection, menuService, injector); super(menuService, injector);
this.itemModel = menuSection.model; this.itemModel = section.model;
} }
ngOnInit(): void { ngOnInit(): void {

View File

@@ -1,6 +1,6 @@
import { Component, Inject, Injector, OnInit } from '@angular/core'; import { Component, Inject, Injector, OnInit } from '@angular/core';
import { rendersSectionForMenu } from 'src/app/shared/menu/menu-section.decorator'; import { rendersSectionForMenu } from 'src/app/shared/menu/menu-section.decorator';
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 { MenuService } from '../../../menu/menu.service'; import { MenuService } from '../../../menu/menu.service';
import { isNotEmpty } from '../../../empty.util'; import { isNotEmpty } from '../../../empty.util';
import { MenuID } from '../../../menu/menu-id.model'; import { MenuID } from '../../../menu/menu-id.model';
@@ -16,7 +16,7 @@ import { MenuSection } from '../../../menu/menu-section.model';
styleUrls: ['./dso-edit-menu-section.component.scss'] styleUrls: ['./dso-edit-menu-section.component.scss']
}) })
@rendersSectionForMenu(MenuID.DSO_EDIT, false) @rendersSectionForMenu(MenuID.DSO_EDIT, false)
export class DsoEditMenuSectionComponent extends MenuSectionComponent implements OnInit { export class DsoEditMenuSectionComponent extends AbstractMenuSectionComponent implements OnInit {
menuID: MenuID = MenuID.DSO_EDIT; menuID: MenuID = MenuID.DSO_EDIT;
itemModel; itemModel;
@@ -24,12 +24,12 @@ export class DsoEditMenuSectionComponent extends MenuSectionComponent implements
canActivate: boolean; canActivate: boolean;
constructor( constructor(
@Inject('sectionDataProvider') menuSection: MenuSection, @Inject('sectionDataProvider') protected section: MenuSection,
protected menuService: MenuService, protected menuService: MenuService,
protected injector: Injector, protected injector: Injector,
) { ) {
super(menuSection, menuService, injector); super(menuService, injector);
this.itemModel = menuSection.model; this.itemModel = section.model;
} }
ngOnInit(): void { ngOnInit(): void {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
import { MenuItemModel } from './menu-item.model';
import { MenuItemType } from '../../menu-item-type.model'; import { MenuItemType } from '../../menu-item-type.model';
import { MenuItemModel } from './menu-item.model';
/** /**
* Model representing an Search Bar Menu Section * Model representing an Search Bar Menu Section
*/ */
export class SearchMenuItemModel implements MenuItemModel { export class SearchMenuItemModel implements MenuItemModel {
type = MenuItemType.SEARCH; type = MenuItemType.SEARCH;
disabled: boolean; disabled?: boolean;
placeholder: string; placeholder: string;
action: string; action: string;
} }

View File

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

View File

@@ -0,0 +1,126 @@
/**
* 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,
Injector,
Optional,
Type,
} from '@angular/core';
import {
ActivatedRouteSnapshot,
RouterStateSnapshot,
} from '@angular/router';
import {
combineLatest,
forkJoin,
map,
Observable,
} from 'rxjs';
import {
find,
switchMap,
take,
tap,
} from 'rxjs/operators';
import { v4 as uuidv4 } from 'uuid';
import { hasValue } from '../empty.util';
import { MenuID } from './menu-id.model';
import { AbstractMenuProvider } from './menu-provider';
import { MenuSection } from './menu-section.model';
import { MenuState } from './menu-state.model';
import { MenuService } from './menu.service';
import { MENU_PROVIDER } from './menu.structure';
@Injectable({
providedIn: 'root',
})
export class MenuProviderService {
constructor(
@Inject(MENU_PROVIDER) @Optional() protected providers: ReadonlyArray<AbstractMenuProvider>,
protected menuService: MenuService,
protected injector: Injector,
) {
}
/**
* 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),
);
}
public resolveStaticMenu(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
): Observable<boolean> {
return combineLatest(
this.providers
.filter(p => p.allRoutes)
.map(provider => provider.getSections(route, state).pipe(
tap((sections) => {
sections.forEach((section: MenuSection) => {
this.menuService.addSection(provider.menuID, {
...section,
id: section.id ?? uuidv4(),
index: section.index ?? provider.index,
shouldPersistOnRouteChange: true,
});
});
}),
switchMap(() => this.waitForMenu$(provider.menuID)),
)),
).pipe(
map(done => done.every(Boolean)),
);
}
public resolveRouteMenu(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
...providerTypes: Type<AbstractMenuProvider>[]
): Observable<any> {
// todo: no why please no
return forkJoin(
providerTypes.map(pt => this.injector.get(pt))
.map((provider: AbstractMenuProvider) => provider.getSections(route, state).pipe(
map(sections => [
provider.menuID,
sections.map((section: MenuSection) => {
return {
...section,
id: section.id ?? uuidv4(),
index: section.index ?? provider.index,
shouldPersistOnRouteChange: false,
};
}),
]),
take(1),
)),
).pipe(
map((entries) => {
return entries.reduce((all, entry) => {
const menuID = entry[0] as unknown as MenuID;
const sections = entry[1] as unknown as MenuSection[];
if (all[menuID] === undefined) {
all[menuID] = [];
}
all[menuID].push(...sections);
return all;
}, {});
}),
);
}
}

View File

@@ -0,0 +1,47 @@
/**
* 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 { Omit } from '@material-ui/core';
import flatten from 'lodash/flatten';
import {
combineLatest,
Observable,
} from 'rxjs';
import { map } from 'rxjs/operators';
import { MenuID } from './menu-id.model';
import { MenuSection } from './menu-section.model';
export type PartialMenuSection = Omit<MenuSection, 'id' | 'active'>;
export interface MenuProvider {
allRoutes?: boolean,
menuID?: MenuID;
index?: number;
getSections(route?: ActivatedRouteSnapshot, state?: RouterStateSnapshot): Observable<PartialMenuSection[]>;
}
export abstract class AbstractMenuProvider implements MenuProvider {
public allRoutes = true;
menuID?: MenuID;
index?: number;
abstract getSections(route?: ActivatedRouteSnapshot, state?: RouterStateSnapshot): Observable<PartialMenuSection[]>;
protected concat(...sections$: Observable<PartialMenuSection[]>[]): Observable<PartialMenuSection[]> {
return combineLatest(sections$).pipe(
map(sections => flatten(sections)),
);
}
}

View File

@@ -1,15 +1,82 @@
import { MenuItemModel } from './menu-item/models/menu-item.model'; import { MenuItemType } from './menu-item-type.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 =
* Represents the state of a single menu section in the store LinkMenuItemModel
*/ | AltmetricMenuItemModel
export class MenuSection { | ExternalLinkMenuItemModel
id: string; | OnClickMenuItemModel
parentID?: string; | SearchMenuItemModel
visible: boolean; | TextMenuItemModel;
active: boolean;
model: MenuItemModel; function itemModelFactory(type: MenuItemType): MenuItemModels {
index?: number; switch (type) {
icon?: string; case MenuItemType.TEXT:
shouldPersistOnRouteChange? = false; return new TextMenuItemModel();
case MenuItemType.LINK:
return new LinkMenuItemModel();
case MenuItemType.ALTMETRIC:
return new AltmetricMenuItemModel();
case MenuItemType.SEARCH:
return new SearchMenuItemModel();
case MenuItemType.ONCLICK:
return new OnClickMenuItemModel();
case MenuItemType.EXTERNAL:
return new ExternalLinkMenuItemModel();
default: {
throw new Error(`No such menu item type: ${type}`);
}
}
}
export interface MenuSection {
/**
* The identifier for this section
*/
id: string;
/**
* Whether this section should be visible.
*/
visible: boolean;
/**
*
*/
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;
} }

View File

@@ -1,14 +1,29 @@
import { Component, Injector, OnDestroy, OnInit } from '@angular/core'; import {
import { MenuService } from '../menu.service'; Component,
import { getComponentForMenuItemType } from '../menu-item.decorator'; Injector,
import { hasNoValue, hasValue } from '../../empty.util'; OnDestroy,
import { BehaviorSubject, Observable, Subscription } from 'rxjs'; OnInit,
import { MenuItemModel } from '../menu-item/models/menu-item.model'; } from '@angular/core';
import { distinctUntilChanged, switchMap } from 'rxjs/operators'; import {
BehaviorSubject,
Observable,
Subscription,
} from 'rxjs';
import {
distinctUntilChanged,
switchMap,
} from 'rxjs/operators';
import { GenericConstructor } from '../../../core/shared/generic-constructor'; import { GenericConstructor } from '../../../core/shared/generic-constructor';
import { MenuSection } from '../menu-section.model'; import {
hasNoValue,
hasValue,
} from '../../empty.util';
import { MenuID } from '../menu-id.model'; import { MenuID } from '../menu-id.model';
import { MenuItemType } from '../menu-item-type.model'; import { MenuItemType } from '../menu-item-type.model';
import { getComponentForMenuItemType } from '../menu-item.decorator';
import { MenuItemModel } from '../menu-item/models/menu-item.model';
import { MenuSection } from '../menu-section.model';
import { MenuService } from '../menu.service';
/** /**
* A basic implementation of a menu section's component * A basic implementation of a menu section's component
@@ -17,7 +32,8 @@ import { MenuItemType } from '../menu-item-type.model';
selector: 'ds-menu-section', selector: 'ds-menu-section',
template: '' template: ''
}) })
export class MenuSectionComponent implements OnInit, OnDestroy { export abstract class AbstractMenuSectionComponent implements OnInit, OnDestroy {
protected abstract section: MenuSection;
/** /**
* Observable that emits whether or not this section is currently active * Observable that emits whether or not this section is currently active
@@ -39,7 +55,7 @@ export class MenuSectionComponent implements OnInit, OnDestroy {
*/ */
sectionMap$: BehaviorSubject<Map<string, { sectionMap$: BehaviorSubject<Map<string, {
injector: Injector, injector: Injector,
component: GenericConstructor<MenuSectionComponent> component: GenericConstructor<AbstractMenuSectionComponent>
}>> = new BehaviorSubject(new Map()); }>> = new BehaviorSubject(new Map());
/** /**
@@ -48,7 +64,10 @@ export class MenuSectionComponent implements OnInit, OnDestroy {
*/ */
subs: Subscription[] = []; subs: Subscription[] = [];
constructor(public section: MenuSection, protected menuService: MenuService, protected injector: Injector) { protected constructor(
protected menuService: MenuService,
protected injector: Injector,
) {
} }
/** /**

View File

@@ -1,17 +1,38 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; import {
import { MenuSectionComponent } from './menu-section.component'; ChangeDetectionStrategy,
Component,
Inject,
Injector,
NO_ERRORS_SCHEMA,
} from '@angular/core';
import { AbstractMenuSectionComponent } from './abstract-menu-section.component';
import { MenuService } from '../menu.service'; import { MenuService } from '../menu.service';
import { MenuServiceStub } from '../../testing/menu-service.stub'; import { MenuServiceStub } from '../../testing/menu-service.stub';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { LinkMenuItemComponent } from '../menu-item/link-menu-item.component'; import { LinkMenuItemComponent } from '../menu-item/link-menu-item.component';
import { MenuSection } from '../menu-section.model'; import { MenuSection } from '../menu-section.model';
@Component({
selector: 'ds-some-menu-section',
template: '',
})
class SomeMenuSectionComponent extends AbstractMenuSectionComponent {
constructor(
@Inject('sectionDataProvider') protected section: MenuSection,
protected menuService: MenuService,
protected injector: Injector,
) {
super(menuService, injector);
}
}
describe('MenuSectionComponent', () => { describe('MenuSectionComponent', () => {
let comp: MenuSectionComponent; let comp: AbstractMenuSectionComponent;
let fixture: ComponentFixture<MenuSectionComponent>; let fixture: ComponentFixture<AbstractMenuSectionComponent>;
let menuService: MenuService; let menuService: MenuService;
let dummySection; let dummySection;
@@ -23,20 +44,20 @@ describe('MenuSectionComponent', () => {
} as any; } as any;
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), NoopAnimationsModule], imports: [TranslateModule.forRoot(), NoopAnimationsModule],
declarations: [MenuSectionComponent], declarations: [AbstractMenuSectionComponent],
providers: [ providers: [
{ provide: Injector, useValue: {} }, { provide: Injector, useValue: {} },
{ provide: MenuService, useClass: MenuServiceStub }, { provide: MenuService, useClass: MenuServiceStub },
{ provide: MenuSection, useValue: dummySection }, { provide: 'sectionDataProvider', useValue: dummySection },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(MenuSectionComponent, { }).overrideComponent(SomeMenuSectionComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default } set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents(); }).compileComponents();
})); }));
beforeEach(() => { beforeEach(() => {
fixture = TestBed.createComponent(MenuSectionComponent); fixture = TestBed.createComponent(SomeMenuSectionComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
menuService = (comp as any).menuService; menuService = (comp as any).menuService;
spyOn(comp as any, 'getMenuItemComponent').and.returnValue(LinkMenuItemComponent); spyOn(comp as any, 'getMenuItemComponent').and.returnValue(LinkMenuItemComponent);

View File

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

View File

@@ -4,7 +4,7 @@ import { MenuService } from './menu.service';
import { distinctUntilChanged, map, mergeMap, switchMap } from 'rxjs/operators'; import { distinctUntilChanged, map, mergeMap, switchMap } from 'rxjs/operators';
import { GenericConstructor } from '../../core/shared/generic-constructor'; import { GenericConstructor } from '../../core/shared/generic-constructor';
import { hasValue, isNotEmptyOperator } from '../empty.util'; import { hasValue, isNotEmptyOperator } from '../empty.util';
import { MenuSectionComponent } from './menu-section/menu-section.component'; import { AbstractMenuSectionComponent } from './menu-section/abstract-menu-section.component';
import { getComponentForMenu } from './menu-section.decorator'; import { getComponentForMenu } from './menu-section.decorator';
import { compareArraysUsingIds } from '../../item-page/simple/item-types/shared/item-relationships-utils'; import { compareArraysUsingIds } from '../../item-page/simple/item-types/shared/item-relationships-utils';
import { MenuSection } from './menu-section.model'; import { MenuSection } from './menu-section.model';
@@ -52,7 +52,7 @@ export class MenuComponent implements OnInit, OnDestroy {
*/ */
sectionMap$: BehaviorSubject<Map<string, { sectionMap$: BehaviorSubject<Map<string, {
injector: Injector, injector: Injector,
component: GenericConstructor<MenuSectionComponent> component: GenericConstructor<AbstractMenuSectionComponent>
}>> = new BehaviorSubject(new Map()); }>> = new BehaviorSubject(new Map());
/** /**
@@ -101,7 +101,7 @@ export class MenuComponent implements OnInit, OnDestroy {
}), }),
isNotEmptyOperator(), isNotEmptyOperator(),
switchMap((section: MenuSection) => this.getSectionComponent(section).pipe( 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) distinctUntilChanged((x, y) => x.section.id === y.section.id)
).subscribe(({ section, component }) => { ).subscribe(({ section, component }) => {
@@ -213,9 +213,9 @@ export class MenuComponent implements OnInit, OnDestroy {
/** /**
* Retrieve the component for a given MenuSection object * Retrieve the component for a given MenuSection object
* @param {MenuSection} section The given MenuSection * @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( return this.menuService.hasSubSections(this.menuID, section.id).pipe(
map((expandable: boolean) => { map((expandable: boolean) => {
return getComponentForMenu(this.menuID, expandable, this.themeService.getThemeName()); return getComponentForMenu(this.menuID, expandable, this.themeService.getThemeName());

View File

@@ -1,16 +1,14 @@
import { MenuSectionComponent } from './menu-section/menu-section.component';
import { MenuComponent } from './menu.component';
import { NgModule } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { RouterModule } from '@angular/router';
import { LinkMenuItemComponent } from './menu-item/link-menu-item.component';
import { TextMenuItemComponent } from './menu-item/text-menu-item.component';
import { OnClickMenuItemComponent } from './menu-item/onclick-menu-item.component';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { ExternalLinkMenuItemComponent } from './menu-item/external-link-menu-item.component'; import { ExternalLinkMenuItemComponent } from './menu-item/external-link-menu-item.component';
import { LinkMenuItemComponent } from './menu-item/link-menu-item.component';
import { OnClickMenuItemComponent } from './menu-item/onclick-menu-item.component';
import { TextMenuItemComponent } from './menu-item/text-menu-item.component';
import { MenuComponent } from './menu.component';
const COMPONENTS = [ const COMPONENTS = [
MenuSectionComponent,
MenuComponent, MenuComponent,
]; ];

View File

@@ -18,7 +18,7 @@ import {
} from './menu.actions'; } from './menu.actions';
import { menusReducer } from './menu.reducer'; import { menusReducer } from './menu.reducer';
import { initialMenusState} from './initial-menus-state'; import { initialMenusState} from './initial-menus-state';
import { MenuSectionIndex } from './menu-section-Index.model'; import { MenuSectionIndex } from './menu-section-index.model';
import { MenuID } from './menu-id.model'; import { MenuID } from './menu-id.model';
let visibleSection1; let visibleSection1;

View File

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

View File

@@ -0,0 +1,35 @@
/**
* 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,
Type,
} from '@angular/core';
import {
ActivatedRouteSnapshot,
RouterStateSnapshot,
} from '@angular/router';
import { Observable } from 'rxjs';
import { AbstractMenuProvider } from './menu-provider';
import { MenuProviderService } from './menu-provider.service';
export function resolveStaticMenus(): (ActivatedRouteSnapshot, RouterStateSnapshot, ProviderMenuService) => Observable<boolean> {
return (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
menuProviderService: MenuProviderService = inject(MenuProviderService),
) => menuProviderService.resolveStaticMenu(route, state);
}
export function resolveRouteMenus(...providerTypes: Type<AbstractMenuProvider>[]): (ActivatedRouteSnapshot, RouterStateSnapshot, ProviderMenuService) => Observable<boolean> {
// todo: runtime error if undeclared should be compile time ideally
return (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
menuProviderService: MenuProviderService = inject(MenuProviderService),
) => menuProviderService.resolveRouteMenu(route, state, ...providerTypes);
}

View File

@@ -0,0 +1,46 @@
/**
* 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 { MenuID } from './menu-id.model';
import { AbstractMenuProvider } from './menu-provider';
import { MenuProviderService } from './menu-provider.service';
export const MENU_PROVIDER = new InjectionToken<AbstractMenuProvider>('MENU_PROVIDER');
type MenuStructure = {
[key in MenuID]: Type<AbstractMenuProvider>[];
};
export function buildMenuStructure(structure: MenuStructure): Provider[] {
const providers: Provider[] = [
MenuProviderService,
];
Object.entries(structure).forEach(([menuID, providerTypes]) => {
for (const [index, providerType] of providerTypes.entries()) {
// todo: should complain if not injectable!
providers.push(providerType);
providers.push({
provide: MENU_PROVIDER,
multi: true,
useFactory(provider: AbstractMenuProvider): AbstractMenuProvider {
provider.menuID = menuID as MenuID;
provider.index = provider.index ?? index;
return provider;
},
deps: [providerType],
});
}
});
return providers;
}

View File

@@ -0,0 +1,94 @@
/**
* 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 {
AbstractExpandableMenuProvider,
MenuSubSection,
MenuTopSection,
} from './expandable-menu-provider';
@Injectable()
export class AccessControlMenuProvider extends AbstractExpandableMenuProvider {
constructor(
protected authorizationService: AuthorizationDataService,
protected scriptDataService: ScriptDataService,
protected modalService: NgbModal,
) {
super();
}
public getTopSection(): Observable<MenuTopSection> {
return observableOf({
model: {
type: MenuItemType.TEXT,
text: 'menu.section.access_control',
},
icon: 'key'
});
}
public getSubSections(): Observable<MenuSubSection[]> {
return observableCombineLatest([
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
this.authorizationService.isAuthorized(FeatureID.CanManageGroups),
]).pipe(
map(([isSiteAdmin, canManageGroups]) => {
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',
},
},
// 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,
// },
] as MenuSubSection[];
}),
);
}
}

View File

@@ -0,0 +1,50 @@
/**
* 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';
@Injectable()
export class AdminSearchMenuProvider 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.admin_search',
link: '/admin/search',
},
icon: 'search',
},
];
}),
);
}
}

View File

@@ -0,0 +1,67 @@
/**
* 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 as observableOf,
} from 'rxjs';
import { map } from 'rxjs/operators';
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 { MenuItemType } from '../menu-item-type.model';
import { TextMenuItemModel } from '../menu-item/models/text.model';
import {
AbstractExpandableMenuProvider,
MenuSubSection,
MenuTopSection,
} from './expandable-menu-provider';
@Injectable()
export class BrowseMenuProvider extends AbstractExpandableMenuProvider {
constructor(
protected browseService: BrowseService,
) {
super();
}
getTopSection(): Observable<MenuTopSection> {
return observableOf(
{
model: {
type: MenuItemType.TEXT,
text: 'menu.section.browse_global',
} as TextMenuItemModel,
icon: 'globe',
},
);
}
getSubSections(): Observable<MenuSubSection[]> {
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}`,
},
};
}),
] as MenuSubSection[];
}),
);
}
}

View File

@@ -0,0 +1,65 @@
/**
* 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 { 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 { Collection } from '../../../core/shared/collection.model';
import { COLLECTION } from '../../../core/shared/collection.resource-type';
import { Community } from '../../../core/shared/community.model';
import { COMMUNITY } from '../../../core/shared/community.resource-type';
import { SubscriptionModalComponent } from '../../subscriptions/subscription-modal/subscription-modal.component';
import { MenuItemType } from '../menu-item-type.model';
import { OnClickMenuItemModel } from '../menu-item/models/onclick.model';
import { PartialMenuSection } from '../menu-provider';
import { DSpaceObjectPageMenuProvider } from './dso.menu';
@Injectable()
export class SubscribeMenuProvider extends DSpaceObjectPageMenuProvider<Community | Collection> {
constructor(
protected authorizationService: AuthorizationDataService,
protected modalService: NgbModal,
protected dsoDataService: DSpaceObjectDataService,
) {
super(dsoDataService);
}
protected isApplicable(dso: Community | Collection): boolean {
// @ts-ignore
return dso.type === COMMUNITY.value || dso.type.value === COLLECTION;
}
public getSectionsForContext(dso: Community | Collection): 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,35 @@
/**
* 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';
@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,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 {
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 { LinkMenuItemModel } from '../menu-item/models/link.model';
import {
AbstractMenuProvider,
PartialMenuSection,
} from '../menu-provider';
@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,53 @@
/**
* 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 { 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 { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { URLCombiner } from '../../../core/url-combiner/url-combiner';
import { MenuItemType } from '../menu-item-type.model';
import { LinkMenuItemModel } from '../menu-item/models/link.model';
import { PartialMenuSection } from '../menu-provider';
import { DSpaceObjectPageMenuProvider } from './dso.menu';
@Injectable()
export class DSpaceObjectEditMenuProvider extends DSpaceObjectPageMenuProvider<DSpaceObject> {
constructor(
protected authorizationDataService: AuthorizationDataService,
protected dsoDataService: DSpaceObjectDataService,
) {
super(dsoDataService);
}
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,56 @@
/**
* 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 } from 'rxjs';
import { map } from 'rxjs/operators';
import { DSpaceObjectDataService } from '../../../core/data/dspace-object-data.service';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { getFirstCompletedRemoteData } from '../../../core/shared/operators';
import { AbstractRouteContextMenuProvider } from './route-context.menu';
export abstract class DSpaceObjectPageMenuProvider<T extends DSpaceObject> extends AbstractRouteContextMenuProvider<T> {
allRoutes = false;
protected constructor(
protected dsoDataService: DSpaceObjectDataService,
) {
super();
}
public getRouteContext(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<T | undefined> {
// todo: would be cool to automatically switch to cached version of specific DSO
// ...but we can't really know which is which because the other resolver may run _after_
// we could refactor the resolver to a function though; then it's less problematic to just call it here
return this.dsoDataService.findById(route.params.id, true, false).pipe(
getFirstCompletedRemoteData(),
map((dsoRD) => {
if (dsoRD.hasSucceeded) {
return dsoRD.payload as T;
} else {
return undefined;
}
})
);
}
/**
* Retrieve the dso or entity type for an object to be used in generic messages
*/
protected getDsoType(dso: T) {
const renderType = dso.getRenderTypes()[0];
if (typeof renderType === 'string' || renderType instanceof String) {
return renderType.toLowerCase();
} else {
return dso.type.toString().toLowerCase();
}
}
}

View File

@@ -0,0 +1,92 @@
/**
* 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 {
AbstractExpandableMenuProvider,
MenuSubSection,
MenuTopSection,
} from './expandable-menu-provider';
@Injectable()
export class EditMenuProvider extends AbstractExpandableMenuProvider {
constructor(
protected authorizationService: AuthorizationDataService,
protected modalService: NgbModal,
) {
super();
}
public getTopSection(): Observable<MenuTopSection> {
return observableOf(
{
model: {
type: MenuItemType.TEXT,
text: 'menu.section.edit',
},
icon: 'pencil',
},
);
}
public getSubSections(): Observable<MenuSubSection[]> {
return combineLatest([
this.authorizationService.isAuthorized(FeatureID.IsCollectionAdmin),
this.authorizationService.isAuthorized(FeatureID.IsCommunityAdmin),
this.authorizationService.isAuthorized(FeatureID.CanEditItem),
]).pipe(
map(([isCollectionAdmin, isCommunityAdmin, canEditItem]) => {
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);
},
},
},
] as MenuSubSection[];
}),
);
}
}

View File

@@ -0,0 +1,64 @@
/**
* 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 { Omit } from '@material-ui/core';
import {
combineLatest,
Observable,
of as observableOf,
} from 'rxjs';
import { map } from 'rxjs/operators';
import { v4 as uuidv4 } from 'uuid';
import {
AbstractMenuProvider,
PartialMenuSection,
} from '../menu-provider';
export type MenuTopSection = Omit<PartialMenuSection, 'visible'>;
export type MenuSubSection = Omit<PartialMenuSection, 'parentID'>;
export abstract class AbstractExpandableMenuProvider extends AbstractMenuProvider {
protected showWithoutSubsections = false;
abstract getTopSection(): Observable<MenuTopSection>;
abstract getSubSections(): Observable<MenuSubSection[]>;
protected includeSubSections(): boolean {
return true;
}
getSections(): Observable<PartialMenuSection[]> {
const full = this.includeSubSections();
const parentID = uuidv4();
return combineLatest([
this.getTopSection(),
full ? this.getSubSections() : observableOf([]),
]).pipe(
map((
[partialTopSection, partialSubSections]: [MenuTopSection, MenuSubSection[]]
) => {
const subSections = partialSubSections.map(partialSub => {
return {
...partialSub,
parentID: parentID,
};
});
return [
...subSections,
{
...partialTopSection,
id: parentID,
visible: full ? subSections.some(sub => sub.visible) : this.showWithoutSubsections,
},
];
})
);
}
}

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 {
AbstractExpandableMenuProvider,
MenuSubSection,
MenuTopSection,
} from './expandable-menu-provider';
@Injectable()
export class ExportMenuProvider extends AbstractExpandableMenuProvider {
constructor(
protected authorizationService: AuthorizationDataService,
protected scriptDataService: ScriptDataService,
protected modalService: NgbModal,
) {
super();
}
public getTopSection(): Observable<MenuTopSection> {
return observableOf(
{
model: {
type: MenuItemType.TEXT,
text: 'menu.section.export',
},
icon: 'file-export',
shouldPersistOnRouteChange: true,
},
);
}
public getSubSections(): Observable<MenuSubSection[]> {
return observableCombineLatest([
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
this.scriptDataService.scriptWithNameExistsAndCanExecute(METADATA_EXPORT_SCRIPT_NAME),
]).pipe(
map(([authorized, metadataExportScriptExists]) => {
return [
{
visible: authorized && metadataExportScriptExists,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.export_metadata',
function: () => {
this.modalService.open(ExportMetadataSelectorComponent);
},
},
shouldPersistOnRouteChange: true,
},
{
visible: authorized && metadataExportScriptExists,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.export_batch',
function: () => {
this.modalService.open(ExportBatchSelectorComponent);
},
},
shouldPersistOnRouteChange: true,
},
] as MenuSubSection[];
}),
);
}
}

View File

@@ -0,0 +1,50 @@
/**
* 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';
@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,80 @@
/**
* 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 {
AbstractExpandableMenuProvider,
MenuSubSection,
MenuTopSection,
} from './expandable-menu-provider';
@Injectable()
export class ImportMenuProvider extends AbstractExpandableMenuProvider {
constructor(
protected authorizationService: AuthorizationDataService,
protected scriptDataService: ScriptDataService,
protected modalService: NgbModal,
) {
super();
}
public getTopSection(): Observable<MenuTopSection> {
return observableOf(
{
model: {
type: MenuItemType.TEXT,
text: 'menu.section.import',
},
icon: 'file-import',
shouldPersistOnRouteChange: true,
},
);
}
public getSubSections(): Observable<MenuSubSection[]> {
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',
},
},
] as MenuSubSection[];
}),
);
}
}

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 { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { TranslateService } from '@ngx-translate/core';
import {
combineLatest,
Observable,
} from 'rxjs';
import { map } from 'rxjs/operators';
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 { Item } from '../../../core/shared/item.model';
import { isNotEmpty } from '../../empty.util';
import { NotificationsService } from '../../notifications/notifications.service';
import { MenuID } from '../menu-id.model';
import { MenuItemType } from '../menu-item-type.model';
import { OnClickMenuItemModel } from '../menu-item/models/onclick.model';
import { PartialMenuSection } from '../menu-provider';
import { MenuService } from '../menu.service';
import { DSpaceObjectPageMenuProvider } from './dso.menu';
@Injectable()
// todo: the "Item-ness" of this class is basically unenforced though...
export class ClaimMenuProvider extends DSpaceObjectPageMenuProvider<Item> {
constructor(
protected authorizationService: AuthorizationDataService,
protected menuService: MenuService,
protected dsoDataService: DSpaceObjectDataService,
protected translate: TranslateService,
protected notificationsService: NotificationsService,
protected researcherProfileService: ResearcherProfileDataService,
protected modalService: NgbModal,
) {
super(dsoDataService);
}
protected isApplicable(item: Item): boolean {
return this.getDsoType(item) === 'person';
}
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[];
}),
);
}
/**
* 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, 'claim-dso-' + item.uuid);
} else {
this.notificationsService.error(
this.translate.get('researcherprofile.error.claim.title'),
this.translate.get('researcherprofile.error.claim.body'),
);
}
});
}
}

View File

@@ -0,0 +1,57 @@
/**
* 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 { 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 { Item } from '../../../core/shared/item.model';
import { URLCombiner } from '../../../core/url-combiner/url-combiner';
import { MenuItemType } from '../menu-item-type.model';
import { LinkMenuItemModel } from '../menu-item/models/link.model';
import { PartialMenuSection } from '../menu-provider';
import { DSpaceObjectPageMenuProvider } from './dso.menu';
@Injectable()
export class OrcidMenuProvider extends DSpaceObjectPageMenuProvider<Item> {
constructor(
protected authorizationService: AuthorizationDataService,
protected dsoDataService: DSpaceObjectDataService,
) {
super(dsoDataService);
}
protected isApplicable(item: Item): boolean {
return this.getDsoType(item) === 'person';
}
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[];
}),
);
}
}

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 { Injectable } from '@angular/core';
import {
combineLatest,
Observable,
} from 'rxjs';
import { map } from 'rxjs/operators';
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 { Item } from '../../../core/shared/item.model';
import { DsoVersioningModalService } from '../../dso-page/dso-versioning-modal-service/dso-versioning-modal.service';
import { MenuItemType } from '../menu-item-type.model';
import { OnClickMenuItemModel } from '../menu-item/models/onclick.model';
import { PartialMenuSection } from '../menu-provider';
import { DSpaceObjectPageMenuProvider } from './dso.menu';
@Injectable()
export class VersioningMenuProvider extends DSpaceObjectPageMenuProvider<Item> {
constructor(
protected authorizationService: AuthorizationDataService,
protected dsoVersioningModalService: DsoVersioningModalService,
protected dsoDataService: DSpaceObjectDataService,
) {
super(dsoDataService);
}
public getSectionsForContext(item: Item): Observable<PartialMenuSection[]> {
console.log(`VersioningMenuProvider.getSectionsForContext()`, item); // todo: remove this
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,52 @@
/**
* 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 { Optional } from '@angular/core';
import {
ActivatedRouteSnapshot,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of,
} from 'rxjs';
import { map } from 'rxjs/operators';
import { DSpaceObjectDataService } from '../../../core/data/dspace-object-data.service';
import { RemoteData } from '../../../core/data/remote-data';
import { Item } from '../../../core/shared/item.model';
import { getFirstCompletedRemoteData } from '../../../core/shared/operators';
import { ItemPageResolver } from '../../../item-page/item-page.resolver';
import { DSpaceObjectPageMenuProvider } from './dso.menu';
export abstract class ItemPageMenuProvider extends DSpaceObjectPageMenuProvider<Item> {
allRoutes = false;
protected constructor(
protected dsoDataService: DSpaceObjectDataService,
@Optional() protected resolver?: ItemPageResolver,
) {
super(dsoDataService);
}
public getRouteContext(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Item | undefined> {
if (this.resolver === null) {
return of(undefined);
}
// todo: it should be better to reuse the exact resolver that the page uses already, since the RD is guaranteed to be cached already
return (this.resolver.resolve(route, state) as Observable<RemoteData<Item>>).pipe(
getFirstCompletedRemoteData(),
map((dsoRD) => {
if (dsoRD.hasSucceeded) {
return dsoRD.payload;
} else {
return undefined;
}
})
);
}
}

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 { 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 { MenuItemType } from '../menu-item-type.model';
import { TextMenuItemModel } from '../menu-item/models/text.model';
import {
AbstractExpandableMenuProvider,
MenuSubSection,
MenuTopSection,
} from './expandable-menu-provider';
@Injectable()
export class NewMenuProvider extends AbstractExpandableMenuProvider {
constructor(
protected authorizationService: AuthorizationDataService,
protected modalService: NgbModal,
) {
super();
}
public getTopSection(): Observable<MenuTopSection> {
return observableOf(
{
model: {
type: MenuItemType.TEXT,
text: 'menu.section.new'
} as TextMenuItemModel,
icon: 'plus',
},
);
}
public getSubSections(): Observable<MenuSubSection[]> {
return combineLatest([
this.authorizationService.isAuthorized(FeatureID.IsCollectionAdmin),
this.authorizationService.isAuthorized(FeatureID.IsCommunityAdmin),
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
this.authorizationService.isAuthorized(FeatureID.CanSubmit),
]).pipe(map(([isCollectionAdmin, isCommunityAdmin, isSiteAdmin, canSubmit]) => {
return [
{
visible: isCollectionAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.new_community',
function: () => {
this.modalService.open(ThemedCreateCommunityParentSelectorComponent);
}
},
},
{
visible: isCommunityAdmin,
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'
},
},
] as MenuSubSection[];
}));
}
}

View File

@@ -0,0 +1,68 @@
/**
* 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 { ScriptDataService } from '../../../core/data/processes/script-data.service';
import { MenuItemType } from '../menu-item-type.model';
import {
AbstractExpandableMenuProvider,
MenuSubSection,
MenuTopSection,
} from './expandable-menu-provider';
@Injectable()
export class NotificationsMenuProvider extends AbstractExpandableMenuProvider {
constructor(
protected authorizationService: AuthorizationDataService,
protected scriptDataService: ScriptDataService,
protected modalService: NgbModal,
) {
super();
}
public getTopSection(): Observable<MenuTopSection> {
return observableOf(
{
model: {
type: MenuItemType.TEXT,
text: 'menu.section.notifications',
},
icon: 'bell',
},
);
}
public getSubSections(): Observable<MenuSubSection[]> {
return combineLatest([
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
this.authorizationService.isAuthorized(FeatureID.CanSeeQA),
]).pipe(
map(([authorized, canSeeQA]) => {
return [
{
visible: authorized && canSeeQA,
model: {
type: MenuItemType.LINK,
text: 'menu.section.quality-assurance',
link: '/admin/notifications/quality-assurance',
},
},
] as MenuSubSection[];
}),
);
}
}

View File

@@ -0,0 +1,50 @@
/**
* 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';
@Injectable()
export class ProcessesMenuProvider 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.processes',
link: '/processes',
},
icon: 'terminal',
},
] as PartialMenuSection[];
}),
);
}
}

View File

@@ -0,0 +1,75 @@
/**
* 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 { ScriptDataService } from '../../../core/data/processes/script-data.service';
import { MenuItemType } from '../menu-item-type.model';
import {
AbstractExpandableMenuProvider,
MenuSubSection,
MenuTopSection,
} from './expandable-menu-provider';
@Injectable()
export class RegistriesMenuProvider extends AbstractExpandableMenuProvider {
constructor(
protected authorizationService: AuthorizationDataService,
protected scriptDataService: ScriptDataService,
protected modalService: NgbModal,
) {
super();
}
public getTopSection(): Observable<MenuTopSection> {
return observableOf(
{
model: {
type: MenuItemType.TEXT,
text: 'menu.section.registries',
},
icon: 'list',
},
);
}
public getSubSections(): Observable<MenuSubSection[]> {
return combineLatest([
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
]).pipe(
map(([authorized]) => {
return [
{
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.registries_metadata',
link: 'admin/registries/metadata',
},
},
{
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.registries_format',
link: 'admin/registries/bitstream-formats',
},
},
] as MenuSubSection[];
}),
);
}
}

View File

@@ -0,0 +1,43 @@
/**
* 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 { hasValue } from '../../empty.util';
import {
AbstractMenuProvider,
PartialMenuSection,
} from '../menu-provider';
export abstract class AbstractRouteContextMenuProvider<T> extends AbstractMenuProvider {
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 (hasValue(routeContext) && this.isApplicable(routeContext)) {
return this.getSectionsForContext(routeContext);
} else {
return observableOf([]);
}
}),
);
}
protected isApplicable(routeContext: T): boolean {
return true;
}
}

View File

@@ -0,0 +1,70 @@
/**
* 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 {
ActivatedRouteSnapshot,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of,
} from 'rxjs';
import { hasNoValue } from '../../empty.util';
import { MenuItemType } from '../menu-item-type.model';
import { PartialMenuSection } from '../menu-provider';
import { AbstractRouteContextMenuProvider } from './route-context.menu';
interface StatisticsLink {
id: string,
link: string,
}
@Injectable()
export class StatisticsMenuProvider extends AbstractRouteContextMenuProvider<string> {
allRoutes = false;
public getRouteContext(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
// todo: this won't work for entities!
let page = state.url.split('/')[1];
const uuid = route.params.id;
// todo: wow
if (page === 'entities') {
page = 'items';
}
if (
!['home', 'items', 'communities', 'collections'].includes(page) ||
(hasNoValue(uuid) && page !== 'home')
) {
return of(undefined);
}
if (page === 'home') {
return of(`statistics`);
} else {
return of(`statistics/${page}/${uuid}`);
}
}
public getSectionsForContext(link: string): Observable<PartialMenuSection[]> {
return of([
{
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics',
link,
},
icon: 'chart-line',
},
] as PartialMenuSection[]);
}
}

View File

@@ -0,0 +1,50 @@
/**
* 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';
@Injectable()
export class SystemWideAlertMenuProvider 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.system-wide-alert',
link: '/admin/system-wide-alert',
},
icon: 'exclamation-circle',
},
] as PartialMenuSection[];
}),
);
}
}

View File

@@ -0,0 +1,50 @@
/**
* 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';
@Injectable()
export class WorkflowMenuProvider 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.workflow',
link: '/admin/workflow',
},
icon: 'user-check',
},
] as PartialMenuSection[];
}),
);
}
}