From 961e15fe5f73e6aaffb1f0b841b60fc1c3147f21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Abel=20G=C3=B3mez?= Date: Wed, 3 Jul 2024 23:13:13 +0200 Subject: [PATCH 001/157] Make the default tab for browsing communities and collections configurable --- config/config.example.yml | 8 ++++++++ .../collection-page/collection-page-routes.ts | 6 +++++- .../community-page/community-page-routes.ts | 6 +++++- .../comcol-page-browse-by.component.ts | 20 +++++++++++++++++-- src/assets/i18n/en.json5 | 4 ++++ src/assets/i18n/es.json5 | 6 ++++++ .../collection-page-config.interface.ts | 1 + src/config/community-page-config.interface.ts | 1 + src/config/default-app-config.ts | 2 ++ src/environments/environment.test.ts | 2 ++ 10 files changed, 52 insertions(+), 4 deletions(-) diff --git a/config/config.example.yml b/config/config.example.yml index 93386274e6..7695aed3a1 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -325,12 +325,20 @@ item: # Community Page Config community: + # Default tab to be shown when browsing a Community. Valid values are: comcols, search, or browse_ + # must be any of the configured "browse by" fields, e.g., dateissued, author, title, or subject + # When the default tab is not the 'search' tab, the search tab is moved to the last position + defaultBrowseTab: search # Search tab config searchSection: showSidebar: true # Collection Page Config collection: + # Default tab to be shown when browsing a Collection. Valid values are: search, or browse_ + # must be any of the configured "browse by" fields, e.g., dateissued, author, title, or subject + # When the default tab is not the 'search' tab, the search tab is moved to the last position + defaultBrowseTab: search # Search tab config searchSection: showSidebar: true diff --git a/src/app/collection-page/collection-page-routes.ts b/src/app/collection-page/collection-page-routes.ts index f2dadc3fbe..2e8f9e3f22 100644 --- a/src/app/collection-page/collection-page-routes.ts +++ b/src/app/collection-page/collection-page-routes.ts @@ -85,9 +85,13 @@ export const ROUTES: Route[] = [ component: ThemedCollectionPageComponent, children: [ { - path: '', + path: 'search', pathMatch: 'full', component: ComcolSearchSectionComponent, + resolve: { + breadcrumb: browseByI18nBreadcrumbResolver, + }, + data: { breadcrumbKey: 'collection.search' }, }, { path: 'browse/:id', diff --git a/src/app/community-page/community-page-routes.ts b/src/app/community-page/community-page-routes.ts index d9505c53b1..5ad9c3526e 100644 --- a/src/app/community-page/community-page-routes.ts +++ b/src/app/community-page/community-page-routes.ts @@ -72,9 +72,13 @@ export const ROUTES: Route[] = [ component: ThemedCommunityPageComponent, children: [ { - path: '', + path: 'search', pathMatch: 'full', component: ComcolSearchSectionComponent, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + data: { breadcrumbKey: 'community.search' }, }, { path: 'subcoms-cols', diff --git a/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts b/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts index a7730ee7dd..a189cf2ddd 100644 --- a/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts +++ b/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts @@ -5,6 +5,7 @@ import { } from '@angular/common'; import { Component, + Inject, Input, OnDestroy, OnInit, @@ -33,6 +34,7 @@ import { take, } from 'rxjs/operators'; +import { APP_CONFIG, AppConfig } from '../../../../config/app-config.interface'; import { getCollectionPageRoute } from '../../../collection-page/collection-page-routing-paths'; import { getCommunityPageRoute } from '../../../community-page/community-page-routing-paths'; import { BrowseService } from '../../../core/browse/browse.service'; @@ -82,6 +84,7 @@ export class ComcolPageBrowseByComponent implements OnDestroy, OnInit { subs: Subscription[] = []; constructor( + @Inject(APP_CONFIG) public appConfig: AppConfig, public router: Router, private browseService: BrowseService, ) { @@ -99,14 +102,14 @@ export class ComcolPageBrowseByComponent implements OnDestroy, OnInit { allOptions.push({ id: 'search', label: 'collection.page.browse.search.head', - routerLink: comColRoute, + routerLink: `${comColRoute}/search`, }); } else if (this.contentType === 'community') { comColRoute = getCommunityPageRoute(this.id); allOptions.push({ id: 'search', label: 'collection.page.browse.search.head', - routerLink: comColRoute, + routerLink: `${comColRoute}/search`, }); allOptions.push({ id: 'comcols', @@ -120,6 +123,10 @@ export class ComcolPageBrowseByComponent implements OnDestroy, OnInit { label: `browse.comcol.by.${config.id}`, routerLink: `${comColRoute}/browse/${config.id}`, }))); + + if (this.appConfig[this.contentType].defaultBrowseTab !== 'search') { + allOptions.push(allOptions.shift()) + } } return allOptions; }), @@ -140,6 +147,15 @@ export class ComcolPageBrowseByComponent implements OnDestroy, OnInit { } } })); + + this.allOptions$.pipe( + take(1), + ).subscribe((allOptions: ComColPageNavOption[]) => { + if (!allOptions.find(o => o.routerLink === this.router.url?.split('?')[0])) { + var option = allOptions.find(o => o.id === this.appConfig[this.contentType].defaultBrowseTab); + void this.router.navigate([option.routerLink], { queryParams: option.params }); + } + }); } ngOnDestroy(): void { diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 8f20c657f9..78be56caa1 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1324,6 +1324,8 @@ "collection.page.news": "News", + "collection.search.breadcrumbs": "Search", + "collection.search.results.head": "Search Results", "collection.select.confirm": "Confirm selected", @@ -1562,6 +1564,8 @@ "community.all-lists.head": "Subcommunities and Collections", + "community.search.breadcrumbs": "Search", + "community.search.results.head": "Search Results", "community.sub-collection-list.head": "Collections in this Community", diff --git a/src/assets/i18n/es.json5 b/src/assets/i18n/es.json5 index 55962d25b5..e359d60523 100644 --- a/src/assets/i18n/es.json5 +++ b/src/assets/i18n/es.json5 @@ -1929,6 +1929,9 @@ // "collection.page.news": "News", "collection.page.news": "Noticias", + // "collection.search.breadcrumbs": "Search", + "collection.search.breadcrumbs": "Buscar", + // "collection.select.confirm": "Confirm selected", "collection.select.confirm": "Confirmar seleccionado", @@ -2250,6 +2253,9 @@ // "community.all-lists.head": "Subcommunities and Collections", "community.all-lists.head": "Subcomunidades y colecciones", + // "community.search.breadcrumbs": "Search", + "community.search.breadcrumbs": "Buscar", + // "community.sub-collection-list.head": "Collections in this Community", "community.sub-collection-list.head": "Colecciones de esta comunidad", diff --git a/src/config/collection-page-config.interface.ts b/src/config/collection-page-config.interface.ts index 6b8352e686..5aec06daea 100644 --- a/src/config/collection-page-config.interface.ts +++ b/src/config/collection-page-config.interface.ts @@ -4,6 +4,7 @@ import { Config } from './config.interface'; * Collection Page Config */ export interface CollectionPageConfig extends Config { + defaultBrowseTab: string; searchSection: CollectionSearchSectionConfig; edit: { undoTimeout: number; diff --git a/src/config/community-page-config.interface.ts b/src/config/community-page-config.interface.ts index 268f4d6a5e..72b859bffc 100644 --- a/src/config/community-page-config.interface.ts +++ b/src/config/community-page-config.interface.ts @@ -4,6 +4,7 @@ import { Config } from './config.interface'; * Community Page Config */ export interface CommunityPageConfig extends Config { + defaultBrowseTab: string; searchSection: CommunitySearchSectionConfig; } diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index 3682d095cd..ffa24cee2b 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -333,6 +333,7 @@ export class DefaultAppConfig implements AppConfig { // Community Page Config community: CommunityPageConfig = { + defaultBrowseTab: 'search', searchSection: { showSidebar: true, }, @@ -340,6 +341,7 @@ export class DefaultAppConfig implements AppConfig { // Collection Page Config collection: CollectionPageConfig = { + defaultBrowseTab: 'search', searchSection: { showSidebar: true, }, diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index cd02e35fb1..ceaf40ca0a 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -267,11 +267,13 @@ export const environment: BuildConfig = { }, }, community: { + defaultBrowseTab: 'search', searchSection: { showSidebar: true, }, }, collection: { + defaultBrowseTab: 'search', searchSection: { showSidebar: true, }, From 33764f4caa2014dbcf9c09af630b96e26e21592a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Abel=20G=C3=B3mez?= Date: Wed, 3 Jul 2024 23:54:58 +0200 Subject: [PATCH 002/157] Minor fixes to avoid lint errors --- .../comcol-page-browse-by.component.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts b/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts index a189cf2ddd..3fcdf8b8c5 100644 --- a/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts +++ b/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts @@ -34,7 +34,10 @@ import { take, } from 'rxjs/operators'; -import { APP_CONFIG, AppConfig } from '../../../../config/app-config.interface'; +import { + APP_CONFIG, + AppConfig +} from '../../../../config/app-config.interface'; import { getCollectionPageRoute } from '../../../collection-page/collection-page-routing-paths'; import { getCommunityPageRoute } from '../../../community-page/community-page-routing-paths'; import { BrowseService } from '../../../core/browse/browse.service'; @@ -125,7 +128,7 @@ export class ComcolPageBrowseByComponent implements OnDestroy, OnInit { }))); if (this.appConfig[this.contentType].defaultBrowseTab !== 'search') { - allOptions.push(allOptions.shift()) + allOptions.push(allOptions.shift()); } } return allOptions; @@ -152,7 +155,7 @@ export class ComcolPageBrowseByComponent implements OnDestroy, OnInit { take(1), ).subscribe((allOptions: ComColPageNavOption[]) => { if (!allOptions.find(o => o.routerLink === this.router.url?.split('?')[0])) { - var option = allOptions.find(o => o.id === this.appConfig[this.contentType].defaultBrowseTab); + const option = allOptions.find(o => o.id === this.appConfig[this.contentType].defaultBrowseTab); void this.router.navigate([option.routerLink], { queryParams: option.params }); } }); From b7f3711de1b9e49f2e43f3873019eea02e4642f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Abel=20G=C3=B3mez?= Date: Thu, 4 Jul 2024 00:04:40 +0200 Subject: [PATCH 003/157] Other minor lint fixes --- .../comcol-page-browse-by/comcol-page-browse-by.component.ts | 2 +- src/assets/i18n/en.json5 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts b/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts index 3fcdf8b8c5..f740722eed 100644 --- a/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts +++ b/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts @@ -36,7 +36,7 @@ import { import { APP_CONFIG, - AppConfig + AppConfig, } from '../../../../config/app-config.interface'; import { getCollectionPageRoute } from '../../../collection-page/collection-page-routing-paths'; import { getCommunityPageRoute } from '../../../community-page/community-page-routing-paths'; diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 78be56caa1..a697fa77c8 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1565,7 +1565,7 @@ "community.all-lists.head": "Subcommunities and Collections", "community.search.breadcrumbs": "Search", - + "community.search.results.head": "Search Results", "community.sub-collection-list.head": "Collections in this Community", From b49216c173d1a25b6aacf21c8d7f4c7cb58ea3b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Abel=20G=C3=B3mez?= Date: Thu, 4 Jul 2024 00:11:30 +0200 Subject: [PATCH 004/157] Fix trailing spaces --- .../comcol-page-browse-by/comcol-page-browse-by.component.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts b/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts index f740722eed..a639737c7c 100644 --- a/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts +++ b/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts @@ -33,8 +33,7 @@ import { startWith, take, } from 'rxjs/operators'; - -import { +import { APP_CONFIG, AppConfig, } from '../../../../config/app-config.interface'; From 6cecd0abbe09c648889c95f9c1e54baba7f9439d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Abel=20G=C3=B3mez?= Date: Thu, 4 Jul 2024 00:48:04 +0200 Subject: [PATCH 005/157] Fix imports --- .../comcol-page-browse-by/comcol-page-browse-by.component.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts b/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts index a639737c7c..8f4b1268f8 100644 --- a/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts +++ b/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts @@ -33,6 +33,7 @@ import { startWith, take, } from 'rxjs/operators'; + import { APP_CONFIG, AppConfig, From 531d3286632cb56a1dc409b2ab3e48d276b17805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Abel=20G=C3=B3mez?= Date: Thu, 4 Jul 2024 05:35:36 +0200 Subject: [PATCH 006/157] Fix resolving the wrong segment of the route in the comcol search component --- .../comcol-search-section/comcol-search-section.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/shared/comcol/sections/comcol-search-section/comcol-search-section.component.ts b/src/app/shared/comcol/sections/comcol-search-section/comcol-search-section.component.ts index 1672ad169f..d0f88ae54e 100644 --- a/src/app/shared/comcol/sections/comcol-search-section/comcol-search-section.component.ts +++ b/src/app/shared/comcol/sections/comcol-search-section/comcol-search-section.component.ts @@ -55,7 +55,7 @@ export class ComcolSearchSectionComponent implements OnInit { } ngOnInit(): void { - this.comcol$ = this.route.data.pipe( + this.comcol$ = this.route.parent.data.pipe( map((data: Data) => (data.dso as RemoteData).payload), ); this.showSidebar$ = this.comcol$.pipe( From 6306776f8b63323822707ef76b73dbd7b1530807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Abel=20G=C3=B3mez?= Date: Thu, 4 Jul 2024 05:54:45 +0200 Subject: [PATCH 007/157] Simplify code detecting a navigation to the default route --- .../comcol-page-browse-by.component.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts b/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts index 8f4b1268f8..b0a3eafeb7 100644 --- a/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts +++ b/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts @@ -145,20 +145,14 @@ export class ComcolPageBrowseByComponent implements OnDestroy, OnInit { ), ]).subscribe(([navOptions, url]: [ComColPageNavOption[], string]) => { for (const option of navOptions) { - if (option.routerLink === url?.split('?')[0]) { + if (url?.split('?')[0].endsWith(`/${this.id}`)) { + const option = navOptions.find(o => o.id === this.appConfig[this.contentType].defaultBrowseTab); + void this.router.navigate([option.routerLink], { queryParams: option.params }); + } else if (option.routerLink === url?.split('?')[0]) { this.currentOption$.next(option); } } })); - - this.allOptions$.pipe( - take(1), - ).subscribe((allOptions: ComColPageNavOption[]) => { - if (!allOptions.find(o => o.routerLink === this.router.url?.split('?')[0])) { - const option = allOptions.find(o => o.id === this.appConfig[this.contentType].defaultBrowseTab); - void this.router.navigate([option.routerLink], { queryParams: option.params }); - } - }); } ngOnDestroy(): void { From 4de99ba19d51b3bdc57fbf93c0853bc0b8f33b5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Abel=20G=C3=B3mez?= Date: Thu, 4 Jul 2024 06:02:31 +0200 Subject: [PATCH 008/157] Refactor condition --- .../comcol-page-browse-by.component.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts b/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts index b0a3eafeb7..2a2489de05 100644 --- a/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts +++ b/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts @@ -144,12 +144,14 @@ export class ComcolPageBrowseByComponent implements OnDestroy, OnInit { distinctUntilChanged(), ), ]).subscribe(([navOptions, url]: [ComColPageNavOption[], string]) => { - for (const option of navOptions) { - if (url?.split('?')[0].endsWith(`/${this.id}`)) { - const option = navOptions.find(o => o.id === this.appConfig[this.contentType].defaultBrowseTab); - void this.router.navigate([option.routerLink], { queryParams: option.params }); - } else if (option.routerLink === url?.split('?')[0]) { - this.currentOption$.next(option); + if (url?.split('?')[0].endsWith(`/${this.id}`)) { + const option = navOptions.find(o => o.id === this.appConfig[this.contentType].defaultBrowseTab); + void this.router.navigate([option.routerLink], { queryParams: option.params }); + } else { + for (const option of navOptions) { + if (option.routerLink === url?.split('?')[0]) { + this.currentOption$.next(option); + } } } })); From 583dc1f3682ccdf68854e3d777554683660dca51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Abel=20G=C3=B3mez?= Date: Thu, 4 Jul 2024 17:24:27 +0200 Subject: [PATCH 009/157] Fix SubComColSectionComponent test --- .../comcol-search-section.component.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/shared/comcol/sections/comcol-search-section/comcol-search-section.component.spec.ts b/src/app/shared/comcol/sections/comcol-search-section/comcol-search-section.component.spec.ts index 48ad8e1c04..f887d1d524 100644 --- a/src/app/shared/comcol/sections/comcol-search-section/comcol-search-section.component.spec.ts +++ b/src/app/shared/comcol/sections/comcol-search-section/comcol-search-section.component.spec.ts @@ -18,6 +18,7 @@ describe('ComcolSearchSectionComponent', () => { beforeEach(async () => { route = new ActivatedRouteStub(); + route.parent = new ActivatedRouteStub(); await TestBed.configureTestingModule({ imports: [ComcolSearchSectionComponent], From 0291942613e7627a03d882b1bba6a88a30e1164b Mon Sep 17 00:00:00 2001 From: Yury Bondarenko Date: Thu, 28 Dec 2023 13:46:39 +0100 Subject: [PATCH 010/157] Proof-of-concept: independent menu section providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../admin-sidebar-section.component.ts | 11 +- ...dable-admin-sidebar-section.component.html | 2 +- ...andable-admin-sidebar-section.component.ts | 5 +- src/app/app-routing.module.ts | 40 +++--- src/app/app.menus.ts | 58 ++++++++ src/app/app.module.ts | 60 ++++++--- .../collection-page-routing.module.ts | 52 +++----- .../community-page-routing.module.ts | 47 +++---- src/app/home-page/home-page-routing.module.ts | 19 +-- src/app/item-page/item-page-routing.module.ts | 62 +++++---- src/app/item-page/item-page.resolver.ts | 4 +- .../expandable-navbar-section.component.ts | 12 +- .../navbar-section.component.ts | 14 +- ...dit-menu-expandable-section.component.scss | 14 +- ...-edit-menu-expandable-section.component.ts | 10 +- .../dso-edit-menu-section.component.ts | 10 +- .../menu/menu-item/models/altmetric.model.ts | 2 +- .../menu-item/models/external-link.model.ts | 2 +- .../menu/menu-item/models/link.model.ts | 2 +- .../menu/menu-item/models/menu-item.model.ts | 2 +- .../menu/menu-item/models/onclick.model.ts | 4 +- .../menu/menu-item/models/search.model.ts | 4 +- .../menu/menu-item/models/text.model.ts | 2 +- src/app/shared/menu/menu-provider.service.ts | 126 ++++++++++++++++++ src/app/shared/menu/menu-provider.ts | 47 +++++++ src/app/shared/menu/menu-section.model.ts | 93 +++++++++++-- ....ts => abstract-menu-section.component.ts} | 41 ++++-- .../menu-section.component.spec.ts | 37 +++-- src/app/shared/menu/menu-state.model.ts | 2 +- src/app/shared/menu/menu.component.ts | 10 +- src/app/shared/menu/menu.module.ts | 16 +-- src/app/shared/menu/menu.reducer.spec.ts | 2 +- src/app/shared/menu/menu.reducer.ts | 2 +- src/app/shared/menu/menu.resolver.ts | 35 +++++ src/app/shared/menu/menu.structure.ts | 46 +++++++ .../menu/providers/access-control.menu.ts | 94 +++++++++++++ .../menu/providers/admin-search.menu.ts | 50 +++++++ src/app/shared/menu/providers/browse.menu.ts | 67 ++++++++++ .../menu/providers/comcol-subscribe.menu.ts | 65 +++++++++ .../menu/providers/community-list.menu.ts | 35 +++++ .../shared/menu/providers/curation.menu.ts | 51 +++++++ .../shared/menu/providers/dso-edit.menu.ts | 53 ++++++++ src/app/shared/menu/providers/dso.menu.ts | 56 ++++++++ src/app/shared/menu/providers/edit.menu.ts | 92 +++++++++++++ .../providers/expandable-menu-provider.ts | 64 +++++++++ src/app/shared/menu/providers/export.menu.ts | 88 ++++++++++++ src/app/shared/menu/providers/health.menu.ts | 50 +++++++ src/app/shared/menu/providers/import.menu.ts | 80 +++++++++++ .../shared/menu/providers/item-claim.menu.ts | 93 +++++++++++++ .../shared/menu/providers/item-orcid.menu.ts | 57 ++++++++ .../menu/providers/item-versioning.menu.ts | 60 +++++++++ src/app/shared/menu/providers/item.menu.ts | 52 ++++++++ src/app/shared/menu/providers/new.menu.ts | 100 ++++++++++++++ .../menu/providers/notifications.menu.ts | 68 ++++++++++ .../shared/menu/providers/processes.menu.ts | 50 +++++++ .../shared/menu/providers/registries.menu.ts | 75 +++++++++++ .../menu/providers/route-context.menu.ts | 43 ++++++ .../shared/menu/providers/statistics.menu.ts | 70 ++++++++++ .../menu/providers/system-wide-alert.menu.ts | 50 +++++++ .../shared/menu/providers/workflow.menu.ts | 50 +++++++ 60 files changed, 2281 insertions(+), 227 deletions(-) create mode 100644 src/app/app.menus.ts create mode 100644 src/app/shared/menu/menu-provider.service.ts create mode 100644 src/app/shared/menu/menu-provider.ts rename src/app/shared/menu/menu-section/{menu-section.component.ts => abstract-menu-section.component.ts} (89%) create mode 100644 src/app/shared/menu/menu.resolver.ts create mode 100644 src/app/shared/menu/menu.structure.ts create mode 100644 src/app/shared/menu/providers/access-control.menu.ts create mode 100644 src/app/shared/menu/providers/admin-search.menu.ts create mode 100644 src/app/shared/menu/providers/browse.menu.ts create mode 100644 src/app/shared/menu/providers/comcol-subscribe.menu.ts create mode 100644 src/app/shared/menu/providers/community-list.menu.ts create mode 100644 src/app/shared/menu/providers/curation.menu.ts create mode 100644 src/app/shared/menu/providers/dso-edit.menu.ts create mode 100644 src/app/shared/menu/providers/dso.menu.ts create mode 100644 src/app/shared/menu/providers/edit.menu.ts create mode 100644 src/app/shared/menu/providers/expandable-menu-provider.ts create mode 100644 src/app/shared/menu/providers/export.menu.ts create mode 100644 src/app/shared/menu/providers/health.menu.ts create mode 100644 src/app/shared/menu/providers/import.menu.ts create mode 100644 src/app/shared/menu/providers/item-claim.menu.ts create mode 100644 src/app/shared/menu/providers/item-orcid.menu.ts create mode 100644 src/app/shared/menu/providers/item-versioning.menu.ts create mode 100644 src/app/shared/menu/providers/item.menu.ts create mode 100644 src/app/shared/menu/providers/new.menu.ts create mode 100644 src/app/shared/menu/providers/notifications.menu.ts create mode 100644 src/app/shared/menu/providers/processes.menu.ts create mode 100644 src/app/shared/menu/providers/registries.menu.ts create mode 100644 src/app/shared/menu/providers/route-context.menu.ts create mode 100644 src/app/shared/menu/providers/statistics.menu.ts create mode 100644 src/app/shared/menu/providers/system-wide-alert.menu.ts create mode 100644 src/app/shared/menu/providers/workflow.menu.ts diff --git a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts index d6cd803622..3606cb299f 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts @@ -1,5 +1,5 @@ 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 { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator'; import { LinkMenuItemModel } from '../../../shared/menu/menu-item/models/link.model'; @@ -19,7 +19,7 @@ import { Router } from '@angular/router'; }) @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 @@ -33,16 +33,17 @@ export class AdminSidebarSectionComponent extends MenuSectionComponent implement isDisabled: boolean; constructor( - @Inject('sectionDataProvider') menuSection: MenuSection, + @Inject('sectionDataProvider') protected section: MenuSection, protected menuService: MenuService, protected injector: Injector, protected router: Router, ) { - super(menuSection, menuService, injector); - this.itemModel = menuSection.model as LinkMenuItemModel; + super(menuService, injector); + this.itemModel = section.model as LinkMenuItemModel; } ngOnInit(): void { + // todo: should support all menu entries? this.isDisabled = this.itemModel?.disabled || isEmpty(this.itemModel?.link); super.ngOnInit(); } diff --git a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.html b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.html index 1f4666bbd0..23f5a958d2 100644 --- a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.html +++ b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.html @@ -13,7 +13,7 @@ (keyup.enter)="toggleSection($event)" >
- +
diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index b04af0df3e..47c97b8782 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -3202,8 +3202,6 @@ "login.breadcrumbs": "Login", - "login.no-auth-methods": "No other authentication methods are available for this DSpace instance. Please contact your administrator.", - "logout.form.header": "Log out from DSpace", "logout.form.submit": "Log out", diff --git a/src/assets/i18n/it.json5 b/src/assets/i18n/it.json5 index 75119cc89b..64694ace22 100644 --- a/src/assets/i18n/it.json5 +++ b/src/assets/i18n/it.json5 @@ -4227,11 +4227,6 @@ // "login.breadcrumbs": "Login", "login.breadcrumbs": "Accesso", - // "login.no-auth-methods": "No other authentication methods are available for this DSpace instance. Please contact your administrator.", - // TODO New key - Add a translation - "login.no-auth-methods": "No other authentication methods are available for this DSpace instance. Please contact your administrator.", - - // "logout.form.header": "Log out from DSpace", "logout.form.header": "Disconnettersi da DSpace", From 005936b18d90229b2251f0142c2a15e4a54885ed Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Fri, 11 Oct 2024 15:51:34 +0200 Subject: [PATCH 018/157] Support withSubs on the menuproviders in appmenu Adds support to define child providers for a parent menu using a .withSubs option. This parent menu will always be displayed as an expandable menu. When no children are visible, the expandable menus will be hidden. --- ...dable-admin-sidebar-section.component.html | 2 +- ...andable-admin-sidebar-section.component.ts | 10 + src/app/app.menus.ts | 12 +- .../collection-page.component.html | 3 - .../community-page.component.html | 3 - src/app/init.service.ts | 6 +- .../expandable-navbar-section.component.html | 42 ++-- .../expandable-navbar-section.component.ts | 12 +- ...dit-menu-expandable-section.component.html | 2 +- ...-edit-menu-expandable-section.component.ts | 23 +- src/app/shared/menu/initial-menus-state.ts | 2 +- src/app/shared/menu/menu-provider.service.ts | 200 ++++++++---------- src/app/shared/menu/menu-provider.ts | 30 ++- src/app/shared/menu/menu-section.model.ts | 2 + src/app/shared/menu/menu.component.ts | 2 +- src/app/shared/menu/menu.service.ts | 2 +- src/app/shared/menu/menu.structure.ts | 78 ++++--- .../menu/providers/comcol-subscribe.menu.ts | 8 +- .../menu/providers/dso-option-menu.service.ts | 30 +++ .../providers/expandable-menu-provider.ts | 9 +- .../expandable-parent-menu-provider.ts | 37 ++++ 21 files changed, 330 insertions(+), 185 deletions(-) create mode 100644 src/app/shared/menu/providers/dso-option-menu.service.ts create mode 100644 src/app/shared/menu/providers/expandable-parent-menu-provider.ts diff --git a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.html b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.html index 23f5a958d2..3edb01d621 100644 --- a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.html +++ b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.html @@ -1,4 +1,4 @@ -
diff --git a/src/app/community-page/community-page.component.html b/src/app/community-page/community-page.component.html index 6d5262d933..671bf28fd1 100644 --- a/src/app/community-page/community-page.component.html +++ b/src/app/community-page/community-page.component.html @@ -21,9 +21,6 @@ -
- -
diff --git a/src/app/init.service.ts b/src/app/init.service.ts index cf10f3e1c3..ce4055ebde 100644 --- a/src/app/init.service.ts +++ b/src/app/init.service.ts @@ -191,11 +191,7 @@ export abstract class InitService { this.breadcrumbsService.listenForRouteChanges(); this.themeService.listenForRouteChanges(); // this.menuService.listenForRouteChanges(); - this.menuProviderService.listenForRouteChanges().subscribe((done) => { - Object.values(MenuID).forEach((menuID) => { - this.menuService.buildRouteMenuSections(menuID); - }); - }) + this.menuProviderService.listenForRouteChanges(); } protected initPersistentMenus(): void { diff --git a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html index b502326164..61ca511674 100644 --- a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html +++ b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html @@ -1,22 +1,24 @@ - diff --git a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts index 6f05eb0e54..b56a29f115 100644 --- a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts +++ b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts @@ -3,10 +3,12 @@ import { MenuSection } from '../../shared/menu/menu-section.model'; import { NavbarSectionComponent } from '../navbar-section/navbar-section.component'; import { MenuService } from '../../shared/menu/menu.service'; import { slide } from '../../shared/animations/slide'; -import { first } from 'rxjs/operators'; +import { first, map } from 'rxjs/operators'; import { HostWindowService } from '../../shared/host-window.service'; import { rendersSectionForMenu } from '../../shared/menu/menu-section.decorator'; import { MenuID } from '../../shared/menu/menu-id.model'; +import { Observable } from 'rxjs'; +import { isNotEmpty } from '../../shared/empty.util'; /** * Represents an expandable section in the navbar @@ -24,6 +26,11 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp */ menuID = MenuID.PUBLIC; + /** + * Emits true when the top section has subsections, else emits false + */ + hasSubSections$: Observable; + constructor( @Inject('sectionDataProvider') protected section: MenuSection, protected menuService: MenuService, @@ -35,6 +42,9 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp ngOnInit() { super.ngOnInit(); + this.hasSubSections$ = this.subSections$.pipe( + map((subSections) => isNotEmpty(subSections)) + ); } /** diff --git a/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.html b/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.html index cb725e7d70..9c8c97ca6b 100644 --- a/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.html +++ b/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.html @@ -1,4 +1,4 @@ -
+
+ +
+
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.scss b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.spec.ts new file mode 100644 index 0000000000..06b984cfa4 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.spec.ts @@ -0,0 +1,353 @@ +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { TranslateModule } from '@ngx-translate/core'; + +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { MetadataField } from '../../../../core/metadata/metadata-field.model'; +import { MetadataSchema } from '../../../../core/metadata/metadata-schema.model'; +import { RegistryService } from '../../../../core/registry/registry.service'; +import { Collection } from '../../../../core/shared/collection.model'; +import { ConfidenceType } from '../../../../core/shared/confidence-type'; +import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; +import { Item } from '../../../../core/shared/item.model'; +import { MetadataValue } from '../../../../core/shared/metadata.models'; +import { Vocabulary } from '../../../../core/submission/vocabularies/models/vocabulary.model'; +import { VocabularyService } from '../../../../core/submission/vocabularies/vocabulary.service'; +import { DynamicOneboxModel } from '../../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model'; +import { DynamicScrollableDropdownModel } from '../../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; +import { createPaginatedList } from '../../../../shared/testing/utils.test'; +import { VocabularyServiceStub } from '../../../../shared/testing/vocabulary-service.stub'; +import { DsoEditMetadataValue } from '../../dso-edit-metadata-form'; +import { DsoEditMetadataAuthorityFieldComponent } from './dso-edit-metadata-authority-field.component'; + +describe('DsoEditMetadataAuthorityFieldComponent', () => { + let component: DsoEditMetadataAuthorityFieldComponent; + let fixture: ComponentFixture; + + let vocabularyService: any; + let itemService: ItemDataService; + let registryService: RegistryService; + let notificationsService: NotificationsService; + + let dso: DSpaceObject; + + const collection = Object.assign(new Collection(), { + uuid: 'fake-uuid', + }); + + const item = Object.assign(new Item(), { + _links: { + self: { href: 'fake-item-url/item' }, + }, + id: 'item', + uuid: 'item', + owningCollection: createSuccessfulRemoteDataObject$(collection), + }); + + const mockVocabularyScrollable: Vocabulary = { + id: 'scrollable', + name: 'scrollable', + scrollable: true, + hierarchical: false, + preloadLevel: 0, + type: 'vocabulary', + _links: { + self: { + href: 'self', + }, + entries: { + href: 'entries', + }, + }, + }; + + const mockVocabularyHierarchical: Vocabulary = { + id: 'hierarchical', + name: 'hierarchical', + scrollable: false, + hierarchical: true, + preloadLevel: 2, + type: 'vocabulary', + _links: { + self: { + href: 'self', + }, + entries: { + href: 'entries', + }, + }, + }; + + const mockVocabularySuggester: Vocabulary = { + id: 'suggester', + name: 'suggester', + scrollable: false, + hierarchical: false, + preloadLevel: 0, + type: 'vocabulary', + _links: { + self: { + href: 'self', + }, + entries: { + href: 'entries', + }, + }, + }; + + let editMetadataValue: DsoEditMetadataValue; + let metadataValue: MetadataValue; + let metadataSchema: MetadataSchema; + let metadataFields: MetadataField[]; + + beforeEach(async () => { + itemService = jasmine.createSpyObj('itemService', { + findByHref: createSuccessfulRemoteDataObject$(item), + }); + vocabularyService = new VocabularyServiceStub(); + registryService = jasmine.createSpyObj('registryService', { + queryMetadataFields: createSuccessfulRemoteDataObject$(createPaginatedList(metadataFields)), + }); + notificationsService = jasmine.createSpyObj('notificationsService', ['error', 'success']); + + metadataValue = Object.assign(new MetadataValue(), { + value: 'Regular Name', + language: 'en', + place: 0, + authority: undefined, + }); + editMetadataValue = new DsoEditMetadataValue(metadataValue); + metadataSchema = Object.assign(new MetadataSchema(), { + id: 0, + prefix: 'metadata', + namespace: 'https://example.com/', + }); + metadataFields = [ + Object.assign(new MetadataField(), { + id: 0, + element: 'regular', + qualifier: null, + schema: createSuccessfulRemoteDataObject$(metadataSchema), + }), + ]; + dso = Object.assign(new DSpaceObject(), { + _links: { + self: { href: 'fake-dso-url/dso' }, + }, + }); + + await TestBed.configureTestingModule({ + imports: [ + DsoEditMetadataAuthorityFieldComponent, + TranslateModule.forRoot(), + ], + providers: [ + { provide: VocabularyService, useValue: vocabularyService }, + { provide: ItemDataService, useValue: itemService }, + { provide: RegistryService, useValue: registryService }, + { provide: NotificationsService, useValue: notificationsService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DsoEditMetadataAuthorityFieldComponent); + component = fixture.componentInstance; + component.mdValue = editMetadataValue; + component.dso = dso; + fixture.detectChanges(); + }); + + describe('when the metadata field uses a scrollable vocabulary and is editing', () => { + beforeEach(waitForAsync(() => { + spyOn(vocabularyService, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularyScrollable)); + metadataValue = Object.assign(new MetadataValue(), { + value: 'Authority Controlled value', + language: 'en', + place: 0, + authority: null, + }); + editMetadataValue = new DsoEditMetadataValue(metadataValue); + editMetadataValue.editing = true; + component.mdValue = editMetadataValue; + component.mdField = 'metadata.scrollable'; + component.ngOnInit(); + fixture.detectChanges(); + })); + + it('should render the DsDynamicScrollableDropdownComponent', () => { + expect(vocabularyService.getVocabularyByMetadataAndCollection).toHaveBeenCalled(); + expect(fixture.debugElement.query(By.css('ds-dynamic-scrollable-dropdown'))).toBeTruthy(); + }); + + it('getModel should return a DynamicScrollableDropdownModel', () => { + const model = component.getModel(); + + expect(model instanceof DynamicScrollableDropdownModel).toBe(true); + expect(model.vocabularyOptions.name).toBe(mockVocabularyScrollable.name); + }); + }); + + describe('when the metadata field uses a hierarchical vocabulary and is editing', () => { + beforeEach(waitForAsync(() => { + spyOn(vocabularyService, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularyHierarchical)); + metadataValue = Object.assign(new MetadataValue(), { + value: 'Authority Controlled value', + language: 'en', + place: 0, + authority: null, + }); + editMetadataValue = new DsoEditMetadataValue(metadataValue); + editMetadataValue.editing = true; + component.mdValue = editMetadataValue; + component.mdField = 'metadata.hierarchical'; + component.ngOnInit(); + fixture.detectChanges(); + })); + + it('should render the DsDynamicOneboxComponent', () => { + expect(vocabularyService.getVocabularyByMetadataAndCollection).toHaveBeenCalled(); + expect(fixture.debugElement.query(By.css('ds-dynamic-onebox'))).toBeTruthy(); + }); + + it('getModel should return a DynamicOneboxModel', () => { + const model = component.getModel(); + + expect(model instanceof DynamicOneboxModel).toBe(true); + expect(model.vocabularyOptions.name).toBe(mockVocabularyHierarchical.name); + }); + }); + + describe('when the metadata field uses a suggester vocabulary and is editing', () => { + beforeEach(waitForAsync(() => { + spyOn(vocabularyService, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularySuggester)); + spyOn(component.confirm, 'emit'); + metadataValue = Object.assign(new MetadataValue(), { + value: 'Authority Controlled value', + language: 'en', + place: 0, + authority: 'authority-key', + confidence: ConfidenceType.CF_UNCERTAIN, + }); + editMetadataValue = new DsoEditMetadataValue(metadataValue); + editMetadataValue.editing = true; + component.mdValue = editMetadataValue; + component.mdField = 'metadata.suggester'; + component.ngOnInit(); + fixture.detectChanges(); + })); + + it('should render the DsDynamicOneboxComponent', () => { + expect(vocabularyService.getVocabularyByMetadataAndCollection).toHaveBeenCalled(); + expect(fixture.debugElement.query(By.css('ds-dynamic-onebox'))).toBeTruthy(); + }); + + it('getModel should return a DynamicOneboxModel', () => { + const model = component.getModel(); + + expect(model instanceof DynamicOneboxModel).toBe(true); + expect(model.vocabularyOptions.name).toBe(mockVocabularySuggester.name); + }); + + describe('authority key edition', () => { + + it('should update confidence to CF_NOVALUE when authority is cleared', () => { + component.mdValue.newValue.authority = ''; + + component.onChangeAuthorityKey(); + + expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_NOVALUE); + expect(component.confirm.emit).toHaveBeenCalledWith(false); + }); + + it('should update confidence to CF_ACCEPTED when authority key is edited', () => { + component.mdValue.newValue.authority = 'newAuthority'; + component.mdValue.originalValue.authority = 'oldAuthority'; + + component.onChangeAuthorityKey(); + + expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_ACCEPTED); + expect(component.confirm.emit).toHaveBeenCalledWith(false); + }); + + it('should not update confidence when authority key remains the same', () => { + component.mdValue.newValue.authority = 'sameAuthority'; + component.mdValue.originalValue.authority = 'sameAuthority'; + + component.onChangeAuthorityKey(); + + expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_UNCERTAIN); + expect(component.confirm.emit).not.toHaveBeenCalled(); + }); + + it('should call onChangeEditingAuthorityStatus with true when clicking the lock button', () => { + spyOn(component, 'onChangeEditingAuthorityStatus'); + const lockButton = fixture.nativeElement.querySelector('#metadata-confirm-btn'); + + lockButton.click(); + + expect(component.onChangeEditingAuthorityStatus).toHaveBeenCalledWith(true); + }); + + it('should disable the input when editingAuthority is false', (done) => { + component.editingAuthority = false; + + fixture.detectChanges(); + + fixture.detectChanges(); + fixture.whenStable().then(() => { + const inputElement = fixture.nativeElement.querySelector('input[data-test="authority-input"]'); + expect(inputElement.disabled).toBeTruthy(); + done(); + }); + }); + + it('should enable the input when editingAuthority is true', (done) => { + component.editingAuthority = true; + + fixture.detectChanges(); + fixture.whenStable().then(() => { + const inputElement = fixture.nativeElement.querySelector('input[data-test="authority-input"]'); + expect(inputElement.disabled).toBeFalsy(); + done(); + }); + + + }); + + it('should update mdValue.newValue properties when authority is present', () => { + const event = { + value: 'Some value', + authority: 'Some authority', + }; + + component.onChangeAuthorityField(event); + + expect(component.mdValue.newValue.value).toBe(event.value); + expect(component.mdValue.newValue.authority).toBe(event.authority); + expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_ACCEPTED); + expect(component.confirm.emit).toHaveBeenCalledWith(false); + }); + + it('should update mdValue.newValue properties when authority is not present', () => { + const event = { + value: 'Some value', + authority: null, + }; + + component.onChangeAuthorityField(event); + + expect(component.mdValue.newValue.value).toBe(event.value); + expect(component.mdValue.newValue.authority).toBeNull(); + expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_UNSET); + expect(component.confirm.emit).toHaveBeenCalledWith(false); + }); + + }); + + }); +}); diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.ts new file mode 100644 index 0000000000..3dfc63d325 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.ts @@ -0,0 +1,303 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + ChangeDetectorRef, + Component, + OnChanges, + OnInit, + SimpleChanges, +} from '@angular/core'; +import { + FormsModule, + UntypedFormControl, + UntypedFormGroup, +} from '@angular/forms'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + BehaviorSubject, + Observable, + of as observableOf, +} from 'rxjs'; +import { + map, + switchMap, + take, + tap, +} from 'rxjs/operators'; + +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { RegistryService } from '../../../../core/registry/registry.service'; +import { ConfidenceType } from '../../../../core/shared/confidence-type'; +import { + getFirstCompletedRemoteData, + metadataFieldsToString, +} from '../../../../core/shared/operators'; +import { Vocabulary } from '../../../../core/submission/vocabularies/models/vocabulary.model'; +import { VocabularyOptions } from '../../../../core/submission/vocabularies/models/vocabulary-options.model'; +import { isNotEmpty } from '../../../../shared/empty.util'; +import { DsDynamicOneboxComponent } from '../../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component'; +import { + DsDynamicOneboxModelConfig, + DynamicOneboxModel, +} from '../../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model'; +import { DsDynamicScrollableDropdownComponent } from '../../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component'; +import { + DynamicScrollableDropdownModel, + DynamicScrollableDropdownModelConfig, +} from '../../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; +import { FormFieldMetadataValueObject } from '../../../../shared/form/builder/models/form-field-metadata-value.model'; +import { AuthorityConfidenceStateDirective } from '../../../../shared/form/directives/authority-confidence-state.directive'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { followLink } from '../../../../shared/utils/follow-link-config.model'; +import { AbstractDsoEditMetadataValueFieldComponent } from '../abstract-dso-edit-metadata-value-field.component'; +import { DsoEditMetadataFieldService } from '../dso-edit-metadata-field.service'; + +/** + * The component used to gather input for authority controlled metadata fields + */ +@Component({ + selector: 'ds-dso-edit-metadata-authority-field', + templateUrl: './dso-edit-metadata-authority-field.component.html', + styleUrls: ['./dso-edit-metadata-authority-field.component.scss'], + standalone: true, + imports: [ + DsDynamicScrollableDropdownComponent, + NgIf, + DsDynamicOneboxComponent, + AuthorityConfidenceStateDirective, + NgbTooltipModule, + AsyncPipe, + TranslateModule, + FormsModule, + ], +}) +export class DsoEditMetadataAuthorityFieldComponent extends AbstractDsoEditMetadataValueFieldComponent implements OnInit, OnChanges { + + /** + * Whether the authority field is currently being edited + */ + public editingAuthority = false; + + /** + * Field group used by authority field + */ + group = new UntypedFormGroup({ authorityField: new UntypedFormControl() }); + + /** + * Model to use for editing authorities values + */ + private model$: BehaviorSubject = new BehaviorSubject(null); + + /** + * Observable with information about the authority vocabulary used + */ + private vocabulary$: Observable; + + /** + * Observables with information about the authority vocabulary type used + */ + isAuthorityControlled$: Observable; + isHierarchicalVocabulary$: Observable; + isScrollableVocabulary$: Observable; + isSuggesterVocabulary$: Observable; + + constructor( + protected cdr: ChangeDetectorRef, + protected dsoEditMetadataFieldService: DsoEditMetadataFieldService, + protected itemService: ItemDataService, + protected notificationsService: NotificationsService, + protected registryService: RegistryService, + protected translate: TranslateService, + ) { + super(); + } + + ngOnInit(): void { + this.initAuthorityProperties(); + } + + /** + * Initialise potential properties of a authority controlled metadata field + */ + initAuthorityProperties(): void { + this.vocabulary$ = this.dsoEditMetadataFieldService.findDsoFieldVocabulary(this.dso, this.mdField); + + this.isAuthorityControlled$ = this.vocabulary$.pipe( + // Create the model used by the authority fields to ensure its existence when the field is initialized + tap((v: Vocabulary) => this.model$.next(this.createModel(v))), + map((result: Vocabulary) => isNotEmpty(result)), + ); + + this.isHierarchicalVocabulary$ = this.vocabulary$.pipe( + map((result: Vocabulary) => isNotEmpty(result) && result.hierarchical), + ); + + this.isScrollableVocabulary$ = this.vocabulary$.pipe( + map((result: Vocabulary) => isNotEmpty(result) && result.scrollable), + ); + + this.isSuggesterVocabulary$ = this.vocabulary$.pipe( + map((result: Vocabulary) => isNotEmpty(result) && !result.hierarchical && !result.scrollable), + ); + + } + + /** + * Returns a {@link DynamicOneboxModel} or {@link DynamicScrollableDropdownModel} model based on the + * vocabulary used. + */ + private createModel(vocabulary: Vocabulary): DynamicOneboxModel | DynamicScrollableDropdownModel { + if (isNotEmpty(vocabulary)) { + let formFieldValue: FormFieldMetadataValueObject | string; + if (isNotEmpty(this.mdValue.newValue.value)) { + formFieldValue = new FormFieldMetadataValueObject(); + formFieldValue.value = this.mdValue.newValue.value; + formFieldValue.display = this.mdValue.newValue.value; + if (this.mdValue.newValue.authority) { + formFieldValue.authority = this.mdValue.newValue.authority; + formFieldValue.confidence = this.mdValue.newValue.confidence; + } + } else { + formFieldValue = this.mdValue.newValue.value; + } + + const vocabularyOptions = vocabulary ? { + closed: false, + name: vocabulary.name, + } as VocabularyOptions : null; + + if (!vocabulary.scrollable) { + const model: DsDynamicOneboxModelConfig = { + id: 'authorityField', + label: `${this.dsoType}.edit.metadata.edit.value`, + vocabularyOptions: vocabularyOptions, + metadataFields: [this.mdField], + value: formFieldValue, + repeatable: false, + submissionId: 'edit-metadata', + hasSelectableMetadata: false, + }; + return new DynamicOneboxModel(model); + } else { + const model: DynamicScrollableDropdownModelConfig = { + id: 'authorityField', + label: `${this.dsoType}.edit.metadata.edit.value`, + placeholder: `${this.dsoType}.edit.metadata.edit.value`, + vocabularyOptions: vocabularyOptions, + metadataFields: [this.mdField], + value: formFieldValue, + repeatable: false, + submissionId: 'edit-metadata', + hasSelectableMetadata: false, + maxOptions: 10, + }; + return new DynamicScrollableDropdownModel(model); + } + } else { + return null; + } + } + + /** + * Change callback for the component. Check if the mdField has changed to retrieve whether it is metadata + * that uses a controlled vocabulary and update the related properties + * + * @param {SimpleChanges} changes + */ + ngOnChanges(changes: SimpleChanges): void { + if (isNotEmpty(changes.mdField) && !changes.mdField.firstChange) { + if (isNotEmpty(changes.mdField.currentValue)) { + if (isNotEmpty(changes.mdField.previousValue) && + changes.mdField.previousValue !== changes.mdField.currentValue) { + // Clear authority value in case it has been assigned with the previous metadataField used + this.mdValue.newValue.authority = null; + this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET; + } + + // Only ask if the current mdField have a period character to reduce request + if (changes.mdField.currentValue.includes('.')) { + this.validateMetadataField().subscribe((isValid: boolean) => { + if (isValid) { + this.initAuthorityProperties(); + this.cdr.detectChanges(); + } + }); + } + } + } + } + + /** + * Validate the metadata field to check if it exists on the server and return an observable boolean for success/error + */ + validateMetadataField(): Observable { + return this.registryService.queryMetadataFields(this.mdField, null, true, false, followLink('schema')).pipe( + getFirstCompletedRemoteData(), + switchMap((rd) => { + if (rd.hasSucceeded) { + return observableOf(rd).pipe( + metadataFieldsToString(), + take(1), + map((fields: string[]) => fields.indexOf(this.mdField) > -1), + ); + } else { + this.notificationsService.error(this.translate.instant(`${this.dsoType}.edit.metadata.metadatafield.error`), rd.errorMessage); + return [false]; + } + }), + ); + } + + /** + * Process the change of authority field value updating the authority key and confidence as necessary + */ + onChangeAuthorityField(event): void { + this.mdValue.newValue.value = event.value; + if (event.authority) { + this.mdValue.newValue.authority = event.authority; + this.mdValue.newValue.confidence = ConfidenceType.CF_ACCEPTED; + } else { + this.mdValue.newValue.authority = null; + this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET; + } + this.confirm.emit(false); + } + + /** + * Returns the {@link DynamicOneboxModel} or {@link DynamicScrollableDropdownModel} model used + * for the authority field + */ + getModel(): DynamicOneboxModel | DynamicScrollableDropdownModel { + return this.model$.value; + } + + /** + * Change the status of the editingAuthority property + * @param status + */ + onChangeEditingAuthorityStatus(status: boolean) { + this.editingAuthority = status; + } + + /** + * Processes the change in authority value, updating the confidence as necessary. + * If the authority key is cleared, the confidence is set to {@link ConfidenceType.CF_NOVALUE}. + * If the authority key is edited and differs from the original, the confidence is set to {@link ConfidenceType.CF_ACCEPTED}. + */ + onChangeAuthorityKey() { + if (this.mdValue.newValue.authority === '') { + this.mdValue.newValue.confidence = ConfidenceType.CF_NOVALUE; + this.confirm.emit(false); + } else if (this.mdValue.newValue.authority !== this.mdValue.originalValue.authority) { + this.mdValue.newValue.confidence = ConfidenceType.CF_ACCEPTED; + this.confirm.emit(false); + } + } + +} diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component.html index 514f3147bb..92c7985a61 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component.html @@ -1,4 +1,5 @@ - diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component.spec.ts index 83bd25cc76..486f4e825e 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component.spec.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component.spec.ts @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed, } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; import { EntityTypeDataService } from '../../../../core/data/entity-type-data.service'; import { EntityTypeDataServiceStub } from '../../../../shared/testing/entity-type-data.service.stub'; @@ -17,8 +18,9 @@ describe('DsoEditMetadataEntityFieldComponent', () => { entityTypeService = new EntityTypeDataServiceStub(); await TestBed.configureTestingModule({ - declarations: [ + imports: [ DsoEditMetadataEntityFieldComponent, + TranslateModule.forRoot(), ], providers: [ { provide: EntityTypeDataService, useValue: entityTypeService }, diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component.ts index 73cf16b2da..fda6f9b24f 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component.ts @@ -1,15 +1,19 @@ +import { + AsyncPipe, + NgForOf, +} from '@angular/common'; import { Component, OnInit, } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; import { Observable } from 'rxjs'; import { EntityTypeDataService } from '../../../../core/data/entity-type-data.service'; import { ItemType } from '../../../../core/shared/item-relationships/item-type.model'; import { getFirstSucceededRemoteListPayload } from '../../../../core/shared/operators'; import { AbstractDsoEditMetadataValueFieldComponent } from '../abstract-dso-edit-metadata-value-field.component'; -import { EditMetadataValueFieldType } from '../dso-edit-metadata-field-type.enum'; -import { editMetadataValueFieldComponent } from '../dso-edit-metadata-value-field-loader/dso-edit-metadata-value-field.decorator'; /** * The component used to gather input for entity-type metadata fields @@ -18,8 +22,14 @@ import { editMetadataValueFieldComponent } from '../dso-edit-metadata-value-fiel selector: 'ds-dso-edit-metadata-entity-field', templateUrl: './dso-edit-metadata-entity-field.component.html', styleUrls: ['./dso-edit-metadata-entity-field.component.scss'], + standalone: true, + imports: [ + AsyncPipe, + FormsModule, + NgForOf, + TranslateModule, + ], }) -@editMetadataValueFieldComponent(EditMetadataValueFieldType.ENTITY_TYPE) export class DsoEditMetadataEntityFieldComponent extends AbstractDsoEditMetadataValueFieldComponent implements OnInit { /** diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field-type.enum.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field-type.enum.ts index 5a9b3c493e..cc9c105c4f 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field-type.enum.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field-type.enum.ts @@ -4,4 +4,5 @@ export enum EditMetadataValueFieldType { PLAIN_TEXT = 'PLAIN_TEXT', ENTITY_TYPE = 'ENTITY_TYPE', + AUTHORITY = 'AUTHORITY', } diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field.service.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field.service.spec.ts new file mode 100644 index 0000000000..daea727838 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field.service.spec.ts @@ -0,0 +1,31 @@ +import { TestBed } from '@angular/core/testing'; + +import { ItemDataService } from '../../../core/data/item-data.service'; +import { VocabularyService } from '../../../core/submission/vocabularies/vocabulary.service'; +import { ItemDataServiceStub } from '../../../shared/testing/item-data.service.stub'; +import { VocabularyServiceStub } from '../../../shared/testing/vocabulary-service.stub'; +import { DsoEditMetadataFieldService } from './dso-edit-metadata-field.service'; + +describe('DsoEditMetadataFieldService', () => { + let service: DsoEditMetadataFieldService; + + let itemService: ItemDataServiceStub; + let vocabularyService: VocabularyServiceStub; + + beforeEach(() => { + itemService = new ItemDataServiceStub(); + vocabularyService = new VocabularyServiceStub(); + + TestBed.configureTestingModule({ + providers: [ + { provide: ItemDataService, useValue: itemService }, + { provide: VocabularyService, useValue: vocabularyService }, + ], + }); + service = TestBed.inject(DsoEditMetadataFieldService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field.service.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field.service.ts new file mode 100644 index 0000000000..d3ccf323a6 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@angular/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { ItemDataService } from '../../../core/data/item-data.service'; +import { Collection } from '../../../core/shared/collection.model'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { Item } from '../../../core/shared/item.model'; +import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; +import { Vocabulary } from '../../../core/submission/vocabularies/models/vocabulary.model'; +import { VocabularyService } from '../../../core/submission/vocabularies/vocabulary.service'; +import { isNotEmpty } from '../../../shared/empty.util'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; + +@Injectable({ + providedIn: 'root', +}) +export class DsoEditMetadataFieldService { + + constructor( + protected itemService: ItemDataService, + protected vocabularyService: VocabularyService, + ) { + } + + /** + * Find the vocabulary of the given {@link mdField} for the given item. + * + * @param dso The item + * @param mdField The metadata field + */ + findDsoFieldVocabulary(dso: DSpaceObject, mdField: string): Observable { + if (isNotEmpty(mdField)) { + const owningCollection$: Observable = this.itemService.findByHref(dso._links.self.href, true, true, followLink('owningCollection')).pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((item: Item) => item.owningCollection), + getFirstSucceededRemoteDataPayload(), + ); + + return owningCollection$.pipe( + switchMap((c: Collection) => this.vocabularyService.getVocabularyByMetadataAndCollection(mdField, c.uuid).pipe( + getFirstSucceededRemoteDataPayload(), + )), + ); + } else { + return observableOf(undefined); + } + } +} diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-text-field/dso-edit-metadata-text-field.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-text-field/dso-edit-metadata-text-field.component.html index 97e49ae39e..a2c754044c 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-text-field/dso-edit-metadata-text-field.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-text-field/dso-edit-metadata-text-field.component.html @@ -1,4 +1,5 @@ - - - - + +
-
-
- - - - -
-
{{ mdRepresentationName$ | async }} diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts index e96959c1d1..c64599f8b5 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts @@ -8,41 +8,26 @@ import { waitForAsync, } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { RouterTestingModule } from '@angular/router/testing'; +import { RouterModule } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { of } from 'rxjs'; -import { MetadataField } from 'src/app/core/metadata/metadata-field.model'; -import { MetadataSchema } from 'src/app/core/metadata/metadata-schema.model'; -import { RegistryService } from 'src/app/core/registry/registry.service'; -import { ConfidenceType } from 'src/app/core/shared/confidence-type'; -import { Vocabulary } from 'src/app/core/submission/vocabularies/models/vocabulary.model'; -import { VocabularyService } from 'src/app/core/submission/vocabularies/vocabulary.service'; -import { DynamicOneboxModel } from 'src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model'; -import { DynamicScrollableDropdownModel } from 'src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; -import { NotificationsService } from 'src/app/shared/notifications/notifications.service'; -import { createPaginatedList } from 'src/app/shared/testing/utils.test'; -import { VocabularyServiceStub } from 'src/app/shared/testing/vocabulary-service.stub'; import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; -import { ItemDataService } from '../../../core/data/item-data.service'; import { RelationshipDataService } from '../../../core/data/relationship-data.service'; -import { Collection } from '../../../core/shared/collection.model'; -import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { Item } from '../../../core/shared/item.model'; import { MetadataValue, VIRTUAL_METADATA_PREFIX, } from '../../../core/shared/metadata.models'; import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model'; -import { DsDynamicOneboxComponent } from '../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component'; -import { DsDynamicScrollableDropdownComponent } from '../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component'; import { ThemedTypeBadgeComponent } from '../../../shared/object-collection/shared/badges/type-badge/themed-type-badge.component'; -import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { DsoEditMetadataFieldServiceStub } from '../../../shared/testing/dso-edit-metadata-field.service.stub'; import { VarDirective } from '../../../shared/utils/var.directive'; import { DsoEditMetadataChangeType, DsoEditMetadataValue, } from '../dso-edit-metadata-form'; +import { DsoEditMetadataFieldService } from '../dso-edit-metadata-value-field/dso-edit-metadata-field.service'; +import { DsoEditMetadataValueFieldLoaderComponent } from '../dso-edit-metadata-value-field/dso-edit-metadata-value-field-loader/dso-edit-metadata-value-field-loader.component'; import { DsoEditMetadataValueComponent } from './dso-edit-metadata-value.component'; const EDIT_BTN = 'edit'; @@ -57,97 +42,12 @@ describe('DsoEditMetadataValueComponent', () => { let relationshipService: RelationshipDataService; let dsoNameService: DSONameService; - let vocabularyServiceStub: any; - let itemService: ItemDataService; - let registryService: RegistryService; - let notificationsService: NotificationsService; + let dsoEditMetadataFieldService: DsoEditMetadataFieldServiceStub; let editMetadataValue: DsoEditMetadataValue; let metadataValue: MetadataValue; - let dso: DSpaceObject; - - const collection = Object.assign(new Collection(), { - uuid: 'fake-uuid', - }); - - const item = Object.assign(new Item(), { - _links: { - self: { href: 'fake-item-url/item' }, - }, - id: 'item', - uuid: 'item', - owningCollection: createSuccessfulRemoteDataObject$(collection), - }); - - const mockVocabularyScrollable: Vocabulary = { - id: 'scrollable', - name: 'scrollable', - scrollable: true, - hierarchical: false, - preloadLevel: 0, - type: 'vocabulary', - _links: { - self: { - href: 'self', - }, - entries: { - href: 'entries', - }, - }, - }; - - const mockVocabularyHierarchical: Vocabulary = { - id: 'hierarchical', - name: 'hierarchical', - scrollable: false, - hierarchical: true, - preloadLevel: 2, - type: 'vocabulary', - _links: { - self: { - href: 'self', - }, - entries: { - href: 'entries', - }, - }, - }; - - const mockVocabularySuggester: Vocabulary = { - id: 'suggester', - name: 'suggester', - scrollable: false, - hierarchical: false, - preloadLevel: 0, - type: 'vocabulary', - _links: { - self: { - href: 'self', - }, - entries: { - href: 'entries', - }, - }, - }; - - let metadataSchema: MetadataSchema; - let metadataFields: MetadataField[]; function initServices(): void { - metadataSchema = Object.assign(new MetadataSchema(), { - id: 0, - prefix: 'metadata', - namespace: 'http://example.com/', - }); - metadataFields = [ - Object.assign(new MetadataField(), { - id: 0, - element: 'regular', - qualifier: null, - schema: createSuccessfulRemoteDataObject$(metadataSchema), - }), - ]; - relationshipService = jasmine.createSpyObj('relationshipService', { resolveMetadataRepresentation: of( new ItemMetadataRepresentation(metadataValue), @@ -156,14 +56,7 @@ describe('DsoEditMetadataValueComponent', () => { dsoNameService = jasmine.createSpyObj('dsoNameService', { getName: 'Related Name', }); - itemService = jasmine.createSpyObj('itemService', { - findByHref: createSuccessfulRemoteDataObject$(item), - }); - vocabularyServiceStub = new VocabularyServiceStub(); - registryService = jasmine.createSpyObj('registryService', { - queryMetadataFields: createSuccessfulRemoteDataObject$(createPaginatedList(metadataFields)), - }); - notificationsService = jasmine.createSpyObj('notificationsService', ['error', 'success']); + dsoEditMetadataFieldService = new DsoEditMetadataFieldServiceStub(); } beforeEach(waitForAsync(async () => { @@ -174,34 +67,29 @@ describe('DsoEditMetadataValueComponent', () => { authority: undefined, }); editMetadataValue = new DsoEditMetadataValue(metadataValue); - dso = Object.assign(new DSpaceObject(), { - _links: { - self: { href: 'fake-dso-url/dso' }, - }, - }); initServices(); await TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot(), - RouterTestingModule.withRoutes([]), + RouterModule.forRoot([]), DsoEditMetadataValueComponent, VarDirective, ], providers: [ { provide: RelationshipDataService, useValue: relationshipService }, { provide: DSONameService, useValue: dsoNameService }, - { provide: VocabularyService, useValue: vocabularyServiceStub }, - { provide: ItemDataService, useValue: itemService }, - { provide: RegistryService, useValue: registryService }, - { provide: NotificationsService, useValue: notificationsService }, + { provide: DsoEditMetadataFieldService, useValue: dsoEditMetadataFieldService }, ], schemas: [NO_ERRORS_SCHEMA], }) .overrideComponent(DsoEditMetadataValueComponent, { remove: { - imports: [DsDynamicOneboxComponent, DsDynamicScrollableDropdownComponent, ThemedTypeBadgeComponent], + imports: [ + DsoEditMetadataValueFieldLoaderComponent, + ThemedTypeBadgeComponent, + ], }, }) .compileComponents(); @@ -211,7 +99,6 @@ describe('DsoEditMetadataValueComponent', () => { fixture = TestBed.createComponent(DsoEditMetadataValueComponent); component = fixture.componentInstance; component.mdValue = editMetadataValue; - component.dso = dso; component.saving$ = of(false); fixture.detectChanges(); }); @@ -297,219 +184,6 @@ describe('DsoEditMetadataValueComponent', () => { assertButton(DRAG_BTN, true, false); }); - describe('when the metadata field not uses a vocabulary and is editing', () => { - beforeEach(waitForAsync(() => { - spyOn(vocabularyServiceStub, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(null, 204)); - metadataValue = Object.assign(new MetadataValue(), { - value: 'Regular value', - language: 'en', - place: 0, - authority: null, - }); - editMetadataValue = new DsoEditMetadataValue(metadataValue); - editMetadataValue.editing = true; - component.mdValue = editMetadataValue; - component.mdField = 'metadata.regular'; - component.ngOnInit(); - fixture.detectChanges(); - })); - - it('should render a textarea', () => { - expect(vocabularyServiceStub.getVocabularyByMetadataAndCollection).toHaveBeenCalled(); - expect(fixture.debugElement.query(By.css('textarea'))).toBeTruthy(); - }); - }); - - describe('when the metadata field uses a scrollable vocabulary and is editing', () => { - beforeEach(waitForAsync(() => { - spyOn(vocabularyServiceStub, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularyScrollable)); - metadataValue = Object.assign(new MetadataValue(), { - value: 'Authority Controlled value', - language: 'en', - place: 0, - authority: null, - }); - editMetadataValue = new DsoEditMetadataValue(metadataValue); - editMetadataValue.editing = true; - component.mdValue = editMetadataValue; - component.mdField = 'metadata.scrollable'; - component.ngOnInit(); - fixture.detectChanges(); - })); - - it('should render the DsDynamicScrollableDropdownComponent', () => { - expect(vocabularyServiceStub.getVocabularyByMetadataAndCollection).toHaveBeenCalled(); - expect(fixture.debugElement.query(By.css('ds-dynamic-scrollable-dropdown'))).toBeTruthy(); - }); - - it('getModel should return a DynamicScrollableDropdownModel', () => { - const model = component.getModel(); - - expect(model instanceof DynamicScrollableDropdownModel).toBe(true); - expect(model.vocabularyOptions.name).toBe(mockVocabularyScrollable.name); - - }); - }); - - describe('when the metadata field uses a hierarchical vocabulary and is editing', () => { - beforeEach(waitForAsync(() => { - spyOn(vocabularyServiceStub, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularyHierarchical)); - metadataValue = Object.assign(new MetadataValue(), { - value: 'Authority Controlled value', - language: 'en', - place: 0, - authority: null, - }); - editMetadataValue = new DsoEditMetadataValue(metadataValue); - editMetadataValue.editing = true; - component.mdValue = editMetadataValue; - component.mdField = 'metadata.hierarchical'; - component.ngOnInit(); - fixture.detectChanges(); - })); - - it('should render the DsDynamicOneboxComponent', () => { - expect(vocabularyServiceStub.getVocabularyByMetadataAndCollection).toHaveBeenCalled(); - expect(fixture.debugElement.query(By.css('ds-dynamic-onebox'))).toBeTruthy(); - }); - - it('getModel should return a DynamicOneboxModel', () => { - const model = component.getModel(); - - expect(model instanceof DynamicOneboxModel).toBe(true); - expect(model.vocabularyOptions.name).toBe(mockVocabularyHierarchical.name); - }); - }); - - describe('when the metadata field uses a suggester vocabulary and is editing', () => { - beforeEach(waitForAsync(() => { - spyOn(vocabularyServiceStub, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularySuggester)); - spyOn(component.confirm, 'emit'); - metadataValue = Object.assign(new MetadataValue(), { - value: 'Authority Controlled value', - language: 'en', - place: 0, - authority: 'authority-key', - confidence: ConfidenceType.CF_UNCERTAIN, - }); - editMetadataValue = new DsoEditMetadataValue(metadataValue); - editMetadataValue.editing = true; - component.mdValue = editMetadataValue; - component.mdField = 'metadata.suggester'; - component.ngOnInit(); - fixture.detectChanges(); - })); - - it('should render the DsDynamicOneboxComponent', () => { - expect(vocabularyServiceStub.getVocabularyByMetadataAndCollection).toHaveBeenCalled(); - expect(fixture.debugElement.query(By.css('ds-dynamic-onebox'))).toBeTruthy(); - }); - - it('getModel should return a DynamicOneboxModel', () => { - const model = component.getModel(); - - expect(model instanceof DynamicOneboxModel).toBe(true); - expect(model.vocabularyOptions.name).toBe(mockVocabularySuggester.name); - }); - - describe('authority key edition', () => { - - it('should update confidence to CF_NOVALUE when authority is cleared', () => { - component.mdValue.newValue.authority = ''; - - component.onChangeAuthorityKey(); - - expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_NOVALUE); - expect(component.confirm.emit).toHaveBeenCalledWith(false); - }); - - it('should update confidence to CF_ACCEPTED when authority key is edited', () => { - component.mdValue.newValue.authority = 'newAuthority'; - component.mdValue.originalValue.authority = 'oldAuthority'; - - component.onChangeAuthorityKey(); - - expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_ACCEPTED); - expect(component.confirm.emit).toHaveBeenCalledWith(false); - }); - - it('should not update confidence when authority key remains the same', () => { - component.mdValue.newValue.authority = 'sameAuthority'; - component.mdValue.originalValue.authority = 'sameAuthority'; - - component.onChangeAuthorityKey(); - - expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_UNCERTAIN); - expect(component.confirm.emit).not.toHaveBeenCalled(); - }); - - it('should call onChangeEditingAuthorityStatus with true when clicking the lock button', () => { - spyOn(component, 'onChangeEditingAuthorityStatus'); - const lockButton = fixture.nativeElement.querySelector('#metadata-confirm-btn'); - - lockButton.click(); - - expect(component.onChangeEditingAuthorityStatus).toHaveBeenCalledWith(true); - }); - - it('should disable the input when editingAuthority is false', (done) => { - component.editingAuthority = false; - - fixture.detectChanges(); - - fixture.detectChanges(); - fixture.whenStable().then(() => { - const inputElement = fixture.nativeElement.querySelector('input[data-test="authority-input"]'); - expect(inputElement.disabled).toBeTruthy(); - done(); - }); - }); - - it('should enable the input when editingAuthority is true', (done) => { - component.editingAuthority = true; - - fixture.detectChanges(); - fixture.whenStable().then(() => { - const inputElement = fixture.nativeElement.querySelector('input[data-test="authority-input"]'); - expect(inputElement.disabled).toBeFalsy(); - done(); - }); - - - }); - - it('should update mdValue.newValue properties when authority is present', () => { - const event = { - value: 'Some value', - authority: 'Some authority', - }; - - component.onChangeAuthorityField(event); - - expect(component.mdValue.newValue.value).toBe(event.value); - expect(component.mdValue.newValue.authority).toBe(event.authority); - expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_ACCEPTED); - expect(component.confirm.emit).toHaveBeenCalledWith(false); - }); - - it('should update mdValue.newValue properties when authority is not present', () => { - const event = { - value: 'Some value', - authority: null, - }; - - component.onChangeAuthorityField(event); - - expect(component.mdValue.newValue.value).toBe(event.value); - expect(component.mdValue.newValue.authority).toBeNull(); - expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_UNSET); - expect(component.confirm.emit).toHaveBeenCalledWith(false); - }); - - }); - - }); - function assertButton(name: string, exists: boolean, disabled: boolean = false): void { describe(`${name} button`, () => { let btn: DebugElement; diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts index 6930dbcd61..192440397c 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts @@ -8,7 +8,6 @@ import { NgIf, } from '@angular/common'; import { - ChangeDetectorRef, Component, EventEmitter, Input, @@ -17,86 +16,48 @@ import { Output, SimpleChanges, } from '@angular/core'; -import { - FormsModule, - UntypedFormControl, - UntypedFormGroup, -} from '@angular/forms'; +import { FormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; import { - TranslateModule, - TranslateService, -} from '@ngx-translate/core'; -import { - BehaviorSubject, EMPTY, Observable, - of as observableOf, } from 'rxjs'; -import { - map, - switchMap, - take, - tap, -} from 'rxjs/operators'; -import { RegistryService } from 'src/app/core/registry/registry.service'; -import { VocabularyService } from 'src/app/core/submission/vocabularies/vocabulary.service'; -import { NotificationsService } from 'src/app/shared/notifications/notifications.service'; +import { map } from 'rxjs/operators'; import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; -import { ItemDataService } from '../../../core/data/item-data.service'; import { RelationshipDataService } from '../../../core/data/relationship-data.service'; import { MetadataService } from '../../../core/metadata/metadata.service'; -import { Collection } from '../../../core/shared/collection.model'; import { ConfidenceType } from '../../../core/shared/confidence-type'; import { Context } from '../../../core/shared/context.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { Item } from '../../../core/shared/item.model'; import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model'; import { MetadataRepresentation, MetadataRepresentationType, } from '../../../core/shared/metadata-representation/metadata-representation.model'; -import { - getFirstCompletedRemoteData, - getFirstSucceededRemoteData, - getFirstSucceededRemoteDataPayload, - getRemoteDataPayload, - metadataFieldsToString, -} from '../../../core/shared/operators'; import { Vocabulary } from '../../../core/submission/vocabularies/models/vocabulary.model'; -import { VocabularyOptions } from '../../../core/submission/vocabularies/models/vocabulary-options.model'; import { getItemPageRoute } from '../../../item-page/item-page-routing-paths'; -import { isNotEmpty } from '../../../shared/empty.util'; -import { DsDynamicOneboxComponent } from '../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component'; -import { - DsDynamicOneboxModelConfig, - DynamicOneboxModel, -} from '../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model'; -import { DsDynamicScrollableDropdownComponent } from '../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component'; -import { - DynamicScrollableDropdownModel, - DynamicScrollableDropdownModelConfig, -} from '../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; -import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; +import { hasValue } from '../../../shared/empty.util'; import { AuthorityConfidenceStateDirective } from '../../../shared/form/directives/authority-confidence-state.directive'; import { ThemedTypeBadgeComponent } from '../../../shared/object-collection/shared/badges/type-badge/themed-type-badge.component'; import { DebounceDirective } from '../../../shared/utils/debounce.directive'; -import { followLink } from '../../../shared/utils/follow-link-config.model'; import { VarDirective } from '../../../shared/utils/var.directive'; import { DsoEditMetadataChangeType, DsoEditMetadataValue, } from '../dso-edit-metadata-form'; +import { DsoEditMetadataFieldService } from '../dso-edit-metadata-value-field/dso-edit-metadata-field.service'; import { EditMetadataValueFieldType } from '../dso-edit-metadata-value-field/dso-edit-metadata-field-type.enum'; +import { DsoEditMetadataValueFieldLoaderComponent } from '../dso-edit-metadata-value-field/dso-edit-metadata-value-field-loader/dso-edit-metadata-value-field-loader.component'; @Component({ selector: 'ds-dso-edit-metadata-value', styleUrls: ['./dso-edit-metadata-value.component.scss', '../dso-edit-metadata-shared/dso-edit-metadata-cells.scss'], templateUrl: './dso-edit-metadata-value.component.html', standalone: true, - imports: [VarDirective, CdkDrag, NgClass, NgIf, FormsModule, DebounceDirective, RouterLink, ThemedTypeBadgeComponent, NgbTooltipModule, CdkDragHandle, AsyncPipe, TranslateModule, DsDynamicScrollableDropdownComponent, DsDynamicOneboxComponent, AuthorityConfidenceStateDirective], + imports: [VarDirective, CdkDrag, NgClass, NgIf, FormsModule, DebounceDirective, RouterLink, ThemedTypeBadgeComponent, NgbTooltipModule, CdkDragHandle, AsyncPipe, TranslateModule, DsoEditMetadataValueFieldLoaderComponent, AuthorityConfidenceStateDirective], }) /** * Component displaying a single editable row for a metadata value @@ -170,12 +131,6 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges { */ public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType; - /** - * The ConfidenceType enumeration for access in the component's template - * @type {ConfidenceType} - */ - public ConfidenceTypeEnum = ConfidenceType; - /** * The item this metadata value represents in case it's virtual (if any, otherwise null) */ @@ -194,58 +149,25 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges { /** * The type of edit field that should be displayed */ - fieldType: EditMetadataValueFieldType; + fieldType$: Observable; - /** - * Whether or not the authority field is currently being edited - */ - public editingAuthority = false; - - /** - * Field group used by authority field - * @type {UntypedFormGroup} - */ - group = new UntypedFormGroup({ authorityField : new UntypedFormControl() }); - - /** - * Model to use for editing authorities values - */ - private model$: BehaviorSubject = new BehaviorSubject(null); - - /** - * Observable with information about the authority vocabulary used - */ - private vocabulary$: Observable; - - /** - * Observables with information about the authority vocabulary type used - */ - private isAuthorityControlled$: Observable; - private isHierarchicalVocabulary$: Observable; - private isScrollableVocabulary$: Observable; - private isSuggesterVocabulary$: Observable; + readonly ConfidenceTypeEnum = ConfidenceType; constructor( protected relationshipService: RelationshipDataService, protected dsoNameService: DSONameService, - protected vocabularyService: VocabularyService, - protected itemService: ItemDataService, - protected cdr: ChangeDetectorRef, - protected registryService: RegistryService, - protected notificationsService: NotificationsService, - protected translate: TranslateService, protected metadataService: MetadataService, + protected dsoEditMetadataFieldService: DsoEditMetadataFieldService, ) { } ngOnInit(): void { this.initVirtualProperties(); - this.initAuthorityProperties(); } ngOnChanges(changes: SimpleChanges): void { if (changes.mdField) { - this.fieldType = this.getFieldType(); + this.fieldType$ = this.getFieldType(); } } @@ -271,240 +193,18 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges { /** * Retrieves the {@link EditMetadataValueFieldType} to be displayed for the current field while in edit mode. */ - getFieldType(): EditMetadataValueFieldType { - if (this.mdField === 'dspace.entity.type') { - return EditMetadataValueFieldType.ENTITY_TYPE; - } - return EditMetadataValueFieldType.PLAIN_TEXT; - } - - - /** - * Initialise potential properties of a authority controlled metadata field - */ - initAuthorityProperties(): void { - - if (isNotEmpty(this.mdField)) { - - const owningCollection$: Observable = this.itemService.findByHref(this.dso._links.self.href, true, true, followLink('owningCollection')) - .pipe( - getFirstSucceededRemoteData(), - getRemoteDataPayload(), - switchMap((item: Item) => item.owningCollection), - getFirstSucceededRemoteData(), - getRemoteDataPayload(), - ); - - this.vocabulary$ = owningCollection$.pipe( - switchMap((c: Collection) => this.vocabularyService - .getVocabularyByMetadataAndCollection(this.mdField, c.uuid) - .pipe( - getFirstSucceededRemoteDataPayload(), - )), - ); - } else { - this.vocabulary$ = observableOf(undefined); - } - - this.isAuthorityControlled$ = this.vocabulary$.pipe( - // Create the model used by the authority fields to ensure its existence when the field is initialized - tap((v: Vocabulary) => this.model$.next(this.createModel(v))), - map((result: Vocabulary) => isNotEmpty(result)), - ); - - this.isHierarchicalVocabulary$ = this.vocabulary$.pipe( - map((result: Vocabulary) => isNotEmpty(result) && result.hierarchical), - ); - - this.isScrollableVocabulary$ = this.vocabulary$.pipe( - map((result: Vocabulary) => isNotEmpty(result) && result.scrollable), - ); - - this.isSuggesterVocabulary$ = this.vocabulary$.pipe( - map((result: Vocabulary) => isNotEmpty(result) && !result.hierarchical && !result.scrollable), - ); - - } - - /** - * Returns a {@link DynamicOneboxModel} or {@link DynamicScrollableDropdownModel} model based on the - * vocabulary used. - */ - private createModel(vocabulary: Vocabulary): DynamicOneboxModel | DynamicScrollableDropdownModel { - if (isNotEmpty(vocabulary)) { - let formFieldValue; - if (isNotEmpty(this.mdValue.newValue.value)) { - formFieldValue = new FormFieldMetadataValueObject(); - formFieldValue.value = this.mdValue.newValue.value; - formFieldValue.display = this.mdValue.newValue.value; - if (this.mdValue.newValue.authority) { - formFieldValue.authority = this.mdValue.newValue.authority; - formFieldValue.confidence = this.mdValue.newValue.confidence; + getFieldType(): Observable { + return this.dsoEditMetadataFieldService.findDsoFieldVocabulary(this.dso, this.mdField).pipe( + map((vocabulary: Vocabulary) => { + if (hasValue(vocabulary)) { + return EditMetadataValueFieldType.AUTHORITY; } - } else { - formFieldValue = this.mdValue.newValue.value; - } - - const vocabularyOptions = vocabulary ? { - closed: false, - name: vocabulary.name, - } as VocabularyOptions : null; - - if (!vocabulary.scrollable) { - const model: DsDynamicOneboxModelConfig = { - id: 'authorityField', - label: `${this.dsoType}.edit.metadata.edit.value`, - vocabularyOptions: vocabularyOptions, - metadataFields: [this.mdField], - value: formFieldValue, - repeatable: false, - submissionId: 'edit-metadata', - hasSelectableMetadata: false, - }; - return new DynamicOneboxModel(model); - } else { - const model: DynamicScrollableDropdownModelConfig = { - id: 'authorityField', - label: `${this.dsoType}.edit.metadata.edit.value`, - placeholder: `${this.dsoType}.edit.metadata.edit.value`, - vocabularyOptions: vocabularyOptions, - metadataFields: [this.mdField], - value: formFieldValue, - repeatable: false, - submissionId: 'edit-metadata', - hasSelectableMetadata: false, - maxOptions: 10, - }; - return new DynamicScrollableDropdownModel(model); - } - } else { - return null; - } - } - - /** - * Change callback for the component. Check if the mdField has changed to retrieve whether it is metadata - * that uses a controlled vocabulary and update the related properties - * - * @param {SimpleChanges} changes - */ - ngOnChanges(changes: SimpleChanges): void { - if (isNotEmpty(changes.mdField) && !changes.mdField.firstChange) { - if (isNotEmpty(changes.mdField.currentValue) ) { - if (isNotEmpty(changes.mdField.previousValue) && - changes.mdField.previousValue !== changes.mdField.currentValue) { - // Clear authority value in case it has been assigned with the previous metadataField used - this.mdValue.newValue.authority = null; - this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET; - } - - // Only ask if the current mdField have a period character to reduce request - if (changes.mdField.currentValue.includes('.')) { - this.validateMetadataField().subscribe((isValid: boolean) => { - if (isValid) { - this.initAuthorityProperties(); - this.cdr.detectChanges(); - } - }); - } - } - } - } - - /** - * Validate the metadata field to check if it exists on the server and return an observable boolean for success/error - */ - validateMetadataField(): Observable { - return this.registryService.queryMetadataFields(this.mdField, null, true, false, followLink('schema')).pipe( - getFirstCompletedRemoteData(), - switchMap((rd) => { - if (rd.hasSucceeded) { - return observableOf(rd).pipe( - metadataFieldsToString(), - take(1), - map((fields: string[]) => fields.indexOf(this.mdField) > -1), - ); - } else { - this.notificationsService.error(this.translate.instant(`${this.dsoType}.edit.metadata.metadatafield.error`), rd.errorMessage); - return [false]; + if (this.mdField === 'dspace.entity.type') { + return EditMetadataValueFieldType.ENTITY_TYPE; } + return EditMetadataValueFieldType.PLAIN_TEXT; }), ); } - /** - * Checks if this field use a authority vocabulary - */ - isAuthorityControlled(): Observable { - return this.isAuthorityControlled$; - } - - /** - * Checks if configured vocabulary is Hierarchical or not - */ - isHierarchicalVocabulary(): Observable { - return this.isHierarchicalVocabulary$; - } - - /** - * Checks if configured vocabulary is Scrollable or not - */ - isScrollableVocabulary(): Observable { - return this.isScrollableVocabulary$; - } - - /** - * Checks if configured vocabulary is Suggester or not - * (a vocabulary not Scrollable and not Hierarchical that uses an autocomplete field) - */ - isSuggesterVocabulary(): Observable { - return this.isSuggesterVocabulary$; - } - - /** - * Process the change of authority field value updating the authority key and confidence as necessary - */ - onChangeAuthorityField(event): void { - this.mdValue.newValue.value = event.value; - if (event.authority) { - this.mdValue.newValue.authority = event.authority; - this.mdValue.newValue.confidence = ConfidenceType.CF_ACCEPTED; - } else { - this.mdValue.newValue.authority = null; - this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET; - } - this.confirm.emit(false); - } - - /** - * Returns the {@link DynamicOneboxModel} or {@link DynamicScrollableDropdownModel} model used - * for the authority field - */ - getModel(): DynamicOneboxModel | DynamicScrollableDropdownModel { - return this.model$.value; - } - - /** - * Change the status of the editingAuthority property - * @param status - */ - onChangeEditingAuthorityStatus(status: boolean) { - this.editingAuthority = status; - } - - /** - * Processes the change in authority value, updating the confidence as necessary. - * If the authority key is cleared, the confidence is set to {@link ConfidenceType.CF_NOVALUE}. - * If the authority key is edited and differs from the original, the confidence is set to {@link ConfidenceType.CF_ACCEPTED}. - */ - onChangeAuthorityKey() { - if (this.mdValue.newValue.authority === '') { - this.mdValue.newValue.confidence = ConfidenceType.CF_NOVALUE; - this.confirm.emit(false); - } else if (this.mdValue.newValue.authority !== this.mdValue.originalValue.authority) { - this.mdValue.newValue.confidence = ConfidenceType.CF_ACCEPTED; - this.confirm.emit(false); - } - } - } diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html index 659f00e539..2416a806a8 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html @@ -41,7 +41,6 @@ { + return observableOf(undefined); + } + +} From a105131b2bb507fe840053b95f4047735041e9a3 Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Fri, 15 Nov 2024 18:25:37 +0100 Subject: [PATCH 023/157] Finalise menu refactor, add typedocs and tests --- ...le-admin-sidebar-section.component.spec.ts | 133 ++-- src/app/app-routing.module.ts | 3 - src/app/browse-by/browse-by-routing.module.ts | 2 - src/app/menu.resolver.spec.ts | 423 ----------- src/app/menu.resolver.ts | 703 ------------------ ...xpandable-navbar-section.component.spec.ts | 6 +- .../dso-page/dso-edit-menu.resolver.spec.ts | 259 ------- .../shared/dso-page/dso-edit-menu.resolver.ts | 229 ------ ...-menu-expandable-section.component.spec.ts | 102 ++- .../dso-edit-menu.component.spec.ts | 1 - ...so-page-subscription-button.component.html | 8 - ...so-page-subscription-button.component.scss | 0 ...page-subscription-button.component.spec.ts | 83 --- .../dso-page-subscription-button.component.ts | 57 -- ...enu-provider.ts => menu-provider.model.ts} | 69 +- .../shared/menu/menu-provider.service.spec.ts | 165 ++++ src/app/shared/menu/menu-provider.service.ts | 75 +- src/app/shared/menu/menu-section.model.ts | 25 +- src/app/shared/menu/menu.component.spec.ts | 2 +- src/app/shared/menu/menu.resolver.ts | 19 - src/app/shared/menu/menu.service.spec.ts | 87 --- src/app/shared/menu/menu.service.ts | 97 +-- src/app/shared/menu/menu.structure.spec.ts | 102 +++ src/app/shared/menu/menu.structure.ts | 10 +- .../menu/providers/access-control.menu.ts | 5 +- .../menu/providers/admin-search.menu.ts | 5 +- src/app/shared/menu/providers/browse.menu.ts | 5 +- .../menu/providers/comcol-subscribe.menu.ts | 5 +- .../menu/providers/community-list.menu.ts | 5 +- .../shared/menu/providers/curation.menu.ts | 5 +- .../shared/menu/providers/dso-edit.menu.ts | 5 +- .../shared/menu/providers/dso-option.menu.ts | 6 +- src/app/shared/menu/providers/edit.menu.ts | 5 +- src/app/shared/menu/providers/export.menu.ts | 5 +- src/app/shared/menu/providers/health.menu.ts | 5 +- .../providers/helper-providers/dso.menu.ts | 8 +- .../expandable-menu-provider.ts | 30 +- .../helper-providers/route-context.menu.ts | 7 +- src/app/shared/menu/providers/import.menu.ts | 5 +- .../shared/menu/providers/item-claim.menu.ts | 5 +- .../shared/menu/providers/item-orcid.menu.ts | 5 +- .../menu/providers/item-versioning.menu.ts | 5 +- src/app/shared/menu/providers/new.menu.ts | 5 +- .../shared/menu/providers/processes.menu.ts | 5 +- .../shared/menu/providers/registries.menu.ts | 5 +- .../shared/menu/providers/statistics.menu.ts | 11 +- .../menu/providers/system-wide-alert.menu.ts | 5 +- .../shared/menu/providers/workflow.menu.ts | 5 +- src/app/shared/shared.module.ts | 4 - src/modules/app/server-init.service.ts | 2 +- 50 files changed, 668 insertions(+), 2155 deletions(-) delete mode 100644 src/app/menu.resolver.spec.ts delete mode 100644 src/app/menu.resolver.ts delete mode 100644 src/app/shared/dso-page/dso-edit-menu.resolver.spec.ts delete mode 100644 src/app/shared/dso-page/dso-edit-menu.resolver.ts delete mode 100644 src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.html delete mode 100644 src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.scss delete mode 100644 src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.spec.ts delete mode 100644 src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.ts rename src/app/shared/menu/{menu-provider.ts => menu-provider.model.ts} (52%) create mode 100644 src/app/shared/menu/menu-provider.service.spec.ts delete mode 100644 src/app/shared/menu/menu.resolver.ts create mode 100644 src/app/shared/menu/menu.structure.spec.ts diff --git a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.spec.ts b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.spec.ts index dd31f757c2..18d042e995 100644 --- a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.spec.ts +++ b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.spec.ts @@ -12,59 +12,108 @@ import { By } from '@angular/platform-browser'; import { TranslateModule } from '@ngx-translate/core'; import { Router } from '@angular/router'; import { RouterStub } from '../../../shared/testing/router.stub'; +import { MenuItemModels } from '../../../shared/menu/menu-section.model'; describe('ExpandableAdminSidebarSectionComponent', () => { let component: ExpandableAdminSidebarSectionComponent; let fixture: ComponentFixture; const menuService = new MenuServiceStub(); const iconString = 'test'; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, TranslateModule.forRoot()], - declarations: [ExpandableAdminSidebarSectionComponent, TestComponent], - providers: [ - { provide: 'sectionDataProvider', useValue: { icon: iconString, model: {} } }, - { provide: MenuService, useValue: menuService }, - { provide: CSSVariableService, useClass: CSSVariableServiceStub }, - { provide: Router, useValue: new RouterStub() }, - ] - }).overrideComponent(ExpandableAdminSidebarSectionComponent, { - set: { - entryComponents: [TestComponent] - } - }) - .compileComponents(); - })); - beforeEach(() => { - spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([])); - fixture = TestBed.createComponent(ExpandableAdminSidebarSectionComponent); - component = fixture.componentInstance; - spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent); - fixture.detectChanges(); - }); - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should set the right icon', () => { - const icon = fixture.debugElement.query(By.css('.shortcut-icon > i.fas')); - expect(icon.nativeElement.getAttribute('class')).toContain('fa-' + iconString); - }); - - describe('when the header text is clicked', () => { - beforeEach(() => { - spyOn(menuService, 'toggleActiveSection'); - const sidebarToggler = fixture.debugElement.query(By.css('.sidebar-section > div.nav-item')); - sidebarToggler.triggerEventHandler('click', { - preventDefault: () => {/**/ + describe('when there are subsections', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, TranslateModule.forRoot()], + declarations: [ExpandableAdminSidebarSectionComponent, TestComponent], + providers: [ + {provide: 'sectionDataProvider', useValue: {icon: iconString, model: {}}}, + {provide: MenuService, useValue: menuService}, + {provide: CSSVariableService, useClass: CSSVariableServiceStub}, + {provide: Router, useValue: new RouterStub()}, + ] + }).overrideComponent(ExpandableAdminSidebarSectionComponent, { + set: { + entryComponents: [TestComponent] } - }); + }) + .compileComponents(); + })); + + beforeEach(() => { + spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([{ + id: 'test', + visible: true, + model: {} as MenuItemModels + }])); + fixture = TestBed.createComponent(ExpandableAdminSidebarSectionComponent); + component = fixture.componentInstance; + spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent); + fixture.detectChanges(); }); - it('should call toggleActiveSection on the menuService', () => { - expect(menuService.toggleActiveSection).toHaveBeenCalled(); + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set the right icon', () => { + const icon = fixture.debugElement.query(By.css('.shortcut-icon > i.fas')); + expect(icon.nativeElement.getAttribute('class')).toContain('fa-' + iconString); + }); + + describe('when the header text is clicked', () => { + beforeEach(() => { + spyOn(menuService, 'toggleActiveSection'); + const sidebarToggler = fixture.debugElement.query(By.css('.sidebar-section > div.nav-item')); + sidebarToggler.triggerEventHandler('click', { + preventDefault: () => {/**/ + } + }); + }); + + it('should call toggleActiveSection on the menuService', () => { + expect(menuService.toggleActiveSection).toHaveBeenCalled(); + }); + }); + }); + + + describe('when there are no subsections', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, TranslateModule.forRoot()], + declarations: [ExpandableAdminSidebarSectionComponent, TestComponent], + providers: [ + {provide: 'sectionDataProvider', useValue: {icon: iconString, model: {}}}, + {provide: MenuService, useValue: menuService}, + {provide: CSSVariableService, useClass: CSSVariableServiceStub}, + {provide: Router, useValue: new RouterStub()}, + ] + }).overrideComponent(ExpandableAdminSidebarSectionComponent, { + set: { + entryComponents: [TestComponent] + } + }) + .compileComponents(); + })); + + beforeEach(() => { + spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([])); + fixture = TestBed.createComponent(ExpandableAdminSidebarSectionComponent); + component = fixture.componentInstance; + spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should not contain a section', () => { + const icon = fixture.debugElement.query(By.css('.shortcut-icon')); + expect(icon).toBeNull(); + const sidebarToggler = fixture.debugElement.query(By.css('.sidebar-section')); + expect(sidebarToggler).toBeNull(); }); }); }); diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 4af778433d..614e04556a 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -36,7 +36,6 @@ import { ThemedPageErrorComponent } from './page-error/themed-page-error.compone 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 { resolveStaticMenus } from './shared/menu/menu.resolver'; @NgModule({ imports: [ @@ -48,8 +47,6 @@ import { PROCESS_MODULE_PATH } from './process-page/process-page-routing.paths'; canActivate: [AuthBlockingGuard], canActivateChild: [ServerCheckGuard], resolve: [ - // resolveStaticMenus(), - // MenuResolver, ], children: [ { path: '', redirectTo: '/home', pathMatch: 'full' }, diff --git a/src/app/browse-by/browse-by-routing.module.ts b/src/app/browse-by/browse-by-routing.module.ts index bb67dc65ae..5f39a4b24e 100644 --- a/src/app/browse-by/browse-by-routing.module.ts +++ b/src/app/browse-by/browse-by-routing.module.ts @@ -4,7 +4,6 @@ import { BrowseByGuard } from './browse-by-guard'; import { BrowseByDSOBreadcrumbResolver } from './browse-by-dso-breadcrumb.resolver'; import { BrowseByI18nBreadcrumbResolver } from './browse-by-i18n-breadcrumb.resolver'; import { ThemedBrowseBySwitcherComponent } from './browse-by-switcher/themed-browse-by-switcher.component'; -import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; @NgModule({ imports: [ @@ -13,7 +12,6 @@ import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; path: '', resolve: { breadcrumb: BrowseByDSOBreadcrumbResolver, - menu: DSOEditMenuResolver }, children: [ { diff --git a/src/app/menu.resolver.spec.ts b/src/app/menu.resolver.spec.ts deleted file mode 100644 index 838d5a53c5..0000000000 --- a/src/app/menu.resolver.spec.ts +++ /dev/null @@ -1,423 +0,0 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; - -import { MenuResolver } from './menu.resolver'; -import { of as observableOf } from 'rxjs'; -import { FeatureID } from './core/data/feature-authorization/feature-id'; -import { TranslateModule } from '@ngx-translate/core'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { RouterTestingModule } from '@angular/router/testing'; -import { AdminSidebarComponent } from './admin/admin-sidebar/admin-sidebar.component'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { MenuService } from './shared/menu/menu.service'; -import { AuthorizationDataService } from './core/data/feature-authorization/authorization-data.service'; -import { ScriptDataService } from './core/data/processes/script-data.service'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { MenuServiceStub } from './shared/testing/menu-service.stub'; -import { MenuID } from './shared/menu/menu-id.model'; -import { BrowseService } from './core/browse/browse.service'; -import { cold } from 'jasmine-marbles'; -import createSpy = jasmine.createSpy; -import { createSuccessfulRemoteDataObject$ } from './shared/remote-data.utils'; -import { createPaginatedList } from './shared/testing/utils.test'; - -const BOOLEAN = { t: true, f: false }; -const MENU_STATE = { - id: 'some menu' -}; -const BROWSE_DEFINITIONS = [ - { id: 'definition1' }, - { id: 'definition2' }, - { id: 'definition3' }, -]; - -describe('MenuResolver', () => { - let resolver: MenuResolver; - - let menuService; - let browseService; - let authorizationService; - let scriptService; - - beforeEach(waitForAsync(() => { - menuService = new MenuServiceStub(); - spyOn(menuService, 'getMenu').and.returnValue(observableOf(MENU_STATE)); - spyOn(menuService, 'addSection'); - - browseService = jasmine.createSpyObj('browseService', { - getBrowseDefinitions: createSuccessfulRemoteDataObject$(createPaginatedList(BROWSE_DEFINITIONS)) - }); - authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(true) - }); - scriptService = jasmine.createSpyObj('scriptService', { - scriptWithNameExistsAndCanExecute: observableOf(true) - }); - - TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), NoopAnimationsModule, RouterTestingModule], - declarations: [AdminSidebarComponent], - providers: [ - { provide: MenuService, useValue: menuService }, - { provide: BrowseService, useValue: browseService }, - { provide: AuthorizationDataService, useValue: authorizationService }, - { provide: ScriptDataService, useValue: scriptService }, - { - provide: NgbModal, useValue: { - open: () => {/*comment*/ - } - } - } - ], - schemas: [NO_ERRORS_SCHEMA] - }); - resolver = TestBed.inject(MenuResolver); - })); - - it('should be created', () => { - expect(resolver).toBeTruthy(); - }); - - describe('resolve', () => { - it('should create all menus', (done) => { - spyOn(resolver, 'createPublicMenu$').and.returnValue(observableOf(true)); - spyOn(resolver, 'createAdminMenu$').and.returnValue(observableOf(true)); - - resolver.resolve(null, null).subscribe(resolved => { - expect(resolved).toBeTrue(); - expect(resolver.createPublicMenu$).toHaveBeenCalled(); - expect(resolver.createAdminMenu$).toHaveBeenCalled(); - done(); - }); - }); - - it('should return an Observable that emits true as soon as all menus are created', () => { - spyOn(resolver, 'createPublicMenu$').and.returnValue(cold('--(t|)', BOOLEAN)); - spyOn(resolver, 'createAdminMenu$').and.returnValue(cold('----(t|)', BOOLEAN)); - - expect(resolver.resolve(null, null)).toBeObservable(cold('----(t|)', BOOLEAN)); - }); - }); - - describe('createPublicMenu$', () => { - it('should retrieve the menu by ID return an Observable that emits true as soon as it is created', () => { - (menuService as any).getMenu.and.returnValue(cold('--u--m--', { - u: undefined, - m: MENU_STATE, - })); - - expect(resolver.createPublicMenu$()).toBeObservable(cold('-----(t|)', BOOLEAN)); - expect(menuService.getMenu).toHaveBeenCalledOnceWith(MenuID.PUBLIC); - }); - - describe('contents', () => { - beforeEach((done) => { - resolver.createPublicMenu$().subscribe((_) => { - done(); - }); - }); - - it('should include community list link', () => { - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, jasmine.objectContaining({ - id: 'browse_global_communities_and_collections', visible: true, - })); - }); - - it('should include browse dropdown', () => { - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, jasmine.objectContaining({ - id: 'browse_global_by_definition1', parentID: 'browse_global', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, jasmine.objectContaining({ - id: 'browse_global_by_definition2', parentID: 'browse_global', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, jasmine.objectContaining({ - id: 'browse_global_by_definition3', parentID: 'browse_global', visible: true, - })); - - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, jasmine.objectContaining({ - id: 'browse_global', visible: true, - })); - }); - }); - }); - - describe('createAdminMenu$', () => { - const dontShowAdminSections = () => { - it('should not show site admin section', () => { - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'admin_search', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'registries', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - parentID: 'registries', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'curation_tasks', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'workflow', visible: false, - })); - }); - - it('should not show access control section', () => { - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'access_control', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - parentID: 'access_control', visible: false, - })); - }); - - // We check that the menu section has not been called with visible set to true - // The reason why we don't check if it has been called with visible set to false - // Is because the function does not get called unless a user is authorised - it('should not show the import section', () => { - expect(menuService.addSection).not.toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'import', visible: true, - })); - }); - - // We check that the menu section has not been called with visible set to true - // The reason why we don't check if it has been called with visible set to false - // Is because the function does not get called unless a user is authorised - it('should not show the export section', () => { - expect(menuService.addSection).not.toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'export', visible: true, - })); - }); - }; - - const dontShowNewSection = () => { - it('should not show the "New" section', () => { - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'new_community', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'new_collection', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'new_item', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'new', visible: false, - })); - }); - }; - - const dontShowEditSection = () => { - it('should not show the "Edit" section', () => { - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'edit_community', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'edit_collection', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'edit_item', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'edit', visible: false, - })); - }); - }; - - it('should retrieve the menu by ID return an Observable that emits true as soon as it is created', () => { - (menuService as any).getMenu.and.returnValue(cold('--u--m', { - u: undefined, - m: MENU_STATE, - })); - - expect(resolver.createAdminMenu$()).toBeObservable(cold('-----(t|)', BOOLEAN)); - expect(menuService.getMenu).toHaveBeenCalledOnceWith(MenuID.ADMIN); - }); - - describe('for regular user', () => { - beforeEach(() => { - authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID) => { - return observableOf(false); - }); - }); - - beforeEach((done) => { - resolver.createAdminMenu$().subscribe((_) => { - done(); - }); - }); - - dontShowAdminSections(); - dontShowNewSection(); - dontShowEditSection(); - }); - - describe('regular user who can submit', () => { - beforeEach(() => { - authorizationService.isAuthorized = createSpy('isAuthorized') - .and.callFake((featureID: FeatureID) => { - return observableOf(featureID === FeatureID.CanSubmit); - }); - }); - - beforeEach((done) => { - resolver.createAdminMenu$().subscribe((_) => { - done(); - }); - }); - - it('should show "New Item" section', () => { - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'new_item', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'new', visible: true, - })); - }); - - dontShowAdminSections(); - dontShowEditSection(); - }); - - describe('regular user who can edit items', () => { - beforeEach(() => { - authorizationService.isAuthorized = createSpy('isAuthorized') - .and.callFake((featureID: FeatureID) => { - return observableOf(featureID === FeatureID.CanEditItem); - }); - }); - - beforeEach((done) => { - resolver.createAdminMenu$().subscribe((_) => { - done(); - }); - }); - - it('should show "Edit Item" section', () => { - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'edit_item', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'edit', visible: true, - })); - }); - - dontShowAdminSections(); - dontShowNewSection(); - }); - - describe('for site admin', () => { - beforeEach(() => { - authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => { - return observableOf(featureID === FeatureID.AdministratorOf); - }); - }); - - beforeEach((done) => { - resolver.createAdminMenu$().subscribe((_) => { - done(); - }); - }); - - it('should show new_process', () => { - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'new_process', visible: true, - })); - }); - - it('should contain site admin section', () => { - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'admin_search', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'registries', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - parentID: 'registries', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'curation_tasks', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'workflow', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'workflow', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'import', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'import_batch', parentID: 'import', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'export', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'export_batch', parentID: 'export', visible: true, - })); - }); - }); - - describe('for community admin', () => { - beforeEach(() => { - authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => { - return observableOf(featureID === FeatureID.IsCommunityAdmin); - }); - }); - - beforeEach((done) => { - resolver.createAdminMenu$().subscribe((_) => { - done(); - }); - }); - - it('should show edit_community', () => { - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'edit_community', visible: true, - })); - }); - }); - - describe('for collection admin', () => { - beforeEach(() => { - authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => { - return observableOf(featureID === FeatureID.IsCollectionAdmin); - }); - }); - - beforeEach((done) => { - resolver.createAdminMenu$().subscribe((_) => { - done(); - }); - }); - - it('should show edit_collection', () => { - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'edit_collection', visible: true, - })); - }); - }); - - describe('for group admin', () => { - beforeEach(() => { - authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => { - return observableOf(featureID === FeatureID.CanManageGroups); - }); - }); - - beforeEach((done) => { - resolver.createAdminMenu$().subscribe((_) => { - done(); - }); - }); - - it('should show access control section', () => { - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'access_control', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - parentID: 'access_control', visible: true, - })); - }); - }); - }); -}); diff --git a/src/app/menu.resolver.ts b/src/app/menu.resolver.ts deleted file mode 100644 index cad6a6ec57..0000000000 --- a/src/app/menu.resolver.ts +++ /dev/null @@ -1,703 +0,0 @@ -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; -import { combineLatest as observableCombineLatest, combineLatest, Observable } from 'rxjs'; -import { MenuID } from './shared/menu/menu-id.model'; -import { MenuState } from './shared/menu/menu-state.model'; -import { MenuItemType } from './shared/menu/menu-item-type.model'; -import { LinkMenuItemModel } from './shared/menu/menu-item/models/link.model'; -import { getFirstCompletedRemoteData } from './core/shared/operators'; -import { PaginatedList } from './core/data/paginated-list.model'; -import { BrowseDefinition } from './core/shared/browse-definition.model'; -import { RemoteData } from './core/data/remote-data'; -import { TextMenuItemModel } from './shared/menu/menu-item/models/text.model'; -import { BrowseService } from './core/browse/browse.service'; -import { MenuService } from './shared/menu/menu.service'; -import { filter, find, map, take } from 'rxjs/operators'; -import { hasValue } from './shared/empty.util'; -import { FeatureID } from './core/data/feature-authorization/feature-id'; -import { - ThemedCreateCommunityParentSelectorComponent -} from './shared/dso-selector/modal-wrappers/create-community-parent-selector/themed-create-community-parent-selector.component'; -import { OnClickMenuItemModel } from './shared/menu/menu-item/models/onclick.model'; -import { - ThemedCreateCollectionParentSelectorComponent -} from './shared/dso-selector/modal-wrappers/create-collection-parent-selector/themed-create-collection-parent-selector.component'; -import { - ThemedCreateItemParentSelectorComponent -} from './shared/dso-selector/modal-wrappers/create-item-parent-selector/themed-create-item-parent-selector.component'; -import { - ThemedEditCommunitySelectorComponent -} from './shared/dso-selector/modal-wrappers/edit-community-selector/themed-edit-community-selector.component'; -import { - ThemedEditCollectionSelectorComponent -} from './shared/dso-selector/modal-wrappers/edit-collection-selector/themed-edit-collection-selector.component'; -import { - ThemedEditItemSelectorComponent -} from './shared/dso-selector/modal-wrappers/edit-item-selector/themed-edit-item-selector.component'; -import { - ExportMetadataSelectorComponent -} from './shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component'; -import { AuthorizationDataService } from './core/data/feature-authorization/authorization-data.service'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { - METADATA_EXPORT_SCRIPT_NAME, - METADATA_IMPORT_SCRIPT_NAME, - ScriptDataService -} from './core/data/processes/script-data.service'; -import { - ExportBatchSelectorComponent -} from './shared/dso-selector/modal-wrappers/export-batch-selector/export-batch-selector.component'; - -/** - * Creates all of the app's menus - */ -@Injectable({ - providedIn: 'root' -}) -export class MenuResolver implements Resolve { - constructor( - protected menuService: MenuService, - protected browseService: BrowseService, - protected authorizationService: AuthorizationDataService, - protected modalService: NgbModal, - protected scriptDataService: ScriptDataService, - ) { - } - - /** - * Initialize all menus - */ - resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return combineLatest([ - this.createPublicMenu$(), - this.createAdminMenu$(), - ]).pipe( - map((menusDone: boolean[]) => menusDone.every(Boolean)), - ); - } - - /** - * Wait for a specific menu to appear - * @param id the ID of the menu to wait for - * @return an Observable that emits true as soon as the menu is created - */ - protected waitForMenu$(id: MenuID): Observable { - return this.menuService.getMenu(id).pipe( - find((menu: MenuState) => hasValue(menu)), - map(() => true), - ); - } - - /** - * Initialize all menu sections and items for {@link MenuID.PUBLIC} - */ - createPublicMenu$(): Observable { - const menuList: any[] = [ - /* Communities & Collections tree */ - { - id: `browse_global_communities_and_collections`, - active: false, - visible: true, - index: 0, - model: { - type: MenuItemType.LINK, - text: `menu.section.browse_global_communities_and_collections`, - link: `/community-list` - } as LinkMenuItemModel - } - ]; - // Read the different Browse-By types from config and add them to the browse menu - this.browseService.getBrowseDefinitions() - .pipe(getFirstCompletedRemoteData>()) - .subscribe((browseDefListRD: RemoteData>) => { - if (browseDefListRD.hasSucceeded) { - browseDefListRD.payload.page.forEach((browseDef: BrowseDefinition) => { - menuList.push({ - id: `browse_global_by_${browseDef.id}`, - parentID: 'browse_global', - active: false, - visible: true, - model: { - type: MenuItemType.LINK, - text: `menu.section.browse_global_by_${browseDef.id}`, - link: `/browse/${browseDef.id}` - } as LinkMenuItemModel - }); - }); - menuList.push( - /* Browse */ - { - id: 'browse_global', - active: false, - visible: true, - index: 1, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.browse_global' - } as TextMenuItemModel, - } - ); - } - menuList.forEach((menuSection) => this.menuService.addSection(MenuID.PUBLIC, Object.assign(menuSection, { - shouldPersistOnRouteChange: true - }))); - }); - - return this.waitForMenu$(MenuID.PUBLIC); - } - - /** - * Initialize all menu sections and items for {@link MenuID.ADMIN} - */ - createAdminMenu$() { - this.createMainMenuSections(); - this.createSiteAdministratorMenuSections(); - this.createExportMenuSections(); - this.createImportMenuSections(); - this.createAccessControlMenuSections(); - - return this.waitForMenu$(MenuID.ADMIN); - } - - /** - * Initialize the main menu sections. - * edit_community / edit_collection is only included if the current user is a Community or Collection admin - */ - createMainMenuSections() { - combineLatest([ - this.authorizationService.isAuthorized(FeatureID.IsCollectionAdmin), - this.authorizationService.isAuthorized(FeatureID.IsCommunityAdmin), - this.authorizationService.isAuthorized(FeatureID.AdministratorOf), - this.authorizationService.isAuthorized(FeatureID.CanSubmit), - this.authorizationService.isAuthorized(FeatureID.CanEditItem), - ]).subscribe(([isCollectionAdmin, isCommunityAdmin, isSiteAdmin, canSubmit, canEditItem]) => { - const newSubMenuList = [ - { - id: 'new_community', - parentID: 'new', - active: false, - visible: isCommunityAdmin, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.new_community', - function: () => { - this.modalService.open(ThemedCreateCommunityParentSelectorComponent); - } - } as OnClickMenuItemModel, - }, - { - id: 'new_collection', - parentID: 'new', - active: false, - visible: isCommunityAdmin, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.new_collection', - function: () => { - this.modalService.open(ThemedCreateCollectionParentSelectorComponent); - } - } as OnClickMenuItemModel, - }, - { - id: 'new_item', - parentID: 'new', - active: false, - visible: canSubmit, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.new_item', - function: () => { - this.modalService.open(ThemedCreateItemParentSelectorComponent); - } - } as OnClickMenuItemModel, - }, - { - id: 'new_process', - parentID: 'new', - active: false, - visible: isSiteAdmin, - model: { - type: MenuItemType.LINK, - text: 'menu.section.new_process', - link: '/processes/new' - } as LinkMenuItemModel, - }, - ]; - const editSubMenuList = [ - /* Edit */ - { - id: 'edit_community', - parentID: 'edit', - active: false, - visible: isCommunityAdmin, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.edit_community', - function: () => { - this.modalService.open(ThemedEditCommunitySelectorComponent); - } - } as OnClickMenuItemModel, - }, - { - id: 'edit_collection', - parentID: 'edit', - active: false, - visible: isCollectionAdmin, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.edit_collection', - function: () => { - this.modalService.open(ThemedEditCollectionSelectorComponent); - } - } as OnClickMenuItemModel, - }, - { - id: 'edit_item', - parentID: 'edit', - active: false, - visible: canEditItem, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.edit_item', - function: () => { - this.modalService.open(ThemedEditItemSelectorComponent); - } - } as OnClickMenuItemModel, - }, - ]; - const newSubMenu = { - id: 'new', - active: false, - visible: newSubMenuList.some(subMenu => subMenu.visible), - model: { - type: MenuItemType.TEXT, - text: 'menu.section.new' - } as TextMenuItemModel, - icon: 'plus', - index: 0 - }; - const editSubMenu = { - id: 'edit', - active: false, - visible: editSubMenuList.some(subMenu => subMenu.visible), - model: { - type: MenuItemType.TEXT, - text: 'menu.section.edit' - } as TextMenuItemModel, - icon: 'pencil-alt', - index: 1 - }; - - const menuList = [ - ...newSubMenuList, - newSubMenu, - ...editSubMenuList, - editSubMenu, - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'new_item_version', - // parentID: 'new', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.new_item_version', - // link: '' - // } as LinkMenuItemModel, - // }, - - /* Statistics */ - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'statistics_task', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.statistics_task', - // link: '' - // } as LinkMenuItemModel, - // icon: 'chart-bar', - // index: 8 - // }, - - /* Control Panel */ - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'control_panel', - // active: false, - // visible: isSiteAdmin, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.control_panel', - // link: '' - // } as LinkMenuItemModel, - // icon: 'cogs', - // index: 9 - // }, - - /* Processes */ - { - id: 'processes', - active: false, - visible: isSiteAdmin, - model: { - type: MenuItemType.LINK, - text: 'menu.section.processes', - link: '/processes' - } as LinkMenuItemModel, - icon: 'terminal', - index: 10 - }, - { - id: 'health', - active: false, - visible: isSiteAdmin, - model: { - type: MenuItemType.LINK, - text: 'menu.section.health', - link: '/health' - } as LinkMenuItemModel, - icon: 'heartbeat', - index: 11 - }, - ]; - menuList.forEach((menuSection) => this.menuService.addSection(MenuID.ADMIN, Object.assign(menuSection, { - shouldPersistOnRouteChange: true - }))); - }); - } - - /** - * Create menu sections dependent on whether or not the current user is a site administrator and on whether or not - * the export scripts exist and the current user is allowed to execute them - */ - createExportMenuSections() { - const menuList = [ - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'export_community', - // parentID: 'export', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.export_community', - // link: '' - // } as LinkMenuItemModel, - // shouldPersistOnRouteChange: true - // }, - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'export_collection', - // parentID: 'export', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.export_collection', - // link: '' - // } as LinkMenuItemModel, - // shouldPersistOnRouteChange: true - // }, - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'export_item', - // parentID: 'export', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.export_item', - // link: '' - // } as LinkMenuItemModel, - // shouldPersistOnRouteChange: true - // }, - ]; - menuList.forEach((menuSection) => this.menuService.addSection(MenuID.ADMIN, menuSection)); - - observableCombineLatest([ - this.authorizationService.isAuthorized(FeatureID.AdministratorOf), - this.scriptDataService.scriptWithNameExistsAndCanExecute(METADATA_EXPORT_SCRIPT_NAME) - ]).pipe( - filter(([authorized, metadataExportScriptExists]: boolean[]) => authorized && metadataExportScriptExists), - take(1) - ).subscribe(() => { - // Hides the export menu for unauthorised people - // If in the future more sub-menus are added, - // it should be reviewed if they need to be in this subscribe - this.menuService.addSection(MenuID.ADMIN, { - id: 'export', - active: false, - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.export' - } as TextMenuItemModel, - icon: 'file-export', - index: 3, - shouldPersistOnRouteChange: true - }); - this.menuService.addSection(MenuID.ADMIN, { - id: 'export_metadata', - parentID: 'export', - active: true, - visible: true, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.export_metadata', - function: () => { - this.modalService.open(ExportMetadataSelectorComponent); - } - } as OnClickMenuItemModel, - shouldPersistOnRouteChange: true - }); - this.menuService.addSection(MenuID.ADMIN, { - id: 'export_batch', - parentID: 'export', - active: false, - visible: true, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.export_batch', - function: () => { - this.modalService.open(ExportBatchSelectorComponent); - } - } as OnClickMenuItemModel, - shouldPersistOnRouteChange: true - }); - }); - } - - /** - * Create menu sections dependent on whether or not the current user is a site administrator and on whether or not - * the import scripts exist and the current user is allowed to execute them - */ - createImportMenuSections() { - const menuList = []; - menuList.forEach((menuSection) => this.menuService.addSection(MenuID.ADMIN, menuSection)); - - observableCombineLatest([ - this.authorizationService.isAuthorized(FeatureID.AdministratorOf), - this.scriptDataService.scriptWithNameExistsAndCanExecute(METADATA_IMPORT_SCRIPT_NAME) - ]).pipe( - filter(([authorized, metadataImportScriptExists]: boolean[]) => authorized && metadataImportScriptExists), - take(1) - ).subscribe(() => { - // Hides the import menu for unauthorised people - // If in the future more sub-menus are added, - // it should be reviewed if they need to be in this subscribe - this.menuService.addSection(MenuID.ADMIN, { - id: 'import', - active: false, - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.import' - } as TextMenuItemModel, - icon: 'file-import', - index: 2, - shouldPersistOnRouteChange: true, - }); - this.menuService.addSection(MenuID.ADMIN, { - id: 'import_metadata', - parentID: 'import', - active: true, - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.import_metadata', - link: '/admin/metadata-import' - } as LinkMenuItemModel, - shouldPersistOnRouteChange: true - }); - this.menuService.addSection(MenuID.ADMIN, { - id: 'import_batch', - parentID: 'import', - active: false, - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.import_batch', - link: '/admin/batch-import' - } as LinkMenuItemModel, - shouldPersistOnRouteChange: true - }); - }); - } - - /** - * Create menu sections dependent on whether or not the current user is a site administrator - */ - createSiteAdministratorMenuSections() { - this.authorizationService.isAuthorized(FeatureID.AdministratorOf).subscribe((authorized) => { - const menuList = [ - /* Admin Search */ - { - id: 'admin_search', - active: false, - visible: authorized, - model: { - type: MenuItemType.LINK, - text: 'menu.section.admin_search', - link: '/admin/search' - } as LinkMenuItemModel, - icon: 'search', - index: 5 - }, - /* Registries */ - { - id: 'registries', - active: false, - visible: authorized, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.registries' - } as TextMenuItemModel, - icon: 'list', - index: 6 - }, - { - id: 'registries_metadata', - parentID: 'registries', - active: false, - visible: authorized, - model: { - type: MenuItemType.LINK, - text: 'menu.section.registries_metadata', - link: 'admin/registries/metadata' - } as LinkMenuItemModel, - }, - { - id: 'registries_format', - parentID: 'registries', - active: false, - visible: authorized, - model: { - type: MenuItemType.LINK, - text: 'menu.section.registries_format', - link: 'admin/registries/bitstream-formats' - } as LinkMenuItemModel, - }, - - /* Curation tasks */ - { - id: 'curation_tasks', - active: false, - visible: authorized, - model: { - type: MenuItemType.LINK, - text: 'menu.section.curation_task', - link: 'admin/curation-tasks' - } as LinkMenuItemModel, - icon: 'filter', - index: 7 - }, - - /* Workflow */ - { - id: 'workflow', - active: false, - visible: authorized, - model: { - type: MenuItemType.LINK, - text: 'menu.section.workflow', - link: '/admin/workflow' - } as LinkMenuItemModel, - icon: 'user-check', - index: 11 - }, - { - id: 'system_wide_alert', - active: false, - visible: authorized, - model: { - type: MenuItemType.LINK, - text: 'menu.section.system-wide-alert', - link: '/admin/system-wide-alert' - } as LinkMenuItemModel, - icon: 'exclamation-circle', - index: 12 - }, - ]; - - menuList.forEach((menuSection) => this.menuService.addSection(MenuID.ADMIN, Object.assign(menuSection, { - shouldPersistOnRouteChange: true - }))); - }); - } - - /** - * Create menu sections dependent on whether or not the current user can manage access control groups - */ - createAccessControlMenuSections() { - observableCombineLatest([ - this.authorizationService.isAuthorized(FeatureID.AdministratorOf), - this.authorizationService.isAuthorized(FeatureID.CanManageGroups) - ]).subscribe(([isSiteAdmin, canManageGroups]) => { - const menuList = [ - /* Access Control */ - { - id: 'access_control_people', - parentID: 'access_control', - active: false, - visible: isSiteAdmin, - model: { - type: MenuItemType.LINK, - text: 'menu.section.access_control_people', - link: '/access-control/epeople' - } as LinkMenuItemModel, - }, - { - id: 'access_control_groups', - parentID: 'access_control', - active: false, - visible: canManageGroups, - model: { - type: MenuItemType.LINK, - text: 'menu.section.access_control_groups', - link: '/access-control/groups' - } as LinkMenuItemModel, - }, - { - id: 'access_control_bulk', - parentID: 'access_control', - active: false, - visible: isSiteAdmin, - model: { - type: MenuItemType.LINK, - text: 'menu.section.access_control_bulk', - link: '/access-control/bulk-access' - } as LinkMenuItemModel, - }, - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'access_control_authorizations', - // parentID: 'access_control', - // active: false, - // visible: authorized, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.access_control_authorizations', - // link: '' - // } as LinkMenuItemModel, - // }, - { - id: 'access_control', - active: false, - visible: canManageGroups || isSiteAdmin, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.access_control' - } as TextMenuItemModel, - icon: 'key', - index: 4 - }, - ]; - - menuList.forEach((menuSection) => this.menuService.addSection(MenuID.ADMIN, Object.assign(menuSection, { - shouldPersistOnRouteChange: true, - }))); - }); - } -} diff --git a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.spec.ts b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.spec.ts index 488c9ab251..ef7eee9868 100644 --- a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.spec.ts +++ b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.spec.ts @@ -10,6 +10,7 @@ import { MenuService } from '../../shared/menu/menu.service'; import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { VarDirective } from '../../shared/utils/var.directive'; +import { MenuItemModels } from '../../shared/menu/menu-section.model'; describe('ExpandableNavbarSectionComponent', () => { let component: ExpandableNavbarSectionComponent; @@ -35,7 +36,7 @@ describe('ExpandableNavbarSectionComponent', () => { })); beforeEach(() => { - spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([])); + spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([{id: 'test', visible: true, model: {} as MenuItemModels}])); fixture = TestBed.createComponent(ExpandableNavbarSectionComponent); component = fixture.componentInstance; @@ -184,7 +185,7 @@ describe('ExpandableNavbarSectionComponent', () => { })); beforeEach(() => { - spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([])); + spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([{id: 'test', visible: true, model: {} as MenuItemModels}])); fixture = TestBed.createComponent(ExpandableNavbarSectionComponent); component = fixture.componentInstance; @@ -195,6 +196,7 @@ describe('ExpandableNavbarSectionComponent', () => { describe('when the mouse enters the section header', () => { beforeEach(() => { spyOn(menuService, 'activateSection'); + console.log(fixture.nativeElement.innerHTML); const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown > a')); sidebarToggler.triggerEventHandler('mouseenter', { preventDefault: () => {/**/ diff --git a/src/app/shared/dso-page/dso-edit-menu.resolver.spec.ts b/src/app/shared/dso-page/dso-edit-menu.resolver.spec.ts deleted file mode 100644 index abfe618174..0000000000 --- a/src/app/shared/dso-page/dso-edit-menu.resolver.spec.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; -import { MenuServiceStub } from '../testing/menu-service.stub'; -import { of as observableOf } from 'rxjs'; -import { TranslateModule, TranslateService } from '@ngx-translate/core'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { RouterTestingModule } from '@angular/router/testing'; -import { AdminSidebarComponent } from '../../admin/admin-sidebar/admin-sidebar.component'; -import { MenuService } from '../menu/menu.service'; -import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { DSOEditMenuResolver } from './dso-edit-menu.resolver'; -import { DsoVersioningModalService } from './dso-versioning-modal-service/dso-versioning-modal.service'; -import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; -import { Item } from '../../core/shared/item.model'; -import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; -import { MenuID } from '../menu/menu-id.model'; -import { MenuItemType } from '../menu/menu-item-type.model'; -import { TextMenuItemModel } from '../menu/menu-item/models/text.model'; -import { LinkMenuItemModel } from '../menu/menu-item/models/link.model'; -import { ResearcherProfileDataService } from '../../core/profile/researcher-profile-data.service'; -import { NotificationsService } from '../notifications/notifications.service'; - -describe('DSOEditMenuResolver', () => { - - const MENU_STATE = { - id: 'some menu' - }; - - let resolver: DSOEditMenuResolver; - - let dSpaceObjectDataService; - let menuService; - let authorizationService; - let dsoVersioningModalService; - let researcherProfileService; - let notificationsService; - let translate; - - const route = { - data: { - menu: { - 'statistics': [{ - id: 'statistics-dummy-1', - active: false, - visible: true, - model: null - }] - } - }, - params: {id: 'test-uuid'}, - }; - - const state = { - url: 'test-url' - }; - - const testObject = Object.assign(new Item(), {uuid: 'test-uuid', type: 'item', _links: {self: {href: 'self-link'}}}); - - const dummySections1 = [{ - id: 'dummy-1', - active: false, - visible: true, - model: null - }, - { - id: 'dummy-2', - active: false, - visible: true, - model: null - }]; - - const dummySections2 = [{ - id: 'dummy-3', - active: false, - visible: true, - model: null - }, - { - id: 'dummy-4', - active: false, - visible: true, - model: null - }, - { - id: 'dummy-5', - active: false, - visible: true, - model: null - }]; - - beforeEach(waitForAsync(() => { - menuService = new MenuServiceStub(); - spyOn(menuService, 'getMenu').and.returnValue(observableOf(MENU_STATE)); - - dSpaceObjectDataService = jasmine.createSpyObj('dSpaceObjectDataService', { - findById: createSuccessfulRemoteDataObject$(testObject) - }); - authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(true) - }); - dsoVersioningModalService = jasmine.createSpyObj('dsoVersioningModalService', { - isNewVersionButtonDisabled: observableOf(false), - getVersioningTooltipMessage: observableOf('message'), - openCreateVersionModal: {} - }); - researcherProfileService = jasmine.createSpyObj('researcherProfileService', { - createFromExternalSourceAndReturnRelatedItemId: observableOf('mock-id'), - }); - translate = jasmine.createSpyObj('translate', { - get: observableOf('translated-message'), - }); - notificationsService = jasmine.createSpyObj('notificationsService', { - success: {}, - error: {}, - }); - - TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), NoopAnimationsModule, RouterTestingModule], - declarations: [AdminSidebarComponent], - providers: [ - {provide: DSpaceObjectDataService, useValue: dSpaceObjectDataService}, - {provide: MenuService, useValue: menuService}, - {provide: AuthorizationDataService, useValue: authorizationService}, - {provide: DsoVersioningModalService, useValue: dsoVersioningModalService}, - {provide: ResearcherProfileDataService, useValue: researcherProfileService}, - {provide: TranslateService, useValue: translate}, - {provide: NotificationsService, useValue: notificationsService}, - { - provide: NgbModal, useValue: { - open: () => {/*comment*/ - } - } - } - ], - schemas: [NO_ERRORS_SCHEMA] - }); - resolver = TestBed.inject(DSOEditMenuResolver); - - spyOn(menuService, 'addSection'); - })); - - it('should be created', () => { - expect(resolver).toBeTruthy(); - }); - - describe('resolve', () => { - it('should create all menus when a dso is found based on the route id param', (done) => { - spyOn(resolver, 'getDsoMenus').and.returnValue( - [observableOf(dummySections1), observableOf(dummySections2)] - ); - resolver.resolve(route as any, null).subscribe(resolved => { - expect(resolved).toEqual( - { - ...route.data.menu, - [MenuID.DSO_EDIT]: [ - ...dummySections1.map((menu) => Object.assign(menu, {id: menu.id + '-test-uuid'})), - ...dummySections2.map((menu) => Object.assign(menu, {id: menu.id + '-test-uuid'})) - ] - } - ); - expect(dSpaceObjectDataService.findById).toHaveBeenCalledWith('test-uuid', true, false); - expect(resolver.getDsoMenus).toHaveBeenCalled(); - done(); - }); - }); - it('should create all menus when a dso is found based on the route scope query param when no id param is present', (done) => { - spyOn(resolver, 'getDsoMenus').and.returnValue( - [observableOf(dummySections1), observableOf(dummySections2)] - ); - const routeWithScope = { - data: { - menu: { - 'statistics': [{ - id: 'statistics-dummy-1', - active: false, - visible: true, - model: null - }] - } - }, - params: {}, - queryParams: {scope: 'test-scope-uuid'}, - }; - - resolver.resolve(routeWithScope as any, null).subscribe(resolved => { - expect(resolved).toEqual( - { - ...route.data.menu, - [MenuID.DSO_EDIT]: [ - ...dummySections1.map((menu) => Object.assign(menu, {id: menu.id + '-test-scope-uuid'})), - ...dummySections2.map((menu) => Object.assign(menu, {id: menu.id + '-test-scope-uuid'})) - ] - } - ); - expect(dSpaceObjectDataService.findById).toHaveBeenCalledWith('test-scope-uuid', true, false); - expect(resolver.getDsoMenus).toHaveBeenCalled(); - done(); - }); - }); - it('should return the statistics menu when no dso is found', (done) => { - (dSpaceObjectDataService.findById as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$()); - - resolver.resolve(route as any, null).subscribe(resolved => { - expect(resolved).toEqual( - { - ...route.data.menu - } - ); - done(); - }); - }); - }); - describe('getDsoMenus', () => { - it('should return as first part the item version, orcid and claim list ', (done) => { - const result = resolver.getDsoMenus(testObject, route, state); - result[0].subscribe((menuList) => { - expect(menuList.length).toEqual(3); - expect(menuList[0].id).toEqual('orcid-dso'); - expect(menuList[0].active).toEqual(false); - // Visible should be false due to the item not being of type person - expect(menuList[0].visible).toEqual(false); - expect(menuList[0].model.type).toEqual(MenuItemType.LINK); - - expect(menuList[1].id).toEqual('version-dso'); - expect(menuList[1].active).toEqual(false); - expect(menuList[1].visible).toEqual(true); - expect(menuList[1].model.type).toEqual(MenuItemType.ONCLICK); - expect((menuList[1].model as TextMenuItemModel).text).toEqual('message'); - expect(menuList[1].model.disabled).toEqual(false); - expect(menuList[1].icon).toEqual('code-branch'); - - expect(menuList[2].id).toEqual('claim-dso'); - expect(menuList[2].active).toEqual(false); - // Visible should be false due to the item not being of type person - expect(menuList[2].visible).toEqual(false); - expect(menuList[2].model.type).toEqual(MenuItemType.ONCLICK); - expect((menuList[2].model as TextMenuItemModel).text).toEqual('item.page.claim.button'); - done(); - }); - - }); - it('should return as second part the common list ', (done) => { - const result = resolver.getDsoMenus(testObject, route, state); - result[1].subscribe((menuList) => { - expect(menuList.length).toEqual(1); - expect(menuList[0].id).toEqual('edit-dso'); - expect(menuList[0].active).toEqual(false); - expect(menuList[0].visible).toEqual(true); - expect(menuList[0].model.type).toEqual(MenuItemType.LINK); - expect((menuList[0].model as LinkMenuItemModel).text).toEqual('item.page.edit'); - expect((menuList[0].model as LinkMenuItemModel).link).toEqual('/items/test-uuid/edit/metadata'); - expect(menuList[0].icon).toEqual('pencil-alt'); - done(); - }); - - }); - }); -}); diff --git a/src/app/shared/dso-page/dso-edit-menu.resolver.ts b/src/app/shared/dso-page/dso-edit-menu.resolver.ts deleted file mode 100644 index 80a69c2830..0000000000 --- a/src/app/shared/dso-page/dso-edit-menu.resolver.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; -import { combineLatest, Observable, of as observableOf } from 'rxjs'; -import { FeatureID } from '../../core/data/feature-authorization/feature-id'; -import { MenuService } from '../menu/menu.service'; -import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; -import { Injectable } from '@angular/core'; -import { LinkMenuItemModel } from '../menu/menu-item/models/link.model'; -import { Item } from '../../core/shared/item.model'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { OnClickMenuItemModel } from '../menu/menu-item/models/onclick.model'; -import { getFirstCompletedRemoteData } from '../../core/shared/operators'; -import { map, switchMap } from 'rxjs/operators'; -import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; -import { URLCombiner } from '../../core/url-combiner/url-combiner'; -import { DsoVersioningModalService } from './dso-versioning-modal-service/dso-versioning-modal.service'; -import { hasNoValue, hasValue, isNotEmpty } from '../empty.util'; -import { MenuID } from '../menu/menu-id.model'; -import { MenuItemType } from '../menu/menu-item-type.model'; -import { MenuSection } from '../menu/menu-section.model'; -import { getDSORoute } from '../../app-routing-paths'; -import { ResearcherProfileDataService } from '../../core/profile/researcher-profile-data.service'; -import { NotificationsService } from '../notifications/notifications.service'; -import { TranslateService } from '@ngx-translate/core'; - -/** - * Creates the menus for the dspace object pages - */ -@Injectable({ - providedIn: 'root' -}) -export class DSOEditMenuResolver implements Resolve<{ [key: string]: MenuSection[] }> { - - constructor( - protected dSpaceObjectDataService: DSpaceObjectDataService, - protected menuService: MenuService, - protected authorizationService: AuthorizationDataService, - protected modalService: NgbModal, - protected dsoVersioningModalService: DsoVersioningModalService, - protected researcherProfileService: ResearcherProfileDataService, - protected notificationsService: NotificationsService, - protected translate: TranslateService, - ) { - } - - /** - * Initialise all dspace object related menus - */ - resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<{ [key: string]: MenuSection[] }> { - let id = route.params.id; - if (hasNoValue(id) && hasValue(route.queryParams.scope)) { - id = route.queryParams.scope; - } - if (hasNoValue(id)) { - // If there's no ID, we're not on a DSO homepage, so pass on any pre-existing menu route data - return observableOf({ ...route.data?.menu }); - } else { - return this.dSpaceObjectDataService.findById(id, true, false).pipe( - getFirstCompletedRemoteData(), - switchMap((dsoRD) => { - if (dsoRD.hasSucceeded) { - const dso = dsoRD.payload; - return combineLatest(this.getDsoMenus(dso, route, state)).pipe( - // Menu sections are retrieved as an array of arrays and flattened into a single array - map((combinedMenus) => [].concat.apply([], combinedMenus)), - map((menus) => this.addDsoUuidToMenuIDs(menus, dso)), - map((menus) => { - return { - ...route.data?.menu, - [MenuID.DSO_EDIT]: menus - }; - }) - ); - } else { - return observableOf({...route.data?.menu}); - } - }) - ); - } - } - - /** - * Return all the menus for a dso based on the route and state - */ - getDsoMenus(dso, route, state): Observable[] { - return [ - this.getItemMenu(dso), - this.getCommonMenu(dso, state) - ]; - } - - /** - * Get the common menus between all dspace objects - */ - protected getCommonMenu(dso, state): Observable { - return combineLatest([ - this.authorizationService.isAuthorized(FeatureID.CanEditMetadata, dso.self), - ]).pipe( - map(([canEditItem]) => { - return [ - { - id: 'edit-dso', - active: false, - visible: canEditItem, - model: { - type: MenuItemType.LINK, - text: this.getDsoType(dso) + '.page.edit', - link: new URLCombiner(getDSORoute(dso), 'edit', 'metadata').toString() - } as LinkMenuItemModel, - icon: 'pencil-alt', - index: 2 - }, - ]; - }) - ); - } - - /** - * Get item specific menus - */ - protected getItemMenu(dso): Observable { - if (dso instanceof Item) { - return combineLatest([ - this.authorizationService.isAuthorized(FeatureID.CanCreateVersion, dso.self), - this.dsoVersioningModalService.isNewVersionButtonDisabled(dso), - this.dsoVersioningModalService.getVersioningTooltipMessage(dso, 'item.page.version.hasDraft', 'item.page.version.create'), - this.authorizationService.isAuthorized(FeatureID.CanSynchronizeWithORCID, dso.self), - this.authorizationService.isAuthorized(FeatureID.CanClaimItem, dso.self), - ]).pipe( - map(([canCreateVersion, disableVersioning, versionTooltip, canSynchronizeWithOrcid, canClaimItem]) => { - const isPerson = this.getDsoType(dso) === 'person'; - return [ - { - id: 'orcid-dso', - active: false, - visible: isPerson && canSynchronizeWithOrcid, - model: { - type: MenuItemType.LINK, - text: 'item.page.orcid.tooltip', - link: new URLCombiner(getDSORoute(dso), 'orcid').toString() - } as LinkMenuItemModel, - icon: 'orcid fab fa-lg', - index: 0 - }, - { - id: 'version-dso', - active: false, - visible: canCreateVersion, - model: { - type: MenuItemType.ONCLICK, - text: versionTooltip, - disabled: disableVersioning, - function: () => { - this.dsoVersioningModalService.openCreateVersionModal(dso); - } - } as OnClickMenuItemModel, - icon: 'code-branch', - index: 1 - }, - { - id: 'claim-dso', - active: false, - visible: isPerson && canClaimItem, - model: { - type: MenuItemType.ONCLICK, - text: 'item.page.claim.button', - function: () => { - this.claimResearcher(dso); - } - } as OnClickMenuItemModel, - icon: 'hand-paper', - index: 3 - }, - ]; - }), - ); - } else { - return observableOf([]); - } - } - - /** - * Claim a researcher by creating a profile - * Shows notifications and/or hides the menu section on success/error - */ - protected claimResearcher(dso) { - this.researcherProfileService.createFromExternalSourceAndReturnRelatedItemId(dso.self) - .subscribe((id: string) => { - if (isNotEmpty(id)) { - this.notificationsService.success(this.translate.get('researcherprofile.success.claim.title'), - this.translate.get('researcherprofile.success.claim.body')); - this.authorizationService.invalidateAuthorizationsRequestCache(); - this.menuService.hideMenuSection(MenuID.DSO_EDIT, 'claim-dso-' + dso.uuid); - } else { - this.notificationsService.error( - this.translate.get('researcherprofile.error.claim.title'), - this.translate.get('researcherprofile.error.claim.body')); - } - }); - } - - /** - * Retrieve the dso or entity type for an object to be used in generic messages - */ - protected getDsoType(dso) { - const renderType = dso.getRenderTypes()[0]; - if (typeof renderType === 'string' || renderType instanceof String) { - return renderType.toLowerCase(); - } else { - return dso.type.toString().toLowerCase(); - } - } - - /** - * Add the dso uuid to all provided menu ids and parent ids - */ - protected addDsoUuidToMenuIDs(menus, dso) { - return menus.map((menu) => { - Object.assign(menu, { - id: menu.id + '-' + dso.uuid - }); - if (hasValue(menu.parentID)) { - Object.assign(menu, { - parentID: menu.parentID + '-' + dso.uuid - }); - } - return menu; - }); - } -} diff --git a/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.spec.ts b/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.spec.ts index 79ab35bd28..613f06f6ad 100644 --- a/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.spec.ts +++ b/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.spec.ts @@ -11,6 +11,7 @@ import { Component } from '@angular/core'; import { DsoEditMenuExpandableSectionComponent } from './dso-edit-menu-expandable-section.component'; import { By } from '@angular/platform-browser'; import { MenuItemType } from 'src/app/shared/menu/menu-item-type.model'; +import { MenuItemModels } from '../../../menu/menu-section.model'; describe('DsoEditMenuExpandableSectionComponent', () => { let component: DsoEditMenuExpandableSectionComponent; @@ -30,39 +31,82 @@ describe('DsoEditMenuExpandableSectionComponent', () => { icon: iconString }; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot()], - declarations: [DsoEditMenuExpandableSectionComponent, TestComponent], - providers: [ - {provide: 'sectionDataProvider', useValue: dummySection}, - {provide: MenuService, useValue: menuService}, - {provide: CSSVariableService, useClass: CSSVariableServiceStub}, - {provide: Router, useValue: new RouterStub()}, - ] - }).overrideComponent(DsoEditMenuExpandableSectionComponent, { - set: { - entryComponents: [TestComponent] - } - }) - .compileComponents(); - })); + describe('when there are subsections', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [DsoEditMenuExpandableSectionComponent, TestComponent], + providers: [ + {provide: 'sectionDataProvider', useValue: dummySection}, + {provide: MenuService, useValue: menuService}, + {provide: CSSVariableService, useClass: CSSVariableServiceStub}, + {provide: Router, useValue: new RouterStub()}, + ] + }).overrideComponent(DsoEditMenuExpandableSectionComponent, { + set: { + entryComponents: [TestComponent] + } + }) + .compileComponents(); + })); - beforeEach(() => { - spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([])); - fixture = TestBed.createComponent(DsoEditMenuExpandableSectionComponent); - component = fixture.componentInstance; - spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent); - fixture.detectChanges(); + beforeEach(() => { + spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([{ + id: 'test', + visible: true, + model: {} as MenuItemModels + }])); + fixture = TestBed.createComponent(DsoEditMenuExpandableSectionComponent); + component = fixture.componentInstance; + spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show a button with the icon', () => { + const button = fixture.debugElement.query(By.css('.btn-dark')); + expect(button.nativeElement.innerHTML).toContain('fa-' + iconString); + }); }); - it('should create', () => { - expect(component).toBeTruthy(); - }); + describe('when there are no subsections', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [DsoEditMenuExpandableSectionComponent, TestComponent], + providers: [ + {provide: 'sectionDataProvider', useValue: dummySection}, + {provide: MenuService, useValue: menuService}, + {provide: CSSVariableService, useClass: CSSVariableServiceStub}, + {provide: Router, useValue: new RouterStub()}, + ] + }).overrideComponent(DsoEditMenuExpandableSectionComponent, { + set: { + entryComponents: [TestComponent] + } + }) + .compileComponents(); + })); - it('should show a button with the icon', () => { - const button = fixture.debugElement.query(By.css('.btn-dark')); - expect(button.nativeElement.innerHTML).toContain('fa-' + iconString); + beforeEach(() => { + spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([])); + fixture = TestBed.createComponent(DsoEditMenuExpandableSectionComponent); + component = fixture.componentInstance; + spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should now show a button', () => { + const button = fixture.debugElement.query(By.css('.btn-dark')); + expect(button).toBeNull(); + }); }); }); diff --git a/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu.component.spec.ts b/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu.component.spec.ts index d7f0ead878..71f8355eb7 100644 --- a/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu.component.spec.ts +++ b/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu.component.spec.ts @@ -10,7 +10,6 @@ import { AuthorizationDataService } from '../../../core/data/feature-authorizati import { AuthService } from '../../../core/auth/auth.service'; import { AuthServiceStub } from '../../testing/auth-service.stub'; import { MenuService } from '../../menu/menu.service'; -import { MenuItemModel } from '../../menu/menu-item/models/menu-item.model'; import { ThemeService } from '../../theme-support/theme.service'; import { getMockThemeService } from '../../mocks/theme-service.mock'; diff --git a/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.html b/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.html deleted file mode 100644 index 15135009fc..0000000000 --- a/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.html +++ /dev/null @@ -1,8 +0,0 @@ - diff --git a/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.scss b/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.scss deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.spec.ts b/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.spec.ts deleted file mode 100644 index 726854778d..0000000000 --- a/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { DsoPageSubscriptionButtonComponent } from './dso-page-subscription-button.component'; -import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; -import { of as observableOf } from 'rxjs'; -import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'; -import { By } from '@angular/platform-browser'; -import { DebugElement } from '@angular/core'; -import { Item } from '../../../core/shared/item.model'; -import { ITEM } from '../../../core/shared/item.resource-type'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { TranslateLoaderMock } from '../../mocks/translate-loader.mock'; - -describe('DsoPageSubscriptionButtonComponent', () => { - let component: DsoPageSubscriptionButtonComponent; - let fixture: ComponentFixture; - let de: DebugElement; - - const authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: jasmine.createSpy('isAuthorized') // observableOf(true) - }); - - const mockItem = Object.assign(new Item(), { - id: 'fake-id', - uuid: 'fake-id', - handle: 'fake/handle', - lastModified: '2018', - type: ITEM, - _links: { - self: { - href: 'https://localhost:8000/items/fake-id' - } - } - }); - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - NgbModalModule, - TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock - } - }) - ], - declarations: [ DsoPageSubscriptionButtonComponent ], - providers: [ - { provide: AuthorizationDataService, useValue: authorizationService }, - ] - }) - .compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(DsoPageSubscriptionButtonComponent); - component = fixture.componentInstance; - de = fixture.debugElement; - component.dso = mockItem; - }); - - describe('when is authorized', () => { - beforeEach(() => { - authorizationService.isAuthorized.and.returnValue(observableOf(true)); - fixture.detectChanges(); - }); - - it('should display subscription button', () => { - expect(de.query(By.css(' [data-test="subscription-button"]'))).toBeTruthy(); - }); - }); - - describe('when is not authorized', () => { - beforeEach(() => { - authorizationService.isAuthorized.and.returnValue(observableOf(false)); - fixture.detectChanges(); - }); - - it('should not display subscription button', () => { - expect(de.query(By.css(' [data-test="subscription-button"]'))).toBeNull(); - }); - }); -}); diff --git a/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.ts b/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.ts deleted file mode 100644 index 54cd9e6bb0..0000000000 --- a/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Component, Input, OnInit } from '@angular/core'; - -import { Observable, of } from 'rxjs'; -import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; - -import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; -import { SubscriptionModalComponent } from '../../subscriptions/subscription-modal/subscription-modal.component'; -import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; - -@Component({ - selector: 'ds-dso-page-subscription-button', - templateUrl: './dso-page-subscription-button.component.html', - styleUrls: ['./dso-page-subscription-button.component.scss'] -}) -/** - * Display a button that opens the modal to manage subscriptions - */ -export class DsoPageSubscriptionButtonComponent implements OnInit { - - /** - * Whether the current user is authorized to edit the DSpaceObject - */ - isAuthorized$: Observable = of(false); - - /** - * Reference to NgbModal - */ - public modalRef: NgbModalRef; - - /** - * DSpaceObject that is being viewed - */ - @Input() dso: DSpaceObject; - - constructor( - protected authorizationService: AuthorizationDataService, - private modalService: NgbModal, - ) { - } - - /** - * check if the current DSpaceObject can be subscribed by the user - */ - ngOnInit(): void { - this.isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanSubscribe, this.dso.self); - } - - /** - * Open the modal to subscribe to the related DSpaceObject - */ - public openSubscriptionModal() { - this.modalRef = this.modalService.open(SubscriptionModalComponent); - this.modalRef.componentInstance.dso = this.dso; - } - -} diff --git a/src/app/shared/menu/menu-provider.ts b/src/app/shared/menu/menu-provider.model.ts similarity index 52% rename from src/app/shared/menu/menu-provider.ts rename to src/app/shared/menu/menu-provider.model.ts index 7044870f17..247963f172 100644 --- a/src/app/shared/menu/menu-provider.ts +++ b/src/app/shared/menu/menu-provider.model.ts @@ -7,13 +7,15 @@ */ /* eslint-disable max-classes-per-file */ import { ActivatedRouteSnapshot, RouterStateSnapshot, } from '@angular/router'; -import flatten from 'lodash/flatten'; -import { combineLatest, Observable, } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { Observable, } from 'rxjs'; import { MenuID } from './menu-id.model'; import { MenuItemModels } from './menu-section.model'; import { Type } from '@angular/core'; +/** + * Partial menu section + * This object acts like a menu section but with certain properties being optional + */ export interface PartialMenuSection { id?: string; visible: boolean; @@ -26,15 +28,28 @@ export interface PartialMenuSection { alwaysRenderExpandable?: boolean; } - +/** + * Interface to represent a menu provider + * Implementations of this provider will contain sections to be added to the menus + */ export interface MenuProvider { shouldPersistOnRouteChange?: boolean, menuID?: MenuID; index?: number; + /** + * Retrieve the sections from the provider. These sections can be route dependent. + * @param route - The route on which the menu sections possibly depend + * @param state - The router snapshot on which the sections possibly depend + */ getSections(route?: ActivatedRouteSnapshot, state?: RouterStateSnapshot): Observable; } +/** + * Class to represent a Menu Provider together with additional information added through the static methods on + * AbstractMenuProvider. This additional information is either the paths on which the sections of this provider should + * be present or a list of child providers + */ export class MenuProviderTypeWithOptions { providerType: Type; paths?: string[]; @@ -42,6 +57,9 @@ export class MenuProviderTypeWithOptions { } +/** + * Abstract class to be extended when creating menu providers + */ export abstract class AbstractMenuProvider implements MenuProvider { /** @@ -54,17 +72,44 @@ export abstract class AbstractMenuProvider implements MenuProvider { * Whether the sections of this menu should be set on the */ shouldPersistOnRouteChange = true; + + /** + * The ID of the menu provider. + * This will be automatically set based on the menu and the index of the provider in the list + */ menuProviderId?: string; + + /** + * The index of the menu provider + * This will be automatically set based on the index of the provider in the list + */ index?: number; + + /** + * The paths on which the sections of this provider will be active + * This will be automatically set based on the paths added based on the paths provided through the 'onRoute' static + * method in the app.menus.ts file + */ activePaths?: string[]; + + /** + * The ID of the parent provider of this provider. + * This will be automatically set based on the provider that calls the 'withSubs' static method with this provider + * in the list of arguments + */ parentID?: string; /** - * Whether the menu section or top section of this provider will always be rendered as expandable and hidden when no children are present + * When true, the sections added by this provider will be assumed to be parent sections with children + * The sections will not be rendered when they have no visible children + * This can be overwritten on the level of sections */ alwaysRenderExpandable? = false; - + /** + * Static method to be called from the app.menus.ts file to define paths on which this provider should the active + * @param paths - The paths on which the sections of this provider should be active + */ public static onRoute(...paths: string[]): MenuProviderTypeWithOptions { if (!AbstractMenuProvider.isPrototypeOf(this)) { throw new Error( @@ -77,7 +122,7 @@ export abstract class AbstractMenuProvider implements MenuProvider { } /** - * Method to add sub menu providers to this top provider + * Static method to be called from the app.menus.ts file to add sub menu providers to this top provider * @param childProviders - the list of sub providers that will provide subsections for this provider */ public static withSubs(childProviders: (Type | MenuProviderTypeWithOptions)[]): MenuProviderTypeWithOptions { @@ -91,13 +136,13 @@ export abstract class AbstractMenuProvider implements MenuProvider { return {providerType: providerType, childProviderTypes: childProviders}; } + /** + * Retrieve the sections from the provider. These sections can be route dependent. + * @param route - The route on which the menu sections possibly depend + * @param state - The router snapshot on which the sections possibly depend + */ abstract getSections(route?: ActivatedRouteSnapshot, state?: RouterStateSnapshot): Observable; - protected concat(...sections$: Observable[]): Observable { - return combineLatest(sections$).pipe( - map(sections => flatten(sections)), - ); - } } diff --git a/src/app/shared/menu/menu-provider.service.spec.ts b/src/app/shared/menu/menu-provider.service.spec.ts new file mode 100644 index 0000000000..44e839bd17 --- /dev/null +++ b/src/app/shared/menu/menu-provider.service.spec.ts @@ -0,0 +1,165 @@ +import { AbstractMenuProvider, PartialMenuSection } from './menu-provider.model'; +import { MenuID } from './menu-id.model'; +import { ActivatedRouteSnapshot, ResolveEnd, RouterStateSnapshot, UrlSegment } from '@angular/router'; +import { of as observableOf } from 'rxjs'; +import { MenuItemType } from './menu-item-type.model'; +import { waitForAsync } from '@angular/core/testing'; +import { MenuProviderService } from './menu-provider.service'; +import { MenuService } from './menu.service'; +import { COMMUNITY_MODULE_PATH } from '../../community-page/community-page-routing-paths'; +import { COLLECTION_MODULE_PATH } from '../../collection-page/collection-page-routing-paths'; + +describe('MenuProviderService', () => { + + class TestMenuProvider extends AbstractMenuProvider { + + constructor( + public menuID: MenuID, + public shouldPersistOnRouteChange: boolean, + public menuProviderId: string, + public index: number, + public activePaths: string[], + public parentID: string, + public alwaysRenderExpandable: boolean, + public sections: PartialMenuSection[] + ) { + super(); + } + + getSections(route?: ActivatedRouteSnapshot, state?: RouterStateSnapshot) { + return observableOf(this.sections); + } + } + + + let menuProviderService: MenuProviderService; + let menuService: MenuService; + + const router = { + events: observableOf(new ResolveEnd(1, 'test-url', 'test-url-after-redirect',{url: 'test-url', root: {url: [new UrlSegment('test-url', {})]}} as any )) + }; + + const section = { + visible: true, model: { + type: MenuItemType.TEXT, + text: `test1`, + }, + }; + + const sectionToBeRemoved = { + id: 'sectionToBeRemoved', + visible: true, model: { + type: MenuItemType.TEXT, + text: `test1`, + }, + }; + + + const persistentProvider1 = new TestMenuProvider(MenuID.PUBLIC, true, 'provider1', 0, undefined, undefined, false, [section]); + const persistentProvider2 = new TestMenuProvider(MenuID.PUBLIC, true, 'provider2', 1, undefined, 'provider1', false, [section]); + const nonPersistentProvider3 = new TestMenuProvider(MenuID.PUBLIC, false, 'provider3', 2, undefined, undefined, false, [section]); + const nonPersistentProvider4 = new TestMenuProvider(MenuID.PUBLIC, false, 'provider4', 3, undefined, 'provider3', false, [section]); + const nonPersistentProvider5WithRoutes = new TestMenuProvider(MenuID.PUBLIC, false, 'provider4', 3, [COMMUNITY_MODULE_PATH, COLLECTION_MODULE_PATH], undefined, false, [section]); + + const listOfProvider = [persistentProvider1, persistentProvider2, nonPersistentProvider3, nonPersistentProvider4, nonPersistentProvider5WithRoutes]; + + const expectedSection1 = generateAddedSection(persistentProvider1, section); + const expectedSection2 = generateAddedSection(persistentProvider2, section); + const expectedSection3 = generateAddedSection(nonPersistentProvider3, section); + const expectedSection4 = generateAddedSection(nonPersistentProvider4, section); + const expectedSection5 = generateAddedSection(nonPersistentProvider5WithRoutes, section); + + function generateAddedSection(provider, sectionToAdd) { + return { + ...sectionToAdd, + id: sectionToAdd.id ?? `${provider.menuProviderId}`, + parentID: sectionToAdd.parentID ?? provider.parentID, + index: sectionToAdd.index ?? provider.index, + shouldPersistOnRouteChange: sectionToAdd.shouldPersistOnRouteChange ?? provider.shouldPersistOnRouteChange, + alwaysRenderExpandable: sectionToAdd.alwaysRenderExpandable ?? provider.alwaysRenderExpandable, + }; + } + + + beforeEach(waitForAsync(() => { + + menuService = jasmine.createSpyObj('MenuService', + { + addSection: {}, + removeSection: {}, + getMenu: observableOf({id: MenuID.PUBLIC}), + getNonPersistentMenuSections: observableOf([sectionToBeRemoved]) + + }); + + menuProviderService = new MenuProviderService(listOfProvider, menuService, router as any); + + })); + + describe('initPersistentMenus', () => { + it('should initialise the menu sections from the persistent providers', () => { + menuProviderService.initPersistentMenus(); + + expect(menuService.addSection).toHaveBeenCalledWith(persistentProvider1.menuID, expectedSection1); + expect(menuService.addSection).toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection2); + expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider3.menuID, expectedSection3); + expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider4.menuID, expectedSection4); + expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider5WithRoutes.menuID, expectedSection5); + }); + }); + + describe('resolveRouteMenus with no matching path specific providers', () => { + it('should remove the current non persistent menus and add the general non persistent menus', () => { + const route = {}; + const state = {url: 'test-url'}; + menuProviderService.resolveRouteMenus(route as any, state as any).subscribe(); + + expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.PUBLIC, sectionToBeRemoved.id); + expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.ADMIN, sectionToBeRemoved.id); + expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.DSO_EDIT, sectionToBeRemoved.id); + + expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider1.menuID, expectedSection1); + expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection2); + expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider3.menuID, expectedSection3); + expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider4.menuID, expectedSection4); + expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider5WithRoutes.menuID, expectedSection5); + }); + }); + + describe('resolveRouteMenus with a matching path specific provider', () => { + it('should remove the current non persistent menus and add the general non persistent menus', () => { + const route = {}; + const state = {url: `xxxx/${COMMUNITY_MODULE_PATH}/xxxxxx`}; + menuProviderService.resolveRouteMenus(route as any, state as any).subscribe(); + + expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.PUBLIC, sectionToBeRemoved.id); + expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.ADMIN, sectionToBeRemoved.id); + expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.DSO_EDIT, sectionToBeRemoved.id); + + expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider1.menuID, expectedSection1); + expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection2); + expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider3.menuID, expectedSection3); + expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider4.menuID, expectedSection4); + expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider5WithRoutes.menuID, expectedSection5); + }); + }); + + describe('listenForRouteChanges ', () => { + it('should listen to the route changes and update the menu sections based on the retrieved state and route', () => { + menuProviderService.listenForRouteChanges(); + + expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.PUBLIC, sectionToBeRemoved.id); + expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.ADMIN, sectionToBeRemoved.id); + expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.DSO_EDIT, sectionToBeRemoved.id); + + expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider1.menuID, expectedSection1); + expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection2); + expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider3.menuID, expectedSection3); + expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider4.menuID, expectedSection4); + expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider5WithRoutes.menuID, expectedSection5); + }); + }); + + +}); + diff --git a/src/app/shared/menu/menu-provider.service.ts b/src/app/shared/menu/menu-provider.service.ts index a984e594f8..9b5d5e73a5 100644 --- a/src/app/shared/menu/menu-provider.service.ts +++ b/src/app/shared/menu/menu-provider.service.ts @@ -6,17 +6,21 @@ * http://www.dspace.org/license/ */ -import { Inject, Injectable, Injector, Optional, } from '@angular/core'; -import { ActivatedRoute, ActivatedRouteSnapshot, ResolveEnd, Router, RouterStateSnapshot, } from '@angular/router'; +import { Inject, Injectable, Optional, } from '@angular/core'; +import { ActivatedRouteSnapshot, ResolveEnd, Router, RouterStateSnapshot, } from '@angular/router'; import { combineLatest, map, Observable, } from 'rxjs'; import { filter, find, switchMap, take, } from 'rxjs/operators'; import { hasValue, isNotEmpty } from '../empty.util'; import { MenuID } from './menu-id.model'; -import { AbstractMenuProvider, PartialMenuSection } from './menu-provider'; +import { AbstractMenuProvider, PartialMenuSection } from './menu-provider.model'; import { MenuState } from './menu-state.model'; import { MenuService } from './menu.service'; import { MENU_PROVIDER } from './menu.structure'; +/** + * Service that is responsible for adding and removing the menu sections created by the providers, both for + * persistent and non-persistent menu sections + */ @Injectable({ providedIn: 'root', }) @@ -24,9 +28,7 @@ export class MenuProviderService { constructor( @Inject(MENU_PROVIDER) @Optional() protected providers: ReadonlyArray, protected menuService: MenuService, - protected injector: Injector, protected router: Router, - protected route: ActivatedRoute, ) { } @@ -42,22 +44,22 @@ export class MenuProviderService { ); } + /** + * Listen for route changes and resolve the route dependent menu sections on route change + */ listenForRouteChanges() { this.router.events.pipe( filter(event => event instanceof ResolveEnd), switchMap((event: ResolveEnd) => { - const currentRoute = this.getCurrentRoute(event.state.root); - return this.resolveRouteMenus(currentRoute, event.state); }), - ).subscribe((done) => { - Object.values(MenuID).forEach((menuID) => { - this.menuService.buildRouteMenuSections(menuID); - }); - }); + ).subscribe(); } + /** + * Get the full current route + */ private getCurrentRoute(route: ActivatedRouteSnapshot): ActivatedRouteSnapshot { while (route.firstChild) { route = route.firstChild; @@ -66,6 +68,9 @@ export class MenuProviderService { } + /** + * Initialise the persistent menu sections + */ public initPersistentMenus() { combineLatest([ ...this.providers @@ -87,20 +92,22 @@ export class MenuProviderService { sections: PartialMenuSection[] }, sectionIndex) => { providerWithSection.sections.forEach((section) => { - this.addSection(providerWithSection, section); + this.addSection(providerWithSection.provider, section); }); return this.waitForMenu$(providerWithSection.provider.menuID); }); return [waitForMenus]; }), map(done => done.every(Boolean)), - ).subscribe((done) => { - Object.values(MenuID).forEach((menuID) => { - this.menuService.buildRouteMenuSections(menuID); - }); - }); + take(1), + ).subscribe(); } + /** + * Resolve the non-persistent route based menu sections + * @param route - the current route + * @param state - the current router state + */ public resolveRouteMenus( route: ActivatedRouteSnapshot, state: RouterStateSnapshot @@ -149,7 +156,7 @@ export class MenuProviderService { sections: PartialMenuSection[] }) => { providerWithSection.sections.forEach((section) => { - this.addSection(providerWithSection, section); + this.addSection(providerWithSection.provider, section); }); return this.waitForMenu$(providerWithSection.provider.menuID); }); @@ -159,22 +166,28 @@ export class MenuProviderService { ); } - private addSection(providerWithSection: { - provider: AbstractMenuProvider; - sections: PartialMenuSection[] - }, section: PartialMenuSection) { - this.menuService.addSection(providerWithSection.provider.menuID, { + /** + * Add the provided section combined with information from the menu provider to the menus + * @param provider - The provider of the section which will be used to provide extra data to the section + * @param section - The partial section to be added to the menus + */ + private addSection(provider: AbstractMenuProvider, section: PartialMenuSection) { + this.menuService.addSection(provider.menuID, { ...section, - id: section.id ?? `${providerWithSection.provider.menuProviderId}`, - parentID: section.parentID ?? providerWithSection.provider.parentID, - index: section.index ?? providerWithSection.provider.index, - shouldPersistOnRouteChange: section.shouldPersistOnRouteChange ?? providerWithSection.provider.shouldPersistOnRouteChange, - alwaysRenderExpandable: section.alwaysRenderExpandable ?? providerWithSection.provider.alwaysRenderExpandable, + id: section.id ?? `${provider.menuProviderId}`, + parentID: section.parentID ?? provider.parentID, + index: section.index ?? provider.index, + shouldPersistOnRouteChange: section.shouldPersistOnRouteChange ?? provider.shouldPersistOnRouteChange, + alwaysRenderExpandable: section.alwaysRenderExpandable ?? provider.alwaysRenderExpandable, }); } - private removeNonPersistentSections(menuSectionsPerMenu) { - menuSectionsPerMenu.forEach((menu) => { + /** + * Remove all non-persistent sections from the menus + * @param menuWithSections - The menu with its sections to be removed + */ + private removeNonPersistentSections(menuWithSections) { + menuWithSections.forEach((menu) => { menu.sections.forEach((section) => { this.menuService.removeSection(menu.menuId, section.id); }); diff --git a/src/app/shared/menu/menu-section.model.ts b/src/app/shared/menu/menu-section.model.ts index 700f179a3f..a91d75302d 100644 --- a/src/app/shared/menu/menu-section.model.ts +++ b/src/app/shared/menu/menu-section.model.ts @@ -1,4 +1,3 @@ -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'; @@ -14,26 +13,6 @@ export type MenuItemModels = | SearchMenuItemModel | TextMenuItemModel; -function itemModelFactory(type: MenuItemType): MenuItemModels { - switch (type) { - case MenuItemType.TEXT: - 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 @@ -80,5 +59,9 @@ export interface MenuSection { */ icon?: string; + /** + * When true, the current section will be assumed to be a parent section with children + * This section will not be rendered when it has no visible children + */ alwaysRenderExpandable?: boolean; } diff --git a/src/app/shared/menu/menu.component.spec.ts b/src/app/shared/menu/menu.component.spec.ts index 707b1c0746..19035b6028 100644 --- a/src/app/shared/menu/menu.component.spec.ts +++ b/src/app/shared/menu/menu.component.spec.ts @@ -32,7 +32,7 @@ describe('MenuComponent', () => { } as TextMenuItemModel, icon: 'globe', visible: true, - } + }; const mockMenuID = 'mock-menuID' as MenuID; diff --git a/src/app/shared/menu/menu.resolver.ts b/src/app/shared/menu/menu.resolver.ts deleted file mode 100644 index 9f01934670..0000000000 --- a/src/app/shared/menu/menu.resolver.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * 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/ - */ - - - - - -// export function resolveStaticMenus(): (ActivatedRouteSnapshot, RouterStateSnapshot, ProviderMenuService) => Observable { -// return ( -// route: ActivatedRouteSnapshot, -// state: RouterStateSnapshot, -// menuProviderService: MenuProviderService = inject(MenuProviderService), -// ) => menuProviderService.resolveStaticMenu(); -// } diff --git a/src/app/shared/menu/menu.service.spec.ts b/src/app/shared/menu/menu.service.spec.ts index 0d8d669a0a..d4cdb7035d 100644 --- a/src/app/shared/menu/menu.service.spec.ts +++ b/src/app/shared/menu/menu.service.spec.ts @@ -39,9 +39,7 @@ describe('MenuService', () => { let topSections; let initialState; let routeDataMenuSection: MenuSection; - let routeDataMenuSectionResolved: MenuSection; let routeDataMenuChildSection: MenuSection; - let toBeRemovedMenuSection: MenuSection; let alreadyPresentMenuSection: MenuSection; let route; let router; @@ -106,16 +104,6 @@ describe('MenuService', () => { link: 'path/:linkparam' } as LinkMenuItemModel }; - routeDataMenuSectionResolved = { - id: 'mockSection_id_param_resolved', - active: false, - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.mockSection', - link: 'path/link_param_resolved' - } as LinkMenuItemModel - }; routeDataMenuChildSection = { id: 'mockChildSection', parentID: 'mockSection', @@ -127,16 +115,6 @@ describe('MenuService', () => { link: '' } as LinkMenuItemModel }; - toBeRemovedMenuSection = { - id: 'toBeRemovedSection', - active: false, - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.toBeRemovedSection', - link: '' - } as LinkMenuItemModel - }; alreadyPresentMenuSection = { id: 'alreadyPresentSection', active: false, @@ -539,69 +517,4 @@ describe('MenuService', () => { expect(store.dispatch).toHaveBeenCalledWith(new DeactivateMenuSectionAction(MenuID.ADMIN, 'fakeID')); }); }); - - describe('buildRouteMenuSections', () => { - it('should add and remove menu sections depending on the current route', () => { - spyOn(service, 'addSection'); - spyOn(service, 'removeSection'); - - spyOn(service, 'getNonPersistentMenuSections').and.returnValue(observableOf([toBeRemovedMenuSection, alreadyPresentMenuSection])); - - service.buildRouteMenuSections(MenuID.PUBLIC); - - expect(service.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, routeDataMenuSectionResolved); - expect(service.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, routeDataMenuChildSection); - expect(service.addSection).not.toHaveBeenCalledWith(MenuID.PUBLIC, alreadyPresentMenuSection); - expect(service.removeSection).toHaveBeenCalledWith(MenuID.PUBLIC, toBeRemovedMenuSection.id); - }); - }); - - describe('listenForRouteChanges', () => { - it('should build the menu sections on NavigationEnd event', () => { - spyOn(service, 'buildRouteMenuSections'); - - service.listenForRouteChanges(); - - expect(service.buildRouteMenuSections).toHaveBeenCalledWith(MenuID.ADMIN); - expect(service.buildRouteMenuSections).toHaveBeenCalledWith(MenuID.PUBLIC); - }); - }); - - describe(`resolveSubstitutions`, () => { - let linkPrefix; - let link; - let uuid; - - beforeEach(() => { - linkPrefix = 'statistics_collection_'; - link = `${linkPrefix}:id`; - uuid = 'f7cc3ca4-3c2c-464d-8af8-add9f84f711c'; - }); - - it(`shouldn't do anything when there are no params`, () => { - let result = (service as any).resolveSubstitutions(link, undefined); - expect(result).toEqual(link); - result = (service as any).resolveSubstitutions(link, null); - expect(result).toEqual(link); - result = (service as any).resolveSubstitutions(link, {}); - expect(result).toEqual(link); - }); - - it(`should replace link params that are also route params`, () => { - const result = (service as any).resolveSubstitutions(link,{ 'id': uuid }); - expect(result).toEqual(linkPrefix + uuid); - }); - - it(`should not replace link params that aren't route params`, () => { - const result = (service as any).resolveSubstitutions(link,{ 'something': 'else' }); - expect(result).toEqual(link); - }); - - it(`should gracefully deal with routes that contain the name of the route param`, () => { - const selfReferentialParam = `:id:something`; - const result = (service as any).resolveSubstitutions(link,{ 'id': selfReferentialParam }); - expect(result).toEqual(linkPrefix + selfReferentialParam); - }); - }); - }); diff --git a/src/app/shared/menu/menu.service.ts b/src/app/shared/menu/menu.service.ts index ed367a1fc3..6a1c4a5752 100644 --- a/src/app/shared/menu/menu.service.ts +++ b/src/app/shared/menu/menu.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { AppState, keySelector } from '../../app.reducer'; import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; -import { filter, map, switchMap, take } from 'rxjs/operators'; +import { map, switchMap } from 'rxjs/operators'; import { ActivateMenuSectionAction, AddMenuSectionAction, @@ -17,12 +17,12 @@ import { ToggleActiveMenuSectionAction, ToggleMenuAction, } from './menu.actions'; -import { hasNoValue, hasValue, hasValueOperator, isNotEmpty, isEmpty } from '../empty.util'; +import { hasNoValue, hasValue, hasValueOperator, isNotEmpty } from '../empty.util'; import { MenuState } from './menu-state.model'; import { MenuSections } from './menu-sections.model'; import { MenuSection } from './menu-section.model'; import { MenuID } from './menu-id.model'; -import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; export function menuKeySelector(key: string, selector): MemoizedSelector { return createSelector(selector, (state) => { @@ -344,95 +344,4 @@ export class MenuService { return this.getMenuSection(menuID, id).pipe(map((section) => section.visible)); } - listenForRouteChanges(): void { - this.router.events.pipe( - filter(event => event instanceof NavigationEnd), - ).subscribe(() => { - Object.values(MenuID).forEach((menuID) => { - this.buildRouteMenuSections(menuID); - }); - }); - } - - /** - * Build menu sections depending on the current route - * - Adds sections found in the current route data that aren't active yet - * - Removes sections that are active, but not present in the current route data - * @param menuID The menu to add/remove sections to/from - */ - buildRouteMenuSections(menuID: MenuID) { - this.getNonPersistentMenuSections(menuID).pipe( - map((sections) => sections.map((section) => section.id)), - take(1) - ).subscribe((shouldNotPersistIDs: string[]) => { - const resolvedSections = this.resolveRouteMenuSections(this.route.root, menuID); - resolvedSections.forEach((section) => { - const index = shouldNotPersistIDs.indexOf(section.id); - if (index > -1) { - shouldNotPersistIDs.splice(index, 1); - } else { - this.addSection(menuID, section); - } - }); - shouldNotPersistIDs.forEach((id) => { - - // this.removeSection(menuID, id); - }); - }); - } - - /** - * Resolve menu sections defined in the current route data (including parent routes) - * @param route The route to resolve data for - * @param menuID The menu to resolve data for - */ - resolveRouteMenuSections(route: ActivatedRoute, menuID: MenuID): MenuSection[] { - const data = route.snapshot.data; - const params = route.snapshot.params; - const last: boolean = hasNoValue(route.firstChild); - - if (hasValue(data) && hasValue(data.menu) && hasValue(data.menu[menuID])) { - let menuSections: MenuSection[] | MenuSection = data.menu[menuID]; - menuSections = this.resolveSubstitutions(menuSections, params); - - if (!Array.isArray(menuSections)) { - menuSections = [menuSections]; - } - - if (!last) { - return [...menuSections, ...this.resolveRouteMenuSections(route.firstChild, menuID)]; - } else { - return [...menuSections]; - } - } - - return !last ? this.resolveRouteMenuSections(route.firstChild, menuID) : []; - } - - protected resolveSubstitutions(object, params) { - let resolved; - if (isEmpty(params)) { - resolved = object; - } else if (typeof object === 'string') { - resolved = object; - Object.entries(params).forEach(([key, value]: [string, string]) => - resolved = resolved.replaceAll(`:${key}`, value) - ); - } else if (Array.isArray(object)) { - resolved = []; - object.forEach((entry, index) => { - resolved[index] = this.resolveSubstitutions(object[index], params); - }); - } else if (typeof object === 'object') { - resolved = {}; - Object.keys(object).forEach((key) => { - resolved[key] = this.resolveSubstitutions(object[key], params); - }); - } else { - resolved = object; - } - return resolved; - } - - } diff --git a/src/app/shared/menu/menu.structure.spec.ts b/src/app/shared/menu/menu.structure.spec.ts new file mode 100644 index 0000000000..26328510f6 --- /dev/null +++ b/src/app/shared/menu/menu.structure.spec.ts @@ -0,0 +1,102 @@ +import { MenuID } from './menu-id.model'; +import { CommunityListMenuProvider } from './providers/community-list.menu'; +import { NewMenuProvider } from './providers/new.menu'; +import { DsoOptionMenu } from './providers/dso-option.menu'; +import { SubscribeMenuProvider } from './providers/comcol-subscribe.menu'; +import { COMMUNITY_MODULE_PATH } from '../../community-page/community-page-routing-paths'; +import { COLLECTION_MODULE_PATH } from '../../collection-page/collection-page-routing-paths'; +import { buildMenuStructure } from './menu.structure'; +import { MenuProviderService } from './menu-provider.service'; +import { BrowseMenuProvider } from './providers/browse.menu'; +import { StatisticsMenuProvider } from './providers/statistics.menu'; +import { EditMenuProvider } from './providers/edit.menu'; +import { ImportMenuProvider } from './providers/import.menu'; +import { ExportMenuProvider } from './providers/export.menu'; +import { AccessControlMenuProvider } from './providers/access-control.menu'; +import { AdminSearchMenuProvider } from './providers/admin-search.menu'; +import { RegistriesMenuProvider } from './providers/registries.menu'; +import { CurationMenuProvider } from './providers/curation.menu'; +import { ProcessesMenuProvider } from './providers/processes.menu'; +import { WorkflowMenuProvider } from './providers/workflow.menu'; +import { HealthMenuProvider } from './providers/health.menu'; +import { SystemWideAlertMenuProvider } from './providers/system-wide-alert.menu'; +import { DSpaceObjectEditMenuProvider } from './providers/dso-edit.menu'; +import { ENTITY_MODULE_PATH, ITEM_MODULE_PATH } from '../../item-page/item-page-routing-paths'; +import { VersioningMenuProvider } from './providers/item-versioning.menu'; +import { OrcidMenuProvider } from './providers/item-orcid.menu'; +import { ClaimMenuProvider } from './providers/item-claim.menu'; + +describe('buildMenuStructure', () => { + const providerStructure = + { + [MenuID.PUBLIC]: [ + CommunityListMenuProvider, + BrowseMenuProvider, + StatisticsMenuProvider, + ], + [MenuID.ADMIN]: [ + NewMenuProvider, + EditMenuProvider, + ImportMenuProvider, + ExportMenuProvider, + AccessControlMenuProvider, + AdminSearchMenuProvider, + RegistriesMenuProvider, + CurationMenuProvider, + ProcessesMenuProvider, + WorkflowMenuProvider, + HealthMenuProvider, + SystemWideAlertMenuProvider, + ], + [MenuID.DSO_EDIT]: [ + DsoOptionMenu.withSubs([ + SubscribeMenuProvider.onRoute(COMMUNITY_MODULE_PATH, COLLECTION_MODULE_PATH), + DSpaceObjectEditMenuProvider.onRoute(COMMUNITY_MODULE_PATH, COLLECTION_MODULE_PATH, ITEM_MODULE_PATH, ENTITY_MODULE_PATH), + VersioningMenuProvider.onRoute(ITEM_MODULE_PATH, ENTITY_MODULE_PATH), + OrcidMenuProvider.onRoute(ITEM_MODULE_PATH, ENTITY_MODULE_PATH), + ClaimMenuProvider.onRoute(ITEM_MODULE_PATH, ENTITY_MODULE_PATH, COLLECTION_MODULE_PATH), + ]), + ], + }; + + const orderedProviderTypeList = + [ + CommunityListMenuProvider, + BrowseMenuProvider, + StatisticsMenuProvider, + NewMenuProvider, + EditMenuProvider, + ImportMenuProvider, + ExportMenuProvider, + AccessControlMenuProvider, + AdminSearchMenuProvider, + RegistriesMenuProvider, + CurationMenuProvider, + ProcessesMenuProvider, + WorkflowMenuProvider, + HealthMenuProvider, + SystemWideAlertMenuProvider, + SubscribeMenuProvider, + DSpaceObjectEditMenuProvider, + VersioningMenuProvider, + OrcidMenuProvider, + ClaimMenuProvider, + DsoOptionMenu, + ]; + + + it('should have a double amount of objects with an additional service after the processing', () => { + const result = buildMenuStructure(providerStructure); + expect(result.length).toEqual(orderedProviderTypeList.length * 2 + 1); + }); + + it('should return a list with the MenuProviderService and then a resolved provider and provider type for each provider in the provided structure', () => { + const result = buildMenuStructure(providerStructure); + expect(result[0]).toEqual(MenuProviderService); + + orderedProviderTypeList.forEach((provider, index) => { + expect((result[(index + 1) * 2 - 1] as any).deps).toEqual([provider]); + expect(result[(index + 1) * 2]).toEqual(provider); + }); + }); +}); diff --git a/src/app/shared/menu/menu.structure.ts b/src/app/shared/menu/menu.structure.ts index bc58f70391..581fff6c9a 100644 --- a/src/app/shared/menu/menu.structure.ts +++ b/src/app/shared/menu/menu.structure.ts @@ -7,7 +7,7 @@ */ import { InjectionToken, Provider, Type, } from '@angular/core'; import { MenuID } from './menu-id.model'; -import { AbstractMenuProvider, MenuProviderTypeWithOptions } from './menu-provider'; +import { AbstractMenuProvider, MenuProviderTypeWithOptions } from './menu-provider.model'; import { MenuProviderService } from './menu-provider.service'; import { hasValue, isNotEmpty } from '../empty.util'; @@ -52,7 +52,7 @@ function processProviderType(providers: Provider[], menuID: string, providerType const childProviderTypes = (providerType as any).childProviderTypes; childProviderTypes.forEach((childProviderType, childIndex: number) => { - processProviderType(providers, menuID, childProviderType, childIndex, `${providerPart.name}`, hasSubProviders); + processProviderType(providers, menuID, childProviderType, childIndex, `${menuID}_${index}`, hasSubProviders); }); processProviderType(providers, menuID, providerPart, index, parentID, true); @@ -83,10 +83,10 @@ function addProviderToList(providers: Provider[], providerType: Type { diff --git a/src/app/shared/menu/providers/curation.menu.ts b/src/app/shared/menu/providers/curation.menu.ts index 1c35e3fcd8..c6ecf38494 100644 --- a/src/app/shared/menu/providers/curation.menu.ts +++ b/src/app/shared/menu/providers/curation.menu.ts @@ -12,8 +12,11 @@ import { AuthorizationDataService } from '../../../core/data/feature-authorizati 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'; +import { AbstractMenuProvider, PartialMenuSection, } from '../menu-provider.model'; +/** + * Menu provider to create the curation menu section + */ @Injectable() export class CurationMenuProvider extends AbstractMenuProvider { constructor( diff --git a/src/app/shared/menu/providers/dso-edit.menu.ts b/src/app/shared/menu/providers/dso-edit.menu.ts index 5d5a480a92..9b341904a3 100644 --- a/src/app/shared/menu/providers/dso-edit.menu.ts +++ b/src/app/shared/menu/providers/dso-edit.menu.ts @@ -15,9 +15,12 @@ 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 { PartialMenuSection } from '../menu-provider.model'; import { DSpaceObjectPageMenuProvider } from './helper-providers/dso.menu'; +/** + * Menu provider to create the DSO edit menu section + */ @Injectable() export class DSpaceObjectEditMenuProvider extends DSpaceObjectPageMenuProvider { constructor( diff --git a/src/app/shared/menu/providers/dso-option.menu.ts b/src/app/shared/menu/providers/dso-option.menu.ts index 4c5ab9c625..3313f7ebca 100644 --- a/src/app/shared/menu/providers/dso-option.menu.ts +++ b/src/app/shared/menu/providers/dso-option.menu.ts @@ -9,11 +9,15 @@ import { Injectable } from '@angular/core'; import { Observable, of, } from 'rxjs'; import { MenuItemType } from '../menu-item-type.model'; -import { PartialMenuSection } from '../menu-provider'; +import { PartialMenuSection } from '../menu-provider.model'; import { DSpaceObjectPageMenuProvider } from './helper-providers/dso.menu'; import { DSpaceObject } from 'src/app/core/shared/dspace-object.model'; import { hasValue } from '../../empty.util'; +/** + * Menu provider to create the parent wrapper menu of the various DSO page menu sections + * This section will be rendered as a button on the DSO pages if sub providers have been added + */ @Injectable() export class DsoOptionMenu extends DSpaceObjectPageMenuProvider { diff --git a/src/app/shared/menu/providers/edit.menu.ts b/src/app/shared/menu/providers/edit.menu.ts index d72b00ac15..000959edb1 100644 --- a/src/app/shared/menu/providers/edit.menu.ts +++ b/src/app/shared/menu/providers/edit.menu.ts @@ -22,8 +22,11 @@ import { } from '../../dso-selector/modal-wrappers/edit-item-selector/themed-edit-item-selector.component'; import { MenuItemType } from '../menu-item-type.model'; import { AbstractExpandableMenuProvider, } from './helper-providers/expandable-menu-provider'; -import { PartialMenuSection } from '../menu-provider'; +import { PartialMenuSection } from '../menu-provider.model'; +/** + * Menu provider to create the admin sidebar edit menu sections + */ @Injectable() export class EditMenuProvider extends AbstractExpandableMenuProvider { constructor( diff --git a/src/app/shared/menu/providers/export.menu.ts b/src/app/shared/menu/providers/export.menu.ts index 77f7504a8e..6a19929fcb 100644 --- a/src/app/shared/menu/providers/export.menu.ts +++ b/src/app/shared/menu/providers/export.menu.ts @@ -20,8 +20,11 @@ import { } from '../../dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component'; import { MenuItemType } from '../menu-item-type.model'; import { AbstractExpandableMenuProvider, } from './helper-providers/expandable-menu-provider'; -import { PartialMenuSection } from '../menu-provider'; +import { PartialMenuSection } from '../menu-provider.model'; +/** + * Menu provider to create the export menu sections + */ @Injectable() export class ExportMenuProvider extends AbstractExpandableMenuProvider { constructor( diff --git a/src/app/shared/menu/providers/health.menu.ts b/src/app/shared/menu/providers/health.menu.ts index bc7eef597e..d7ff771f57 100644 --- a/src/app/shared/menu/providers/health.menu.ts +++ b/src/app/shared/menu/providers/health.menu.ts @@ -11,8 +11,11 @@ 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'; +import { AbstractMenuProvider, PartialMenuSection, } from '../menu-provider.model'; +/** + * Menu provider to create the health menu section + */ @Injectable() export class HealthMenuProvider extends AbstractMenuProvider { constructor( diff --git a/src/app/shared/menu/providers/helper-providers/dso.menu.ts b/src/app/shared/menu/providers/helper-providers/dso.menu.ts index f88e452aff..9b2d8642fa 100644 --- a/src/app/shared/menu/providers/helper-providers/dso.menu.ts +++ b/src/app/shared/menu/providers/helper-providers/dso.menu.ts @@ -12,8 +12,14 @@ import { AbstractRouteContextMenuProvider } from './route-context.menu'; import { RemoteData } from '../../../../core/data/remote-data'; import { hasValue } from '../../../empty.util'; +/** + * Helper provider for DSpace object page based menus + */ export abstract class DSpaceObjectPageMenuProvider extends AbstractRouteContextMenuProvider { + /** + * Retrieve the dso from the current route data + */ public getRouteContext(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { const dsoRD: RemoteData = route.data.dso; if (hasValue(dsoRD) && dsoRD.hasSucceeded && hasValue(dsoRD.payload)) { @@ -24,7 +30,7 @@ export abstract class DSpaceObjectPageMenuProvider extends AbstractRouteContextM } /** - * Retrieve the dso or entity type for an object to be used in generic messages + * Retrieve the dso or entity type for an object to be used in section messages */ protected getDsoType(dso: DSpaceObject) { const renderType = dso.getRenderTypes()[0]; diff --git a/src/app/shared/menu/providers/helper-providers/expandable-menu-provider.ts b/src/app/shared/menu/providers/helper-providers/expandable-menu-provider.ts index 276b892e6e..e95aa80103 100644 --- a/src/app/shared/menu/providers/helper-providers/expandable-menu-provider.ts +++ b/src/app/shared/menu/providers/helper-providers/expandable-menu-provider.ts @@ -5,29 +5,35 @@ * * http://www.dspace.org/license/ */ -import { combineLatest, Observable, of as observableOf, } from 'rxjs'; +import { combineLatest, Observable, } from 'rxjs'; import { map } from 'rxjs/operators'; -import { AbstractMenuProvider, PartialMenuSection, } from '../../menu-provider'; +import { AbstractMenuProvider, PartialMenuSection, } from '../../menu-provider.model'; +/** + * Helper provider for basic expandable menus + */ export abstract class AbstractExpandableMenuProvider extends AbstractMenuProvider { alwaysRenderExpandable = true; - + /** + * Get the top section for this expandable menu + */ abstract getTopSection(): Observable; + /** + * Get the subsections for this expandable menu + */ abstract getSubSections(): Observable; - protected includeSubSections(): boolean { - return true; - } - + /** + * Retrieve all sections + * This method will combine both the top section and subsections + */ getSections(): Observable { - const full = this.includeSubSections(); - return combineLatest([ this.getTopSection(), - full ? this.getSubSections() : observableOf([]), + this.getSubSections(), ]).pipe( map(( [partialTopSection, partialSubSections]: [PartialMenuSection, PartialMenuSection[]] @@ -35,8 +41,9 @@ export abstract class AbstractExpandableMenuProvider extends AbstractMenuProvide const subSections = partialSubSections.map((partialSub, index) => { return { ...partialSub, - id: partialSub.id ?? `${this.menuProviderId}_Sub-${index}`, + id: partialSub.id ?? `${this.menuProviderId}_${index}`, parentID: this.menuProviderId, + alwaysRenderExpandable: false, }; }); @@ -45,6 +52,7 @@ export abstract class AbstractExpandableMenuProvider extends AbstractMenuProvide { ...partialTopSection, id: this.menuProviderId, + alwaysRenderExpandable: this.alwaysRenderExpandable, }, ]; }) diff --git a/src/app/shared/menu/providers/helper-providers/route-context.menu.ts b/src/app/shared/menu/providers/helper-providers/route-context.menu.ts index 2ffc97ac5e..bd03bcc0f3 100644 --- a/src/app/shared/menu/providers/helper-providers/route-context.menu.ts +++ b/src/app/shared/menu/providers/helper-providers/route-context.menu.ts @@ -8,8 +8,11 @@ import { ActivatedRouteSnapshot, RouterStateSnapshot, } from '@angular/router'; import { Observable, of as observableOf, } from 'rxjs'; import { switchMap } from 'rxjs/operators'; -import { AbstractMenuProvider, PartialMenuSection, } from '../../menu-provider'; +import { AbstractMenuProvider, PartialMenuSection, } from '../../menu-provider.model'; +/** + * Helper provider for route dependent menus + */ export abstract class AbstractRouteContextMenuProvider extends AbstractMenuProvider { shouldPersistOnRouteChange = false; @@ -21,7 +24,7 @@ export abstract class AbstractRouteContextMenuProvider extends AbstractMenuPr return this.getRouteContext(route, state).pipe( switchMap((routeContext: T) => { - if (this.isApplicable(routeContext)) { + if (this.isApplicable(routeContext)) { return this.getSectionsForContext(routeContext); } else { return observableOf([]); diff --git a/src/app/shared/menu/providers/import.menu.ts b/src/app/shared/menu/providers/import.menu.ts index 3e0cf3228e..8a758ae2c1 100644 --- a/src/app/shared/menu/providers/import.menu.ts +++ b/src/app/shared/menu/providers/import.menu.ts @@ -14,8 +14,11 @@ 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, } from './helper-providers/expandable-menu-provider'; -import { PartialMenuSection } from '../menu-provider'; +import { PartialMenuSection } from '../menu-provider.model'; +/** + * Menu provider to create the import menu sections + */ @Injectable() export class ImportMenuProvider extends AbstractExpandableMenuProvider { constructor( diff --git a/src/app/shared/menu/providers/item-claim.menu.ts b/src/app/shared/menu/providers/item-claim.menu.ts index f2afb70ea7..92b27fc02e 100644 --- a/src/app/shared/menu/providers/item-claim.menu.ts +++ b/src/app/shared/menu/providers/item-claim.menu.ts @@ -19,11 +19,14 @@ 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 { PartialMenuSection } from '../menu-provider.model'; import { MenuService } from '../menu.service'; import { DSpaceObjectPageMenuProvider } from './helper-providers/dso.menu'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +/** + * Menu provider to create the menu section on person entity pages to claim a researcher by creating a profile + */ @Injectable() export class ClaimMenuProvider extends DSpaceObjectPageMenuProvider { constructor( diff --git a/src/app/shared/menu/providers/item-orcid.menu.ts b/src/app/shared/menu/providers/item-orcid.menu.ts index 1416b54a34..208a3b26c6 100644 --- a/src/app/shared/menu/providers/item-orcid.menu.ts +++ b/src/app/shared/menu/providers/item-orcid.menu.ts @@ -15,9 +15,12 @@ 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 { PartialMenuSection } from '../menu-provider.model'; import { DSpaceObjectPageMenuProvider } from './helper-providers/dso.menu'; +/** + * Menu provider to create the Orcid synchronisation menu section on person entity pages + */ @Injectable() export class OrcidMenuProvider extends DSpaceObjectPageMenuProvider { constructor( diff --git a/src/app/shared/menu/providers/item-versioning.menu.ts b/src/app/shared/menu/providers/item-versioning.menu.ts index 15349a7016..9e30a16998 100644 --- a/src/app/shared/menu/providers/item-versioning.menu.ts +++ b/src/app/shared/menu/providers/item-versioning.menu.ts @@ -14,9 +14,12 @@ 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 { PartialMenuSection } from '../menu-provider.model'; import { DSpaceObjectPageMenuProvider } from './helper-providers/dso.menu'; +/** + * Menu provider to create the versioning menu section on item pages + */ @Injectable() export class VersioningMenuProvider extends DSpaceObjectPageMenuProvider { constructor( diff --git a/src/app/shared/menu/providers/new.menu.ts b/src/app/shared/menu/providers/new.menu.ts index 07c54fa9cb..6962970457 100644 --- a/src/app/shared/menu/providers/new.menu.ts +++ b/src/app/shared/menu/providers/new.menu.ts @@ -23,8 +23,11 @@ import { import { MenuItemType } from '../menu-item-type.model'; import { TextMenuItemModel } from '../menu-item/models/text.model'; import { AbstractExpandableMenuProvider, } from './helper-providers/expandable-menu-provider'; -import { PartialMenuSection } from '../menu-provider'; +import { PartialMenuSection } from '../menu-provider.model'; +/** + * Menu provider to create the admin sidebar new menu sections + */ @Injectable() export class NewMenuProvider extends AbstractExpandableMenuProvider { constructor( diff --git a/src/app/shared/menu/providers/processes.menu.ts b/src/app/shared/menu/providers/processes.menu.ts index 6943572beb..0aa3c03071 100644 --- a/src/app/shared/menu/providers/processes.menu.ts +++ b/src/app/shared/menu/providers/processes.menu.ts @@ -11,8 +11,11 @@ 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'; +import { AbstractMenuProvider, PartialMenuSection, } from '../menu-provider.model'; +/** + * Menu provider to create the scripts and processes menu section + */ @Injectable() export class ProcessesMenuProvider extends AbstractMenuProvider { constructor( diff --git a/src/app/shared/menu/providers/registries.menu.ts b/src/app/shared/menu/providers/registries.menu.ts index d69aa79d0e..6464d1d48b 100644 --- a/src/app/shared/menu/providers/registries.menu.ts +++ b/src/app/shared/menu/providers/registries.menu.ts @@ -14,8 +14,11 @@ 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, } from './helper-providers/expandable-menu-provider'; -import { PartialMenuSection } from '../menu-provider'; +import { PartialMenuSection } from '../menu-provider.model'; +/** + * Menu provider to create the registries menu sections + */ @Injectable() export class RegistriesMenuProvider extends AbstractExpandableMenuProvider { constructor( diff --git a/src/app/shared/menu/providers/statistics.menu.ts b/src/app/shared/menu/providers/statistics.menu.ts index 2d29cc575c..4690cdf1aa 100644 --- a/src/app/shared/menu/providers/statistics.menu.ts +++ b/src/app/shared/menu/providers/statistics.menu.ts @@ -11,17 +11,18 @@ import { ActivatedRouteSnapshot, RouterStateSnapshot, } from '@angular/router'; import { Observable, of, } from 'rxjs'; import { hasNoValue, hasValue } from '../../empty.util'; import { MenuItemType } from '../menu-item-type.model'; -import { PartialMenuSection } from '../menu-provider'; +import { PartialMenuSection } from '../menu-provider.model'; import { AbstractRouteContextMenuProvider } from './helper-providers/route-context.menu'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { RemoteData } from '../../../core/data/remote-data'; import { getDSORoute } from '../../../app-routing-paths'; -interface StatisticsLink { - id: string, - link: string, -} +/** + * Menu provider to create the statistics menu section depending on the page it is on + * When the user is on a DSO page or a derivative, this menu section will contain a link to the statistics of that DSO + * In all other cases the menu section will contain a link to the repository wide statistics + */ @Injectable() export class StatisticsMenuProvider extends AbstractRouteContextMenuProvider { diff --git a/src/app/shared/menu/providers/system-wide-alert.menu.ts b/src/app/shared/menu/providers/system-wide-alert.menu.ts index e038a9c757..99d3a14a6c 100644 --- a/src/app/shared/menu/providers/system-wide-alert.menu.ts +++ b/src/app/shared/menu/providers/system-wide-alert.menu.ts @@ -11,8 +11,11 @@ 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'; +import { AbstractMenuProvider, PartialMenuSection, } from '../menu-provider.model'; +/** + * Menu provider to create the system wide alert menu section + */ @Injectable() export class SystemWideAlertMenuProvider extends AbstractMenuProvider { constructor( diff --git a/src/app/shared/menu/providers/workflow.menu.ts b/src/app/shared/menu/providers/workflow.menu.ts index bcdeb424a8..ec075bc52b 100644 --- a/src/app/shared/menu/providers/workflow.menu.ts +++ b/src/app/shared/menu/providers/workflow.menu.ts @@ -11,8 +11,11 @@ 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'; +import { AbstractMenuProvider, PartialMenuSection, } from '../menu-provider.model'; +/** + * Menu provider to create the workflow admin menu section + */ @Injectable() export class WorkflowMenuProvider extends AbstractMenuProvider { constructor( diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 0f7871f7f9..c6e2ddc3f3 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -273,9 +273,6 @@ import { AdvancedClaimedTaskActionRatingComponent } from './mydspace-actions/claimed-task/rating/advanced-claimed-task-action-rating.component'; import { ClaimedTaskActionsDeclineTaskComponent } from './mydspace-actions/claimed-task/decline-task/claimed-task-actions-decline-task.component'; -import { - DsoPageSubscriptionButtonComponent -} from './dso-page/dso-page-subscription-button/dso-page-subscription-button.component'; import { EpersonGroupListComponent } from './eperson-group-list/eperson-group-list.component'; import { EpersonSearchBoxComponent } from './eperson-group-list/eperson-search-box/eperson-search-box.component'; import { GroupSearchBoxComponent } from './eperson-group-list/group-search-box/group-search-box.component'; @@ -395,7 +392,6 @@ const COMPONENTS = [ ItemPageTitleFieldComponent, ThemedSearchNavbarComponent, ListableNotificationObjectComponent, - DsoPageSubscriptionButtonComponent, MetadataFieldWrapperComponent, ContextHelpWrapperComponent, EpersonGroupListComponent, diff --git a/src/modules/app/server-init.service.ts b/src/modules/app/server-init.service.ts index c7abf6d085..20b294cc27 100644 --- a/src/modules/app/server-init.service.ts +++ b/src/modules/app/server-init.service.ts @@ -70,7 +70,7 @@ export class ServerInitService extends InitService { this.initAngulartics(); this.initRouteListeners(); this.themeService.listenForThemeChanges(false); - // this.initPersistentMenus(); + this.menuProviderService.initPersistentMenus(); await this.authenticationReady$().toPromise(); From b2715501b04931925632478390203ce9e46bbdc5 Mon Sep 17 00:00:00 2001 From: Yury Bondarenko Date: Mon, 25 Nov 2024 16:49:12 +0100 Subject: [PATCH 024/157] Update tests to match --- .../shared/menu/providers/access-control.menu.spec.ts | 7 ++++--- .../shared/menu/providers/admin-search.menu.spec.ts | 2 +- src/app/shared/menu/providers/browse.menu.spec.ts | 7 ++++--- .../shared/menu/providers/community-list.menu.spec.ts | 2 +- src/app/shared/menu/providers/curation.menu.spec.ts | 2 +- src/app/shared/menu/providers/edit.menu.spec.ts | 7 ++++--- src/app/shared/menu/providers/export.menu.spec.ts | 10 ++++------ src/app/shared/menu/providers/health.menu.spec.ts | 2 +- src/app/shared/menu/providers/import.menu.spec.ts | 8 ++++---- src/app/shared/menu/providers/new.menu.spec.ts | 7 ++++--- src/app/shared/menu/providers/processes.menu.spec.ts | 2 +- src/app/shared/menu/providers/registries.menu.spec.ts | 7 ++++--- .../menu/providers/system-wide-alert.menu.spec.ts | 2 +- src/app/shared/menu/providers/workflow.menu.spec.ts | 2 +- 14 files changed, 35 insertions(+), 32 deletions(-) diff --git a/src/app/shared/menu/providers/access-control.menu.spec.ts b/src/app/shared/menu/providers/access-control.menu.spec.ts index d9284a75c4..878025065b 100644 --- a/src/app/shared/menu/providers/access-control.menu.spec.ts +++ b/src/app/shared/menu/providers/access-control.menu.spec.ts @@ -8,7 +8,7 @@ import { TestBed } from '@angular/core/testing'; import { MenuItemType } from '../menu-item-type.model'; -import { MenuSubSection, MenuTopSection } from './expandable-menu-provider'; +import { PartialMenuSection } from '../menu-provider.model'; import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; import { of as observableOf } from 'rxjs'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; @@ -17,7 +17,8 @@ import { AccessControlMenuProvider } from './access-control.menu'; import { ScriptDataService } from '../../../core/data/processes/script-data.service'; import { ScriptServiceStub } from '../../testing/script-service.stub'; -const expectedTopSection: MenuTopSection = { +const expectedTopSection: PartialMenuSection = { + visible: true, model: { type: MenuItemType.TEXT, text: 'menu.section.access_control', @@ -25,7 +26,7 @@ const expectedTopSection: MenuTopSection = { icon: 'key' }; -const expectedSubSections: MenuSubSection[] = [ +const expectedSubSections: PartialMenuSection[] = [ { visible: true, model: { diff --git a/src/app/shared/menu/providers/admin-search.menu.spec.ts b/src/app/shared/menu/providers/admin-search.menu.spec.ts index 0471215578..6862419408 100644 --- a/src/app/shared/menu/providers/admin-search.menu.spec.ts +++ b/src/app/shared/menu/providers/admin-search.menu.spec.ts @@ -7,7 +7,7 @@ */ import { TestBed } from '@angular/core/testing'; -import { PartialMenuSection } from '../menu-provider'; +import { PartialMenuSection } from '../menu-provider.model'; import { MenuItemType } from '../menu-item-type.model'; import { AdminSearchMenuProvider } from './admin-search.menu'; import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; diff --git a/src/app/shared/menu/providers/browse.menu.spec.ts b/src/app/shared/menu/providers/browse.menu.spec.ts index 20ac0f92be..1715645b4d 100644 --- a/src/app/shared/menu/providers/browse.menu.spec.ts +++ b/src/app/shared/menu/providers/browse.menu.spec.ts @@ -9,7 +9,7 @@ import { TestBed } from '@angular/core/testing'; import { BrowseMenuProvider } from './browse.menu'; import { MenuItemType } from '../menu-item-type.model'; -import { MenuSubSection, MenuTopSection } from './expandable-menu-provider'; +import { PartialMenuSection } from '../menu-provider.model'; import { BrowseService } from '../../../core/browse/browse.service'; import { BrowseServiceStub } from '../../testing/browse-service.stub'; import { ObjectCacheService } from '../../../core/cache/object-cache.service'; @@ -18,7 +18,8 @@ import { BrowseDefinition } from '../../../core/shared/browse-definition.model'; import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; import { createPaginatedList } from '../../testing/utils.test'; -const expectedTopSection: MenuTopSection = { +const expectedTopSection: PartialMenuSection = { + visible: true, model: { type: MenuItemType.TEXT, text: 'menu.section.browse_global', @@ -26,7 +27,7 @@ const expectedTopSection: MenuTopSection = { icon: 'globe', }; -const expectedSubSections: MenuSubSection[] = [ +const expectedSubSections: PartialMenuSection[] = [ { visible: true, model: { diff --git a/src/app/shared/menu/providers/community-list.menu.spec.ts b/src/app/shared/menu/providers/community-list.menu.spec.ts index b0e037d228..16d6af4e45 100644 --- a/src/app/shared/menu/providers/community-list.menu.spec.ts +++ b/src/app/shared/menu/providers/community-list.menu.spec.ts @@ -8,7 +8,7 @@ import { TestBed } from '@angular/core/testing'; import { CommunityListMenuProvider } from './community-list.menu'; -import { PartialMenuSection } from '../menu-provider'; +import { PartialMenuSection } from '../menu-provider.model'; import { MenuItemType } from '../menu-item-type.model'; const expectedSections: PartialMenuSection[] = [ diff --git a/src/app/shared/menu/providers/curation.menu.spec.ts b/src/app/shared/menu/providers/curation.menu.spec.ts index 5225091f75..7cfe9ef8e7 100644 --- a/src/app/shared/menu/providers/curation.menu.spec.ts +++ b/src/app/shared/menu/providers/curation.menu.spec.ts @@ -7,7 +7,7 @@ */ import { TestBed } from '@angular/core/testing'; -import { PartialMenuSection } from '../menu-provider'; +import { PartialMenuSection } from '../menu-provider.model'; import { MenuItemType } from '../menu-item-type.model'; import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; import { of as observableOf } from 'rxjs'; diff --git a/src/app/shared/menu/providers/edit.menu.spec.ts b/src/app/shared/menu/providers/edit.menu.spec.ts index b3204cc818..f47010d734 100644 --- a/src/app/shared/menu/providers/edit.menu.spec.ts +++ b/src/app/shared/menu/providers/edit.menu.spec.ts @@ -8,14 +8,15 @@ import { TestBed } from '@angular/core/testing'; import { MenuItemType } from '../menu-item-type.model'; -import { MenuSubSection, MenuTopSection } from './expandable-menu-provider'; +import { PartialMenuSection } from '../menu-provider.model'; import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; import { of as observableOf } from 'rxjs'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; import { EditMenuProvider } from './edit.menu'; -const expectedTopSection: MenuTopSection = { +const expectedTopSection: PartialMenuSection = { + visible: true, model: { type: MenuItemType.TEXT, text: 'menu.section.edit' @@ -23,7 +24,7 @@ const expectedTopSection: MenuTopSection = { icon: 'pencil', }; -const expectedSubSections: MenuSubSection[] = [ +const expectedSubSections: PartialMenuSection[] = [ { visible: true, model: { diff --git a/src/app/shared/menu/providers/export.menu.spec.ts b/src/app/shared/menu/providers/export.menu.spec.ts index 662622b594..72dfbe8167 100644 --- a/src/app/shared/menu/providers/export.menu.spec.ts +++ b/src/app/shared/menu/providers/export.menu.spec.ts @@ -8,7 +8,7 @@ import { TestBed } from '@angular/core/testing'; import { MenuItemType } from '../menu-item-type.model'; -import { MenuSubSection, MenuTopSection } from './expandable-menu-provider'; +import { PartialMenuSection } from '../menu-provider.model'; import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; import { of as observableOf } from 'rxjs'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; @@ -16,16 +16,16 @@ import { ScriptDataService } from '../../../core/data/processes/script-data.serv import { ScriptServiceStub } from '../../testing/script-service.stub'; import { ExportMenuProvider } from './export.menu'; -const expectedTopSection: MenuTopSection = { +const expectedTopSection: PartialMenuSection = { + visible: true, model: { type: MenuItemType.TEXT, text: 'menu.section.export', }, icon: 'file-export', - shouldPersistOnRouteChange: true, }; -const expectedSubSections: MenuSubSection[] = [ +const expectedSubSections: PartialMenuSection[] = [ { visible: true, model: { @@ -33,7 +33,6 @@ const expectedSubSections: MenuSubSection[] = [ text: 'menu.section.export_metadata', function: jasmine.any(Function) as any, }, - shouldPersistOnRouteChange: true, }, { visible: true, @@ -42,7 +41,6 @@ const expectedSubSections: MenuSubSection[] = [ text: 'menu.section.export_batch', function: jasmine.any(Function) as any, }, - shouldPersistOnRouteChange: true, } ]; diff --git a/src/app/shared/menu/providers/health.menu.spec.ts b/src/app/shared/menu/providers/health.menu.spec.ts index 6e10e3cc5f..ee0a649c10 100644 --- a/src/app/shared/menu/providers/health.menu.spec.ts +++ b/src/app/shared/menu/providers/health.menu.spec.ts @@ -7,7 +7,7 @@ */ import { TestBed } from '@angular/core/testing'; -import { PartialMenuSection } from '../menu-provider'; +import { PartialMenuSection } from '../menu-provider.model'; import { MenuItemType } from '../menu-item-type.model'; import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; import { of as observableOf } from 'rxjs'; diff --git a/src/app/shared/menu/providers/import.menu.spec.ts b/src/app/shared/menu/providers/import.menu.spec.ts index 08223f31d7..71e6a89325 100644 --- a/src/app/shared/menu/providers/import.menu.spec.ts +++ b/src/app/shared/menu/providers/import.menu.spec.ts @@ -8,7 +8,7 @@ import { TestBed } from '@angular/core/testing'; import { MenuItemType } from '../menu-item-type.model'; -import { MenuSubSection, MenuTopSection } from './expandable-menu-provider'; +import { PartialMenuSection } from '../menu-provider.model'; import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; import { of as observableOf } from 'rxjs'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; @@ -16,16 +16,16 @@ import { ImportMenuProvider } from './import.menu'; import { ScriptDataService } from '../../../core/data/processes/script-data.service'; import { ScriptServiceStub } from '../../testing/script-service.stub'; -const expectedTopSection: MenuTopSection = { +const expectedTopSection: PartialMenuSection = { + visible: true, model: { type: MenuItemType.TEXT, text: 'menu.section.import', }, icon: 'file-import', - shouldPersistOnRouteChange: true, }; -const expectedSubSections: MenuSubSection[] = [ +const expectedSubSections: PartialMenuSection[] = [ { visible: true, model: { diff --git a/src/app/shared/menu/providers/new.menu.spec.ts b/src/app/shared/menu/providers/new.menu.spec.ts index 6376b5e2d3..f98362ea8e 100644 --- a/src/app/shared/menu/providers/new.menu.spec.ts +++ b/src/app/shared/menu/providers/new.menu.spec.ts @@ -8,14 +8,15 @@ import { TestBed } from '@angular/core/testing'; import { MenuItemType } from '../menu-item-type.model'; -import { MenuSubSection, MenuTopSection } from './expandable-menu-provider'; +import { PartialMenuSection } from '../menu-provider.model'; import { NewMenuProvider } from './new.menu'; import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; import { of as observableOf } from 'rxjs'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; -const expectedTopSection: MenuTopSection = { +const expectedTopSection: PartialMenuSection = { + visible: true, model: { type: MenuItemType.TEXT, text: 'menu.section.new' @@ -23,7 +24,7 @@ const expectedTopSection: MenuTopSection = { icon: 'plus', }; -const expectedSubSections: MenuSubSection[] = [ +const expectedSubSections: PartialMenuSection[] = [ { visible: true, model: { diff --git a/src/app/shared/menu/providers/processes.menu.spec.ts b/src/app/shared/menu/providers/processes.menu.spec.ts index 78ff544a58..cef2f695eb 100644 --- a/src/app/shared/menu/providers/processes.menu.spec.ts +++ b/src/app/shared/menu/providers/processes.menu.spec.ts @@ -7,7 +7,7 @@ */ import { TestBed } from '@angular/core/testing'; -import { PartialMenuSection } from '../menu-provider'; +import { PartialMenuSection } from '../menu-provider.model'; import { MenuItemType } from '../menu-item-type.model'; import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; import { of as observableOf } from 'rxjs'; diff --git a/src/app/shared/menu/providers/registries.menu.spec.ts b/src/app/shared/menu/providers/registries.menu.spec.ts index b1aa981f7f..ef8f468039 100644 --- a/src/app/shared/menu/providers/registries.menu.spec.ts +++ b/src/app/shared/menu/providers/registries.menu.spec.ts @@ -8,7 +8,7 @@ import { TestBed } from '@angular/core/testing'; import { MenuItemType } from '../menu-item-type.model'; -import { MenuSubSection, MenuTopSection } from './expandable-menu-provider'; +import { PartialMenuSection } from '../menu-provider.model'; import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; import { of as observableOf } from 'rxjs'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; @@ -16,7 +16,8 @@ import { ScriptDataService } from '../../../core/data/processes/script-data.serv import { ScriptServiceStub } from '../../testing/script-service.stub'; import { RegistriesMenuProvider } from './registries.menu'; -const expectedTopSection: MenuTopSection = { +const expectedTopSection: PartialMenuSection = { + visible: true, model: { type: MenuItemType.TEXT, text: 'menu.section.registries', @@ -24,7 +25,7 @@ const expectedTopSection: MenuTopSection = { icon: 'list', }; -const expectedSubSections: MenuSubSection[] = [ +const expectedSubSections: PartialMenuSection[] = [ { visible: true, model: { diff --git a/src/app/shared/menu/providers/system-wide-alert.menu.spec.ts b/src/app/shared/menu/providers/system-wide-alert.menu.spec.ts index df5e126814..1cda898373 100644 --- a/src/app/shared/menu/providers/system-wide-alert.menu.spec.ts +++ b/src/app/shared/menu/providers/system-wide-alert.menu.spec.ts @@ -7,7 +7,7 @@ */ import { TestBed } from '@angular/core/testing'; -import { PartialMenuSection } from '../menu-provider'; +import { PartialMenuSection } from '../menu-provider.model'; import { MenuItemType } from '../menu-item-type.model'; import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; import { of as observableOf } from 'rxjs'; diff --git a/src/app/shared/menu/providers/workflow.menu.spec.ts b/src/app/shared/menu/providers/workflow.menu.spec.ts index 2886d0dbf3..5ed2af9d18 100644 --- a/src/app/shared/menu/providers/workflow.menu.spec.ts +++ b/src/app/shared/menu/providers/workflow.menu.spec.ts @@ -7,7 +7,7 @@ */ import { TestBed } from '@angular/core/testing'; -import { PartialMenuSection } from '../menu-provider'; +import { PartialMenuSection } from '../menu-provider.model'; import { MenuItemType } from '../menu-item-type.model'; import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; import { of as observableOf } from 'rxjs'; From 44d2450543efab8a220ee477e3a7bb0d1487f0e8 Mon Sep 17 00:00:00 2001 From: Alexandre Vryghem Date: Fri, 29 Nov 2024 15:15:51 +0100 Subject: [PATCH 025/157] 121550: Fixed dso edit menu crashing when dso isn't resolved yet --- src/app/shared/menu/providers/dso-option.menu.ts | 5 ----- src/app/shared/menu/providers/helper-providers/dso.menu.ts | 4 ++++ 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/app/shared/menu/providers/dso-option.menu.ts b/src/app/shared/menu/providers/dso-option.menu.ts index 3313f7ebca..0fba0e19ef 100644 --- a/src/app/shared/menu/providers/dso-option.menu.ts +++ b/src/app/shared/menu/providers/dso-option.menu.ts @@ -12,7 +12,6 @@ import { MenuItemType } from '../menu-item-type.model'; import { PartialMenuSection } from '../menu-provider.model'; import { DSpaceObjectPageMenuProvider } from './helper-providers/dso.menu'; import { DSpaceObject } from 'src/app/core/shared/dspace-object.model'; -import { hasValue } from '../../empty.util'; /** * Menu provider to create the parent wrapper menu of the various DSO page menu sections @@ -23,10 +22,6 @@ export class DsoOptionMenu extends DSpaceObjectPageMenuProvider { alwaysRenderExpandable = true; - protected isApplicable(dso: DSpaceObject): boolean { - return hasValue(dso); - } - getSectionsForContext(dso: DSpaceObject): Observable { return of([ { diff --git a/src/app/shared/menu/providers/helper-providers/dso.menu.ts b/src/app/shared/menu/providers/helper-providers/dso.menu.ts index 9b2d8642fa..4736e65f41 100644 --- a/src/app/shared/menu/providers/helper-providers/dso.menu.ts +++ b/src/app/shared/menu/providers/helper-providers/dso.menu.ts @@ -40,4 +40,8 @@ export abstract class DSpaceObjectPageMenuProvider extends AbstractRouteContextM return dso.type.toString().toLowerCase(); } } + + protected isApplicable(dso: DSpaceObject): boolean { + return hasValue(dso); + } } From d85d5fbd71bcf02c3a536e7fcab63e3873eb5c5a Mon Sep 17 00:00:00 2001 From: Alexandre Vryghem Date: Fri, 13 Dec 2024 12:25:39 +0100 Subject: [PATCH 026/157] 121550: Use IDs on the routes in order to assign AbstractRouteContextMenuProviders --- src/app/app.menus.ts | 32 ++++++++++++++----- .../collection-page-routing.module.ts | 4 +++ .../community-page-routing.module.ts | 4 +++ src/app/item-page/item-page-routing.module.ts | 7 ++++ src/app/shared/menu/menu-provider.model.ts | 7 ++-- src/app/shared/menu/menu-provider.service.ts | 5 +-- src/app/shared/menu/menu-route.model.ts | 9 ++++++ src/app/shared/menu/menu.structure.ts | 3 +- 8 files changed, 57 insertions(+), 14 deletions(-) create mode 100644 src/app/shared/menu/menu-route.model.ts diff --git a/src/app/app.menus.ts b/src/app/app.menus.ts index 7679db4c42..e530cfd55d 100644 --- a/src/app/app.menus.ts +++ b/src/app/app.menus.ts @@ -27,10 +27,8 @@ 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'; -import { COMMUNITY_MODULE_PATH } from './community-page/community-page-routing-paths'; -import { COLLECTION_MODULE_PATH } from './collection-page/collection-page-routing-paths'; -import { ENTITY_MODULE_PATH, ITEM_MODULE_PATH } from './item-page/item-page-routing-paths'; import { DsoOptionMenu } from './shared/menu/providers/dso-option.menu'; +import { MenuRoute } from './shared/menu/menu-route.model'; export const MENUS = buildMenuStructure({ [MenuID.PUBLIC]: [ @@ -54,11 +52,29 @@ export const MENUS = buildMenuStructure({ ], [MenuID.DSO_EDIT]: [ DsoOptionMenu.withSubs([ - SubscribeMenuProvider.onRoute(COMMUNITY_MODULE_PATH, COLLECTION_MODULE_PATH), - DSpaceObjectEditMenuProvider.onRoute(COMMUNITY_MODULE_PATH, COLLECTION_MODULE_PATH, ITEM_MODULE_PATH, ENTITY_MODULE_PATH), - VersioningMenuProvider.onRoute(ITEM_MODULE_PATH, ENTITY_MODULE_PATH), - OrcidMenuProvider.onRoute(ITEM_MODULE_PATH, ENTITY_MODULE_PATH), - ClaimMenuProvider.onRoute(ITEM_MODULE_PATH, ENTITY_MODULE_PATH, COLLECTION_MODULE_PATH), + SubscribeMenuProvider.onRoute( + MenuRoute.SIMPLE_COMMUNITY_PAGE, + MenuRoute.SIMPLE_COLLECTION_PAGE, + ), + DSpaceObjectEditMenuProvider.onRoute( + MenuRoute.SIMPLE_COMMUNITY_PAGE, + MenuRoute.SIMPLE_COLLECTION_PAGE, + MenuRoute.SIMPLE_ITEM_PAGE, + MenuRoute.FULL_ITEM_PAGE, + ), + VersioningMenuProvider.onRoute( + MenuRoute.SIMPLE_ITEM_PAGE, + MenuRoute.FULL_ITEM_PAGE, + ), + OrcidMenuProvider.onRoute( + MenuRoute.SIMPLE_ITEM_PAGE, + MenuRoute.FULL_ITEM_PAGE, + ), + ClaimMenuProvider.onRoute( + MenuRoute.SIMPLE_ITEM_PAGE, + MenuRoute.FULL_ITEM_PAGE, + MenuRoute.SIMPLE_COLLECTION_PAGE, + ), ]), ], }); diff --git a/src/app/collection-page/collection-page-routing.module.ts b/src/app/collection-page/collection-page-routing.module.ts index 7930c1ae6f..51d70ac8f1 100644 --- a/src/app/collection-page/collection-page-routing.module.ts +++ b/src/app/collection-page/collection-page-routing.module.ts @@ -19,6 +19,7 @@ import { DeleteCollectionPageComponent } from './delete-collection-page/delete-c import { ItemTemplatePageResolver } from './edit-item-template-page/item-template-page.resolver'; import { ThemedEditItemTemplatePageComponent } from './edit-item-template-page/themed-edit-item-template-page.component'; import { ThemedCollectionPageComponent } from './themed-collection-page.component'; +import { MenuRoute } from '../shared/menu/menu-route.model'; @NgModule({ imports: [ @@ -62,6 +63,9 @@ import { ThemedCollectionPageComponent } from './themed-collection-page.componen path: '', component: ThemedCollectionPageComponent, pathMatch: 'full', + data: { + menuRoute: MenuRoute.SIMPLE_COLLECTION_PAGE, + }, } ], }, diff --git a/src/app/community-page/community-page-routing.module.ts b/src/app/community-page/community-page-routing.module.ts index 006493c02c..e05314ce54 100644 --- a/src/app/community-page/community-page-routing.module.ts +++ b/src/app/community-page/community-page-routing.module.ts @@ -15,6 +15,7 @@ import { CreateCommunityPageComponent } from './create-community-page/create-com 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'; +import { MenuRoute } from '../shared/menu/menu-route.model'; @NgModule({ imports: [ @@ -48,6 +49,9 @@ import { ThemedCommunityPageComponent } from './themed-community-page.component' path: '', component: ThemedCommunityPageComponent, pathMatch: 'full', + data: { + menuRoute: MenuRoute.SIMPLE_COMMUNITY_PAGE, + }, } ], }, diff --git a/src/app/item-page/item-page-routing.module.ts b/src/app/item-page/item-page-routing.module.ts index 1c93dab744..2b11c42381 100644 --- a/src/app/item-page/item-page-routing.module.ts +++ b/src/app/item-page/item-page-routing.module.ts @@ -18,6 +18,7 @@ import { OrcidPageGuard } from './orcid-page/orcid-page.guard'; 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'; +import { MenuRoute } from '../shared/menu/menu-route.model'; @NgModule({ imports: [ @@ -34,10 +35,16 @@ import { VersionResolver } from './version-page/version.resolver'; path: '', component: ThemedItemPageComponent, pathMatch: 'full', + data: { + menuRoute: MenuRoute.SIMPLE_ITEM_PAGE, + }, }, { path: 'full', component: ThemedFullItemPageComponent, + data: { + menuRoute: MenuRoute.FULL_ITEM_PAGE, + }, }, { path: ITEM_EDIT_PATH, diff --git a/src/app/shared/menu/menu-provider.model.ts b/src/app/shared/menu/menu-provider.model.ts index 247963f172..8a517934b9 100644 --- a/src/app/shared/menu/menu-provider.model.ts +++ b/src/app/shared/menu/menu-provider.model.ts @@ -11,6 +11,7 @@ import { Observable, } from 'rxjs'; import { MenuID } from './menu-id.model'; import { MenuItemModels } from './menu-section.model'; import { Type } from '@angular/core'; +import { MenuRoute } from './menu-route.model'; /** * Partial menu section @@ -52,7 +53,7 @@ export interface MenuProvider { */ export class MenuProviderTypeWithOptions { providerType: Type; - paths?: string[]; + paths?: MenuRoute[]; childProviderTypes?: (Type | MenuProviderTypeWithOptions)[]; } @@ -90,7 +91,7 @@ export abstract class AbstractMenuProvider implements MenuProvider { * This will be automatically set based on the paths added based on the paths provided through the 'onRoute' static * method in the app.menus.ts file */ - activePaths?: string[]; + activePaths?: MenuRoute[]; /** * The ID of the parent provider of this provider. @@ -110,7 +111,7 @@ export abstract class AbstractMenuProvider implements MenuProvider { * Static method to be called from the app.menus.ts file to define paths on which this provider should the active * @param paths - The paths on which the sections of this provider should be active */ - public static onRoute(...paths: string[]): MenuProviderTypeWithOptions { + public static onRoute(...paths: MenuRoute[]): MenuProviderTypeWithOptions { if (!AbstractMenuProvider.isPrototypeOf(this)) { throw new Error( 'onRoute should only be called from concrete subclasses of AbstractMenuProvider' diff --git a/src/app/shared/menu/menu-provider.service.ts b/src/app/shared/menu/menu-provider.service.ts index 9b5d5e73a5..c0f34be481 100644 --- a/src/app/shared/menu/menu-provider.service.ts +++ b/src/app/shared/menu/menu-provider.service.ts @@ -16,6 +16,7 @@ import { AbstractMenuProvider, PartialMenuSection } from './menu-provider.model' import { MenuState } from './menu-state.model'; import { MenuService } from './menu.service'; import { MENU_PROVIDER } from './menu.structure'; +import { MenuRoute } from './menu-route.model'; /** * Service that is responsible for adding and removing the menu sections created by the providers, both for @@ -126,8 +127,8 @@ export class MenuProviderService { .filter(provider => { let shouldUpdate = false; if (!provider.shouldPersistOnRouteChange && isNotEmpty(provider.activePaths)) { - provider.activePaths.forEach((path) => { - if (state.url.includes(path)) { + provider.activePaths.forEach((path: MenuRoute) => { + if (route.data.menuRoute === path) { shouldUpdate = true; } }); diff --git a/src/app/shared/menu/menu-route.model.ts b/src/app/shared/menu/menu-route.model.ts new file mode 100644 index 0000000000..db0fe03169 --- /dev/null +++ b/src/app/shared/menu/menu-route.model.ts @@ -0,0 +1,9 @@ +/** + * The menu route IDs that can be used for route resolvers + */ +export enum MenuRoute { + SIMPLE_COMMUNITY_PAGE = 'simple-community-page', + SIMPLE_COLLECTION_PAGE = 'simple-collection-page', + SIMPLE_ITEM_PAGE = 'simple-item-page', + FULL_ITEM_PAGE = 'full-item-page', +} diff --git a/src/app/shared/menu/menu.structure.ts b/src/app/shared/menu/menu.structure.ts index 581fff6c9a..81103ce182 100644 --- a/src/app/shared/menu/menu.structure.ts +++ b/src/app/shared/menu/menu.structure.ts @@ -10,6 +10,7 @@ import { MenuID } from './menu-id.model'; import { AbstractMenuProvider, MenuProviderTypeWithOptions } from './menu-provider.model'; import { MenuProviderService } from './menu-provider.service'; import { hasValue, isNotEmpty } from '../empty.util'; +import { MenuRoute } from './menu-route.model'; export const MENU_PROVIDER = new InjectionToken('MENU_PROVIDER'); @@ -75,7 +76,7 @@ function processProviderType(providers: Provider[], menuID: string, providerType * @param hasSubProviders - Whether this provider has sub providers * @param paths - The paths this provider should be active on if relevant */ -function addProviderToList(providers: Provider[], providerType: Type, menuID: string, index: number, parentID?: string, hasSubProviders?: boolean, paths?: string[]) { +function addProviderToList(providers: Provider[], providerType: Type, menuID: string, index: number, parentID?: string, hasSubProviders?: boolean, paths?: MenuRoute[]) { const resolvedProvider = { provide: MENU_PROVIDER, multi: true, From 4729ae950d8821e1193e076c11d1c0e30503d027 Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Thu, 2 Jan 2025 14:17:06 +0100 Subject: [PATCH 027/157] Fix merge issues --- .../shared/menu/providers/access-control.menu.spec.ts | 7 ++++--- .../shared/menu/providers/admin-search.menu.spec.ts | 2 +- src/app/shared/menu/providers/browse.menu.spec.ts | 9 +++++---- .../shared/menu/providers/community-list.menu.spec.ts | 2 +- src/app/shared/menu/providers/curation.menu.spec.ts | 2 +- src/app/shared/menu/providers/edit.menu.spec.ts | 7 ++++--- src/app/shared/menu/providers/export.menu.spec.ts | 10 ++++------ src/app/shared/menu/providers/health.menu.spec.ts | 2 +- src/app/shared/menu/providers/import.menu.spec.ts | 8 ++++---- src/app/shared/menu/providers/new.menu.spec.ts | 9 +++++---- src/app/shared/menu/providers/processes.menu.spec.ts | 2 +- src/app/shared/menu/providers/registries.menu.spec.ts | 7 ++++--- .../menu/providers/system-wide-alert.menu.spec.ts | 2 +- src/app/shared/menu/providers/workflow.menu.spec.ts | 2 +- 14 files changed, 37 insertions(+), 34 deletions(-) diff --git a/src/app/shared/menu/providers/access-control.menu.spec.ts b/src/app/shared/menu/providers/access-control.menu.spec.ts index d9284a75c4..dfb3b625e1 100644 --- a/src/app/shared/menu/providers/access-control.menu.spec.ts +++ b/src/app/shared/menu/providers/access-control.menu.spec.ts @@ -8,7 +8,6 @@ import { TestBed } from '@angular/core/testing'; import { MenuItemType } from '../menu-item-type.model'; -import { MenuSubSection, MenuTopSection } from './expandable-menu-provider'; import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; import { of as observableOf } from 'rxjs'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; @@ -16,8 +15,10 @@ import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; import { AccessControlMenuProvider } from './access-control.menu'; import { ScriptDataService } from '../../../core/data/processes/script-data.service'; import { ScriptServiceStub } from '../../testing/script-service.stub'; +import { PartialMenuSection } from '../menu-provider.model'; -const expectedTopSection: MenuTopSection = { +const expectedTopSection: PartialMenuSection = { + visible: true, model: { type: MenuItemType.TEXT, text: 'menu.section.access_control', @@ -25,7 +26,7 @@ const expectedTopSection: MenuTopSection = { icon: 'key' }; -const expectedSubSections: MenuSubSection[] = [ +const expectedSubSections: PartialMenuSection[] = [ { visible: true, model: { diff --git a/src/app/shared/menu/providers/admin-search.menu.spec.ts b/src/app/shared/menu/providers/admin-search.menu.spec.ts index 0471215578..ffd51425d7 100644 --- a/src/app/shared/menu/providers/admin-search.menu.spec.ts +++ b/src/app/shared/menu/providers/admin-search.menu.spec.ts @@ -7,12 +7,12 @@ */ import { TestBed } from '@angular/core/testing'; -import { PartialMenuSection } from '../menu-provider'; import { MenuItemType } from '../menu-item-type.model'; import { AdminSearchMenuProvider } from './admin-search.menu'; import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; import { of as observableOf } from 'rxjs'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { PartialMenuSection } from '../menu-provider.model'; const expectedSections: PartialMenuSection[] = [ { diff --git a/src/app/shared/menu/providers/browse.menu.spec.ts b/src/app/shared/menu/providers/browse.menu.spec.ts index 20ac0f92be..546d598183 100644 --- a/src/app/shared/menu/providers/browse.menu.spec.ts +++ b/src/app/shared/menu/providers/browse.menu.spec.ts @@ -9,7 +9,6 @@ import { TestBed } from '@angular/core/testing'; import { BrowseMenuProvider } from './browse.menu'; import { MenuItemType } from '../menu-item-type.model'; -import { MenuSubSection, MenuTopSection } from './expandable-menu-provider'; import { BrowseService } from '../../../core/browse/browse.service'; import { BrowseServiceStub } from '../../testing/browse-service.stub'; import { ObjectCacheService } from '../../../core/cache/object-cache.service'; @@ -17,16 +16,18 @@ import { getMockObjectCacheService } from '../../mocks/object-cache.service.mock import { BrowseDefinition } from '../../../core/shared/browse-definition.model'; import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; import { createPaginatedList } from '../../testing/utils.test'; +import { PartialMenuSection } from '../menu-provider.model'; -const expectedTopSection: MenuTopSection = { - model: { +const expectedTopSection: PartialMenuSection = { + visible: true, + model: { type: MenuItemType.TEXT, text: 'menu.section.browse_global', }, icon: 'globe', }; -const expectedSubSections: MenuSubSection[] = [ +const expectedSubSections: PartialMenuSection[] = [ { visible: true, model: { diff --git a/src/app/shared/menu/providers/community-list.menu.spec.ts b/src/app/shared/menu/providers/community-list.menu.spec.ts index b0e037d228..ef5bd81f2a 100644 --- a/src/app/shared/menu/providers/community-list.menu.spec.ts +++ b/src/app/shared/menu/providers/community-list.menu.spec.ts @@ -8,8 +8,8 @@ import { TestBed } from '@angular/core/testing'; import { CommunityListMenuProvider } from './community-list.menu'; -import { PartialMenuSection } from '../menu-provider'; import { MenuItemType } from '../menu-item-type.model'; +import { PartialMenuSection } from '../menu-provider.model'; const expectedSections: PartialMenuSection[] = [ { diff --git a/src/app/shared/menu/providers/curation.menu.spec.ts b/src/app/shared/menu/providers/curation.menu.spec.ts index 5225091f75..d3e92fbc00 100644 --- a/src/app/shared/menu/providers/curation.menu.spec.ts +++ b/src/app/shared/menu/providers/curation.menu.spec.ts @@ -7,12 +7,12 @@ */ import { TestBed } from '@angular/core/testing'; -import { PartialMenuSection } from '../menu-provider'; import { MenuItemType } from '../menu-item-type.model'; import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; import { of as observableOf } from 'rxjs'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { CurationMenuProvider } from './curation.menu'; +import { PartialMenuSection } from '../menu-provider.model'; const expectedSections: PartialMenuSection[] = [ { diff --git a/src/app/shared/menu/providers/edit.menu.spec.ts b/src/app/shared/menu/providers/edit.menu.spec.ts index b3204cc818..570e2c4e9e 100644 --- a/src/app/shared/menu/providers/edit.menu.spec.ts +++ b/src/app/shared/menu/providers/edit.menu.spec.ts @@ -8,14 +8,15 @@ import { TestBed } from '@angular/core/testing'; import { MenuItemType } from '../menu-item-type.model'; -import { MenuSubSection, MenuTopSection } from './expandable-menu-provider'; import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; import { of as observableOf } from 'rxjs'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; import { EditMenuProvider } from './edit.menu'; +import { PartialMenuSection } from '../menu-provider.model'; -const expectedTopSection: MenuTopSection = { +const expectedTopSection: PartialMenuSection = { + visible: true, model: { type: MenuItemType.TEXT, text: 'menu.section.edit' @@ -23,7 +24,7 @@ const expectedTopSection: MenuTopSection = { icon: 'pencil', }; -const expectedSubSections: MenuSubSection[] = [ +const expectedSubSections: PartialMenuSection[] = [ { visible: true, model: { diff --git a/src/app/shared/menu/providers/export.menu.spec.ts b/src/app/shared/menu/providers/export.menu.spec.ts index 662622b594..7e46d2811b 100644 --- a/src/app/shared/menu/providers/export.menu.spec.ts +++ b/src/app/shared/menu/providers/export.menu.spec.ts @@ -8,24 +8,24 @@ import { TestBed } from '@angular/core/testing'; import { MenuItemType } from '../menu-item-type.model'; -import { MenuSubSection, MenuTopSection } from './expandable-menu-provider'; import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; import { of as observableOf } from 'rxjs'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { ScriptDataService } from '../../../core/data/processes/script-data.service'; import { ScriptServiceStub } from '../../testing/script-service.stub'; import { ExportMenuProvider } from './export.menu'; +import { PartialMenuSection } from '../menu-provider.model'; -const expectedTopSection: MenuTopSection = { +const expectedTopSection: PartialMenuSection = { + visible: true, model: { type: MenuItemType.TEXT, text: 'menu.section.export', }, icon: 'file-export', - shouldPersistOnRouteChange: true, }; -const expectedSubSections: MenuSubSection[] = [ +const expectedSubSections: PartialMenuSection[] = [ { visible: true, model: { @@ -33,7 +33,6 @@ const expectedSubSections: MenuSubSection[] = [ text: 'menu.section.export_metadata', function: jasmine.any(Function) as any, }, - shouldPersistOnRouteChange: true, }, { visible: true, @@ -42,7 +41,6 @@ const expectedSubSections: MenuSubSection[] = [ text: 'menu.section.export_batch', function: jasmine.any(Function) as any, }, - shouldPersistOnRouteChange: true, } ]; diff --git a/src/app/shared/menu/providers/health.menu.spec.ts b/src/app/shared/menu/providers/health.menu.spec.ts index 6e10e3cc5f..692fd2277c 100644 --- a/src/app/shared/menu/providers/health.menu.spec.ts +++ b/src/app/shared/menu/providers/health.menu.spec.ts @@ -7,12 +7,12 @@ */ import { TestBed } from '@angular/core/testing'; -import { PartialMenuSection } from '../menu-provider'; import { MenuItemType } from '../menu-item-type.model'; import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; import { of as observableOf } from 'rxjs'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { HealthMenuProvider } from './health.menu'; +import { PartialMenuSection } from '../menu-provider.model'; const expectedSections: PartialMenuSection[] = [ { diff --git a/src/app/shared/menu/providers/import.menu.spec.ts b/src/app/shared/menu/providers/import.menu.spec.ts index 08223f31d7..183dead399 100644 --- a/src/app/shared/menu/providers/import.menu.spec.ts +++ b/src/app/shared/menu/providers/import.menu.spec.ts @@ -8,24 +8,24 @@ import { TestBed } from '@angular/core/testing'; import { MenuItemType } from '../menu-item-type.model'; -import { MenuSubSection, MenuTopSection } from './expandable-menu-provider'; import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; import { of as observableOf } from 'rxjs'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { ImportMenuProvider } from './import.menu'; import { ScriptDataService } from '../../../core/data/processes/script-data.service'; import { ScriptServiceStub } from '../../testing/script-service.stub'; +import { PartialMenuSection } from '../menu-provider.model'; -const expectedTopSection: MenuTopSection = { +const expectedTopSection: PartialMenuSection = { + visible: true, model: { type: MenuItemType.TEXT, text: 'menu.section.import', }, icon: 'file-import', - shouldPersistOnRouteChange: true, }; -const expectedSubSections: MenuSubSection[] = [ +const expectedSubSections: PartialMenuSection[] = [ { visible: true, model: { diff --git a/src/app/shared/menu/providers/new.menu.spec.ts b/src/app/shared/menu/providers/new.menu.spec.ts index 6376b5e2d3..c03f788275 100644 --- a/src/app/shared/menu/providers/new.menu.spec.ts +++ b/src/app/shared/menu/providers/new.menu.spec.ts @@ -8,22 +8,23 @@ import { TestBed } from '@angular/core/testing'; import { MenuItemType } from '../menu-item-type.model'; -import { MenuSubSection, MenuTopSection } from './expandable-menu-provider'; import { NewMenuProvider } from './new.menu'; import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; import { of as observableOf } from 'rxjs'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { PartialMenuSection } from '../menu-provider.model'; -const expectedTopSection: MenuTopSection = { - model: { +const expectedTopSection: PartialMenuSection = { + visible: true, + model: { type: MenuItemType.TEXT, text: 'menu.section.new' }, icon: 'plus', }; -const expectedSubSections: MenuSubSection[] = [ +const expectedSubSections: PartialMenuSection[] = [ { visible: true, model: { diff --git a/src/app/shared/menu/providers/processes.menu.spec.ts b/src/app/shared/menu/providers/processes.menu.spec.ts index 78ff544a58..eb72a02765 100644 --- a/src/app/shared/menu/providers/processes.menu.spec.ts +++ b/src/app/shared/menu/providers/processes.menu.spec.ts @@ -7,12 +7,12 @@ */ import { TestBed } from '@angular/core/testing'; -import { PartialMenuSection } from '../menu-provider'; import { MenuItemType } from '../menu-item-type.model'; import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; import { of as observableOf } from 'rxjs'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { ProcessesMenuProvider } from './processes.menu'; +import { PartialMenuSection } from '../menu-provider.model'; const expectedSections: PartialMenuSection[] = [ { diff --git a/src/app/shared/menu/providers/registries.menu.spec.ts b/src/app/shared/menu/providers/registries.menu.spec.ts index b1aa981f7f..ae8c419163 100644 --- a/src/app/shared/menu/providers/registries.menu.spec.ts +++ b/src/app/shared/menu/providers/registries.menu.spec.ts @@ -8,15 +8,16 @@ import { TestBed } from '@angular/core/testing'; import { MenuItemType } from '../menu-item-type.model'; -import { MenuSubSection, MenuTopSection } from './expandable-menu-provider'; import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; import { of as observableOf } from 'rxjs'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { ScriptDataService } from '../../../core/data/processes/script-data.service'; import { ScriptServiceStub } from '../../testing/script-service.stub'; import { RegistriesMenuProvider } from './registries.menu'; +import { PartialMenuSection } from '../menu-provider.model'; -const expectedTopSection: MenuTopSection = { +const expectedTopSection: PartialMenuSection = { + visible: true, model: { type: MenuItemType.TEXT, text: 'menu.section.registries', @@ -24,7 +25,7 @@ const expectedTopSection: MenuTopSection = { icon: 'list', }; -const expectedSubSections: MenuSubSection[] = [ +const expectedSubSections: PartialMenuSection[] = [ { visible: true, model: { diff --git a/src/app/shared/menu/providers/system-wide-alert.menu.spec.ts b/src/app/shared/menu/providers/system-wide-alert.menu.spec.ts index df5e126814..dbed3f6f59 100644 --- a/src/app/shared/menu/providers/system-wide-alert.menu.spec.ts +++ b/src/app/shared/menu/providers/system-wide-alert.menu.spec.ts @@ -7,12 +7,12 @@ */ import { TestBed } from '@angular/core/testing'; -import { PartialMenuSection } from '../menu-provider'; import { MenuItemType } from '../menu-item-type.model'; import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; import { of as observableOf } from 'rxjs'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { SystemWideAlertMenuProvider } from './system-wide-alert.menu'; +import { PartialMenuSection } from '../menu-provider.model'; const expectedSections: PartialMenuSection[] = [ { diff --git a/src/app/shared/menu/providers/workflow.menu.spec.ts b/src/app/shared/menu/providers/workflow.menu.spec.ts index 2886d0dbf3..14b14db115 100644 --- a/src/app/shared/menu/providers/workflow.menu.spec.ts +++ b/src/app/shared/menu/providers/workflow.menu.spec.ts @@ -7,12 +7,12 @@ */ import { TestBed } from '@angular/core/testing'; -import { PartialMenuSection } from '../menu-provider'; import { MenuItemType } from '../menu-item-type.model'; import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; import { of as observableOf } from 'rxjs'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { WorkflowMenuProvider } from './workflow.menu'; +import { PartialMenuSection } from '../menu-provider.model'; const expectedSections: PartialMenuSection[] = [ { From cb4a7b31f08d156e1a7e4b25c4ecb43d818c4ef5 Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Mon, 6 Jan 2025 14:38:19 +0100 Subject: [PATCH 028/157] Add missing provider tests --- src/app/app.menus.ts | 4 +- src/app/shared/menu/menu.structure.spec.ts | 6 +- .../providers/access-control.menu.spec.ts | 76 +++++----- .../menu/providers/admin-search.menu.spec.ts | 28 ++-- .../shared/menu/providers/browse.menu.spec.ts | 50 ++++--- .../providers/comcol-subscribe.menu.spec.ts | 64 ++++++++ .../providers/community-list.menu.spec.ts | 24 +-- .../menu/providers/curation.menu.spec.ts | 26 ++-- .../menu/providers/dso-edit.menu.spec.ts | 65 ++++++++ .../menu/providers/dso-option.menu.spec.ts | 53 +++++++ .../shared/menu/providers/dso-option.menu.ts | 2 +- .../shared/menu/providers/edit.menu.spec.ts | 75 +++++----- .../shared/menu/providers/export.menu.spec.ts | 60 ++++---- .../shared/menu/providers/health.menu.spec.ts | 26 ++-- .../helper-providers/dso.menu.spec.ts | 139 ++++++++++++++++++ .../expandable-menu-provider.spec.ts | 113 ++++++++++++++ .../route-context.menu.spec.ts | 96 ++++++++++++ .../helper-providers/route-context.menu.ts | 2 +- .../shared/menu/providers/import.menu.spec.ts | 60 ++++---- .../menu/providers/item-claim.menu.spec.ts | 134 +++++++++++++++++ .../shared/menu/providers/item-claim.menu.ts | 17 +-- .../menu/providers/item-orcid.menu.spec.ts | 94 ++++++++++++ .../shared/menu/providers/item-orcid.menu.ts | 14 +- .../providers/item-versioning.menu.spec.ts | 98 ++++++++++++ .../shared/menu/providers/new.menu.spec.ts | 70 ++++----- .../menu/providers/processes.menu.spec.ts | 27 ++-- .../menu/providers/registries.menu.spec.ts | 61 ++++---- .../menu/providers/statistics.menu.spec.ts | 127 ++++++++++++++++ .../shared/menu/providers/statistics.menu.ts | 2 +- .../providers/system-wide-alert.menu.spec.ts | 27 ++-- .../menu/providers/workflow.menu.spec.ts | 27 ++-- 31 files changed, 1329 insertions(+), 338 deletions(-) create mode 100644 src/app/shared/menu/providers/comcol-subscribe.menu.spec.ts create mode 100644 src/app/shared/menu/providers/dso-edit.menu.spec.ts create mode 100644 src/app/shared/menu/providers/dso-option.menu.spec.ts create mode 100644 src/app/shared/menu/providers/helper-providers/dso.menu.spec.ts create mode 100644 src/app/shared/menu/providers/helper-providers/expandable-menu-provider.spec.ts create mode 100644 src/app/shared/menu/providers/helper-providers/route-context.menu.spec.ts create mode 100644 src/app/shared/menu/providers/item-claim.menu.spec.ts create mode 100644 src/app/shared/menu/providers/item-orcid.menu.spec.ts create mode 100644 src/app/shared/menu/providers/item-versioning.menu.spec.ts create mode 100644 src/app/shared/menu/providers/statistics.menu.spec.ts diff --git a/src/app/app.menus.ts b/src/app/app.menus.ts index e530cfd55d..8240a9cb1b 100644 --- a/src/app/app.menus.ts +++ b/src/app/app.menus.ts @@ -27,7 +27,7 @@ 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'; -import { DsoOptionMenu } from './shared/menu/providers/dso-option.menu'; +import { DsoOptionMenuProvider } from './shared/menu/providers/dso-option.menu'; import { MenuRoute } from './shared/menu/menu-route.model'; export const MENUS = buildMenuStructure({ @@ -51,7 +51,7 @@ export const MENUS = buildMenuStructure({ SystemWideAlertMenuProvider, ], [MenuID.DSO_EDIT]: [ - DsoOptionMenu.withSubs([ + DsoOptionMenuProvider.withSubs([ SubscribeMenuProvider.onRoute( MenuRoute.SIMPLE_COMMUNITY_PAGE, MenuRoute.SIMPLE_COLLECTION_PAGE, diff --git a/src/app/shared/menu/menu.structure.spec.ts b/src/app/shared/menu/menu.structure.spec.ts index a54622731b..f62ef03cd1 100644 --- a/src/app/shared/menu/menu.structure.spec.ts +++ b/src/app/shared/menu/menu.structure.spec.ts @@ -1,7 +1,7 @@ import { MenuID } from './menu-id.model'; import { CommunityListMenuProvider } from './providers/community-list.menu'; import { NewMenuProvider } from './providers/new.menu'; -import { DsoOptionMenu } from './providers/dso-option.menu'; +import { DsoOptionMenuProvider } from './providers/dso-option.menu'; import { SubscribeMenuProvider } from './providers/comcol-subscribe.menu'; import { buildMenuStructure } from './menu.structure'; import { MenuProviderService } from './menu-provider.service'; @@ -47,7 +47,7 @@ describe('buildMenuStructure', () => { SystemWideAlertMenuProvider, ], [MenuID.DSO_EDIT]: [ - DsoOptionMenu.withSubs([ + DsoOptionMenuProvider.withSubs([ SubscribeMenuProvider.onRoute( MenuRoute.SIMPLE_COMMUNITY_PAGE, MenuRoute.SIMPLE_COLLECTION_PAGE @@ -97,7 +97,7 @@ describe('buildMenuStructure', () => { VersioningMenuProvider, OrcidMenuProvider, ClaimMenuProvider, - DsoOptionMenu, + DsoOptionMenuProvider, ]; diff --git a/src/app/shared/menu/providers/access-control.menu.spec.ts b/src/app/shared/menu/providers/access-control.menu.spec.ts index dfb3b625e1..b55b851927 100644 --- a/src/app/shared/menu/providers/access-control.menu.spec.ts +++ b/src/app/shared/menu/providers/access-control.menu.spec.ts @@ -17,43 +17,43 @@ import { ScriptDataService } from '../../../core/data/processes/script-data.serv import { ScriptServiceStub } from '../../testing/script-service.stub'; import { PartialMenuSection } from '../menu-provider.model'; -const expectedTopSection: PartialMenuSection = { - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.access_control', - }, - icon: 'key' -}; - -const expectedSubSections: PartialMenuSection[] = [ - { - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.access_control_people', - link: '/access-control/epeople', - }, - }, - { - visible: false, - model: { - type: MenuItemType.LINK, - text: 'menu.section.access_control_groups', - link: '/access-control/groups', - }, - }, - { - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.access_control_bulk', - link: '/access-control/bulk-access', - }, - }, -]; - describe('AccessControlMenuProvider', () => { + const expectedTopSection: PartialMenuSection = { + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.access_control', + }, + icon: 'key' + }; + + const expectedSubSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.access_control_people', + link: '/access-control/epeople', + }, + }, + { + visible: false, + model: { + type: MenuItemType.LINK, + text: 'menu.section.access_control_groups', + link: '/access-control/groups', + }, + }, + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.access_control_bulk', + link: '/access-control/bulk-access', + }, + }, + ]; + let provider: AccessControlMenuProvider; let authorizationServiceStub = new AuthorizationDataServiceStub(); @@ -69,8 +69,8 @@ describe('AccessControlMenuProvider', () => { TestBed.configureTestingModule({ providers: [ AccessControlMenuProvider, - { provide: AuthorizationDataService, useValue: authorizationServiceStub }, - { provide: ScriptDataService, useClass: ScriptServiceStub }, + {provide: AuthorizationDataService, useValue: authorizationServiceStub}, + {provide: ScriptDataService, useClass: ScriptServiceStub}, ], }); provider = TestBed.inject(AccessControlMenuProvider); diff --git a/src/app/shared/menu/providers/admin-search.menu.spec.ts b/src/app/shared/menu/providers/admin-search.menu.spec.ts index ffd51425d7..b47b2354d4 100644 --- a/src/app/shared/menu/providers/admin-search.menu.spec.ts +++ b/src/app/shared/menu/providers/admin-search.menu.spec.ts @@ -14,19 +14,21 @@ import { of as observableOf } from 'rxjs'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { PartialMenuSection } from '../menu-provider.model'; -const expectedSections: PartialMenuSection[] = [ - { - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.admin_search', - link: '/admin/search', - }, - icon: 'search', - }, -]; - describe('AdminSearchMenuProvider', () => { + + const expectedSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.admin_search', + link: '/admin/search', + }, + icon: 'search', + }, + ]; + + let provider: AdminSearchMenuProvider; let authorizationServiceStub = new AuthorizationDataServiceStub(); @@ -38,7 +40,7 @@ describe('AdminSearchMenuProvider', () => { TestBed.configureTestingModule({ providers: [ AdminSearchMenuProvider, - { provide: AuthorizationDataService, useValue: authorizationServiceStub }, + {provide: AuthorizationDataService, useValue: authorizationServiceStub}, ], }); provider = TestBed.inject(AdminSearchMenuProvider); diff --git a/src/app/shared/menu/providers/browse.menu.spec.ts b/src/app/shared/menu/providers/browse.menu.spec.ts index 546d598183..0585db6d82 100644 --- a/src/app/shared/menu/providers/browse.menu.spec.ts +++ b/src/app/shared/menu/providers/browse.menu.spec.ts @@ -18,51 +18,53 @@ import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; import { createPaginatedList } from '../../testing/utils.test'; import { PartialMenuSection } from '../menu-provider.model'; -const expectedTopSection: PartialMenuSection = { - visible: true, - model: { +describe('BrowseMenuProvider', () => { + + const expectedTopSection: PartialMenuSection = { + visible: true, + model: { type: MenuItemType.TEXT, text: 'menu.section.browse_global', }, icon: 'globe', }; -const expectedSubSections: PartialMenuSection[] = [ - { - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.browse_global_by_author', - link: '/browse/author', + const expectedSubSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.browse_global_by_author', + link: '/browse/author', + }, }, - }, - { - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.browse_global_by_subject', - link: '/browse/subject', + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.browse_global_by_subject', + link: '/browse/subject', + }, }, - }, -]; + ]; + -describe('BrowseMenuProvider', () => { let provider: BrowseMenuProvider; let browseServiceStub = new BrowseServiceStub(); beforeEach(() => { spyOn(browseServiceStub, 'getBrowseDefinitions').and.returnValue( createSuccessfulRemoteDataObject$(createPaginatedList([ - { id: 'author' } as BrowseDefinition, - { id: 'subject' } as BrowseDefinition, + {id: 'author'} as BrowseDefinition, + {id: 'subject'} as BrowseDefinition, ])) ); TestBed.configureTestingModule({ providers: [ BrowseMenuProvider, - { provide: BrowseService, useValue: browseServiceStub }, - { provide: ObjectCacheService, useValue: getMockObjectCacheService() }, + {provide: BrowseService, useValue: browseServiceStub}, + {provide: ObjectCacheService, useValue: getMockObjectCacheService()}, ], }); provider = TestBed.inject(BrowseMenuProvider); diff --git a/src/app/shared/menu/providers/comcol-subscribe.menu.spec.ts b/src/app/shared/menu/providers/comcol-subscribe.menu.spec.ts new file mode 100644 index 0000000000..32d64cb748 --- /dev/null +++ b/src/app/shared/menu/providers/comcol-subscribe.menu.spec.ts @@ -0,0 +1,64 @@ +import { PartialMenuSection } from '../menu-provider.model'; +import { MenuItemType } from '../menu-item-type.model'; +import { TestBed } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { SubscribeMenuProvider } from './comcol-subscribe.menu'; +import { Collection } from '../../../core/shared/collection.model'; + +describe('SubscribeMenuProvider', () => { + + const expectedSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.ONCLICK, + text: 'subscriptions.tooltip', + function: jasmine.any(Function) as any, + }, + icon: 'bell', + } + ]; + + let provider: SubscribeMenuProvider; + + const dso: Collection = Object.assign(new Collection(), {_links: {self: {href: 'self-link'}}}); + + + let authorizationService; + let modalService; + + beforeEach(() => { + + authorizationService = jasmine.createSpyObj('authorizationService', { + 'isAuthorized': observableOf(true) + }); + + modalService = jasmine.createSpyObj('modalService', ['open']); + + TestBed.configureTestingModule({ + providers: [ + SubscribeMenuProvider, + {provide: AuthorizationDataService, useValue: authorizationService}, + {provide: NgbModal, useValue: modalService}, + ], + }); + provider = TestBed.inject(SubscribeMenuProvider); + }); + + it('should be created', () => { + expect(provider).toBeTruthy(); + }); + + describe('getSectionsForContext', () => { + it('should return the expected sections', (done) => { + provider.getSectionsForContext(dso).subscribe((sections) => { + expect(sections).toEqual(expectedSections); + done(); + }); + }); + }); + + +}); diff --git a/src/app/shared/menu/providers/community-list.menu.spec.ts b/src/app/shared/menu/providers/community-list.menu.spec.ts index ef5bd81f2a..435e2d5396 100644 --- a/src/app/shared/menu/providers/community-list.menu.spec.ts +++ b/src/app/shared/menu/providers/community-list.menu.spec.ts @@ -11,19 +11,19 @@ import { CommunityListMenuProvider } from './community-list.menu'; import { MenuItemType } from '../menu-item-type.model'; import { PartialMenuSection } from '../menu-provider.model'; -const expectedSections: PartialMenuSection[] = [ - { - visible: true, - model: { - type: MenuItemType.LINK, - text: `menu.section.browse_global_communities_and_collections`, - link: `/community-list`, - }, - icon: 'diagram-project' - }, -]; - describe('CommunityListMenuProvider', () => { + const expectedSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.LINK, + text: `menu.section.browse_global_communities_and_collections`, + link: `/community-list`, + }, + icon: 'diagram-project' + }, + ]; + let provider: CommunityListMenuProvider; beforeEach(() => { diff --git a/src/app/shared/menu/providers/curation.menu.spec.ts b/src/app/shared/menu/providers/curation.menu.spec.ts index d3e92fbc00..93476490b4 100644 --- a/src/app/shared/menu/providers/curation.menu.spec.ts +++ b/src/app/shared/menu/providers/curation.menu.spec.ts @@ -14,19 +14,19 @@ import { AuthorizationDataService } from '../../../core/data/feature-authorizati import { CurationMenuProvider } from './curation.menu'; import { PartialMenuSection } from '../menu-provider.model'; -const expectedSections: PartialMenuSection[] = [ - { - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.curation_task', - link: 'admin/curation-tasks', - }, - icon: 'filter', - }, -]; - describe('CurationMenuProvider', () => { + const expectedSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.curation_task', + link: 'admin/curation-tasks', + }, + icon: 'filter', + }, + ]; + let provider: CurationMenuProvider; let authorizationServiceStub = new AuthorizationDataServiceStub(); @@ -38,7 +38,7 @@ describe('CurationMenuProvider', () => { TestBed.configureTestingModule({ providers: [ CurationMenuProvider, - { provide: AuthorizationDataService, useValue: authorizationServiceStub }, + {provide: AuthorizationDataService, useValue: authorizationServiceStub}, ], }); provider = TestBed.inject(CurationMenuProvider); diff --git a/src/app/shared/menu/providers/dso-edit.menu.spec.ts b/src/app/shared/menu/providers/dso-edit.menu.spec.ts new file mode 100644 index 0000000000..15ca5c8130 --- /dev/null +++ b/src/app/shared/menu/providers/dso-edit.menu.spec.ts @@ -0,0 +1,65 @@ +import { PartialMenuSection } from '../menu-provider.model'; +import { MenuItemType } from '../menu-item-type.model'; +import { TestBed } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { Collection } from '../../../core/shared/collection.model'; +import { DSpaceObjectEditMenuProvider } from './dso-edit.menu'; +import { URLCombiner } from '../../../core/url-combiner/url-combiner'; +import { COLLECTION } from '../../../core/shared/collection.resource-type'; + +describe('DSpaceObjectEditMenuProvider', () => { + + const expectedSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'collection.page.edit', + link: new URLCombiner('/collections/test-uuid', 'edit', 'metadata').toString(), + }, + icon: 'pencil-alt', + } + ]; + + let provider: DSpaceObjectEditMenuProvider; + + const dso: Collection = Object.assign(new Collection(), { + type: COLLECTION.value, + uuid: 'test-uuid', + _links: {self: {href: 'self-link'}}, + }); + + + let authorizationService; + + beforeEach(() => { + + authorizationService = jasmine.createSpyObj('authorizationService', { + 'isAuthorized': observableOf(true) + }); + + TestBed.configureTestingModule({ + providers: [ + DSpaceObjectEditMenuProvider, + {provide: AuthorizationDataService, useValue: authorizationService}, + ], + }); + provider = TestBed.inject(DSpaceObjectEditMenuProvider); + }); + + it('should be created', () => { + expect(provider).toBeTruthy(); + }); + + describe('getSectionsForContext', () => { + it('should return the expected sections', (done) => { + provider.getSectionsForContext(dso).subscribe((sections) => { + expect(sections).toEqual(expectedSections); + done(); + }); + }); + }); + + +}); diff --git a/src/app/shared/menu/providers/dso-option.menu.spec.ts b/src/app/shared/menu/providers/dso-option.menu.spec.ts new file mode 100644 index 0000000000..d04c63e98d --- /dev/null +++ b/src/app/shared/menu/providers/dso-option.menu.spec.ts @@ -0,0 +1,53 @@ +import { PartialMenuSection } from '../menu-provider.model'; +import { MenuItemType } from '../menu-item-type.model'; +import { TestBed } from '@angular/core/testing'; +import { Collection } from '../../../core/shared/collection.model'; +import { COLLECTION } from '../../../core/shared/collection.resource-type'; +import { DsoOptionMenuProvider } from './dso-option.menu'; + +describe('DsoOptionMenuProvider', () => { + + const expectedSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'collection.page.options', + }, + icon: 'ellipsis-vertical', + } + ]; + + let provider: DsoOptionMenuProvider; + + const dso: Collection = Object.assign(new Collection(), { + type: COLLECTION.value, + uuid: 'test-uuid', + _links: {self: {href: 'self-link'}}, + }); + + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + DsoOptionMenuProvider, + ], + }); + provider = TestBed.inject(DsoOptionMenuProvider); + }); + + it('should be created', () => { + expect(provider).toBeTruthy(); + }); + + describe('getSectionsForContext', () => { + it('should return the expected sections', (done) => { + provider.getSectionsForContext(dso).subscribe((sections) => { + expect(sections).toEqual(expectedSections); + done(); + }); + }); + }); + + +}); diff --git a/src/app/shared/menu/providers/dso-option.menu.ts b/src/app/shared/menu/providers/dso-option.menu.ts index 0fba0e19ef..45b3777a2e 100644 --- a/src/app/shared/menu/providers/dso-option.menu.ts +++ b/src/app/shared/menu/providers/dso-option.menu.ts @@ -18,7 +18,7 @@ import { DSpaceObject } from 'src/app/core/shared/dspace-object.model'; * This section will be rendered as a button on the DSO pages if sub providers have been added */ @Injectable() -export class DsoOptionMenu extends DSpaceObjectPageMenuProvider { +export class DsoOptionMenuProvider extends DSpaceObjectPageMenuProvider { alwaysRenderExpandable = true; diff --git a/src/app/shared/menu/providers/edit.menu.spec.ts b/src/app/shared/menu/providers/edit.menu.spec.ts index 570e2c4e9e..643f0efc24 100644 --- a/src/app/shared/menu/providers/edit.menu.spec.ts +++ b/src/app/shared/menu/providers/edit.menu.spec.ts @@ -15,43 +15,44 @@ import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; import { EditMenuProvider } from './edit.menu'; import { PartialMenuSection } from '../menu-provider.model'; -const expectedTopSection: PartialMenuSection = { - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.edit' - }, - icon: 'pencil', -}; - -const expectedSubSections: PartialMenuSection[] = [ - { - visible: true, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.edit_community', - function: jasmine.any(Function) as any, - }, - }, - { - visible: false, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.edit_collection', - function: jasmine.any(Function) as any, - }, - }, - { - visible: true, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.edit_item', - function: jasmine.any(Function) as any, - }, - }, -]; - describe('EditMenuProvider', () => { + + const expectedTopSection: PartialMenuSection = { + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.edit' + }, + icon: 'pencil', + }; + + const expectedSubSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.ONCLICK, + text: 'menu.section.edit_community', + function: jasmine.any(Function) as any, + }, + }, + { + visible: false, + model: { + type: MenuItemType.ONCLICK, + text: 'menu.section.edit_collection', + function: jasmine.any(Function) as any, + }, + }, + { + visible: true, + model: { + type: MenuItemType.ONCLICK, + text: 'menu.section.edit_item', + function: jasmine.any(Function) as any, + }, + }, + ]; + let provider: EditMenuProvider; let authorizationServiceStub = new AuthorizationDataServiceStub(); @@ -67,7 +68,7 @@ describe('EditMenuProvider', () => { TestBed.configureTestingModule({ providers: [ EditMenuProvider, - { provide: AuthorizationDataService, useValue: authorizationServiceStub }, + {provide: AuthorizationDataService, useValue: authorizationServiceStub}, ], }); provider = TestBed.inject(EditMenuProvider); diff --git a/src/app/shared/menu/providers/export.menu.spec.ts b/src/app/shared/menu/providers/export.menu.spec.ts index 7e46d2811b..9ffe75627e 100644 --- a/src/app/shared/menu/providers/export.menu.spec.ts +++ b/src/app/shared/menu/providers/export.menu.spec.ts @@ -16,35 +16,35 @@ import { ScriptServiceStub } from '../../testing/script-service.stub'; import { ExportMenuProvider } from './export.menu'; import { PartialMenuSection } from '../menu-provider.model'; -const expectedTopSection: PartialMenuSection = { - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.export', - }, - icon: 'file-export', -}; - -const expectedSubSections: PartialMenuSection[] = [ - { - visible: true, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.export_metadata', - function: jasmine.any(Function) as any, - }, - }, - { - visible: true, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.export_batch', - function: jasmine.any(Function) as any, - }, - } -]; - describe('ExportMenuProvider', () => { + const expectedTopSection: PartialMenuSection = { + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.export', + }, + icon: 'file-export', + }; + + const expectedSubSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.ONCLICK, + text: 'menu.section.export_metadata', + function: jasmine.any(Function) as any, + }, + }, + { + visible: true, + model: { + type: MenuItemType.ONCLICK, + text: 'menu.section.export_batch', + function: jasmine.any(Function) as any, + }, + } + ]; + let provider: ExportMenuProvider; let authorizationServiceStub = new AuthorizationDataServiceStub(); @@ -56,8 +56,8 @@ describe('ExportMenuProvider', () => { TestBed.configureTestingModule({ providers: [ ExportMenuProvider, - { provide: AuthorizationDataService, useValue: authorizationServiceStub }, - { provide: ScriptDataService, useClass: ScriptServiceStub }, + {provide: AuthorizationDataService, useValue: authorizationServiceStub}, + {provide: ScriptDataService, useClass: ScriptServiceStub}, ], }); provider = TestBed.inject(ExportMenuProvider); diff --git a/src/app/shared/menu/providers/health.menu.spec.ts b/src/app/shared/menu/providers/health.menu.spec.ts index 692fd2277c..82ba2ce790 100644 --- a/src/app/shared/menu/providers/health.menu.spec.ts +++ b/src/app/shared/menu/providers/health.menu.spec.ts @@ -14,19 +14,19 @@ import { AuthorizationDataService } from '../../../core/data/feature-authorizati import { HealthMenuProvider } from './health.menu'; import { PartialMenuSection } from '../menu-provider.model'; -const expectedSections: PartialMenuSection[] = [ - { - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.health', - link: '/health', - }, - icon: 'heartbeat', - }, -]; - describe('HealthMenuProvider', () => { + const expectedSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.health', + link: '/health', + }, + icon: 'heartbeat', + }, + ]; + let provider: HealthMenuProvider; let authorizationServiceStub = new AuthorizationDataServiceStub(); @@ -38,7 +38,7 @@ describe('HealthMenuProvider', () => { TestBed.configureTestingModule({ providers: [ HealthMenuProvider, - { provide: AuthorizationDataService, useValue: authorizationServiceStub }, + {provide: AuthorizationDataService, useValue: authorizationServiceStub}, ], }); provider = TestBed.inject(HealthMenuProvider); diff --git a/src/app/shared/menu/providers/helper-providers/dso.menu.spec.ts b/src/app/shared/menu/providers/helper-providers/dso.menu.spec.ts new file mode 100644 index 0000000000..5d214da5f0 --- /dev/null +++ b/src/app/shared/menu/providers/helper-providers/dso.menu.spec.ts @@ -0,0 +1,139 @@ +import { DSpaceObjectPageMenuProvider } from './dso.menu'; +import { TestBed } from '@angular/core/testing'; +import { Item } from '../../../../core/shared/item.model'; +import { ITEM } from '../../../../core/shared/item.resource-type'; +import { createSuccessfulRemoteDataObject } from '../../../remote-data.utils'; +import { Collection } from '../../../../core/shared/collection.model'; +import { COLLECTION } from '../../../../core/shared/collection.resource-type'; + + +describe('DSpaceObjectPageMenuProvider', () => { + + let provider: DSpaceObjectPageMenuProvider; + + const item: Item = Object.assign(new Item(), { + uuid: 'test-item-uuid', + type: ITEM.value, + _links: {self: {href: 'self-link'}}, + metadata: { + 'dc.title': [{ + 'value': 'Untyped Item' + }] + } + }); + + const item2: Item = Object.assign(new Item(), { + uuid: 'test-item2-uuid', + type: ITEM.value, + _links: {self: {href: 'self-link'}}, + metadata: { + 'dc.title': [{ + 'value': 'Untyped Item 2' + }] + } + }); + + const person: Item = Object.assign(new Item(), { + uuid: 'test-uuid', + type: ITEM.value, + _links: {self: {href: 'self-link'}}, + metadata: { + 'dc.title': [{ + 'value': 'Person Entity' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }], + } + }); + + const collection: Collection = Object.assign(new Collection(), { + uuid: 'test-collection-uuid', + type: COLLECTION.value, + _links: {self: {href: 'self-link'}}, + metadata: { + 'dc.title': [{ + 'value': 'Collection' + }] + } + }); + + + beforeEach(() => { + + TestBed.configureTestingModule({ + providers: [ + DSpaceObjectPageMenuProvider, + ], + }); + provider = TestBed.inject(DSpaceObjectPageMenuProvider); + }); + + it('should be created', () => { + expect(provider).toBeTruthy(); + }); + + describe('getRouteContext', () => { + it('should get the dso from the route', (done) => { + const route = {data: {dso: createSuccessfulRemoteDataObject(item)}} as any; + + provider.getRouteContext(route, undefined).subscribe((dso) => { + expect(dso).toEqual(item); + done(); + }); + }); + + it('return undefined when no DSO is present on the current route', (done) => { + const route = { + data: {}, + parent: { + data: {}, + parent: { + data: {dso: createSuccessfulRemoteDataObject(item)}, + parent: {data: {dso: createSuccessfulRemoteDataObject(item2)}} + } + } + } as any; + + provider.getRouteContext(route, undefined).subscribe((dso) => { + expect(dso).toBeUndefined(); + done(); + }); + }); + it('should return undefined when no dso is found in the route', (done) => { + const route = {data: {}, parent: {data: {}, parent: {data: {}, parent: {data: {}}}}} as any; + + provider.getRouteContext(route, undefined).subscribe((dso) => { + expect(dso).toBeUndefined(); + done(); + }); + }); + }); + + describe('getDsoType', () => { + it('should return the object type for an untyped item', () => { + const dsoType = (provider as any).getDsoType(item); + expect(dsoType).toEqual('item'); + }); + it('should return the entity type for an entity item', () => { + const dsoType = (provider as any).getDsoType(person); + expect(dsoType).toEqual('person'); + }); + it('should return the object type for a colletion', () => { + const dsoType = (provider as any).getDsoType(collection); + expect(dsoType).toEqual('collection'); + }); + }); + + describe('isApplicable', () => { + it('should return true when a dso is provided', () => { + const isApplicable = (provider as any).isApplicable(collection); + expect(isApplicable).toBeTrue(); + }); + it('should return false when no dso is provided', () => { + const isApplicable = (provider as any).isApplicable(undefined); + expect(isApplicable).toBeFalse(); + }); + }); + +}); diff --git a/src/app/shared/menu/providers/helper-providers/expandable-menu-provider.spec.ts b/src/app/shared/menu/providers/helper-providers/expandable-menu-provider.spec.ts new file mode 100644 index 0000000000..ff9cce812f --- /dev/null +++ b/src/app/shared/menu/providers/helper-providers/expandable-menu-provider.spec.ts @@ -0,0 +1,113 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { TestBed } from '@angular/core/testing'; +import { Observable, of as observableOf } from 'rxjs'; +import { AbstractExpandableMenuProvider } from './expandable-menu-provider'; +import { MenuItemType } from '../../menu-item-type.model'; +import { PartialMenuSection } from '../../menu-provider.model'; +import { MenuID } from '../../menu-id.model'; + + +describe('AbstractExpandableMenuProvider', () => { + const topSection: PartialMenuSection = { + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'top.section.test', + }, + icon: 'file-import', + }; + + const subSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'sub.section.test.1', + }, + }, + { + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'sub.section.test.2', + }, + }, + ]; + + class TestClass extends AbstractExpandableMenuProvider { + getTopSection(): Observable { + return observableOf(topSection); + } + + getSubSections(): Observable { + return observableOf(subSections); + } + + } + + const expectedSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'sub.section.test.1', + }, + id: `${MenuID.ADMIN}_1_0`, + parentID: `${MenuID.ADMIN}_1`, + alwaysRenderExpandable: false, + }, + { + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'sub.section.test.2', + }, + id: `${MenuID.ADMIN}_1_1`, + parentID: `${MenuID.ADMIN}_1`, + alwaysRenderExpandable: false, + }, + { + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'top.section.test', + }, + icon: 'file-import', + id: `${MenuID.ADMIN}_1`, + alwaysRenderExpandable: true, + } + ]; + + let provider: AbstractExpandableMenuProvider; + + beforeEach(() => { + + TestBed.configureTestingModule({ + providers: [ + TestClass, + ], + }); + provider = TestBed.inject(TestClass); + provider.menuProviderId = `${MenuID.ADMIN}_1`; + }); + + it('should be created', () => { + expect(provider).toBeTruthy(); + }); + + it('getSections should return a combination of top and sub sections', (done) => { + + provider.getSections().subscribe((section) => { + expect(section).toEqual(expectedSections); + done(); + }); + }); + +}); diff --git a/src/app/shared/menu/providers/helper-providers/route-context.menu.spec.ts b/src/app/shared/menu/providers/helper-providers/route-context.menu.spec.ts new file mode 100644 index 0000000000..1120681c05 --- /dev/null +++ b/src/app/shared/menu/providers/helper-providers/route-context.menu.spec.ts @@ -0,0 +1,96 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { TestBed } from '@angular/core/testing'; +import { Observable, of as observableOf } from 'rxjs'; +import { MenuItemType } from '../../menu-item-type.model'; +import { PartialMenuSection } from '../../menu-provider.model'; +import { MenuID } from '../../menu-id.model'; +import { AbstractRouteContextMenuProvider } from './route-context.menu'; +import { CacheableObject } from '../../../../core/cache/cacheable-object.model'; +import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; + + +describe('AbstractExpandableMenuProvider', () => { + + class TestClass extends AbstractRouteContextMenuProvider { + getRouteContext(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(object); + } + + getSectionsForContext(routeContext: CacheableObject): Observable { + return observableOf(expectedSections); + } + } + + const object = Object.assign(new CacheableObject()); + + + const expectedSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'sub.section.test.1', + }, + id: `${MenuID.ADMIN}_1_0`, + parentID: `${MenuID.ADMIN}_1`, + alwaysRenderExpandable: false, + }, + { + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'sub.section.test.2', + }, + id: `${MenuID.ADMIN}_1_1`, + parentID: `${MenuID.ADMIN}_1`, + alwaysRenderExpandable: false, + }, + { + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'top.section.test', + }, + icon: 'file-import', + id: `${MenuID.ADMIN}_1`, + alwaysRenderExpandable: true, + } + ]; + + let provider: AbstractRouteContextMenuProvider; + + beforeEach(() => { + + TestBed.configureTestingModule({ + providers: [ + TestClass, + ], + }); + provider = TestBed.inject(TestClass); + provider.menuProviderId = `${MenuID.ADMIN}_1`; + }); + + it('should be created', () => { + expect(provider).toBeTruthy(); + }); + + it('getSections should return the sections based on the retrieved route context and sections for that context', (done) => { + spyOn(provider, 'getRouteContext').and.callThrough(); + spyOn(provider, 'getSectionsForContext').and.callThrough(); + + provider.getSections(undefined, undefined).subscribe((sections) => { + expect(sections).toEqual(expectedSections); + expect(provider.getRouteContext).toHaveBeenCalled(); + expect(provider.getSectionsForContext).toHaveBeenCalledWith(object); + done(); + }); + }); + +}); diff --git a/src/app/shared/menu/providers/helper-providers/route-context.menu.ts b/src/app/shared/menu/providers/helper-providers/route-context.menu.ts index bd03bcc0f3..56bd091f2b 100644 --- a/src/app/shared/menu/providers/helper-providers/route-context.menu.ts +++ b/src/app/shared/menu/providers/helper-providers/route-context.menu.ts @@ -24,7 +24,7 @@ export abstract class AbstractRouteContextMenuProvider extends AbstractMenuPr return this.getRouteContext(route, state).pipe( switchMap((routeContext: T) => { - if (this.isApplicable(routeContext)) { + if (this.isApplicable(routeContext)) { return this.getSectionsForContext(routeContext); } else { return observableOf([]); diff --git a/src/app/shared/menu/providers/import.menu.spec.ts b/src/app/shared/menu/providers/import.menu.spec.ts index 183dead399..f882a17ad6 100644 --- a/src/app/shared/menu/providers/import.menu.spec.ts +++ b/src/app/shared/menu/providers/import.menu.spec.ts @@ -16,35 +16,35 @@ import { ScriptDataService } from '../../../core/data/processes/script-data.serv import { ScriptServiceStub } from '../../testing/script-service.stub'; import { PartialMenuSection } from '../menu-provider.model'; -const expectedTopSection: PartialMenuSection = { - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.import', - }, - icon: 'file-import', -}; - -const expectedSubSections: PartialMenuSection[] = [ - { - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.import_metadata', - link: '/admin/metadata-import', - }, - }, - { - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.import_batch', - link: '/admin/batch-import', - }, - }, -]; - describe('ImportMenuProvider', () => { + const expectedTopSection: PartialMenuSection = { + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.import', + }, + icon: 'file-import', + }; + + const expectedSubSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.import_metadata', + link: '/admin/metadata-import', + }, + }, + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.import_batch', + link: '/admin/batch-import', + }, + }, + ]; + let provider: ImportMenuProvider; let authorizationServiceStub = new AuthorizationDataServiceStub(); @@ -56,8 +56,8 @@ describe('ImportMenuProvider', () => { TestBed.configureTestingModule({ providers: [ ImportMenuProvider, - { provide: AuthorizationDataService, useValue: authorizationServiceStub }, - { provide: ScriptDataService, useClass: ScriptServiceStub }, + {provide: AuthorizationDataService, useValue: authorizationServiceStub}, + {provide: ScriptDataService, useClass: ScriptServiceStub}, ], }); provider = TestBed.inject(ImportMenuProvider); diff --git a/src/app/shared/menu/providers/item-claim.menu.spec.ts b/src/app/shared/menu/providers/item-claim.menu.spec.ts new file mode 100644 index 0000000000..0889dc6713 --- /dev/null +++ b/src/app/shared/menu/providers/item-claim.menu.spec.ts @@ -0,0 +1,134 @@ +import { PartialMenuSection } from '../menu-provider.model'; +import { MenuItemType } from '../menu-item-type.model'; +import { TestBed } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ClaimMenuProvider } from './item-claim.menu'; +import { Item } from '../../../core/shared/item.model'; +import { TranslateModule } from '@ngx-translate/core'; +import { NotificationsServiceStub } from '../../testing/notifications-service.stub'; +import { MenuService } from '../menu.service'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { ResearcherProfileDataService } from '../../../core/profile/researcher-profile-data.service'; +import { ITEM } from '../../../core/shared/item.resource-type'; + +describe('ClaimMenuProvider', () => { + + const expectedSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.ONCLICK, + text: 'item.page.claim.button', + function: jasmine.any(Function) as any, + }, + icon: 'hand-paper', + } + ]; + + let provider: ClaimMenuProvider; + + const item: Item = Object.assign(new Item(), { + type: ITEM.value, + _links: {self: {href: 'self-link'}}, + metadata: { + 'dc.title': [{ + 'value': 'Untyped Item' + }] + } + + }); + const person: Item = Object.assign(new Item(), { + type: ITEM.value, + _links: {self: {href: 'self-link'}}, + metadata: { + 'dc.title': [{ + 'value': 'Person Entity' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }], + } + }); + + + let authorizationService; + let menuService; + let notificationsService; + let researcherProfileService; + let modalService; + + + beforeEach(() => { + + authorizationService = jasmine.createSpyObj('authorizationService', { + 'isAuthorized': observableOf(true), + 'invalidateAuthorizationsRequestCache': {} + }); + + menuService = jasmine.createSpyObj('menuService', ['hideMenuSection']); + + notificationsService = new NotificationsServiceStub(); + + researcherProfileService = jasmine.createSpyObj('authorizationService', { + 'createFromExternalSourceAndReturnRelatedItemId': observableOf('profile-id') + }); + + modalService = jasmine.createSpyObj('modalService', ['open']); + + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + providers: [ + ClaimMenuProvider, + {provide: AuthorizationDataService, useValue: authorizationService}, + {provide: MenuService, useValue: menuService}, + {provide: NotificationsService, useValue: notificationsService}, + {provide: ResearcherProfileDataService, useValue: researcherProfileService}, + {provide: NgbModal, useValue: modalService}, + ], + }); + provider = TestBed.inject(ClaimMenuProvider); + }); + + it('should be created', () => { + expect(provider).toBeTruthy(); + }); + + describe('getSectionsForContext', () => { + it('should return the expected sections', (done) => { + provider.getSectionsForContext(person).subscribe((sections) => { + expect(sections).toEqual(expectedSections); + done(); + }); + }); + }); + + describe('isApplicable', () => { + it('should return true whe the provided dspace object is a person entity', () => { + const result = (provider as any).isApplicable(person); + expect(result).toBeTrue(); + }); + it('should return true whe the provided dspace object is not a person entity', () => { + const result = (provider as any).isApplicable(item); + expect(result).toBeFalse(); + }); + }); + + describe('claimResearcher', () => { + it('should show a success notification and hide the menu when an id is returned by the researcher profile service', () => { + (provider as any).claimResearcher(person); + expect(notificationsService.success).toHaveBeenCalled(); + expect(authorizationService.invalidateAuthorizationsRequestCache).toHaveBeenCalled(); + expect(menuService.hideMenuSection).toHaveBeenCalled(); + }); + it('should show an error notification when no id is returned by the researcher profile service', () => { + (researcherProfileService.createFromExternalSourceAndReturnRelatedItemId as jasmine.Spy).and.returnValue(observableOf(null)); + (provider as any).claimResearcher(person); + expect(notificationsService.error).toHaveBeenCalled(); + expect(authorizationService.invalidateAuthorizationsRequestCache).not.toHaveBeenCalled(); + expect(menuService.hideMenuSection).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/shared/menu/providers/item-claim.menu.ts b/src/app/shared/menu/providers/item-claim.menu.ts index 92b27fc02e..06f26a773d 100644 --- a/src/app/shared/menu/providers/item-claim.menu.ts +++ b/src/app/shared/menu/providers/item-claim.menu.ts @@ -40,14 +40,6 @@ export class ClaimMenuProvider extends DSpaceObjectPageMenuProvider { super(); } - - protected isApplicable(item: DSpaceObject): boolean { - if (item instanceof Item) { - return this.getDsoType(item) === 'person'; - } - return false; - } - public getSectionsForContext(item: Item): Observable { return combineLatest([ this.authorizationService.isAuthorized(FeatureID.CanClaimItem, item.self), @@ -70,6 +62,13 @@ export class ClaimMenuProvider extends DSpaceObjectPageMenuProvider { ); } + protected isApplicable(item: DSpaceObject): boolean { + if (item instanceof Item) { + return this.getDsoType(item) === 'person'; + } + return false; + } + /** * Claim a researcher by creating a profile * Shows notifications and/or hides the menu section on success/error @@ -82,7 +81,7 @@ export class ClaimMenuProvider extends DSpaceObjectPageMenuProvider { this.translate.get('researcherprofile.success.claim.body'), ); this.authorizationService.invalidateAuthorizationsRequestCache(); - this.menuService.hideMenuSection(MenuID.DSO_EDIT, 'claim-dso-' + item.uuid); + this.menuService.hideMenuSection(MenuID.DSO_EDIT, this.menuProviderId); } else { this.notificationsService.error( this.translate.get('researcherprofile.error.claim.title'), diff --git a/src/app/shared/menu/providers/item-orcid.menu.spec.ts b/src/app/shared/menu/providers/item-orcid.menu.spec.ts new file mode 100644 index 0000000000..127e583ce2 --- /dev/null +++ b/src/app/shared/menu/providers/item-orcid.menu.spec.ts @@ -0,0 +1,94 @@ +import { PartialMenuSection } from '../menu-provider.model'; +import { MenuItemType } from '../menu-item-type.model'; +import { TestBed } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { Item } from '../../../core/shared/item.model'; +import { TranslateModule } from '@ngx-translate/core'; +import { ITEM } from '../../../core/shared/item.resource-type'; +import { URLCombiner } from '../../../core/url-combiner/url-combiner'; +import { OrcidMenuProvider } from './item-orcid.menu'; + +describe('OrcidMenuProvider', () => { + + const expectedSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'item.page.orcid.tooltip', + link: new URLCombiner('/entities/person/test-uuid', 'orcid').toString(), + }, + icon: 'orcid fab fa-lg', + } + ]; + + let provider: OrcidMenuProvider; + + const item: Item = Object.assign(new Item(), { + type: ITEM.value, + _links: {self: {href: 'self-link'}}, + metadata: { + 'dc.title': [{ + 'value': 'Untyped Item' + }] + } + }); + + const person: Item = Object.assign(new Item(), { + uuid: 'test-uuid', + type: ITEM.value, + _links: {self: {href: 'self-link'}}, + metadata: { + 'dc.title': [{ + 'value': 'Person Entity' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }], + } + }); + + + let authorizationService; + + beforeEach(() => { + authorizationService = jasmine.createSpyObj('authorizationService', { + 'isAuthorized': observableOf(true), + }); + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + providers: [ + OrcidMenuProvider, + {provide: AuthorizationDataService, useValue: authorizationService}, + ], + }); + provider = TestBed.inject(OrcidMenuProvider); + }); + + it('should be created', () => { + expect(provider).toBeTruthy(); + }); + + describe('getSectionsForContext', () => { + it('should return the expected sections', (done) => { + provider.getSectionsForContext(person).subscribe((sections) => { + expect(sections).toEqual(expectedSections); + done(); + }); + }); + }); + + describe('isApplicable', () => { + it('should return true whe the provided dspace object is a person entity', () => { + const result = (provider as any).isApplicable(person); + expect(result).toBeTrue(); + }); + it('should return true whe the provided dspace object is not a person entity', () => { + const result = (provider as any).isApplicable(item); + expect(result).toBeFalse(); + }); + }); + +}); diff --git a/src/app/shared/menu/providers/item-orcid.menu.ts b/src/app/shared/menu/providers/item-orcid.menu.ts index 208a3b26c6..3dc916ee6d 100644 --- a/src/app/shared/menu/providers/item-orcid.menu.ts +++ b/src/app/shared/menu/providers/item-orcid.menu.ts @@ -29,13 +29,6 @@ export class OrcidMenuProvider extends DSpaceObjectPageMenuProvider { super(); } - protected isApplicable(item: Item): boolean { - if (item instanceof Item) { - return this.getDsoType(item) === 'person'; - } - return false; - } - public getSectionsForContext(item: Item): Observable { return combineLatest([ this.authorizationService.isAuthorized(FeatureID.CanSynchronizeWithORCID, item.self), @@ -55,4 +48,11 @@ export class OrcidMenuProvider extends DSpaceObjectPageMenuProvider { }), ); } + + protected isApplicable(item: Item): boolean { + if (item instanceof Item) { + return this.getDsoType(item) === 'person'; + } + return false; + } } diff --git a/src/app/shared/menu/providers/item-versioning.menu.spec.ts b/src/app/shared/menu/providers/item-versioning.menu.spec.ts new file mode 100644 index 0000000000..e5a80e78b7 --- /dev/null +++ b/src/app/shared/menu/providers/item-versioning.menu.spec.ts @@ -0,0 +1,98 @@ +import { PartialMenuSection } from '../menu-provider.model'; +import { MenuItemType } from '../menu-item-type.model'; +import { TestBed } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { Item } from '../../../core/shared/item.model'; +import { TranslateModule } from '@ngx-translate/core'; +import { ITEM } from '../../../core/shared/item.resource-type'; +import { VersioningMenuProvider } from './item-versioning.menu'; +import { DsoVersioningModalService } from '../../dso-page/dso-versioning-modal-service/dso-versioning-modal.service'; + +describe('VersioningMenuProvider', () => { + + const expectedSectionsWhenVersionNotPresent: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.ONCLICK, + text: 'item.page.version.create', + disabled: false, + function: jasmine.any(Function) as any, + }, + icon: 'code-branch', + } + ]; + const expectedSectionsWhenVersionPresent: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.ONCLICK, + text: 'item.page.version.hasDraft', + disabled: true, + function: jasmine.any(Function) as any, + }, + icon: 'code-branch', + } + ]; + + let provider: VersioningMenuProvider; + + const item: Item = Object.assign(new Item(), { + type: ITEM.value, + _links: {self: {href: 'self-link'}}, + metadata: { + 'dc.title': [{ + 'value': 'Untyped Item' + }] + } + }); + + + let authorizationService; + let dsoVersioningModalService; + + beforeEach(() => { + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(true), + }); + + dsoVersioningModalService = jasmine.createSpyObj('dsoVersioningModalService', { + isNewVersionButtonDisabled: observableOf(false), + getVersioningTooltipMessage: observableOf('item.page.version.create'), + }); + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + providers: [ + VersioningMenuProvider, + {provide: AuthorizationDataService, useValue: authorizationService}, + {provide: DsoVersioningModalService, useValue: dsoVersioningModalService}, + ], + }); + provider = TestBed.inject(VersioningMenuProvider); + }); + + it('should be created', () => { + expect(provider).toBeTruthy(); + }); + + describe('getSectionsForContext', () => { + it('should return the section to create a new version when no version draft is present yet', (done) => { + provider.getSectionsForContext(item).subscribe((sections) => { + expect(sections).toEqual(expectedSectionsWhenVersionNotPresent); + done(); + }); + }); + it('should return the section to that a version is present when a version draft is present', (done) => { + (dsoVersioningModalService.isNewVersionButtonDisabled as jasmine.Spy).and.returnValue(observableOf(true)); + (dsoVersioningModalService.getVersioningTooltipMessage as jasmine.Spy).and.returnValue(observableOf('item.page.version.hasDraft')); + + provider.getSectionsForContext(item).subscribe((sections) => { + expect(sections).toEqual(expectedSectionsWhenVersionPresent); + done(); + }); + }); + }); + +}); diff --git a/src/app/shared/menu/providers/new.menu.spec.ts b/src/app/shared/menu/providers/new.menu.spec.ts index c03f788275..01fa942548 100644 --- a/src/app/shared/menu/providers/new.menu.spec.ts +++ b/src/app/shared/menu/providers/new.menu.spec.ts @@ -15,51 +15,51 @@ import { AuthorizationDataService } from '../../../core/data/feature-authorizati import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; import { PartialMenuSection } from '../menu-provider.model'; -const expectedTopSection: PartialMenuSection = { - visible: true, - model: { +describe('NewMenuProvider', () => { + const expectedTopSection: PartialMenuSection = { + visible: true, + model: { type: MenuItemType.TEXT, text: 'menu.section.new' }, icon: 'plus', }; -const expectedSubSections: PartialMenuSection[] = [ - { - visible: true, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.new_community', - function: jasmine.any(Function) as any, + const expectedSubSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.ONCLICK, + text: 'menu.section.new_community', + function: jasmine.any(Function) as any, + }, }, - }, - { - visible: false, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.new_collection', - function: jasmine.any(Function) as any, + { + visible: false, + model: { + type: MenuItemType.ONCLICK, + text: 'menu.section.new_collection', + function: jasmine.any(Function) as any, + }, }, - }, - { - visible: true, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.new_item', - function: jasmine.any(Function) as any, + { + visible: true, + model: { + type: MenuItemType.ONCLICK, + text: 'menu.section.new_item', + function: jasmine.any(Function) as any, + }, }, - }, - { - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.new_process', - link: '/processes/new' + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.new_process', + link: '/processes/new' + }, }, - }, -]; + ]; -describe('NewMenuProvider', () => { let provider: NewMenuProvider; let authorizationServiceStub = new AuthorizationDataServiceStub(); @@ -75,7 +75,7 @@ describe('NewMenuProvider', () => { TestBed.configureTestingModule({ providers: [ NewMenuProvider, - { provide: AuthorizationDataService, useValue: authorizationServiceStub }, + {provide: AuthorizationDataService, useValue: authorizationServiceStub}, ], }); provider = TestBed.inject(NewMenuProvider); diff --git a/src/app/shared/menu/providers/processes.menu.spec.ts b/src/app/shared/menu/providers/processes.menu.spec.ts index eb72a02765..b0d353634c 100644 --- a/src/app/shared/menu/providers/processes.menu.spec.ts +++ b/src/app/shared/menu/providers/processes.menu.spec.ts @@ -14,19 +14,20 @@ import { AuthorizationDataService } from '../../../core/data/feature-authorizati import { ProcessesMenuProvider } from './processes.menu'; import { PartialMenuSection } from '../menu-provider.model'; -const expectedSections: PartialMenuSection[] = [ - { - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.processes', - link: '/processes', - }, - icon: 'terminal', - }, -]; - describe('ProcessesMenuProvider', () => { + + const expectedSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.processes', + link: '/processes', + }, + icon: 'terminal', + }, + ]; + let provider: ProcessesMenuProvider; let authorizationServiceStub = new AuthorizationDataServiceStub(); @@ -38,7 +39,7 @@ describe('ProcessesMenuProvider', () => { TestBed.configureTestingModule({ providers: [ ProcessesMenuProvider, - { provide: AuthorizationDataService, useValue: authorizationServiceStub }, + {provide: AuthorizationDataService, useValue: authorizationServiceStub}, ], }); provider = TestBed.inject(ProcessesMenuProvider); diff --git a/src/app/shared/menu/providers/registries.menu.spec.ts b/src/app/shared/menu/providers/registries.menu.spec.ts index ae8c419163..2a182d606b 100644 --- a/src/app/shared/menu/providers/registries.menu.spec.ts +++ b/src/app/shared/menu/providers/registries.menu.spec.ts @@ -16,35 +16,36 @@ import { ScriptServiceStub } from '../../testing/script-service.stub'; import { RegistriesMenuProvider } from './registries.menu'; import { PartialMenuSection } from '../menu-provider.model'; -const expectedTopSection: PartialMenuSection = { - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.registries', - }, - icon: 'list', -}; - -const expectedSubSections: PartialMenuSection[] = [ - { - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.registries_metadata', - link: 'admin/registries/metadata', - }, - }, - { - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.registries_format', - link: 'admin/registries/bitstream-formats', - }, - }, -]; - describe('RegistriesMenuProvider', () => { + + const expectedTopSection: PartialMenuSection = { + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.registries', + }, + icon: 'list', + }; + + const expectedSubSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.registries_metadata', + link: 'admin/registries/metadata', + }, + }, + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.registries_format', + link: 'admin/registries/bitstream-formats', + }, + }, + ]; + let provider: RegistriesMenuProvider; let authorizationServiceStub = new AuthorizationDataServiceStub(); @@ -56,8 +57,8 @@ describe('RegistriesMenuProvider', () => { TestBed.configureTestingModule({ providers: [ RegistriesMenuProvider, - { provide: AuthorizationDataService, useValue: authorizationServiceStub }, - { provide: ScriptDataService, useClass: ScriptServiceStub }, + {provide: AuthorizationDataService, useValue: authorizationServiceStub}, + {provide: ScriptDataService, useClass: ScriptServiceStub}, ], }); provider = TestBed.inject(RegistriesMenuProvider); diff --git a/src/app/shared/menu/providers/statistics.menu.spec.ts b/src/app/shared/menu/providers/statistics.menu.spec.ts new file mode 100644 index 0000000000..0e5dbf83c7 --- /dev/null +++ b/src/app/shared/menu/providers/statistics.menu.spec.ts @@ -0,0 +1,127 @@ +import { PartialMenuSection } from '../menu-provider.model'; +import { MenuItemType } from '../menu-item-type.model'; +import { TestBed } from '@angular/core/testing'; +import { Item } from '../../../core/shared/item.model'; +import { TranslateModule } from '@ngx-translate/core'; +import { ITEM } from '../../../core/shared/item.resource-type'; +import { StatisticsMenuProvider } from './statistics.menu'; +import { createSuccessfulRemoteDataObject } from '../../remote-data.utils'; + +describe('StatisticsMenuProvider', () => { + + const expectedSectionsNoDSO: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.statistics', + link: `statistics`, + }, + icon: 'chart-line', + } + ]; + + const expectedSectionsForItem: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.statistics', + link: `statistics/items/test-item-uuid`, + }, + icon: 'chart-line', + } + ]; + + let provider: StatisticsMenuProvider; + + const item: Item = Object.assign(new Item(), { + uuid: 'test-item-uuid', + type: ITEM.value, + _links: {self: {href: 'self-link'}}, + metadata: { + 'dc.title': [{ + 'value': 'Untyped Item' + }] + } + }); + + const item2: Item = Object.assign(new Item(), { + uuid: 'test-item2-uuid', + type: ITEM.value, + _links: {self: {href: 'self-link'}}, + metadata: { + 'dc.title': [{ + 'value': 'Untyped Item 2' + }] + } + }); + + beforeEach(() => { + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + providers: [ + StatisticsMenuProvider, + ], + }); + provider = TestBed.inject(StatisticsMenuProvider); + }); + + it('should be created', () => { + expect(provider).toBeTruthy(); + }); + + describe('getSectionsForContext', () => { + it('should return the general statistics link when no DSO is provided', (done) => { + provider.getSectionsForContext(undefined).subscribe((sections) => { + expect(sections).toEqual(expectedSectionsNoDSO); + done(); + }); + }); + it('should return a statistics link to the DSO when a DSO is provided', (done) => { + provider.getSectionsForContext(item).subscribe((sections) => { + expect(sections).toEqual(expectedSectionsForItem); + done(); + }); + }); + }); + + describe('getRouteContext', () => { + it('should get the dso from the route', (done) => { + const route = {data: {dso: createSuccessfulRemoteDataObject(item)}} as any; + + provider.getRouteContext(route, undefined).subscribe((dso) => { + expect(dso).toEqual(item); + done(); + }); + }); + it('should get the dso from first parent route with a dso when the route itself has none', (done) => { + const route = { + data: {}, + parent: { + data: {}, + parent: { + data: {dso: createSuccessfulRemoteDataObject(item)}, + parent: {data: {dso: createSuccessfulRemoteDataObject(item2)}} + } + } + } as any; + + provider.getRouteContext(route, undefined).subscribe((dso) => { + expect(dso).toEqual(item); + expect(dso).not.toEqual(item2); + done(); + }); + }); + it('should return undefined when no dso is found in the route', (done) => { + const route = {data: {}, parent: {data: {}, parent: {data: {}, parent: {data: {}}}}} as any; + + provider.getRouteContext(route, undefined).subscribe((dso) => { + expect(dso).toBeUndefined(); + done(); + }); + }); + }); + +}); diff --git a/src/app/shared/menu/providers/statistics.menu.ts b/src/app/shared/menu/providers/statistics.menu.ts index 4690cdf1aa..356f2f53d2 100644 --- a/src/app/shared/menu/providers/statistics.menu.ts +++ b/src/app/shared/menu/providers/statistics.menu.ts @@ -49,7 +49,7 @@ export class StatisticsMenuProvider extends AbstractRouteContextMenuProvider { + + const expectedSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.system-wide-alert', + link: '/admin/system-wide-alert', + }, + icon: 'exclamation-circle', + }, + ]; + let provider: SystemWideAlertMenuProvider; let authorizationServiceStub = new AuthorizationDataServiceStub(); @@ -38,7 +39,7 @@ describe('SystemWideAlertMenuProvider', () => { TestBed.configureTestingModule({ providers: [ SystemWideAlertMenuProvider, - { provide: AuthorizationDataService, useValue: authorizationServiceStub }, + {provide: AuthorizationDataService, useValue: authorizationServiceStub}, ], }); provider = TestBed.inject(SystemWideAlertMenuProvider); diff --git a/src/app/shared/menu/providers/workflow.menu.spec.ts b/src/app/shared/menu/providers/workflow.menu.spec.ts index 14b14db115..5c92bae656 100644 --- a/src/app/shared/menu/providers/workflow.menu.spec.ts +++ b/src/app/shared/menu/providers/workflow.menu.spec.ts @@ -14,19 +14,20 @@ import { AuthorizationDataService } from '../../../core/data/feature-authorizati import { WorkflowMenuProvider } from './workflow.menu'; import { PartialMenuSection } from '../menu-provider.model'; -const expectedSections: PartialMenuSection[] = [ - { - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.workflow', - link: '/admin/workflow', - }, - icon: 'user-check', - }, -]; - describe('WorkflowMenuProvider', () => { + + const expectedSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.workflow', + link: '/admin/workflow', + }, + icon: 'user-check', + }, + ]; + let provider: WorkflowMenuProvider; let authorizationServiceStub = new AuthorizationDataServiceStub(); @@ -38,7 +39,7 @@ describe('WorkflowMenuProvider', () => { TestBed.configureTestingModule({ providers: [ WorkflowMenuProvider, - { provide: AuthorizationDataService, useValue: authorizationServiceStub }, + {provide: AuthorizationDataService, useValue: authorizationServiceStub}, ], }); provider = TestBed.inject(WorkflowMenuProvider); From 4f1013a20d1be64c2517d15bbf6ef94b622ce72a Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Mon, 20 Jan 2025 13:27:36 +0100 Subject: [PATCH 029/157] Fix small issues --- src/app/app.menus.ts | 1 - src/app/shared/menu/menu-provider.model.ts | 7 +++++-- src/app/shared/menu/menu-provider.service.ts | 15 ++++++++------- src/app/shared/menu/menu.structure.ts | 2 +- .../helper-providers/expandable-menu-provider.ts | 8 ++++++++ src/app/shared/menu/providers/item-claim.menu.ts | 2 +- 6 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/app/app.menus.ts b/src/app/app.menus.ts index 8240a9cb1b..b4989b4bb6 100644 --- a/src/app/app.menus.ts +++ b/src/app/app.menus.ts @@ -73,7 +73,6 @@ export const MENUS = buildMenuStructure({ ClaimMenuProvider.onRoute( MenuRoute.SIMPLE_ITEM_PAGE, MenuRoute.FULL_ITEM_PAGE, - MenuRoute.SIMPLE_COLLECTION_PAGE, ), ]), ], diff --git a/src/app/shared/menu/menu-provider.model.ts b/src/app/shared/menu/menu-provider.model.ts index 8a517934b9..026ef034bd 100644 --- a/src/app/shared/menu/menu-provider.model.ts +++ b/src/app/shared/menu/menu-provider.model.ts @@ -22,7 +22,6 @@ export interface PartialMenuSection { visible: boolean; model: MenuItemModels; parentID?: string; - index?: number; active?: boolean; shouldPersistOnRouteChange?: boolean; icon?: string; @@ -65,7 +64,7 @@ export abstract class AbstractMenuProvider implements MenuProvider { /** * ID of the menu this provider is part of - * If not set up, this will be set based on the provider class name + * This will be set to the menu ID of the menu in which it is present in the app.menus.ts file */ menuID?: MenuID; @@ -144,6 +143,10 @@ export abstract class AbstractMenuProvider implements MenuProvider { */ abstract getSections(route?: ActivatedRouteSnapshot, state?: RouterStateSnapshot): Observable; + protected getAutomatedSectionId(indexOfSectionInProvider: number): string { + return `${this.menuProviderId}_${indexOfSectionInProvider};` + } + } diff --git a/src/app/shared/menu/menu-provider.service.ts b/src/app/shared/menu/menu-provider.service.ts index c0f34be481..2ef19010c1 100644 --- a/src/app/shared/menu/menu-provider.service.ts +++ b/src/app/shared/menu/menu-provider.service.ts @@ -92,8 +92,8 @@ export class MenuProviderService { provider: AbstractMenuProvider, sections: PartialMenuSection[] }, sectionIndex) => { - providerWithSection.sections.forEach((section) => { - this.addSection(providerWithSection.provider, section); + providerWithSection.sections.forEach((section, index) => { + this.addSection(providerWithSection.provider, section, index); }); return this.waitForMenu$(providerWithSection.provider.menuID); }); @@ -156,8 +156,8 @@ export class MenuProviderService { provider: AbstractMenuProvider, sections: PartialMenuSection[] }) => { - providerWithSection.sections.forEach((section) => { - this.addSection(providerWithSection.provider, section); + providerWithSection.sections.forEach((section, index) => { + this.addSection(providerWithSection.provider, section, index); }); return this.waitForMenu$(providerWithSection.provider.menuID); }); @@ -172,12 +172,13 @@ export class MenuProviderService { * @param provider - The provider of the section which will be used to provide extra data to the section * @param section - The partial section to be added to the menus */ - private addSection(provider: AbstractMenuProvider, section: PartialMenuSection) { + private addSection(provider: AbstractMenuProvider, section: PartialMenuSection, index: number) { this.menuService.addSection(provider.menuID, { ...section, - id: section.id ?? `${provider.menuProviderId}`, + id: section.id ?? `${provider.menuProviderId}_${index}`, parentID: section.parentID ?? provider.parentID, - index: section.index ?? provider.index, + index: provider.index, + active: section.active ?? true, shouldPersistOnRouteChange: section.shouldPersistOnRouteChange ?? provider.shouldPersistOnRouteChange, alwaysRenderExpandable: section.alwaysRenderExpandable ?? provider.alwaysRenderExpandable, }); diff --git a/src/app/shared/menu/menu.structure.ts b/src/app/shared/menu/menu.structure.ts index 81103ce182..0541d110e0 100644 --- a/src/app/shared/menu/menu.structure.ts +++ b/src/app/shared/menu/menu.structure.ts @@ -53,7 +53,7 @@ function processProviderType(providers: Provider[], menuID: string, providerType const childProviderTypes = (providerType as any).childProviderTypes; childProviderTypes.forEach((childProviderType, childIndex: number) => { - processProviderType(providers, menuID, childProviderType, childIndex, `${menuID}_${index}`, hasSubProviders); + processProviderType(providers, menuID, childProviderType, childIndex, `${menuID}_${index}_0`, hasSubProviders); }); processProviderType(providers, menuID, providerPart, index, parentID, true); diff --git a/src/app/shared/menu/providers/helper-providers/expandable-menu-provider.ts b/src/app/shared/menu/providers/helper-providers/expandable-menu-provider.ts index e95aa80103..40ab500268 100644 --- a/src/app/shared/menu/providers/helper-providers/expandable-menu-provider.ts +++ b/src/app/shared/menu/providers/helper-providers/expandable-menu-provider.ts @@ -58,4 +58,12 @@ export abstract class AbstractExpandableMenuProvider extends AbstractMenuProvide }) ); } + + protected getAutomatedSectionIdForTopSection(): string { + return this.getAutomatedSectionId(0); + } + protected getAutomatedSectionIdForSubsection(indexOfSubSectionInProvider: number): string { + return `${this.menuProviderId}_0_${indexOfSubSectionInProvider};` + } + } diff --git a/src/app/shared/menu/providers/item-claim.menu.ts b/src/app/shared/menu/providers/item-claim.menu.ts index 06f26a773d..e61872a704 100644 --- a/src/app/shared/menu/providers/item-claim.menu.ts +++ b/src/app/shared/menu/providers/item-claim.menu.ts @@ -81,7 +81,7 @@ export class ClaimMenuProvider extends DSpaceObjectPageMenuProvider { this.translate.get('researcherprofile.success.claim.body'), ); this.authorizationService.invalidateAuthorizationsRequestCache(); - this.menuService.hideMenuSection(MenuID.DSO_EDIT, this.menuProviderId); + this.menuService.hideMenuSection(MenuID.DSO_EDIT, this.getAutomatedSectionId(0)); } else { this.notificationsService.error( this.translate.get('researcherprofile.error.claim.title'), From 2225c2e4287f1d74b7f16d155e9a06cd7b91661d Mon Sep 17 00:00:00 2001 From: Andrea Barbasso <´andrea.barbasso@4science.com´> Date: Wed, 5 Feb 2025 11:37:00 +0100 Subject: [PATCH 030/157] [CST-18964] add matomo integration --- package-lock.json | 13 +++++ package.json | 1 + src/app/core/shared/search/search.service.ts | 9 +++- .../shared/cookies/browser-orejime.service.ts | 10 +++- .../shared/cookies/orejime-configuration.ts | 15 ++++++ src/app/statistics/matomo.service.spec.ts | 16 ++++++ src/app/statistics/matomo.service.ts | 52 +++++++++++++++++++ src/app/statistics/mock-matomo-tracker.ts | 3 ++ src/assets/i18n/en.json5 | 4 ++ src/assets/i18n/it.json5 | 7 +++ src/config/app-config.interface.ts | 2 + src/config/default-app-config.ts | 3 ++ src/config/matomo-config.interface.ts | 9 ++++ src/modules/app/browser-app.config.ts | 12 +++++ src/modules/app/browser-init.service.ts | 7 +++ src/modules/app/server-app.config.ts | 6 +++ 16 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 src/app/statistics/matomo.service.spec.ts create mode 100644 src/app/statistics/matomo.service.ts create mode 100644 src/app/statistics/mock-matomo-tracker.ts create mode 100644 src/config/matomo-config.interface.ts diff --git a/package-lock.json b/package-lock.json index b75ddc9ccf..7c22e2d799 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,7 @@ "ng2-file-upload": "5.0.0", "ng2-nouislider": "^2.0.0", "ngx-infinite-scroll": "^16.0.0", + "ngx-matomo-client": "^6.4.1", "ngx-pagination": "6.0.3", "ngx-skeleton-loader": "^9.0.0", "ngx-ui-switch": "^14.1.0", @@ -16935,6 +16936,18 @@ "@angular/forms": ">=10.0.0" } }, + "node_modules/ngx-matomo-client": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/ngx-matomo-client/-/ngx-matomo-client-6.4.1.tgz", + "integrity": "sha512-GRriCGW0ULCg9oSZw3ule+o9esELVVJTJ0Z99/zYKGjlyrrHLn5a1e0GSdgICubo59gP1cg9NwsOC0BH7oio9A==", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": "^17.0.0 || ^18.0.0", + "@angular/core": "^17.0.0 || ^18.0.0" + } + }, "node_modules/ngx-pagination": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/ngx-pagination/-/ngx-pagination-6.0.3.tgz", diff --git a/package.json b/package.json index 9e72a4cc9d..0c8ddd5ac9 100644 --- a/package.json +++ b/package.json @@ -153,6 +153,7 @@ "ng2-file-upload": "5.0.0", "ng2-nouislider": "^2.0.0", "ngx-infinite-scroll": "^16.0.0", + "ngx-matomo-client": "^6.4.1", "ngx-pagination": "6.0.3", "ngx-skeleton-loader": "^9.0.0", "ngx-ui-switch": "^14.1.0", diff --git a/src/app/core/shared/search/search.service.ts b/src/app/core/shared/search/search.service.ts index 60a2da6af1..7afefd9659 100644 --- a/src/app/core/shared/search/search.service.ts +++ b/src/app/core/shared/search/search.service.ts @@ -1,6 +1,7 @@ /* eslint-disable max-classes-per-file */ import { Injectable } from '@angular/core'; import { Angulartics2 } from 'angulartics2'; +import { MatomoTracker } from 'ngx-matomo-client'; import { BehaviorSubject, combineLatest as observableCombineLatest, @@ -112,6 +113,7 @@ export class SearchService { private paginationService: PaginationService, private searchConfigurationService: SearchConfigurationService, private angulartics2: Angulartics2, + private matomoTracker: MatomoTracker, ) { this.searchDataService = new SearchDataService(); } @@ -367,7 +369,7 @@ export class SearchService { const appliedFilter = appliedFilters[i]; filters.push(appliedFilter); } - this.angulartics2.eventTrack.next({ + const searchTrackObject = { action: 'search', properties: { searchOptions: config, @@ -384,7 +386,10 @@ export class SearchService { filters: filters, clickedObject, }, - }); + }; + + this.matomoTracker.trackSiteSearch(config.query, config.scope, searchQueryResponse.pageInfo.totalElements, searchTrackObject); + this.angulartics2.eventTrack.next(searchTrackObject); } /** diff --git a/src/app/shared/cookies/browser-orejime.service.ts b/src/app/shared/cookies/browser-orejime.service.ts index a2c66f9500..ba4a61fa2c 100644 --- a/src/app/shared/cookies/browser-orejime.service.ts +++ b/src/app/shared/cookies/browser-orejime.service.ts @@ -39,6 +39,7 @@ import { OrejimeService } from './orejime.service'; import { ANONYMOUS_STORAGE_NAME_OREJIME, getOrejimeConfiguration, + MATOMO_OREJIME_KEY, } from './orejime-configuration'; /** @@ -133,8 +134,10 @@ export class BrowserOrejimeService extends OrejimeService { ), ); - const appsToHide$: Observable = observableCombineLatest([hideGoogleAnalytics$, hideRegistrationVerification$]).pipe( - map(([hideGoogleAnalytics, hideRegistrationVerification]) => { + const hideMatomo$ = observableOf(!(environment.matomo?.trackerUrl && environment.matomo?.siteId)); + + const appsToHide$: Observable = observableCombineLatest([hideGoogleAnalytics$, hideRegistrationVerification$, hideMatomo$]).pipe( + map(([hideGoogleAnalytics, hideRegistrationVerification, hideMatomo]) => { const appsToHideArray: string[] = []; if (hideGoogleAnalytics) { appsToHideArray.push(this.GOOGLE_ANALYTICS_SERVICE_NAME); @@ -142,6 +145,9 @@ export class BrowserOrejimeService extends OrejimeService { if (hideRegistrationVerification) { appsToHideArray.push(CAPTCHA_NAME); } + if (hideMatomo) { + appsToHideArray.push(MATOMO_OREJIME_KEY); + } return appsToHideArray; }), ); diff --git a/src/app/shared/cookies/orejime-configuration.ts b/src/app/shared/cookies/orejime-configuration.ts index 8e99ff30b2..76499fce7d 100644 --- a/src/app/shared/cookies/orejime-configuration.ts +++ b/src/app/shared/cookies/orejime-configuration.ts @@ -19,6 +19,10 @@ export const ANONYMOUS_STORAGE_NAME_OREJIME = 'orejime-anonymous'; export const GOOGLE_ANALYTICS_OREJIME_KEY = 'google-analytics'; +export const MATOMO_OREJIME_KEY = 'matomo'; + +export const MATOMO_COOKIE = 'dsMatomo'; + /** * Orejime configuration * For more information see https://github.com/empreinte-digitale/orejime @@ -134,6 +138,17 @@ export function getOrejimeConfiguration(_window: NativeWindowRef): any { HAS_AGREED_END_USER, ], }, + { + name: MATOMO_OREJIME_KEY, + purposes: ['statistical'], + required: false, + cookies: [ + MATOMO_COOKIE, + ], + callback: (consent: boolean) => { + _window?.nativeWindow.changeMatomoConsent(consent); + }, + }, { name: GOOGLE_ANALYTICS_OREJIME_KEY, purposes: ['statistical'], diff --git a/src/app/statistics/matomo.service.spec.ts b/src/app/statistics/matomo.service.spec.ts new file mode 100644 index 0000000000..4c84d26f44 --- /dev/null +++ b/src/app/statistics/matomo.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { MatomoService } from './matomo.service'; + +describe('MatomoService', () => { + let service: MatomoService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(MatomoService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/statistics/matomo.service.ts b/src/app/statistics/matomo.service.ts new file mode 100644 index 0000000000..d507bb0a50 --- /dev/null +++ b/src/app/statistics/matomo.service.ts @@ -0,0 +1,52 @@ +import { + inject, + Injectable, +} from '@angular/core'; +import { + MatomoInitializerService, + MatomoTracker, +} from 'ngx-matomo-client'; + +import { environment } from '../../environments/environment'; +import { NativeWindowService } from '../core/services/window.service'; +import { OrejimeService } from '../shared/cookies/orejime.service'; + +@Injectable({ + providedIn: 'root', +}) +export class MatomoService { + + matomoInitializer = inject(MatomoInitializerService); + matomoTracker = inject(MatomoTracker); + orejimeService = inject(OrejimeService); + _window = inject(NativeWindowService); + + init() { + if (this._window.nativeWindow) { + this._window.nativeWindow.changeMatomoConsent = this.changeMatomoConsent; + } + + if (environment.production) { + const preferences$ = this.orejimeService.getSavedPreferences(); + + preferences$.subscribe(preferences => { + this.changeMatomoConsent(preferences.matomo); + + if (environment.matomo?.siteId && environment.matomo?.trackerUrl) { + this.matomoInitializer.initializeTracker({ + siteId: environment.matomo.siteId, + trackerUrl: environment.matomo.trackerUrl, + }); + } + }); + } + } + + changeMatomoConsent = (consent: boolean) => { + if (consent) { + this.matomoTracker.setConsentGiven(); + } else { + this.matomoTracker.forgetConsentGiven(); + } + }; +} diff --git a/src/app/statistics/mock-matomo-tracker.ts b/src/app/statistics/mock-matomo-tracker.ts new file mode 100644 index 0000000000..cfd2ca2993 --- /dev/null +++ b/src/app/statistics/mock-matomo-tracker.ts @@ -0,0 +1,3 @@ +export class MockMatomoTracker { + trackSiteSearch = () => {}; +} diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 053e5133d9..08c75b13b4 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1630,6 +1630,10 @@ "cookies.consent.app.description.google-recaptcha": "We use google reCAPTCHA service during registration and password recovery", + "cookies.consent.app.title.matomo": "Matomo", + + "cookies.consent.app.description.matomo": "Allows us to track statistical data", + "cookies.consent.purpose.functional": "Functional", "cookies.consent.purpose.statistical": "Statistical", diff --git a/src/assets/i18n/it.json5 b/src/assets/i18n/it.json5 index 098e9752bc..2d99a178a9 100644 --- a/src/assets/i18n/it.json5 +++ b/src/assets/i18n/it.json5 @@ -2029,6 +2029,13 @@ "cookies.consent.app.description.google-recaptcha": "Utilizziamo il servizio Google reCAPTCHA nelle fasi di registrazione e recupero password", + // "cookies.consent.app.title.matomo": "Matomo", + "cookies.consent.app.title.matomo": "Matomo", + + // "cookies.consent.app.description.matomo": "Allows us to track statistical data", + "cookies.consent.app.description.matomo": "Ci permette di tracciare i dati statistici", + + // "cookies.consent.purpose.functional": "Functional", "cookies.consent.purpose.functional": "Funzionale", diff --git a/src/config/app-config.interface.ts b/src/config/app-config.interface.ts index 7f5f019958..a52cd6be35 100644 --- a/src/config/app-config.interface.ts +++ b/src/config/app-config.interface.ts @@ -24,6 +24,7 @@ import { InfoConfig } from './info-config.interface'; import { ItemConfig } from './item-config.interface'; import { LangConfig } from './lang-config.interface'; import { MarkdownConfig } from './markdown-config.interface'; +import { MatomoConfig } from './matomo-config.interface'; import { MediaViewerConfig } from './media-viewer-config.interface'; import { INotificationBoardOptions } from './notifications-config.interfaces'; import { QualityAssuranceConfig } from './quality-assurance.config'; @@ -66,6 +67,7 @@ interface AppConfig extends Config { search: SearchConfig; notifyMetrics: AdminNotifyMetricsRow[]; liveRegion: LiveRegionConfig; + matomo?: MatomoConfig; } /** diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index 3c5e0ef0da..2ea08946fe 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -19,6 +19,7 @@ import { InfoConfig } from './info-config.interface'; import { ItemConfig } from './item-config.interface'; import { LangConfig } from './lang-config.interface'; import { MarkdownConfig } from './markdown-config.interface'; +import { MatomoConfig } from './matomo-config.interface'; import { MediaViewerConfig } from './media-viewer-config.interface'; import { INotificationBoardOptions } from './notifications-config.interfaces'; import { QualityAssuranceConfig } from './quality-assurance.config'; @@ -599,4 +600,6 @@ export class DefaultAppConfig implements AppConfig { messageTimeOutDurationMs: 30000, isVisible: false, }; + + matomo: MatomoConfig = {}; } diff --git a/src/config/matomo-config.interface.ts b/src/config/matomo-config.interface.ts new file mode 100644 index 0000000000..95dc267268 --- /dev/null +++ b/src/config/matomo-config.interface.ts @@ -0,0 +1,9 @@ +import { Config } from './config.interface'; + +/** + * Configuration interface for Matomo tracking + */ +export interface MatomoConfig extends Config { + trackerUrl?: string; + siteId?: string; +} diff --git a/src/modules/app/browser-app.config.ts b/src/modules/app/browser-app.config.ts index 213b9de2f7..4173d60d5f 100644 --- a/src/modules/app/browser-app.config.ts +++ b/src/modules/app/browser-app.config.ts @@ -29,6 +29,11 @@ import { Angulartics2GoogleTagManager, Angulartics2RouterlessModule, } from 'angulartics2'; +import { + provideMatomo, + withRouteData, + withRouter, +} from 'ngx-matomo-client'; import { commonAppConfig } from '../../app/app.config'; import { storeModuleConfig } from '../../app/app.reducer'; @@ -157,5 +162,12 @@ export const browserAppConfig: ApplicationConfig = mergeApplicationConfig({ provide: MathService, useClass: ClientMathService, }, + provideMatomo( + { + mode: 'deferred', + }, + withRouter(), + withRouteData(), + ), ], }, commonAppConfig); diff --git a/src/modules/app/browser-init.service.ts b/src/modules/app/browser-init.service.ts index 525067da3a..562c0e4d0d 100644 --- a/src/modules/app/browser-init.service.ts +++ b/src/modules/app/browser-init.service.ts @@ -45,6 +45,7 @@ import { MenuService } from '../../app/shared/menu/menu.service'; import { ThemeService } from '../../app/shared/theme-support/theme.service'; import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider'; import { GoogleAnalyticsService } from '../../app/statistics/google-analytics.service'; +import { MatomoService } from '../../app/statistics/matomo.service'; import { StoreAction, StoreActionTypes, @@ -85,6 +86,7 @@ export class BrowserInitService extends InitService { protected router: Router, private requestService: RequestService, private halService: HALEndpointService, + private matomoService: MatomoService, ) { super( @@ -124,6 +126,7 @@ export class BrowserInitService extends InitService { this.initI18n(); this.initAngulartics(); this.initGoogleAnalytics(); + this.initMatomo(); this.initRouteListeners(); this.themeService.listenForThemeChanges(true); this.trackAuthTokenExpiration(); @@ -173,6 +176,10 @@ export class BrowserInitService extends InitService { this.googleAnalyticsService.addTrackingIdToPage(); } + protected initMatomo(): void { + this.matomoService.init(); + } + /** * During an external authentication flow invalidate the * data in the cache. This allows the app to fetch fresh content. diff --git a/src/modules/app/server-app.config.ts b/src/modules/app/server-app.config.ts index 4cf9369ddd..9f4bf5c50f 100644 --- a/src/modules/app/server-app.config.ts +++ b/src/modules/app/server-app.config.ts @@ -28,6 +28,7 @@ import { Angulartics2GoogleAnalytics, Angulartics2GoogleGlobalSiteTag, } from 'angulartics2'; +import { MatomoTracker } from 'ngx-matomo-client'; import { commonAppConfig } from '../../app/app.config'; import { storeModuleConfig } from '../../app/app.reducer'; @@ -55,6 +56,7 @@ import { XSRFService } from '../../app/core/xsrf/xsrf.service'; import { AngularticsProviderMock } from '../../app/shared/mocks/angulartics-provider.service.mock'; import { Angulartics2Mock } from '../../app/shared/mocks/angulartics2.service.mock'; import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider'; +import { MockMatomoTracker } from '../../app/statistics/mock-matomo-tracker'; import { ServerSubmissionService } from '../../app/submission/server-submission.service'; import { SubmissionService } from '../../app/submission/submission.service'; import { TranslateServerLoader } from '../../ngx-translate-loaders/translate-server.loader'; @@ -144,5 +146,9 @@ export const serverAppConfig: ApplicationConfig = mergeApplicationConfig({ provide: MathService, useClass: ServerMathService, }, + { + provide: MatomoTracker, + useClass: MockMatomoTracker, + }, ], }, commonAppConfig); From fd2120904c41a92d22101d958e9c3418a23283c3 Mon Sep 17 00:00:00 2001 From: Andrea Barbasso <´andrea.barbasso@4science.com´> Date: Wed, 5 Feb 2025 17:05:18 +0100 Subject: [PATCH 031/157] [CST-18964] add tests --- .../core/shared/search/search.service.spec.ts | 4 ++ src/app/statistics/matomo.service.spec.ts | 70 ++++++++++++++++++- src/app/statistics/mock-matomo-tracker.ts | 1 + 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/app/core/shared/search/search.service.spec.ts b/src/app/core/shared/search/search.service.spec.ts index 64b305ff64..365523b9cf 100644 --- a/src/app/core/shared/search/search.service.spec.ts +++ b/src/app/core/shared/search/search.service.spec.ts @@ -36,6 +36,8 @@ import { ViewMode } from '../view-mode.model'; import { SearchService } from './search.service'; import { SearchConfigurationService } from './search-configuration.service'; import anything = jasmine.anything; +import { MatomoTestingModule } from 'ngx-matomo-client/testing'; + @Component({ template: '', @@ -55,6 +57,7 @@ describe('SearchService', () => { TestBed.configureTestingModule({ imports: [ CommonModule, + MatomoTestingModule.forRoot(), RouterTestingModule.withRoutes([ { path: 'search', component: DummyComponent, pathMatch: 'full' }, ]), @@ -122,6 +125,7 @@ describe('SearchService', () => { TestBed.configureTestingModule({ imports: [ CommonModule, + MatomoTestingModule.forRoot(), RouterTestingModule.withRoutes([ { path: 'search', component: DummyComponent, pathMatch: 'full' }, ]), diff --git a/src/app/statistics/matomo.service.spec.ts b/src/app/statistics/matomo.service.spec.ts index 4c84d26f44..60cbffdfb9 100644 --- a/src/app/statistics/matomo.service.spec.ts +++ b/src/app/statistics/matomo.service.spec.ts @@ -1,16 +1,84 @@ import { TestBed } from '@angular/core/testing'; +import { + MatomoInitializerService, + MatomoTracker, +} from 'ngx-matomo-client'; +import { MatomoTestingModule } from 'ngx-matomo-client/testing'; +import { of } from 'rxjs'; +import { environment } from '../../environments/environment'; +import { + NativeWindowRef, + NativeWindowService, +} from '../core/services/window.service'; +import { OrejimeService } from '../shared/cookies/orejime.service'; import { MatomoService } from './matomo.service'; describe('MatomoService', () => { let service: MatomoService; + let matomoTracker: jasmine.SpyObj; + let matomoInitializer: jasmine.SpyObj; + let orejimeService: jasmine.SpyObj; + let nativeWindowService: jasmine.SpyObj; beforeEach(() => { - TestBed.configureTestingModule({}); + matomoTracker = jasmine.createSpyObj('MatomoTracker', ['setConsentGiven', 'forgetConsentGiven']); + matomoInitializer = jasmine.createSpyObj('MatomoInitializerService', ['initializeTracker']); + orejimeService = jasmine.createSpyObj('OrejimeService', ['getSavedPreferences']); + nativeWindowService = jasmine.createSpyObj('NativeWindowService', [], { nativeWindow: {} }); + + TestBed.configureTestingModule({ + imports: [MatomoTestingModule.forRoot()], + providers: [ + { provide: MatomoTracker, useValue: matomoTracker }, + { provide: MatomoInitializerService, useValue: matomoInitializer }, + { provide: OrejimeService, useValue: orejimeService }, + { provide: NativeWindowService, useValue: nativeWindowService }, + ], + }); + service = TestBed.inject(MatomoService); }); it('should be created', () => { expect(service).toBeTruthy(); }); + + it('should set changeMatomoConsent on native window', () => { + orejimeService.getSavedPreferences.and.returnValue(of({ matomo: true })); + service.init(); + expect(nativeWindowService.nativeWindow.changeMatomoConsent).toBe(service.changeMatomoConsent); + }); + + it('should initialize tracker with correct parameters in production', () => { + environment.production = true; + environment.matomo = { siteId: '1', trackerUrl: 'http://example.com' }; + orejimeService.getSavedPreferences.and.returnValue(of({ matomo: true })); + + service.init(); + + expect(matomoTracker.setConsentGiven).toHaveBeenCalled(); + expect(matomoInitializer.initializeTracker).toHaveBeenCalledWith({ + siteId: '1', + trackerUrl: 'http://example.com', + }); + }); + + it('should not initialize tracker if not in production', () => { + environment.production = false; + + service.init(); + + expect(matomoInitializer.initializeTracker).not.toHaveBeenCalled(); + }); + + it('should call setConsentGiven when consent is true', () => { + service.changeMatomoConsent(true); + expect(matomoTracker.setConsentGiven).toHaveBeenCalled(); + }); + + it('should call forgetConsentGiven when consent is false', () => { + service.changeMatomoConsent(false); + expect(matomoTracker.forgetConsentGiven).toHaveBeenCalled(); + }); }); diff --git a/src/app/statistics/mock-matomo-tracker.ts b/src/app/statistics/mock-matomo-tracker.ts index cfd2ca2993..a58934380a 100644 --- a/src/app/statistics/mock-matomo-tracker.ts +++ b/src/app/statistics/mock-matomo-tracker.ts @@ -1,3 +1,4 @@ export class MockMatomoTracker { trackSiteSearch = () => {}; + trackPageView = () => {}; } From 3f7c42c51e15ad0799fc4f64ac8e3c250847e3a6 Mon Sep 17 00:00:00 2001 From: Yury Bondarenko Date: Fri, 7 Feb 2025 13:46:22 +0100 Subject: [PATCH 032/157] Menu sections should be inactive by default If an expandable section in the navbar is acive by default, it will expand whn the page loads --- src/app/shared/menu/menu-provider.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/shared/menu/menu-provider.service.ts b/src/app/shared/menu/menu-provider.service.ts index 2ef19010c1..99021b8941 100644 --- a/src/app/shared/menu/menu-provider.service.ts +++ b/src/app/shared/menu/menu-provider.service.ts @@ -178,7 +178,7 @@ export class MenuProviderService { id: section.id ?? `${provider.menuProviderId}_${index}`, parentID: section.parentID ?? provider.parentID, index: provider.index, - active: section.active ?? true, + active: section.active ?? false, shouldPersistOnRouteChange: section.shouldPersistOnRouteChange ?? provider.shouldPersistOnRouteChange, alwaysRenderExpandable: section.alwaysRenderExpandable ?? provider.alwaysRenderExpandable, }); From fbffcca945c6b6d9e86cf9a6e7e6cbd6d181a28c Mon Sep 17 00:00:00 2001 From: Yury Bondarenko Date: Fri, 7 Feb 2025 16:35:17 +0100 Subject: [PATCH 033/157] Resolve post-merge issues - Menu providers weren't included because main configuration is no longer a module - Route definitions didn't get merged because they're no longer modules - Removed old resolver & service (they're providers now) --- src/app/app-routes.ts | 2 - src/app/app.config.ts | 5 + .../collection-page/collection-page-routes.ts | 25 +- .../community-page/community-page-routes.ts | 25 +- src/app/item-page/item-page-routes.ts | 33 +- src/app/menuResolver.ts | 21 -- .../dso-edit-menu-resolver.service.ts | 324 ------------------ .../statistics-page/statistics-page-routes.ts | 13 + 8 files changed, 35 insertions(+), 413 deletions(-) delete mode 100644 src/app/menuResolver.ts delete mode 100644 src/app/shared/dso-page/dso-edit-menu-resolver.service.ts diff --git a/src/app/app-routes.ts b/src/app/app-routes.ts index 51101f5a2d..db71c866f0 100644 --- a/src/app/app-routes.ts +++ b/src/app/app-routes.ts @@ -34,7 +34,6 @@ import { forgotPasswordCheckGuard } from './core/rest-property/forgot-password-c import { ServerCheckGuard } from './core/server-check/server-check.guard'; import { ThemedForbiddenComponent } from './forbidden/themed-forbidden.component'; import { ITEM_MODULE_PATH } from './item-page/item-page-routing-paths'; -import { menuResolver } from './menuResolver'; import { provideSuggestionNotificationsState } from './notifications/provide-suggestion-notifications-state'; import { ThemedPageErrorComponent } from './page-error/themed-page-error.component'; import { ThemedPageInternalServerErrorComponent } from './page-internal-server-error/themed-page-internal-server-error.component'; @@ -50,7 +49,6 @@ export const APP_ROUTES: Route[] = [ path: '', canActivate: [authBlockingGuard], canActivateChild: [ServerCheckGuard], - resolve: [menuResolver], children: [ { path: '', redirectTo: '/home', pathMatch: 'full' }, { diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 77b29206cb..15d7cf4952 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -38,6 +38,7 @@ import { StoreDevModules } from '../config/store/devtools'; import { environment } from '../environments/environment'; import { EagerThemesModule } from '../themes/eager-themes.module'; import { appEffects } from './app.effects'; +import { MENUS } from './app.menus'; import { appMetaReducers, debugMetaReducers, @@ -156,6 +157,10 @@ export const commonAppConfig: ApplicationConfig = { }, // register the dynamic matcher used by form. MUST be provided by the app module ...DYNAMIC_MATCHER_PROVIDERS, + + // DI-composable menus + ...MENUS, + provideCore(), ], }; diff --git a/src/app/collection-page/collection-page-routes.ts b/src/app/collection-page/collection-page-routes.ts index e20e3ba8af..ffac37c0d1 100644 --- a/src/app/collection-page/collection-page-routes.ts +++ b/src/app/collection-page/collection-page-routes.ts @@ -8,16 +8,14 @@ import { communityBreadcrumbResolver } from '../core/breadcrumbs/community-bread import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; import { ComcolBrowseByComponent } from '../shared/comcol/sections/comcol-browse-by/comcol-browse-by.component'; import { ComcolSearchSectionComponent } from '../shared/comcol/sections/comcol-search-section/comcol-search-section.component'; -import { dsoEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; -import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; -import { MenuItemType } from '../shared/menu/menu-item-type.model'; -import { collectionPageResolver } from './collection-page.resolver'; +import { MenuRoute } from '../shared/menu/menu-route.model'; 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 { CreateCollectionPageComponent } from './create-collection-page/create-collection-page.component'; import { createCollectionPageGuard } from './create-collection-page/create-collection-page.guard'; import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component'; @@ -82,8 +80,8 @@ export const ROUTES: Route[] = [ { path: '', component: ThemedCollectionPageComponent, - resolve: { - menu: dsoEditMenuResolver, + data: { + menuRoute: MenuRoute.SIMPLE_COLLECTION_PAGE, }, children: [ { @@ -104,20 +102,5 @@ export const ROUTES: Route[] = [ ], }, ], - 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, - }], - }, - }, }, ]; diff --git a/src/app/community-page/community-page-routes.ts b/src/app/community-page/community-page-routes.ts index 2c8a7942a4..588cc59799 100644 --- a/src/app/community-page/community-page-routes.ts +++ b/src/app/community-page/community-page-routes.ts @@ -7,15 +7,13 @@ import { communityBreadcrumbResolver } from '../core/breadcrumbs/community-bread import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; import { ComcolBrowseByComponent } from '../shared/comcol/sections/comcol-browse-by/comcol-browse-by.component'; import { ComcolSearchSectionComponent } from '../shared/comcol/sections/comcol-search-section/comcol-search-section.component'; -import { dsoEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; -import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; -import { MenuItemType } from '../shared/menu/menu-item-type.model'; -import { communityPageResolver } from './community-page.resolver'; +import { MenuRoute } from '../shared/menu/menu-route.model'; 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'; @@ -69,8 +67,8 @@ export const ROUTES: Route[] = [ { path: '', component: ThemedCommunityPageComponent, - resolve: { - menu: dsoEditMenuResolver, + data: { + menuRoute: MenuRoute.SIMPLE_COMMUNITY_PAGE, }, children: [ { @@ -100,20 +98,5 @@ export const ROUTES: Route[] = [ ], }, ], - 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, - }], - }, - }, }, ]; diff --git a/src/app/item-page/item-page-routes.ts b/src/app/item-page/item-page-routes.ts index 854d66fabe..1b63ecb520 100644 --- a/src/app/item-page/item-page-routes.ts +++ b/src/app/item-page/item-page-routes.ts @@ -3,23 +3,21 @@ import { Route } from '@angular/router'; import { REQUEST_COPY_MODULE_PATH } from '../app-routing-paths'; import { authenticatedGuard } from '../core/auth/authenticated.guard'; import { itemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.resolver'; -import { dsoEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; -import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; -import { MenuItemType } from '../shared/menu/menu-item-type.model'; +import { MenuRoute } from '../shared/menu/menu-route.model'; import { BitstreamRequestACopyPageComponent } from './bitstreams/request-a-copy/bitstream-request-a-copy-page.component'; import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component'; import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component'; -import { itemPageResolver } from './item-page.resolver'; 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 { orcidPageGuard } from './orcid-page/orcid-page.guard'; import { ThemedItemPageComponent } from './simple/themed-item-page.component'; -import { versionResolver } from './version-page/version.resolver'; import { VersionPageComponent } from './version-page/version-page/version-page.component'; +import { versionResolver } from './version-page/version.resolver'; export const ROUTES: Route[] = [ { @@ -34,16 +32,18 @@ export const ROUTES: Route[] = [ path: '', component: ThemedItemPageComponent, pathMatch: 'full', - resolve: { - menu: dsoEditMenuResolver, + data: { + menuRoute: MenuRoute.SIMPLE_ITEM_PAGE, }, + }, { path: 'full', component: ThemedFullItemPageComponent, - resolve: { - menu: dsoEditMenuResolver, + data: { + menuRoute: MenuRoute.FULL_ITEM_PAGE, }, + }, { path: ITEM_EDIT_PATH, @@ -65,21 +65,6 @@ export const ROUTES: Route[] = [ 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', diff --git a/src/app/menuResolver.ts b/src/app/menuResolver.ts deleted file mode 100644 index 68ad4494dd..0000000000 --- a/src/app/menuResolver.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { inject } from '@angular/core'; -import { - ActivatedRouteSnapshot, - ResolveFn, - RouterStateSnapshot, -} from '@angular/router'; -import { Observable } from 'rxjs'; - -import { MenuResolverService } from './menu-resolver.service'; - - -/** - * Initialize all menus - */ -export const menuResolver: ResolveFn = ( - route: ActivatedRouteSnapshot, - state: RouterStateSnapshot, - menuResolverService: MenuResolverService = inject(MenuResolverService), -): Observable => { - return menuResolverService.resolve(route, state); -}; diff --git a/src/app/shared/dso-page/dso-edit-menu-resolver.service.ts b/src/app/shared/dso-page/dso-edit-menu-resolver.service.ts deleted file mode 100644 index 8c4fd15b7e..0000000000 --- a/src/app/shared/dso-page/dso-edit-menu-resolver.service.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { Injectable } from '@angular/core'; -import { - ActivatedRouteSnapshot, - RouterStateSnapshot, -} from '@angular/router'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { TranslateService } from '@ngx-translate/core'; -import { - combineLatest, - Observable, - of as observableOf, -} from 'rxjs'; -import { - map, - switchMap, -} from 'rxjs/operators'; - -import { getDSORoute } from '../../app-routing-paths'; -import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; -import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; -import { FeatureID } from '../../core/data/feature-authorization/feature-id'; -import { ResearcherProfileDataService } from '../../core/profile/researcher-profile-data.service'; -import { Collection } from '../../core/shared/collection.model'; -import { Community } from '../../core/shared/community.model'; -import { Item } from '../../core/shared/item.model'; -import { - getFirstCompletedRemoteData, - getRemoteDataPayload, -} from '../../core/shared/operators'; -import { CorrectionTypeDataService } from '../../core/submission/correctiontype-data.service'; -import { URLCombiner } from '../../core/url-combiner/url-combiner'; -import { - hasNoValue, - hasValue, - isNotEmpty, -} from '../empty.util'; -import { MenuService } from '../menu/menu.service'; -import { MenuID } from '../menu/menu-id.model'; -import { LinkMenuItemModel } from '../menu/menu-item/models/link.model'; -import { OnClickMenuItemModel } from '../menu/menu-item/models/onclick.model'; -import { MenuItemType } from '../menu/menu-item-type.model'; -import { MenuSection } from '../menu/menu-section.model'; -import { NotificationsService } from '../notifications/notifications.service'; -import { SubscriptionModalComponent } from '../subscriptions/subscription-modal/subscription-modal.component'; -import { DsoVersioningModalService } from './dso-versioning-modal-service/dso-versioning-modal.service'; -import { - DsoWithdrawnReinstateModalService, - REQUEST_REINSTATE, - REQUEST_WITHDRAWN, -} from './dso-withdrawn-reinstate-service/dso-withdrawn-reinstate-modal.service'; - -/** - * Creates the menus for the dspace object pages - */ -@Injectable({ - providedIn: 'root', -}) -export class DSOEditMenuResolverService { - - constructor( - protected dSpaceObjectDataService: DSpaceObjectDataService, - protected menuService: MenuService, - protected authorizationService: AuthorizationDataService, - protected modalService: NgbModal, - protected dsoVersioningModalService: DsoVersioningModalService, - protected researcherProfileService: ResearcherProfileDataService, - protected notificationsService: NotificationsService, - protected translate: TranslateService, - protected dsoWithdrawnReinstateModalService: DsoWithdrawnReinstateModalService, - private correctionTypeDataService: CorrectionTypeDataService, - ) { - } - - /** - * Initialise all dspace object related menus - */ - resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<{ [key: string]: MenuSection[] }> { - let id = route.params.id; - if (hasNoValue(id) && hasValue(route.queryParams.scope)) { - id = route.queryParams.scope; - } - if (hasNoValue(id)) { - // If there's no ID, we're not on a DSO homepage, so pass on any pre-existing menu route data - return observableOf({ ...route.data?.menu }); - } else { - return this.dSpaceObjectDataService.findById(id, true, false).pipe( - getFirstCompletedRemoteData(), - switchMap((dsoRD) => { - if (dsoRD.hasSucceeded) { - const dso = dsoRD.payload; - return combineLatest(this.getDsoMenus(dso, route, state)).pipe( - // Menu sections are retrieved as an array of arrays and flattened into a single array - map((combinedMenus) => [].concat.apply([], combinedMenus)), - map((menus) => this.addDsoUuidToMenuIDs(menus, dso)), - map((menus) => { - return { - ...route.data?.menu, - [MenuID.DSO_EDIT]: menus, - }; - }), - ); - } else { - return observableOf({ ...route.data?.menu }); - } - }), - ); - } - } - - /** - * Return all the menus for a dso based on the route and state - */ - getDsoMenus(dso, route, state): Observable[] { - return [ - this.getItemMenu(dso), - this.getComColMenu(dso), - this.getCommonMenu(dso, state), - ]; - } - - /** - * Get the common menus between all dspace objects - */ - protected getCommonMenu(dso, state): Observable { - return combineLatest([ - this.authorizationService.isAuthorized(FeatureID.CanEditMetadata, dso.self), - ]).pipe( - map(([canEditItem]) => { - return [ - { - id: 'edit-dso', - active: false, - visible: canEditItem, - model: { - type: MenuItemType.LINK, - text: this.getDsoType(dso) + '.page.edit', - link: new URLCombiner(getDSORoute(dso), 'edit', 'metadata').toString(), - } as LinkMenuItemModel, - icon: 'pencil-alt', - index: 2, - }, - ]; - }), - ); - } - - /** - * Get item specific menus - */ - protected getItemMenu(dso): Observable { - if (dso instanceof Item) { - return combineLatest([ - this.authorizationService.isAuthorized(FeatureID.CanCreateVersion, dso.self), - this.dsoVersioningModalService.isNewVersionButtonDisabled(dso), - this.dsoVersioningModalService.getVersioningTooltipMessage(dso, 'item.page.version.hasDraft', 'item.page.version.create'), - this.authorizationService.isAuthorized(FeatureID.CanSynchronizeWithORCID, dso.self), - this.authorizationService.isAuthorized(FeatureID.CanClaimItem, dso.self), - this.correctionTypeDataService.findByItem(dso.uuid, true).pipe( - getFirstCompletedRemoteData(), - getRemoteDataPayload()), - ]).pipe( - map(([canCreateVersion, disableVersioning, versionTooltip, canSynchronizeWithOrcid, canClaimItem, correction]) => { - const isPerson = this.getDsoType(dso) === 'person'; - return [ - { - id: 'orcid-dso', - active: false, - visible: isPerson && canSynchronizeWithOrcid, - model: { - type: MenuItemType.LINK, - text: 'item.page.orcid.tooltip', - link: new URLCombiner(getDSORoute(dso), 'orcid').toString(), - } as LinkMenuItemModel, - icon: 'orcid fab fa-lg', - index: 0, - }, - { - id: 'version-dso', - active: false, - visible: canCreateVersion, - model: { - type: MenuItemType.ONCLICK, - text: versionTooltip, - disabled: disableVersioning, - function: () => { - this.dsoVersioningModalService.openCreateVersionModal(dso); - }, - } as OnClickMenuItemModel, - icon: 'code-branch', - index: 1, - }, - { - id: 'claim-dso', - active: false, - visible: isPerson && canClaimItem, - model: { - type: MenuItemType.ONCLICK, - text: 'item.page.claim.button', - function: () => { - this.claimResearcher(dso); - }, - } as OnClickMenuItemModel, - icon: 'hand-paper', - index: 3, - }, - { - id: 'withdrawn-item', - active: false, - visible: dso.isArchived && correction?.page.some((c) => c.topic === REQUEST_WITHDRAWN), - model: { - type: MenuItemType.ONCLICK, - text:'item.page.withdrawn', - function: () => { - this.dsoWithdrawnReinstateModalService.openCreateWithdrawnReinstateModal(dso, 'request-withdrawn', dso.isArchived); - }, - } as OnClickMenuItemModel, - icon: 'eye-slash', - index: 4, - }, - { - id: 'reinstate-item', - active: false, - visible: dso.isWithdrawn && correction?.page.some((c) => c.topic === REQUEST_REINSTATE), - model: { - type: MenuItemType.ONCLICK, - text:'item.page.reinstate', - function: () => { - this.dsoWithdrawnReinstateModalService.openCreateWithdrawnReinstateModal(dso, 'request-reinstate', dso.isArchived); - }, - } as OnClickMenuItemModel, - icon: 'eye', - index: 5, - }, - ]; - }), - ); - } else { - return observableOf([]); - } - } - - /** - * Get Community/Collection-specific menus - */ - protected getComColMenu(dso): Observable { - if (dso instanceof Community || dso instanceof Collection) { - return combineLatest([ - this.authorizationService.isAuthorized(FeatureID.CanSubscribe, dso.self), - ]).pipe( - map(([canSubscribe]) => { - return [ - { - id: 'subscribe', - active: false, - visible: canSubscribe, - model: { - type: MenuItemType.ONCLICK, - text: 'subscriptions.tooltip', - function: () => { - const modalRef = this.modalService.open(SubscriptionModalComponent); - modalRef.componentInstance.dso = dso; - }, - } as OnClickMenuItemModel, - icon: 'bell', - index: 4, - }, - ]; - }), - ); - } else { - return observableOf([]); - } - } - - /** - * Claim a researcher by creating a profile - * Shows notifications and/or hides the menu section on success/error - */ - protected claimResearcher(dso) { - this.researcherProfileService.createFromExternalSourceAndReturnRelatedItemId(dso.self) - .subscribe((id: string) => { - if (isNotEmpty(id)) { - this.notificationsService.success(this.translate.get('researcherprofile.success.claim.title'), - this.translate.get('researcherprofile.success.claim.body')); - this.authorizationService.invalidateAuthorizationsRequestCache(); - this.menuService.hideMenuSection(MenuID.DSO_EDIT, 'claim-dso-' + dso.uuid); - } else { - this.notificationsService.error( - this.translate.get('researcherprofile.error.claim.title'), - this.translate.get('researcherprofile.error.claim.body')); - } - }); - } - - /** - * Retrieve the dso or entity type for an object to be used in generic messages - */ - protected getDsoType(dso) { - const renderType = dso.getRenderTypes()[0]; - if (typeof renderType === 'string' || renderType instanceof String) { - return renderType.toLowerCase(); - } else { - return dso.type.toString().toLowerCase(); - } - } - - /** - * Add the dso uuid to all provided menu ids and parent ids - */ - protected addDsoUuidToMenuIDs(menus, dso) { - return menus.map((menu) => { - Object.assign(menu, { - id: menu.id + '-' + dso.uuid, - }); - if (hasValue(menu.parentID)) { - Object.assign(menu, { - parentID: menu.parentID + '-' + dso.uuid, - }); - } - return menu; - }); - } - -} diff --git a/src/app/statistics-page/statistics-page-routes.ts b/src/app/statistics-page/statistics-page-routes.ts index 69bcc6b41c..96de7b8273 100644 --- a/src/app/statistics-page/statistics-page-routes.ts +++ b/src/app/statistics-page/statistics-page-routes.ts @@ -41,6 +41,19 @@ export const ROUTES: Route[] = [ component: ThemedItemStatisticsPageComponent, canActivate: [statisticsAdministratorGuard], }, + { + path: `entities/:entityType/:id`, + resolve: { + scope: itemResolver, + breadcrumb: i18nBreadcrumbResolver, + }, + data: { + title: 'statistics.title', + breadcrumbKey: 'statistics', + }, + component: ThemedItemStatisticsPageComponent, + canActivate: [statisticsAdministratorGuard], + }, { path: `collections/:id`, resolve: { From 3fecbc245bc8e3c6c2b6744a431f57dbbb2e77c6 Mon Sep 17 00:00:00 2001 From: Yury Bondarenko Date: Fri, 7 Feb 2025 17:57:42 +0100 Subject: [PATCH 034/157] Fix tests pt. 1 --- ...le-admin-sidebar-section.component.spec.ts | 7 +-- ...-menu-expandable-section.component.spec.ts | 3 +- .../menu-section.component.spec.ts | 4 +- src/app/shared/menu/menu.component.spec.ts | 39 +------------ src/app/shared/menu/menu.component.ts | 45 +++------------ .../shared/menu/providers/browse.menu.spec.ts | 2 +- .../menu/providers/statistics.menu.spec.ts | 27 +++++++++ .../shared/menu/providers/statistics.menu.ts | 55 ++++++++++++------- 8 files changed, 77 insertions(+), 105 deletions(-) diff --git a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.spec.ts b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.spec.ts index 0ec0307427..fb227926b6 100644 --- a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.spec.ts +++ b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.spec.ts @@ -79,17 +79,14 @@ describe('ExpandableAdminSidebarSectionComponent', () => { describe('when there are no subsections', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, TranslateModule.forRoot()], - declarations: [ExpandableAdminSidebarSectionComponent, TestComponent], + imports: [NoopAnimationsModule, TranslateModule.forRoot(), ExpandableAdminSidebarSectionComponent, TestComponent], providers: [ { provide: 'sectionDataProvider', useValue: { icon: iconString, model: {} } }, { provide: MenuService, useValue: menuService }, { provide: CSSVariableService, useClass: CSSVariableServiceStub }, { provide: Router, useValue: new RouterStub() }, ], - }).overrideComponent(ExpandableAdminSidebarSectionComponent, { - }) - .compileComponents(); + }).compileComponents(); })); beforeEach(() => { diff --git a/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.spec.ts b/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.spec.ts index 35eecb60ab..fa11538526 100644 --- a/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.spec.ts +++ b/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.spec.ts @@ -74,8 +74,7 @@ describe('DsoEditMenuExpandableSectionComponent', () => { describe('when there are no subsections', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot()], - declarations: [DsoEditMenuExpandableSectionComponent, TestComponent], + imports: [TranslateModule.forRoot(), DsoEditMenuExpandableSectionComponent, TestComponent], providers: [ { provide: 'sectionDataProvider', useValue: dummySection }, { provide: MenuService, useValue: menuService }, diff --git a/src/app/shared/menu/menu-section/menu-section.component.spec.ts b/src/app/shared/menu/menu-section/menu-section.component.spec.ts index 952a1c77ba..1cfaa1dd99 100644 --- a/src/app/shared/menu/menu-section/menu-section.component.spec.ts +++ b/src/app/shared/menu/menu-section/menu-section.component.spec.ts @@ -23,6 +23,7 @@ import { AbstractMenuSectionComponent } from './abstract-menu-section.component' @Component({ selector: 'ds-some-menu-section', template: '', + standalone: true, }) class SomeMenuSectionComponent extends AbstractMenuSectionComponent { constructor( @@ -47,8 +48,7 @@ describe('MenuSectionComponent', () => { active: false, } as any; TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), NoopAnimationsModule, SomeMenuSectionComponent], - declarations: [AbstractMenuSectionComponent], + imports: [TranslateModule.forRoot(), NoopAnimationsModule, SomeMenuSectionComponent, AbstractMenuSectionComponent], providers: [ { provide: Injector, useValue: {} }, { provide: MenuService, useClass: MenuServiceStub }, diff --git a/src/app/shared/menu/menu.component.spec.ts b/src/app/shared/menu/menu.component.spec.ts index e128fac3ab..57ad797b37 100644 --- a/src/app/shared/menu/menu.component.spec.ts +++ b/src/app/shared/menu/menu.component.spec.ts @@ -54,6 +54,7 @@ const mockMenuID = 'mock-menuID' as MenuID; // eslint-disable-next-line @angular-eslint/component-selector selector: '', template: '', + standalone: true, }) @rendersSectionForMenu(mockMenuID, true) class TestExpandableMenuComponent { @@ -63,6 +64,7 @@ class TestExpandableMenuComponent { // eslint-disable-next-line @angular-eslint/component-selector selector: '', template: '', + standalone: true, }) @rendersSectionForMenu(mockMenuID, false) class TestMenuComponent { @@ -85,8 +87,6 @@ describe('MenuComponent', () => { visible: true, }; - const mockMenuID = 'mock-menuID' as MenuID; - const mockStatisticSection = { 'id': 'statistics_site', 'active': true, 'visible': true, 'index': 2, 'type': 'statistics', 'model': { 'type': 1, 'text': 'menu.section.statistics', 'link': 'statistics' } }; let authorizationService: AuthorizationDataService; @@ -144,7 +144,7 @@ describe('MenuComponent', () => { }); TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), NoopAnimationsModule, RouterTestingModule, MenuComponent, StoreModule.forRoot(authReducer, storeModuleConfig)], + imports: [TranslateModule.forRoot(), NoopAnimationsModule, RouterTestingModule, MenuComponent, StoreModule.forRoot(authReducer, storeModuleConfig), TestExpandableMenuComponent, TestMenuComponent], providers: [ Injector, { provide: ThemeService, useValue: getMockThemeService() }, @@ -152,8 +152,6 @@ describe('MenuComponent', () => { provideMockStore({ initialState }), { provide: AuthorizationDataService, useValue: authorizationService }, { provide: ActivatedRoute, useValue: routeStub }, - TestExpandableMenuComponent, - TestMenuComponent, ], schemas: [NO_ERRORS_SCHEMA], }).overrideComponent(MenuComponent, { @@ -272,35 +270,4 @@ describe('MenuComponent', () => { expect(menuService.collapseMenuPreview).toHaveBeenCalledWith(comp.menuID); })); }); - - describe('when unauthorized statistics', () => { - - beforeEach(() => { - (authorizationService as any).isAuthorized.and.returnValue(observableOf(false)); - fixture.detectChanges(); - }); - - it('should return observable of empty object', done => { - comp.getAuthorizedStatistics(mockStatisticSection).subscribe((res) => { - expect(res).toEqual({}); - done(); - }); - }); - }); - - describe('get authorized statistics', () => { - - beforeEach(() => { - (authorizationService as any).isAuthorized.and.returnValue(observableOf(true)); - fixture.detectChanges(); - }); - - it('should return observable of statistics section menu', done => { - comp.getAuthorizedStatistics(mockStatisticSection).subscribe((res) => { - expect(res).toEqual(mockStatisticSection); - done(); - }); - }); - }); - }); diff --git a/src/app/shared/menu/menu.component.ts b/src/app/shared/menu/menu.component.ts index 97f9d95480..4db4f724e1 100644 --- a/src/app/shared/menu/menu.component.ts +++ b/src/app/shared/menu/menu.component.ts @@ -9,29 +9,26 @@ import { ActivatedRoute } from '@angular/router'; import { BehaviorSubject, Observable, - of as observableOf, Subscription, } from 'rxjs'; import { distinctUntilChanged, map, - mergeMap, switchMap, } from 'rxjs/operators'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; -import { FeatureID } from '../../core/data/feature-authorization/feature-id'; import { GenericConstructor } from '../../core/shared/generic-constructor'; import { hasValue, isNotEmptyOperator, } from '../empty.util'; import { ThemeService } from '../theme-support/theme.service'; -import { MenuService } from './menu.service'; import { MenuID } from './menu-id.model'; import { getComponentForMenu } from './menu-section.decorator'; import { MenuSection } from './menu-section.model'; import { AbstractMenuSectionComponent } from './menu-section/abstract-menu-section.component'; +import { MenuService } from './menu.service'; /** * A basic implementation of a MenuComponent @@ -93,8 +90,12 @@ export class MenuComponent implements OnInit, OnDestroy { private activatedRouteLastChild: ActivatedRoute; - constructor(protected menuService: MenuService, protected injector: Injector, public authorizationService: AuthorizationDataService, - public route: ActivatedRoute, protected themeService: ThemeService, + constructor( + protected menuService: MenuService, + protected injector: Injector, + public authorizationService: AuthorizationDataService, + public route: ActivatedRoute, + protected themeService: ThemeService, ) { } @@ -113,12 +114,6 @@ export class MenuComponent implements OnInit, OnDestroy { // if you return an array from a switchMap it will emit each element as a separate event. // So this switchMap is equivalent to a subscribe with a forEach inside switchMap((sections: MenuSection[]) => sections), - mergeMap((section: MenuSection) => { - if (section.id.includes('statistics')) { - return this.getAuthorizedStatistics(section); - } - return observableOf(section); - }), isNotEmptyOperator(), switchMap((section: MenuSection) => this.getSectionComponent(section).pipe( map((component: GenericConstructor) => ({ section, component })), @@ -146,32 +141,6 @@ export class MenuComponent implements OnInit, OnDestroy { } } - /** - * Get section of statistics after checking authorization - */ - getAuthorizedStatistics(section) { - return this.activatedRouteLastChild.data.pipe( - switchMap((data) => { - return this.authorizationService.isAuthorized(FeatureID.CanViewUsageStatistics, this.getObjectUrl(data)).pipe( - map((canViewUsageStatistics: boolean) => { - if (!canViewUsageStatistics) { - return {}; - } else { - return section; - } - })); - }), - ); - } - - /** - * Get statistics route dso data - */ - getObjectUrl(data) { - const object = data.site ? data.site : data.dso?.payload; - return object?._links?.self?.href; - } - /** * Collapse this menu when it's currently expanded, expand it when its currently collapsed * @param {Event} event The user event that triggered this method diff --git a/src/app/shared/menu/providers/browse.menu.spec.ts b/src/app/shared/menu/providers/browse.menu.spec.ts index 663c4825ab..ce395b05ee 100644 --- a/src/app/shared/menu/providers/browse.menu.spec.ts +++ b/src/app/shared/menu/providers/browse.menu.spec.ts @@ -51,7 +51,7 @@ describe('BrowseMenuProvider', () => { let provider: BrowseMenuProvider; - let browseServiceStub = new BrowseServiceStub(); + let browseServiceStub = BrowseServiceStub; beforeEach(() => { spyOn(browseServiceStub, 'getBrowseDefinitions').and.returnValue( diff --git a/src/app/shared/menu/providers/statistics.menu.spec.ts b/src/app/shared/menu/providers/statistics.menu.spec.ts index ee1c0c47ad..09748d4360 100644 --- a/src/app/shared/menu/providers/statistics.menu.spec.ts +++ b/src/app/shared/menu/providers/statistics.menu.spec.ts @@ -1,5 +1,8 @@ +import { inject } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { Item } from '../../../core/shared/item.model'; import { ITEM } from '../../../core/shared/item.resource-type'; @@ -34,6 +37,18 @@ describe('StatisticsMenuProvider', () => { }, ]; + const expectedSectionsForItemInvisible: PartialMenuSection[] = [ + { + visible: false, + model: { + type: MenuItemType.LINK, + text: 'menu.section.statistics', + link: `statistics/items/test-item-uuid`, + }, + icon: 'chart-line', + }, + ]; + let provider: StatisticsMenuProvider; const item: Item = Object.assign(new Item(), { @@ -57,13 +72,18 @@ describe('StatisticsMenuProvider', () => { }], }, }); + let authorizationService: AuthorizationDataService; beforeEach(() => { + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(true), + }); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], providers: [ StatisticsMenuProvider, + { provide: AuthorizationDataService, useValue: authorizationService }, ], }); provider = TestBed.inject(StatisticsMenuProvider); @@ -86,6 +106,13 @@ describe('StatisticsMenuProvider', () => { done(); }); }); + it('should not return anything if not authorized to view statistics', (done) => { + (TestBed.inject(AuthorizationDataService) as any).isAuthorized.and.returnValue(observableOf(false)); + provider.getSectionsForContext(item).subscribe((sections) => { + expect(sections).toEqual(expectedSectionsForItemInvisible); + done(); + }); + }); }); describe('getRouteContext', () => { diff --git a/src/app/shared/menu/providers/statistics.menu.ts b/src/app/shared/menu/providers/statistics.menu.ts index 37f17cd260..f2ac73b708 100644 --- a/src/app/shared/menu/providers/statistics.menu.ts +++ b/src/app/shared/menu/providers/statistics.menu.ts @@ -12,11 +12,15 @@ import { RouterStateSnapshot, } from '@angular/router'; import { + combineLatest, + map, Observable, of, } from 'rxjs'; import { getDSORoute } from '../../../app-routing-paths'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; import { RemoteData } from '../../../core/data/remote-data'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { @@ -34,6 +38,11 @@ import { AbstractRouteContextMenuProvider } from './helper-providers/route-conte */ @Injectable() export class StatisticsMenuProvider extends AbstractRouteContextMenuProvider { + constructor( + protected authorizationService: AuthorizationDataService, + ) { + super(); + } public getRouteContext(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { let dsoRD: RemoteData = route.data.dso; @@ -51,28 +60,32 @@ export class StatisticsMenuProvider extends AbstractRouteContextMenuProvider { + return combineLatest([ + this.authorizationService.isAuthorized(FeatureID.CanViewUsageStatistics, dso?._links.self.href), + ]).pipe( + map(([authorized]) => { + let link = `statistics`; - let link = `statistics`; + let dsoRoute; + if (hasValue(dso)) { + dsoRoute = getDSORoute(dso); + if (hasValue(dsoRoute)) { + link = `statistics${dsoRoute}`; + } + } - let dsoRoute; - if (hasValue(dso)) { - dsoRoute = getDSORoute(dso); - if (hasValue(dsoRoute)) { - link = `statistics${dsoRoute}`; - } - } - - return of([ - { - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.statistics', - link, - }, - icon: 'chart-line', - }, - ] as PartialMenuSection[]); + return [ + { + visible: authorized, + model: { + type: MenuItemType.LINK, + text: 'menu.section.statistics', + link, + }, + icon: 'chart-line', + }, + ]; + }), + ); } - } From 516dd99a2641317747acbcc135d0b59408ff96cd Mon Sep 17 00:00:00 2001 From: Yury Bondarenko Date: Fri, 7 Feb 2025 18:31:01 +0100 Subject: [PATCH 035/157] Fix remaining lint issues --- src/app/collection-page/collection-page-routes.ts | 2 +- src/app/community-page/community-page-routes.ts | 2 +- src/app/item-page/item-page-routes.ts | 4 ++-- src/app/shared/menu/menu-provider.model.ts | 2 +- src/app/shared/menu/menu.component.ts | 2 +- src/app/shared/menu/providers/statistics.menu.spec.ts | 3 +-- 6 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/app/collection-page/collection-page-routes.ts b/src/app/collection-page/collection-page-routes.ts index ffac37c0d1..9df71148e8 100644 --- a/src/app/collection-page/collection-page-routes.ts +++ b/src/app/collection-page/collection-page-routes.ts @@ -9,13 +9,13 @@ import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso import { ComcolBrowseByComponent } from '../shared/comcol/sections/comcol-browse-by/comcol-browse-by.component'; import { ComcolSearchSectionComponent } from '../shared/comcol/sections/comcol-search-section/comcol-search-section.component'; import { MenuRoute } from '../shared/menu/menu-route.model'; +import { collectionPageResolver } from './collection-page.resolver'; 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 { CreateCollectionPageComponent } from './create-collection-page/create-collection-page.component'; import { createCollectionPageGuard } from './create-collection-page/create-collection-page.guard'; import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component'; diff --git a/src/app/community-page/community-page-routes.ts b/src/app/community-page/community-page-routes.ts index 588cc59799..ede79d687d 100644 --- a/src/app/community-page/community-page-routes.ts +++ b/src/app/community-page/community-page-routes.ts @@ -8,12 +8,12 @@ import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso import { ComcolBrowseByComponent } from '../shared/comcol/sections/comcol-browse-by/comcol-browse-by.component'; import { ComcolSearchSectionComponent } from '../shared/comcol/sections/comcol-search-section/comcol-search-section.component'; import { MenuRoute } from '../shared/menu/menu-route.model'; +import { communityPageResolver } from './community-page.resolver'; 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'; diff --git a/src/app/item-page/item-page-routes.ts b/src/app/item-page/item-page-routes.ts index 1b63ecb520..7949c40471 100644 --- a/src/app/item-page/item-page-routes.ts +++ b/src/app/item-page/item-page-routes.ts @@ -7,17 +7,17 @@ import { MenuRoute } from '../shared/menu/menu-route.model'; import { BitstreamRequestACopyPageComponent } from './bitstreams/request-a-copy/bitstream-request-a-copy-page.component'; import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component'; import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component'; +import { itemPageResolver } from './item-page.resolver'; 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 { orcidPageGuard } from './orcid-page/orcid-page.guard'; 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'; +import { VersionPageComponent } from './version-page/version-page/version-page.component'; export const ROUTES: Route[] = [ { diff --git a/src/app/shared/menu/menu-provider.model.ts b/src/app/shared/menu/menu-provider.model.ts index 2117ffe8b6..9f6166519f 100644 --- a/src/app/shared/menu/menu-provider.model.ts +++ b/src/app/shared/menu/menu-provider.model.ts @@ -5,8 +5,8 @@ * * http://www.dspace.org/license/ */ -import { Type } from '@angular/core'; /* eslint-disable max-classes-per-file */ +import { Type } from '@angular/core'; import { ActivatedRouteSnapshot, RouterStateSnapshot, diff --git a/src/app/shared/menu/menu.component.ts b/src/app/shared/menu/menu.component.ts index 4db4f724e1..b3ed0a650d 100644 --- a/src/app/shared/menu/menu.component.ts +++ b/src/app/shared/menu/menu.component.ts @@ -24,11 +24,11 @@ import { isNotEmptyOperator, } from '../empty.util'; import { ThemeService } from '../theme-support/theme.service'; +import { MenuService } from './menu.service'; import { MenuID } from './menu-id.model'; import { getComponentForMenu } from './menu-section.decorator'; import { MenuSection } from './menu-section.model'; import { AbstractMenuSectionComponent } from './menu-section/abstract-menu-section.component'; -import { MenuService } from './menu.service'; /** * A basic implementation of a MenuComponent diff --git a/src/app/shared/menu/providers/statistics.menu.spec.ts b/src/app/shared/menu/providers/statistics.menu.spec.ts index 09748d4360..a50f8d3746 100644 --- a/src/app/shared/menu/providers/statistics.menu.spec.ts +++ b/src/app/shared/menu/providers/statistics.menu.spec.ts @@ -1,9 +1,8 @@ -import { inject } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; -import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { Item } from '../../../core/shared/item.model'; import { ITEM } from '../../../core/shared/item.resource-type'; import { createSuccessfulRemoteDataObject } from '../../remote-data.utils'; From 8b90d999acc4b332ff55f38a0fa5f6e0dde86e4e Mon Sep 17 00:00:00 2001 From: Andreas Awouters Date: Wed, 5 Feb 2025 13:59:19 +0100 Subject: [PATCH 036/157] 125969: Move constructor to server-cookie.service This makes it possible to remove the REQUEST import from cookie.service.ts which solves a typescript issue. --- src/app/core/services/cookie.service.ts | 8 +------- src/app/core/services/server-cookie.service.ts | 7 ++++++- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/app/core/services/cookie.service.ts b/src/app/core/services/cookie.service.ts index 0098e3ace4..939e19f317 100644 --- a/src/app/core/services/cookie.service.ts +++ b/src/app/core/services/cookie.service.ts @@ -1,7 +1,4 @@ -import { Inject, Injectable } from '@angular/core'; - -import { REQUEST } from '@nguniversal/express-engine/tokens'; - +import { Injectable } from '@angular/core'; import { Subject , Observable } from 'rxjs'; import { CookieAttributes } from 'js-cookie'; @@ -22,9 +19,6 @@ export abstract class CookieService implements ICookieService { protected readonly cookieSource = new Subject<{ readonly [key: string]: any }>(); public readonly cookies$ = this.cookieSource.asObservable(); - constructor(@Inject(REQUEST) protected req: any) { - } - public abstract set(name: string, value: any, options?: CookieAttributes): void; public abstract remove(name: string, options?: CookieAttributes): void; diff --git a/src/app/core/services/server-cookie.service.ts b/src/app/core/services/server-cookie.service.ts index 6ae3525c74..1cc7d4414f 100644 --- a/src/app/core/services/server-cookie.service.ts +++ b/src/app/core/services/server-cookie.service.ts @@ -1,10 +1,15 @@ -import { Injectable } from '@angular/core'; +import { Injectable, Inject } from '@angular/core'; import { CookieAttributes } from 'js-cookie'; import { CookieService, ICookieService } from './cookie.service'; +import { REQUEST } from '@nguniversal/express-engine/tokens'; @Injectable() export class ServerCookieService extends CookieService implements ICookieService { + constructor(@Inject(REQUEST) protected req: any) { + super(); + } + public set(name: string, value: any, options?: CookieAttributes): void { return; } From f82426b1dc8853b6a984fba2523474f145ecd3eb Mon Sep 17 00:00:00 2001 From: Andreas Awouters Date: Wed, 5 Feb 2025 16:05:48 +0100 Subject: [PATCH 037/157] 125969: Get rid of REQUEST and RESPONSE imports in AuthService --- src/app/core/auth/auth.service.ts | 30 +++++------- src/app/core/auth/server-auth.service.ts | 60 ++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 22 deletions(-) diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 6604936cde..b2bf590184 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -1,7 +1,6 @@ -import { Inject, Injectable, Optional } from '@angular/core'; +import { Inject, Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { HttpHeaders } from '@angular/common/http'; -import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { Observable, of as observableOf } from 'rxjs'; import { filter, map, startWith, switchMap, take } from 'rxjs/operators'; @@ -79,18 +78,17 @@ export class AuthService { */ private tokenRefreshTimer; - constructor(@Inject(REQUEST) protected req: any, - @Inject(NativeWindowService) protected _window: NativeWindowRef, - @Optional() @Inject(RESPONSE) private response: any, - protected authRequestService: AuthRequestService, - protected epersonService: EPersonDataService, - protected router: Router, - protected routeService: RouteService, - protected storage: CookieService, - protected store: Store, - protected hardRedirectService: HardRedirectService, - private notificationService: NotificationsService, - private translateService: TranslateService + constructor( + @Inject(NativeWindowService) protected _window: NativeWindowRef, + protected authRequestService: AuthRequestService, + protected epersonService: EPersonDataService, + protected router: Router, + protected routeService: RouteService, + protected storage: CookieService, + protected store: Store, + protected hardRedirectService: HardRedirectService, + protected notificationService: NotificationsService, + protected translateService: TranslateService ) { this.store.pipe( // when this service is constructed the store is not fully initialized yet @@ -473,10 +471,6 @@ export class AuthService { if (this._window.nativeWindow.location) { // Hard redirect to login page, so that all state is definitely lost this._window.nativeWindow.location.href = redirectUrl; - } else if (this.response) { - if (!this.response._headerSent) { - this.response.redirect(302, redirectUrl); - } } else { this.router.navigateByUrl(redirectUrl); } diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index fc8ab18bfb..e2d5ec8131 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -1,15 +1,25 @@ -import { Injectable } from '@angular/core'; +import { Injectable, Inject, Optional } from '@angular/core'; import { HttpHeaders } from '@angular/common/http'; - +import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; - import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import { AuthService } from './auth.service'; +import { AuthService, LOGIN_ROUTE } from './auth.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; import { RemoteData } from '../data/remote-data'; +import { NativeWindowService, NativeWindowRef } from '../services/window.service'; +import { AuthRequestService } from './auth-request.service'; +import { EPersonDataService } from '../eperson/eperson-data.service'; +import { Router } from '@angular/router'; +import { RouteService } from '../services/route.service'; +import { CookieService } from '../services/cookie.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '../../app.reducer'; +import { HardRedirectService } from '../services/hard-redirect.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; /** * The auth service. @@ -17,6 +27,34 @@ import { RemoteData } from '../data/remote-data'; @Injectable() export class ServerAuthService extends AuthService { + constructor( + @Inject(REQUEST) protected req: any, + @Optional() @Inject(RESPONSE) private response: any, + @Inject(NativeWindowService) protected _window: NativeWindowRef, + protected authRequestService: AuthRequestService, + protected epersonService: EPersonDataService, + protected router: Router, + protected routeService: RouteService, + protected storage: CookieService, + protected store: Store, + protected hardRedirectService: HardRedirectService, + protected notificationService: NotificationsService, + protected translateService: TranslateService + ) { + super( + _window, + authRequestService, + epersonService, + router, + routeService, + storage, + store, + hardRedirectService, + notificationService, + translateService + ); + } + /** * Returns the authenticated user * @returns {User} @@ -60,4 +98,18 @@ export class ServerAuthService extends AuthService { map((rd: RemoteData) => Object.assign(new AuthStatus(), rd.payload)) ); } + + override redirectToLoginWhenTokenExpired() { + const redirectUrl = LOGIN_ROUTE + '?expired=true'; + if (this._window.nativeWindow.location) { + // Hard redirect to login page, so that all state is definitely lost + this._window.nativeWindow.location.href = redirectUrl; + } else if (this.response) { + if (!this.response._headerSent) { + this.response.redirect(302, redirectUrl); + } + } else { + this.router.navigateByUrl(redirectUrl); + } + } } From 961bb11b59e44b67e86bd74fe5c114163a7fbd59 Mon Sep 17 00:00:00 2001 From: Andreas Awouters Date: Thu, 6 Feb 2025 11:18:53 +0100 Subject: [PATCH 038/157] 125969: Fix auth service tests --- src/app/core/auth/auth.service.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index b38d17aecd..32ceee35d6 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -260,7 +260,7 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (state as any).core.auth = authenticatedState; }); - authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); + authService = new AuthService(window, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); })); it('should return true when user is logged in', () => { @@ -345,7 +345,7 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (state as any).core.auth = authenticatedState; }); - authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); + authService = new AuthService(window, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); storage = (authService as any).storage; routeServiceMock = TestBed.inject(RouteService); routerStub = TestBed.inject(Router); @@ -565,7 +565,7 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (state as any).core.auth = unAuthenticatedState; }); - authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); + authService = new AuthService(window, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); })); it('should return null for the shortlived token', () => { @@ -605,7 +605,7 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (state as any).core.auth = idleState; }); - authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); + authService = new AuthService(window, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); })); it('isUserIdle should return true when user is not idle', () => { From f6263f86a4808e80d9db0a2863e8dadea5ec6b25 Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Thu, 13 Feb 2025 15:57:02 +0100 Subject: [PATCH 039/157] Fix tests and issue with the getID methods --- src/app/shared/menu/menu-provider.model.ts | 2 +- .../shared/menu/menu-provider.service.spec.ts | 29 ++++++++++++------- .../expandable-menu-provider.ts | 2 +- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/app/shared/menu/menu-provider.model.ts b/src/app/shared/menu/menu-provider.model.ts index 026ef034bd..1e72d25206 100644 --- a/src/app/shared/menu/menu-provider.model.ts +++ b/src/app/shared/menu/menu-provider.model.ts @@ -144,7 +144,7 @@ export abstract class AbstractMenuProvider implements MenuProvider { abstract getSections(route?: ActivatedRouteSnapshot, state?: RouterStateSnapshot): Observable; protected getAutomatedSectionId(indexOfSectionInProvider: number): string { - return `${this.menuProviderId}_${indexOfSectionInProvider};` + return `${this.menuProviderId}_${indexOfSectionInProvider}`; } } diff --git a/src/app/shared/menu/menu-provider.service.spec.ts b/src/app/shared/menu/menu-provider.service.spec.ts index 41b4d78626..4576a3b1d8 100644 --- a/src/app/shared/menu/menu-provider.service.spec.ts +++ b/src/app/shared/menu/menu-provider.service.spec.ts @@ -38,7 +38,8 @@ describe('MenuProviderService', () => { const router = { events: observableOf(new ResolveEnd(1, 'test-url', 'test-url-after-redirect', { url: 'test-url', - root: {url: [new UrlSegment('test-url', {})], data: {} + root: { + url: [new UrlSegment('test-url', {})], data: {} }, data: {} } as any)) @@ -61,7 +62,7 @@ describe('MenuProviderService', () => { const persistentProvider1 = new TestMenuProvider(MenuID.PUBLIC, true, 'provider1', 0, undefined, undefined, false, [section]); - const persistentProvider2 = new TestMenuProvider(MenuID.PUBLIC, true, 'provider2', 1, undefined, 'provider1', false, [section]); + const persistentProvider2 = new TestMenuProvider(MenuID.PUBLIC, true, 'provider2', 1, undefined, 'provider1', false, [section, section]); const nonPersistentProvider3 = new TestMenuProvider(MenuID.PUBLIC, false, 'provider3', 2, undefined, undefined, false, [section]); const nonPersistentProvider4 = new TestMenuProvider(MenuID.PUBLIC, false, 'provider4', 3, undefined, 'provider3', false, [section]); const nonPersistentProvider5WithRoutes = new TestMenuProvider(MenuID.PUBLIC, false, 'provider4', 3, [MenuRoute.SIMPLE_COMMUNITY_PAGE, MenuRoute.SIMPLE_COLLECTION_PAGE,], undefined, false, [section]); @@ -69,17 +70,19 @@ describe('MenuProviderService', () => { const listOfProvider = [persistentProvider1, persistentProvider2, nonPersistentProvider3, nonPersistentProvider4, nonPersistentProvider5WithRoutes]; const expectedSection1 = generateAddedSection(persistentProvider1, section); - const expectedSection2 = generateAddedSection(persistentProvider2, section); + const expectedSection21 = generateAddedSection(persistentProvider2, section); + const expectedSection22 = generateAddedSection(persistentProvider2, section, 1); const expectedSection3 = generateAddedSection(nonPersistentProvider3, section); const expectedSection4 = generateAddedSection(nonPersistentProvider4, section); const expectedSection5 = generateAddedSection(nonPersistentProvider5WithRoutes, section); - function generateAddedSection(provider, sectionToAdd) { + function generateAddedSection(provider, sectionToAdd, index = 0) { return { ...sectionToAdd, - id: sectionToAdd.id ?? `${provider.menuProviderId}`, + id: sectionToAdd.id ?? `${provider.menuProviderId}_${index}`, parentID: sectionToAdd.parentID ?? provider.parentID, index: sectionToAdd.index ?? provider.index, + active: false, shouldPersistOnRouteChange: sectionToAdd.shouldPersistOnRouteChange ?? provider.shouldPersistOnRouteChange, alwaysRenderExpandable: sectionToAdd.alwaysRenderExpandable ?? provider.alwaysRenderExpandable, }; @@ -106,7 +109,8 @@ describe('MenuProviderService', () => { menuProviderService.initPersistentMenus(); expect(menuService.addSection).toHaveBeenCalledWith(persistentProvider1.menuID, expectedSection1); - expect(menuService.addSection).toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection2); + expect(menuService.addSection).toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection21); + expect(menuService.addSection).toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection22); expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider3.menuID, expectedSection3); expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider4.menuID, expectedSection4); expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider5WithRoutes.menuID, expectedSection5); @@ -115,7 +119,7 @@ describe('MenuProviderService', () => { describe('resolveRouteMenus with no matching path specific providers', () => { it('should remove the current non persistent menus and add the general non persistent menus', () => { - const route = {data:{}}; + const route = {data: {}}; const state = {url: 'test-url'}; menuProviderService.resolveRouteMenus(route as any, state as any).subscribe(); @@ -124,7 +128,8 @@ describe('MenuProviderService', () => { expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.DSO_EDIT, sectionToBeRemoved.id); expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider1.menuID, expectedSection1); - expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection2); + expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection21); + expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection22); expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider3.menuID, expectedSection3); expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider4.menuID, expectedSection4); expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider5WithRoutes.menuID, expectedSection5); @@ -133,7 +138,7 @@ describe('MenuProviderService', () => { describe('resolveRouteMenus with a matching path specific provider', () => { it('should remove the current non persistent menus and add the general non persistent menus', () => { - const route = {data:{ menuRoute: MenuRoute.SIMPLE_COMMUNITY_PAGE}}; + const route = {data: {menuRoute: MenuRoute.SIMPLE_COMMUNITY_PAGE}}; const state = {url: `xxxx/${COMMUNITY_MODULE_PATH}/xxxxxx`}; menuProviderService.resolveRouteMenus(route as any, state as any).subscribe(); @@ -142,7 +147,8 @@ describe('MenuProviderService', () => { expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.DSO_EDIT, sectionToBeRemoved.id); expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider1.menuID, expectedSection1); - expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection2); + expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection21); + expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection22); expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider3.menuID, expectedSection3); expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider4.menuID, expectedSection4); expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider5WithRoutes.menuID, expectedSection5); @@ -158,7 +164,8 @@ describe('MenuProviderService', () => { expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.DSO_EDIT, sectionToBeRemoved.id); expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider1.menuID, expectedSection1); - expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection2); + expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection21); + expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection22); expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider3.menuID, expectedSection3); expect(menuService.addSection).toHaveBeenCalledWith(nonPersistentProvider4.menuID, expectedSection4); expect(menuService.addSection).not.toHaveBeenCalledWith(nonPersistentProvider5WithRoutes.menuID, expectedSection5); diff --git a/src/app/shared/menu/providers/helper-providers/expandable-menu-provider.ts b/src/app/shared/menu/providers/helper-providers/expandable-menu-provider.ts index 40ab500268..2b773fa24a 100644 --- a/src/app/shared/menu/providers/helper-providers/expandable-menu-provider.ts +++ b/src/app/shared/menu/providers/helper-providers/expandable-menu-provider.ts @@ -63,7 +63,7 @@ export abstract class AbstractExpandableMenuProvider extends AbstractMenuProvide return this.getAutomatedSectionId(0); } protected getAutomatedSectionIdForSubsection(indexOfSubSectionInProvider: number): string { - return `${this.menuProviderId}_0_${indexOfSubSectionInProvider};` + return `${this.menuProviderId}_0_${indexOfSubSectionInProvider}`; } } From 276452e4b9eedda288008a3807d1c0c6ccdfc479 Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Fri, 14 Feb 2025 11:08:47 +0100 Subject: [PATCH 040/157] Update DSO menu to also work for subpaths of the DSO and add missing messages --- .../helper-providers/dso.menu.spec.ts | 4 +-- .../providers/helper-providers/dso.menu.ts | 10 +++++-- .../shared/menu/providers/statistics.menu.ts | 28 +++++-------------- src/assets/i18n/en.json5 | 20 +++++++++++++ 4 files changed, 37 insertions(+), 25 deletions(-) diff --git a/src/app/shared/menu/providers/helper-providers/dso.menu.spec.ts b/src/app/shared/menu/providers/helper-providers/dso.menu.spec.ts index 5d214da5f0..63ed018248 100644 --- a/src/app/shared/menu/providers/helper-providers/dso.menu.spec.ts +++ b/src/app/shared/menu/providers/helper-providers/dso.menu.spec.ts @@ -83,7 +83,7 @@ describe('DSpaceObjectPageMenuProvider', () => { }); }); - it('return undefined when no DSO is present on the current route', (done) => { + it('return the first parent DSO when no DSO is present on the current route', (done) => { const route = { data: {}, parent: { @@ -96,7 +96,7 @@ describe('DSpaceObjectPageMenuProvider', () => { } as any; provider.getRouteContext(route, undefined).subscribe((dso) => { - expect(dso).toBeUndefined(); + expect(dso).toEqual(item); done(); }); }); diff --git a/src/app/shared/menu/providers/helper-providers/dso.menu.ts b/src/app/shared/menu/providers/helper-providers/dso.menu.ts index 4736e65f41..2cd32a7a7e 100644 --- a/src/app/shared/menu/providers/helper-providers/dso.menu.ts +++ b/src/app/shared/menu/providers/helper-providers/dso.menu.ts @@ -8,9 +8,9 @@ import { ActivatedRouteSnapshot, RouterStateSnapshot, } from '@angular/router'; import { Observable, of } from 'rxjs'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; +import { hasNoValue, hasValue } from '../../../empty.util'; import { AbstractRouteContextMenuProvider } from './route-context.menu'; import { RemoteData } from '../../../../core/data/remote-data'; -import { hasValue } from '../../../empty.util'; /** * Helper provider for DSpace object page based menus @@ -21,7 +21,13 @@ export abstract class DSpaceObjectPageMenuProvider extends AbstractRouteContextM * Retrieve the dso from the current route data */ public getRouteContext(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - const dsoRD: RemoteData = route.data.dso; + let dsoRD: RemoteData = route.data.dso; + // Check if one of the parent routes has a DSO + while (hasValue(route.parent) && hasNoValue(dsoRD)) { + route = route.parent; + dsoRD = route.data.dso; + } + if (hasValue(dsoRD) && dsoRD.hasSucceeded && hasValue(dsoRD.payload)) { return of(dsoRD.payload); } else { diff --git a/src/app/shared/menu/providers/statistics.menu.ts b/src/app/shared/menu/providers/statistics.menu.ts index 356f2f53d2..7bc1083e03 100644 --- a/src/app/shared/menu/providers/statistics.menu.ts +++ b/src/app/shared/menu/providers/statistics.menu.ts @@ -7,16 +7,13 @@ */ import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, RouterStateSnapshot, } from '@angular/router'; import { Observable, of, } from 'rxjs'; -import { hasNoValue, hasValue } from '../../empty.util'; +import { hasValue } from '../../empty.util'; import { MenuItemType } from '../menu-item-type.model'; import { PartialMenuSection } from '../menu-provider.model'; -import { AbstractRouteContextMenuProvider } from './helper-providers/route-context.menu'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { RemoteData } from '../../../core/data/remote-data'; import { getDSORoute } from '../../../app-routing-paths'; - +import { DSpaceObjectPageMenuProvider } from './helper-providers/dso.menu'; /** * Menu provider to create the statistics menu section depending on the page it is on @@ -24,22 +21,7 @@ import { getDSORoute } from '../../../app-routing-paths'; * In all other cases the menu section will contain a link to the repository wide statistics */ @Injectable() -export class StatisticsMenuProvider extends AbstractRouteContextMenuProvider { - - public getRouteContext(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - let dsoRD: RemoteData = route.data.dso; - // Check if one of the parent routes has a DSO - while (hasValue(route.parent) && hasNoValue(dsoRD)) { - route = route.parent; - dsoRD = route.data.dso; - } - - if (hasValue(dsoRD) && dsoRD.hasSucceeded && hasValue(dsoRD.payload)) { - return of(dsoRD.payload); - } else { - return of(undefined); - } - } +export class StatisticsMenuProvider extends DSpaceObjectPageMenuProvider { public getSectionsForContext(dso: DSpaceObject): Observable { @@ -66,4 +48,8 @@ export class StatisticsMenuProvider extends AbstractRouteContextMenuProvider Date: Fri, 14 Feb 2025 13:11:15 +0100 Subject: [PATCH 041/157] Fix merge issues and tests - Migrate create-report sections to a new provider - Fix menu component test - Add dso option sections to com/col sub paths - Fix issue with breadcrumbs on the collection page --- src/app/app-routes.ts | 2 +- src/app/app.menus.ts | 8 ++ .../collection-page/collection-page-routes.ts | 5 +- .../community-page/community-page-routes.ts | 10 +- src/app/shared/menu/menu-route.model.ts | 3 + src/app/shared/menu/menu.component.spec.ts | 3 + .../menu/providers/create-report.menu.spec.ts | 100 ++++++++++++++++++ .../menu/providers/create-report.menu.ts | 93 ++++++++++++++++ .../helper-providers/dso.menu.spec.ts | 16 +-- .../providers/helper-providers/dso.menu.ts | 5 +- .../shared/menu/providers/statistics.menu.ts | 12 ++- 11 files changed, 240 insertions(+), 17 deletions(-) create mode 100644 src/app/shared/menu/providers/create-report.menu.spec.ts create mode 100644 src/app/shared/menu/providers/create-report.menu.ts diff --git a/src/app/app-routes.ts b/src/app/app-routes.ts index db71c866f0..25324a66be 100644 --- a/src/app/app-routes.ts +++ b/src/app/app-routes.ts @@ -106,7 +106,7 @@ export const APP_ROUTES: Route[] = [ path: COLLECTION_MODULE_PATH, loadChildren: () => import('./collection-page/collection-page-routes') .then((m) => m.ROUTES), - data: { showBreadcrumbs: false, enableRSS: true }, + data: { enableRSS: true }, canActivate: [endUserAgreementCurrentUserGuard], }, { diff --git a/src/app/app.menus.ts b/src/app/app.menus.ts index b5bf4f167f..46dba503a9 100644 --- a/src/app/app.menus.ts +++ b/src/app/app.menus.ts @@ -13,6 +13,7 @@ import { AdminSearchMenuProvider } from './shared/menu/providers/admin-search.me 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 { CreateReportMenuProvider } from './shared/menu/providers/create-report.menu'; import { CurationMenuProvider } from './shared/menu/providers/curation.menu'; import { DSpaceObjectEditMenuProvider } from './shared/menu/providers/dso-edit.menu'; import { DsoOptionMenuProvider } from './shared/menu/providers/dso-option.menu'; @@ -42,6 +43,7 @@ export const MENUS = buildMenuStructure({ ImportMenuProvider, ExportMenuProvider, AccessControlMenuProvider, + CreateReportMenuProvider, AdminSearchMenuProvider, RegistriesMenuProvider, CurationMenuProvider, @@ -54,11 +56,17 @@ export const MENUS = buildMenuStructure({ DsoOptionMenuProvider.withSubs([ SubscribeMenuProvider.onRoute( MenuRoute.SIMPLE_COMMUNITY_PAGE, + MenuRoute.SIMPLE_COMMUNITY_BROWSE_PAGE, + MenuRoute.SIMPLE_COMMUNITY_SUBCOLCOM_PAGE, MenuRoute.SIMPLE_COLLECTION_PAGE, + MenuRoute.SIMPLE_COLLECTION_BROWSE_PAGE, ), DSpaceObjectEditMenuProvider.onRoute( MenuRoute.SIMPLE_COMMUNITY_PAGE, + MenuRoute.SIMPLE_COMMUNITY_BROWSE_PAGE, + MenuRoute.SIMPLE_COMMUNITY_SUBCOLCOM_PAGE, MenuRoute.SIMPLE_COLLECTION_PAGE, + MenuRoute.SIMPLE_COLLECTION_BROWSE_PAGE, MenuRoute.SIMPLE_ITEM_PAGE, MenuRoute.FULL_ITEM_PAGE, ), diff --git a/src/app/collection-page/collection-page-routes.ts b/src/app/collection-page/collection-page-routes.ts index 9df71148e8..6e368e5953 100644 --- a/src/app/collection-page/collection-page-routes.ts +++ b/src/app/collection-page/collection-page-routes.ts @@ -97,7 +97,10 @@ export const ROUTES: Route[] = [ resolve: { breadcrumb: browseByI18nBreadcrumbResolver, }, - data: { breadcrumbKey: 'browse.metadata' }, + data: { + breadcrumbKey: 'browse.metadata', + menuRoute: MenuRoute.SIMPLE_COLLECTION_BROWSE_PAGE, + }, }, ], }, diff --git a/src/app/community-page/community-page-routes.ts b/src/app/community-page/community-page-routes.ts index ede79d687d..4295ee62a1 100644 --- a/src/app/community-page/community-page-routes.ts +++ b/src/app/community-page/community-page-routes.ts @@ -83,7 +83,10 @@ export const ROUTES: Route[] = [ resolve: { breadcrumb: i18nBreadcrumbResolver, }, - data: { breadcrumbKey: 'community.subcoms-cols' }, + data: { + breadcrumbKey: 'community.subcoms-cols', + menuRoute: MenuRoute.SIMPLE_COMMUNITY_SUBCOLCOM_PAGE, + }, }, { path: 'browse/:id', @@ -93,7 +96,10 @@ export const ROUTES: Route[] = [ resolve: { breadcrumb: browseByI18nBreadcrumbResolver, }, - data: { breadcrumbKey: 'browse.metadata' }, + data: { + breadcrumbKey: 'browse.metadata', + menuRoute: MenuRoute.SIMPLE_COMMUNITY_BROWSE_PAGE, + }, }, ], }, diff --git a/src/app/shared/menu/menu-route.model.ts b/src/app/shared/menu/menu-route.model.ts index db0fe03169..ac65c03805 100644 --- a/src/app/shared/menu/menu-route.model.ts +++ b/src/app/shared/menu/menu-route.model.ts @@ -3,7 +3,10 @@ */ export enum MenuRoute { SIMPLE_COMMUNITY_PAGE = 'simple-community-page', + SIMPLE_COMMUNITY_BROWSE_PAGE = 'simple-community-browse-page', + SIMPLE_COMMUNITY_SUBCOLCOM_PAGE = 'simple-community-subcolcom-page', SIMPLE_COLLECTION_PAGE = 'simple-collection-page', + SIMPLE_COLLECTION_BROWSE_PAGE = 'simple-collection-browse-page', SIMPLE_ITEM_PAGE = 'simple-item-page', FULL_ITEM_PAGE = 'full-item-page', } diff --git a/src/app/shared/menu/menu.component.spec.ts b/src/app/shared/menu/menu.component.spec.ts index 57ad797b37..59aaccb6f8 100644 --- a/src/app/shared/menu/menu.component.spec.ts +++ b/src/app/shared/menu/menu.component.spec.ts @@ -125,6 +125,7 @@ describe('MenuComponent', () => { id: 'section1', active: false, visible: true, + alwaysRenderExpandable: false, model: { type: MenuItemType.LINK, text: 'test', @@ -190,6 +191,7 @@ describe('MenuComponent', () => { id: 'section1', active: false, visible: true, + alwaysRenderExpandable: false, model: { type: MenuItemType.LINK, text: 'test', @@ -201,6 +203,7 @@ describe('MenuComponent', () => { parentID: 'section1', active: false, visible: true, + alwaysRenderExpandable: false, model: { type: MenuItemType.LINK, text: 'test', diff --git a/src/app/shared/menu/providers/create-report.menu.spec.ts b/src/app/shared/menu/providers/create-report.menu.spec.ts new file mode 100644 index 0000000000..0e2a8841d9 --- /dev/null +++ b/src/app/shared/menu/providers/create-report.menu.spec.ts @@ -0,0 +1,100 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { TestBed } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs'; + +import { ConfigurationDataService } from '../../../core/data/configuration-data.service'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { ConfigurationProperty } from '../../../core/shared/configuration-property.model'; +import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; +import { AuthorizationDataServiceStub } from '../../testing/authorization-service.stub'; +import { ConfigurationDataServiceStub } from '../../testing/configuration-data.service.stub'; +import { LinkMenuItemModel } from '../menu-item/models/link.model'; +import { TextMenuItemModel } from '../menu-item/models/text.model'; +import { MenuItemType } from '../menu-item-type.model'; +import { PartialMenuSection } from '../menu-provider.model'; +import { CreateReportMenuProvider } from './create-report.menu'; + +describe('CreateReportMenuProvider', () => { + const expectedTopSection: PartialMenuSection = { + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.reports', + } as TextMenuItemModel, + icon: 'file-alt', + }; + + const expectedSubSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.reports.collections', + link: '/admin/reports/collections', + } as LinkMenuItemModel, + icon: 'user-check', + }, + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.reports.queries', + link: '/admin/reports/queries', + } as LinkMenuItemModel, + icon: 'user-check', + }, + ]; + + let provider: CreateReportMenuProvider; + let authorizationServiceStub = new AuthorizationDataServiceStub(); + let configurationDataService = new ConfigurationDataServiceStub(); + + beforeEach(() => { + spyOn(authorizationServiceStub, 'isAuthorized').and.callFake((id: FeatureID) => { + if (id === FeatureID.CanManageGroups) { + return observableOf(false); + } else { + return observableOf(true); + } + }); + + spyOn(configurationDataService, 'findByPropertyName').and.callFake((property: string) => { + return createSuccessfulRemoteDataObject$(Object.assign({}, new ConfigurationProperty(), { values: ['true'] })); + }); + + TestBed.configureTestingModule({ + providers: [ + CreateReportMenuProvider, + { provide: AuthorizationDataService, useValue: authorizationServiceStub }, + { provide: ConfigurationDataService, useValue: configurationDataService }, + ], + }); + provider = TestBed.inject(CreateReportMenuProvider); + }); + + it('should be created', () => { + expect(provider).toBeTruthy(); + }); + + it('getTopSection should return expected menu section', (done) => { + provider.getTopSection().subscribe((section) => { + expect(section).toEqual(expectedTopSection); + done(); + }); + }); + + it('getSubSections should return expected menu sections', (done) => { + provider.getSubSections().subscribe((sections) => { + expect(sections).toEqual(expectedSubSections); + done(); + }); + }); +}); diff --git a/src/app/shared/menu/providers/create-report.menu.ts b/src/app/shared/menu/providers/create-report.menu.ts new file mode 100644 index 0000000000..0b326d9355 --- /dev/null +++ b/src/app/shared/menu/providers/create-report.menu.ts @@ -0,0 +1,93 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { Injectable } from '@angular/core'; +import { + combineLatest as observableCombineLatest, + Observable, +} from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { ConfigurationDataService } from '../../../core/data/configuration-data.service'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { RemoteData } from '../../../core/data/remote-data'; +import { ConfigurationProperty } from '../../../core/shared/configuration-property.model'; +import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; +import { LinkMenuItemModel } from '../menu-item/models/link.model'; +import { TextMenuItemModel } from '../menu-item/models/text.model'; +import { MenuItemType } from '../menu-item-type.model'; +import { PartialMenuSection } from '../menu-provider.model'; +import { AbstractExpandableMenuProvider } from './helper-providers/expandable-menu-provider'; + +/** + * Menu provider to create the report menu sections + */ +@Injectable() +export class CreateReportMenuProvider extends AbstractExpandableMenuProvider { + constructor( + protected authorizationService: AuthorizationDataService, + protected configurationDataService: ConfigurationDataService, + ) { + super(); + } + + getSubSections(): Observable { + return observableCombineLatest([ + this.configurationDataService.findByPropertyName('contentreport.enable').pipe( + getFirstCompletedRemoteData(), + map((res: RemoteData) => res.hasSucceeded && res.payload && res.payload.values[0] === 'true'), + ), + this.authorizationService.isAuthorized(FeatureID.AdministratorOf), + ]).pipe( + map(([reportEnabled, isSiteAdmin]: [boolean, boolean]) => { + return [ + /* Collections Report */ + { + visible: isSiteAdmin && reportEnabled, + model: { + type: MenuItemType.LINK, + text: 'menu.section.reports.collections', + link: '/admin/reports/collections', + } as LinkMenuItemModel, + icon: 'user-check', + }, + /* Queries Report */ + { + visible: isSiteAdmin && reportEnabled, + model: { + type: MenuItemType.LINK, + text: 'menu.section.reports.queries', + link: '/admin/reports/queries', + } as LinkMenuItemModel, + icon: 'user-check', + }, + ]; + })); + } + + getTopSection(): Observable { + return observableCombineLatest([ + this.configurationDataService.findByPropertyName('contentreport.enable').pipe( + getFirstCompletedRemoteData(), + map((res: RemoteData) => res.hasSucceeded && res.payload && res.payload.values[0] === 'true'), + ), + this.authorizationService.isAuthorized(FeatureID.AdministratorOf), + ]).pipe( + map(([reportEnabled, isSiteAdmin]: [boolean, boolean]) => { + return { + visible: isSiteAdmin && reportEnabled, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.reports', + } as TextMenuItemModel, + icon: 'file-alt', + }; + })); + } +} diff --git a/src/app/shared/menu/providers/helper-providers/dso.menu.spec.ts b/src/app/shared/menu/providers/helper-providers/dso.menu.spec.ts index 38f59a430c..0270d38715 100644 --- a/src/app/shared/menu/providers/helper-providers/dso.menu.spec.ts +++ b/src/app/shared/menu/providers/helper-providers/dso.menu.spec.ts @@ -14,7 +14,7 @@ describe('DSpaceObjectPageMenuProvider', () => { const item: Item = Object.assign(new Item(), { uuid: 'test-item-uuid', type: ITEM.value, - _links: {self: {href: 'self-link'}}, + _links: { self: { href: 'self-link' } }, metadata: { 'dc.title': [{ 'value': 'Untyped Item', @@ -25,7 +25,7 @@ describe('DSpaceObjectPageMenuProvider', () => { const item2: Item = Object.assign(new Item(), { uuid: 'test-item2-uuid', type: ITEM.value, - _links: {self: {href: 'self-link'}}, + _links: { self: { href: 'self-link' } }, metadata: { 'dc.title': [{ 'value': 'Untyped Item 2', @@ -36,7 +36,7 @@ describe('DSpaceObjectPageMenuProvider', () => { const person: Item = Object.assign(new Item(), { uuid: 'test-uuid', type: ITEM.value, - _links: {self: {href: 'self-link'}}, + _links: { self: { href: 'self-link' } }, metadata: { 'dc.title': [{ 'value': 'Person Entity', @@ -50,7 +50,7 @@ describe('DSpaceObjectPageMenuProvider', () => { const collection: Collection = Object.assign(new Collection(), { uuid: 'test-collection-uuid', type: COLLECTION.value, - _links: {self: {href: 'self-link'}}, + _links: { self: { href: 'self-link' } }, metadata: { 'dc.title': [{ 'value': 'Collection', @@ -75,7 +75,7 @@ describe('DSpaceObjectPageMenuProvider', () => { describe('getRouteContext', () => { it('should get the dso from the route', (done) => { - const route = {data: {dso: createSuccessfulRemoteDataObject(item)}} as any; + const route = { data: { dso: createSuccessfulRemoteDataObject(item) } } as any; provider.getRouteContext(route, undefined).subscribe((dso) => { expect(dso).toEqual(item); @@ -89,8 +89,8 @@ describe('DSpaceObjectPageMenuProvider', () => { parent: { data: {}, parent: { - data: {dso: createSuccessfulRemoteDataObject(item)}, - parent: {data: {dso: createSuccessfulRemoteDataObject(item2)}}, + data: { dso: createSuccessfulRemoteDataObject(item) }, + parent: { data: { dso: createSuccessfulRemoteDataObject(item2) } }, }, }, } as any; @@ -101,7 +101,7 @@ describe('DSpaceObjectPageMenuProvider', () => { }); }); it('should return undefined when no dso is found in the route', (done) => { - const route = {data: {}, parent: {data: {}, parent: {data: {}, parent: {data: {}}}}} as any; + const route = { data: {}, parent: { data: {}, parent: { data: {}, parent: { data: {} } } } } as any; provider.getRouteContext(route, undefined).subscribe((dso) => { expect(dso).toBeUndefined(); diff --git a/src/app/shared/menu/providers/helper-providers/dso.menu.ts b/src/app/shared/menu/providers/helper-providers/dso.menu.ts index f19b1cbe53..6440a8340e 100644 --- a/src/app/shared/menu/providers/helper-providers/dso.menu.ts +++ b/src/app/shared/menu/providers/helper-providers/dso.menu.ts @@ -16,7 +16,10 @@ import { import { RemoteData } from '../../../../core/data/remote-data'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; -import { hasNoValue, hasValue } from '../../../empty.util'; +import { + hasNoValue, + hasValue, +} from '../../../empty.util'; import { AbstractRouteContextMenuProvider } from './route-context.menu'; /** diff --git a/src/app/shared/menu/providers/statistics.menu.ts b/src/app/shared/menu/providers/statistics.menu.ts index bf7cb2c3a1..b7bbd96e29 100644 --- a/src/app/shared/menu/providers/statistics.menu.ts +++ b/src/app/shared/menu/providers/statistics.menu.ts @@ -7,15 +7,19 @@ */ import { Injectable } from '@angular/core'; -import { combineLatest, map, Observable, } from 'rxjs'; +import { + combineLatest, + map, + Observable, +} from 'rxjs'; +import { getDSORoute } from '../../../app-routing-paths'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; -import { hasValue, } from '../../empty.util'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { hasValue } from '../../empty.util'; import { MenuItemType } from '../menu-item-type.model'; import { PartialMenuSection } from '../menu-provider.model'; -import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { getDSORoute } from '../../../app-routing-paths'; import { DSpaceObjectPageMenuProvider } from './helper-providers/dso.menu'; /** From 59481abd7f19793a0c94905f85cb433e59feffdc Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Mon, 17 Feb 2025 11:15:46 +0100 Subject: [PATCH 042/157] Remove circulary dependency --- src/app/shared/menu/menu.structure.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/app/shared/menu/menu.structure.ts b/src/app/shared/menu/menu.structure.ts index 0541d110e0..cb74af8a69 100644 --- a/src/app/shared/menu/menu.structure.ts +++ b/src/app/shared/menu/menu.structure.ts @@ -8,7 +8,6 @@ import { InjectionToken, Provider, Type, } from '@angular/core'; import { MenuID } from './menu-id.model'; import { AbstractMenuProvider, MenuProviderTypeWithOptions } from './menu-provider.model'; -import { MenuProviderService } from './menu-provider.service'; import { hasValue, isNotEmpty } from '../empty.util'; import { MenuRoute } from './menu-route.model'; @@ -24,7 +23,6 @@ type MenuStructure = { */ export function buildMenuStructure(structure: MenuStructure): Provider[] { const providers: Provider[] = [ - MenuProviderService, ]; Object.entries(structure).forEach(([menuID, providerTypes]) => { From ea53111aa11ca496c1eb5b992a837691045ad30f Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Mon, 17 Feb 2025 12:57:47 +0100 Subject: [PATCH 043/157] Remove circulary dependency --- src/app/shared/menu/menu.structure.spec.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/app/shared/menu/menu.structure.spec.ts b/src/app/shared/menu/menu.structure.spec.ts index f62ef03cd1..51ab2b2afe 100644 --- a/src/app/shared/menu/menu.structure.spec.ts +++ b/src/app/shared/menu/menu.structure.spec.ts @@ -4,7 +4,6 @@ import { NewMenuProvider } from './providers/new.menu'; import { DsoOptionMenuProvider } from './providers/dso-option.menu'; import { SubscribeMenuProvider } from './providers/comcol-subscribe.menu'; import { buildMenuStructure } from './menu.structure'; -import { MenuProviderService } from './menu-provider.service'; import { BrowseMenuProvider } from './providers/browse.menu'; import { StatisticsMenuProvider } from './providers/statistics.menu'; import { EditMenuProvider } from './providers/edit.menu'; @@ -103,16 +102,15 @@ describe('buildMenuStructure', () => { it('should have a double amount of objects with an additional service after the processing', () => { const result = buildMenuStructure(providerStructure); - expect(result.length).toEqual(orderedProviderTypeList.length * 2 + 1); + expect(result.length).toEqual(orderedProviderTypeList.length * 2); }); it('should return a list with the MenuProviderService and then a resolved provider and provider type for each provider in the provided structure', () => { const result = buildMenuStructure(providerStructure); - expect(result[0]).toEqual(MenuProviderService); orderedProviderTypeList.forEach((provider, index) => { - expect((result[(index + 1) * 2 - 1] as any).deps).toEqual([provider]); - expect(result[(index + 1) * 2]).toEqual(provider); + expect((result[index * 2] as any).deps).toEqual([provider]); + expect(result[index * 2 + 1]).toEqual(provider); }); }); }); From 8b9af3b04cdac76af688ad02b85268581058c84c Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Mon, 17 Feb 2025 12:59:58 +0100 Subject: [PATCH 044/157] Remove circulary dependency --- ...enu-section-Index.model.ts => menu-section-index.model.ts} | 0 src/app/shared/menu/menu.structure.spec.ts | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/app/shared/menu/{menu-section-Index.model.ts => menu-section-index.model.ts} (100%) diff --git a/src/app/shared/menu/menu-section-Index.model.ts b/src/app/shared/menu/menu-section-index.model.ts similarity index 100% rename from src/app/shared/menu/menu-section-Index.model.ts rename to src/app/shared/menu/menu-section-index.model.ts diff --git a/src/app/shared/menu/menu.structure.spec.ts b/src/app/shared/menu/menu.structure.spec.ts index 51ab2b2afe..c11b18590e 100644 --- a/src/app/shared/menu/menu.structure.spec.ts +++ b/src/app/shared/menu/menu.structure.spec.ts @@ -100,12 +100,12 @@ describe('buildMenuStructure', () => { ]; - it('should have a double amount of objects with an additional service after the processing', () => { + it('should have a double amount of objects after the processing', () => { const result = buildMenuStructure(providerStructure); expect(result.length).toEqual(orderedProviderTypeList.length * 2); }); - it('should return a list with the MenuProviderService and then a resolved provider and provider type for each provider in the provided structure', () => { + it('should return a list with a resolved provider and provider type for each provider in the provided structure', () => { const result = buildMenuStructure(providerStructure); orderedProviderTypeList.forEach((provider, index) => { From bb02acf13e4e640066e753600d66012c7395757e Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Fri, 21 Feb 2025 13:23:54 +0100 Subject: [PATCH 045/157] Fix id related issues and add accessibility handle --- src/app/shared/menu/menu-provider.model.ts | 1 + src/app/shared/menu/menu-section.model.ts | 5 +++++ src/app/shared/menu/menu.structure.ts | 6 +++++- .../expandable-menu-provider.spec.ts | 12 ++++++------ .../helper-providers/expandable-menu-provider.ts | 13 +++++++------ .../helper-providers/route-context.menu.spec.ts | 2 +- 6 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/app/shared/menu/menu-provider.model.ts b/src/app/shared/menu/menu-provider.model.ts index 1e72d25206..c17c5e207e 100644 --- a/src/app/shared/menu/menu-provider.model.ts +++ b/src/app/shared/menu/menu-provider.model.ts @@ -19,6 +19,7 @@ import { MenuRoute } from './menu-route.model'; */ export interface PartialMenuSection { id?: string; + accessibilityHandle?: string; visible: boolean; model: MenuItemModels; parentID?: string; diff --git a/src/app/shared/menu/menu-section.model.ts b/src/app/shared/menu/menu-section.model.ts index a91d75302d..8462e27fd8 100644 --- a/src/app/shared/menu/menu-section.model.ts +++ b/src/app/shared/menu/menu-section.model.ts @@ -19,6 +19,11 @@ export interface MenuSection { */ id: string; + /** + * Accessibility handle that can be used to find a specific menu in the html + */ + accessibilityHandle?: string; + /** * Whether this section should be visible. */ diff --git a/src/app/shared/menu/menu.structure.ts b/src/app/shared/menu/menu.structure.ts index cb74af8a69..cd8ff84433 100644 --- a/src/app/shared/menu/menu.structure.ts +++ b/src/app/shared/menu/menu.structure.ts @@ -83,7 +83,11 @@ function addProviderToList(providers: Provider[], providerType: Type { type: MenuItemType.TEXT, text: 'sub.section.test.1', }, - id: `${MenuID.ADMIN}_1_0`, - parentID: `${MenuID.ADMIN}_1`, + id: `${MenuID.ADMIN}_1_0_0`, + parentID: `${MenuID.ADMIN}_1_0`, alwaysRenderExpandable: false, }, { @@ -69,8 +69,8 @@ describe('AbstractExpandableMenuProvider', () => { type: MenuItemType.TEXT, text: 'sub.section.test.2', }, - id: `${MenuID.ADMIN}_1_1`, - parentID: `${MenuID.ADMIN}_1`, + id: `${MenuID.ADMIN}_1_0_1`, + parentID: `${MenuID.ADMIN}_1_0`, alwaysRenderExpandable: false, }, { @@ -80,9 +80,9 @@ describe('AbstractExpandableMenuProvider', () => { text: 'top.section.test', }, icon: 'file-import', - id: `${MenuID.ADMIN}_1`, + id: `${MenuID.ADMIN}_1_0`, alwaysRenderExpandable: true, - } + }, ]; let provider: AbstractExpandableMenuProvider; diff --git a/src/app/shared/menu/providers/helper-providers/expandable-menu-provider.ts b/src/app/shared/menu/providers/helper-providers/expandable-menu-provider.ts index 2b773fa24a..f769b4987c 100644 --- a/src/app/shared/menu/providers/helper-providers/expandable-menu-provider.ts +++ b/src/app/shared/menu/providers/helper-providers/expandable-menu-provider.ts @@ -36,13 +36,14 @@ export abstract class AbstractExpandableMenuProvider extends AbstractMenuProvide this.getSubSections(), ]).pipe( map(( - [partialTopSection, partialSubSections]: [PartialMenuSection, PartialMenuSection[]] + [partialTopSection, partialSubSections]: [PartialMenuSection, PartialMenuSection[]], ) => { + const parentID = partialTopSection.id ?? this.getAutomatedSectionIdForTopSection(); const subSections = partialSubSections.map((partialSub, index) => { return { ...partialSub, - id: partialSub.id ?? `${this.menuProviderId}_${index}`, - parentID: this.menuProviderId, + id: partialSub.id ?? this.getAutomatedSectionIdForSubsection(index), + parentID: parentID, alwaysRenderExpandable: false, }; }); @@ -51,11 +52,11 @@ export abstract class AbstractExpandableMenuProvider extends AbstractMenuProvide ...subSections, { ...partialTopSection, - id: this.menuProviderId, + id: parentID, alwaysRenderExpandable: this.alwaysRenderExpandable, }, ]; - }) + }), ); } @@ -63,7 +64,7 @@ export abstract class AbstractExpandableMenuProvider extends AbstractMenuProvide return this.getAutomatedSectionId(0); } protected getAutomatedSectionIdForSubsection(indexOfSubSectionInProvider: number): string { - return `${this.menuProviderId}_0_${indexOfSubSectionInProvider}`; + return `${this.getAutomatedSectionIdForTopSection()}_${indexOfSubSectionInProvider}`; } } diff --git a/src/app/shared/menu/providers/helper-providers/route-context.menu.spec.ts b/src/app/shared/menu/providers/helper-providers/route-context.menu.spec.ts index 1120681c05..82539e8b7f 100644 --- a/src/app/shared/menu/providers/helper-providers/route-context.menu.spec.ts +++ b/src/app/shared/menu/providers/helper-providers/route-context.menu.spec.ts @@ -16,7 +16,7 @@ import { CacheableObject } from '../../../../core/cache/cacheable-object.model'; import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; -describe('AbstractExpandableMenuProvider', () => { +describe('AbstractRouteContextMenuProvider', () => { class TestClass extends AbstractRouteContextMenuProvider { getRouteContext(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { From 43e4f9de499e3cb19d0193f00b2b592b497a7a44 Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Fri, 21 Feb 2025 14:36:04 +0100 Subject: [PATCH 046/157] Fix e2e issues --- cypress/e2e/item-statistics.cy.ts | 2 +- .../admin-sidebar-section.component.html | 4 ++-- .../admin-sidebar-section.component.ts | 10 ++++++---- .../expandable-admin-sidebar-section.component.html | 6 +++--- src/app/shared/menu/providers/edit.menu.spec.ts | 1 + src/app/shared/menu/providers/edit.menu.ts | 3 ++- src/app/shared/menu/providers/export.menu.spec.ts | 1 + src/app/shared/menu/providers/export.menu.ts | 3 ++- src/app/shared/menu/providers/new.menu.spec.ts | 1 + src/app/shared/menu/providers/new.menu.ts | 3 ++- 10 files changed, 21 insertions(+), 13 deletions(-) diff --git a/cypress/e2e/item-statistics.cy.ts b/cypress/e2e/item-statistics.cy.ts index 6518f595a9..3456e0207b 100644 --- a/cypress/e2e/item-statistics.cy.ts +++ b/cypress/e2e/item-statistics.cy.ts @@ -7,7 +7,7 @@ describe('Item Statistics Page', () => { it('should load if you click on "Statistics" from an Item/Entity page', () => { cy.visit('/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'))); cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click(); - cy.location('pathname').should('eq', ITEMSTATISTICSPAGE); + cy.location('pathname').should('eq', '/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'))); }); it('should contain element ds-item-statistics-page when navigating to an item statistics page', () => { diff --git a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.html b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.html index 30a7a3353b..8d0ac83c32 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.html +++ b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.html @@ -3,7 +3,7 @@ [ngClass]="{ disabled: isDisabled }" role="menuitem" [attr.aria-disabled]="isDisabled" - [attr.aria-labelledby]="adminMenuSectionTitleId(section.id)" + [attr.aria-labelledby]="adminMenuSectionTitleAccessibilityHandle(section)" [routerLink]="itemModel.link" (keyup.space)="navigate($event)" (keyup.enter)="navigate($event)" @@ -14,7 +14,7 @@