From 67a68c667e04e0d2cb6a657e239f47b3fb84a13e Mon Sep 17 00:00:00 2001 From: guillermo2519 Date: Mon, 30 Mar 2026 15:49:18 -0600 Subject: [PATCH 1/4] feat (3320): add Documentation link to Admin Sidebar --- .../admin-sidebar-section.component.html | 66 +++++++--- .../admin-sidebar-section.component.spec.ts | 117 ++++++++++++++++-- .../admin-sidebar-section.component.ts | 27 +++- src/app/app.menus.ts | 3 + .../menu/providers/documentation.menu.ts | 87 +++++++++++++ src/assets/i18n/en.json5 | 2 + 6 files changed, 269 insertions(+), 33 deletions(-) create mode 100644 src/app/shared/menu/providers/documentation.menu.ts 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 8d0ac83c32e..eb67b8826c7 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 @@ -1,23 +1,49 @@
- - - diff --git a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts index c8207c51a43..3e3ebc3591f 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts @@ -11,6 +11,7 @@ import { CSSVariableServiceStub } from '@dspace/core/testing/css-variable-servic import { TranslateModule } from '@ngx-translate/core'; import { MenuService } from '../../../shared/menu/menu.service'; +import { MenuItemType } from '../../../shared/menu/menu-item-type.model'; import { MenuServiceStub } from '../../../shared/menu/menu-service.stub'; import { CSSVariableService } from '../../../shared/sass-helper/css-variable.service'; import { AdminSidebarSectionComponent } from './admin-sidebar-section.component'; @@ -25,9 +26,18 @@ describe('AdminSidebarSectionComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, RouterTestingModule, TranslateModule.forRoot(), AdminSidebarSectionComponent, TestComponent], + imports: [ + NoopAnimationsModule, + RouterTestingModule, + TranslateModule.forRoot(), + AdminSidebarSectionComponent, + TestComponent, + ], providers: [ - { provide: 'sectionDataProvider', useValue: { model: { link: 'google.com' }, icon: iconString } }, + { + provide: 'sectionDataProvider', + useValue: { model: { link: 'google.com' }, icon: iconString }, + }, { provide: MenuService, useValue: menuService }, { provide: CSSVariableService, useClass: CSSVariableServiceStub }, ], @@ -46,22 +56,42 @@ describe('AdminSidebarSectionComponent', () => { }); it('should set the right icon', () => { - const icon = fixture.debugElement.query(By.css('[data-test="sidebar-section-icon"]')).query(By.css('i.fas')); + const icon = fixture.debugElement + .query(By.css('[data-test="sidebar-section-icon"]')) + .query(By.css('i.fas')); expect(icon.nativeElement.getAttribute('class')).toContain('fa-' + iconString); }); + it('should not contain the disabled class', () => { const disabled = fixture.debugElement.query(By.css('.disabled')); expect(disabled).toBeFalsy(); }); + it('should navigate on keypress', () => { + const routerSpy = spyOn((component as any).router, 'navigate'); + const event = { preventDefault: jasmine.createSpy() }; + component.navigate(event); + expect(routerSpy).toHaveBeenCalled(); + }); + }); + describe('when disabled', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, RouterTestingModule, TranslateModule.forRoot(), AdminSidebarSectionComponent, TestComponent], + imports: [ + NoopAnimationsModule, + RouterTestingModule, + TranslateModule.forRoot(), + AdminSidebarSectionComponent, + TestComponent, + ], providers: [ - { provide: 'sectionDataProvider', useValue: { model: { link: 'google.com', disabled: true }, icon: iconString } }, + { + provide: 'sectionDataProvider', + useValue: { model: { link: 'google.com', disabled: true }, icon: iconString }, + }, { provide: MenuService, useValue: menuService }, { provide: CSSVariableService, useClass: CSSVariableServiceStub }, ], @@ -80,18 +110,88 @@ describe('AdminSidebarSectionComponent', () => { }); it('should set the right icon', () => { - const icon = fixture.debugElement.query(By.css('[data-test="sidebar-section-icon"]')).query(By.css('i.fas')); + const icon = fixture.debugElement + .query(By.css('[data-test="sidebar-section-icon"]')) + .query(By.css('i.fas')); expect(icon.nativeElement.getAttribute('class')).toContain('fa-' + iconString); }); + it('should contain the disabled class', () => { const disabled = fixture.debugElement.query(By.css('.disabled')); expect(disabled).toBeTruthy(); }); + + it('should not navigate when disabled', () => { + const routerSpy = spyOn((component as any).router, 'navigate'); + spyOn(window, 'open'); + const event = { preventDefault: jasmine.createSpy() }; + component.navigate(event); + expect(routerSpy).not.toHaveBeenCalled(); + expect(window.open).not.toHaveBeenCalled(); + }); + + }); + + describe('when external link', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + RouterTestingModule, + TranslateModule.forRoot(), + AdminSidebarSectionComponent, + TestComponent, + ], + providers: [ + { + provide: 'sectionDataProvider', + useValue: { + model: { + type: MenuItemType.EXTERNAL, + href: 'https://test.com', + }, + icon: iconString, + }, + }, + { provide: MenuService, useValue: menuService }, + { provide: CSSVariableService, useClass: CSSVariableServiceStub }, + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AdminSidebarSectionComponent); + component = fixture.componentInstance; + spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent); + fixture.detectChanges(); + }); + + it('should detect external link', () => { + expect(component.isExternalLink).toBeTrue(); + }); + + it('should render external link icon', () => { + const icon = fixture.debugElement.query(By.css('.fa-external-link')); + expect(icon).toBeTruthy(); + }); + + it('should not be disabled when external href exists', () => { + expect(component.isDisabled).toBeFalse(); + }); + + it('should open external link on navigate', () => { + spyOn(window, 'open'); + const event = { preventDefault: jasmine.createSpy() }; + component.navigate(event); + expect(window.open).toHaveBeenCalledWith('https://test.com', '_blank'); + }); + }); }); -// declare a test component +// test component @Component({ selector: 'ds-test-cmp', template: ``, @@ -99,5 +199,4 @@ describe('AdminSidebarSectionComponent', () => { RouterTestingModule, ], }) -class TestComponent { -} +class TestComponent {} 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 5634b1c2620..bf770d11251 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 @@ -14,7 +14,9 @@ import { TranslateModule } from '@ngx-translate/core'; import { MenuService } from '../../../shared/menu/menu.service'; import { MenuID } from '../../../shared/menu/menu-id.model'; +import { ExternalLinkMenuItemModel } from '../../../shared/menu/menu-item/models/external-link.model'; import { LinkMenuItemModel } from '../../../shared/menu/menu-item/models/link.model'; +import { MenuItemType } from '../../../shared/menu/menu-item-type.model'; import { MenuSection } from '../../../shared/menu/menu-section.model'; import { AbstractMenuSectionComponent } from '../../../shared/menu/menu-section/abstract-menu-section.component'; import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe'; @@ -47,6 +49,11 @@ export class AdminSidebarSectionComponent extends AbstractMenuSectionComponent i */ isDisabled: boolean; + /** + * Whether this section links to an external URL + */ + isExternalLink: boolean; + constructor( @Inject('sectionDataProvider') protected section: MenuSection, protected menuService: MenuService, @@ -54,19 +61,31 @@ export class AdminSidebarSectionComponent extends AbstractMenuSectionComponent i protected router: Router, ) { super(menuService, injector); - this.itemModel = section.model as LinkMenuItemModel; + this.isExternalLink = section.model.type === MenuItemType.EXTERNAL; + if (this.isExternalLink) { + this.itemModel = section.model as ExternalLinkMenuItemModel; + } else { + this.itemModel = section.model as LinkMenuItemModel; + } } ngOnInit(): void { - // todo: should support all menu entries? - this.isDisabled = this.itemModel?.disabled || isEmpty(this.itemModel?.link); + if (this.isExternalLink) { + this.isDisabled = this.itemModel?.disabled || isEmpty((this.itemModel as ExternalLinkMenuItemModel)?.href); + } else { + this.isDisabled = this.itemModel?.disabled || isEmpty(this.itemModel?.link); + } super.ngOnInit(); } navigate(event: any): void { event.preventDefault(); if (!this.isDisabled) { - this.router.navigate(this.itemModel.link); + if (this.isExternalLink) { + window.open((this.itemModel as ExternalLinkMenuItemModel).href, '_blank'); + } else { + this.router.navigate(this.itemModel.link); + } } } diff --git a/src/app/app.menus.ts b/src/app/app.menus.ts index e230b039719..b665d203441 100644 --- a/src/app/app.menus.ts +++ b/src/app/app.menus.ts @@ -18,6 +18,7 @@ import { SubscribeMenuProvider } from './shared/menu/providers/comcol-subscribe. 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 { DocumentationMenuProvider } from './shared/menu/providers/documentation.menu'; import { DSpaceObjectEditMenuProvider } from './shared/menu/providers/dso-edit.menu'; import { DsoOptionMenuProvider } from './shared/menu/providers/dso-option.menu'; import { EditMenuProvider } from './shared/menu/providers/edit.menu'; @@ -75,6 +76,7 @@ export const MENUS = buildMenuStructure({ SystemWideAlertMenuProvider, CoarNotifyMenuProvider, AuditOverviewMenuProvider, + DocumentationMenuProvider, ], [MenuID.DSO_EDIT]: [ DsoOptionMenuProvider.withSubs([ @@ -107,3 +109,4 @@ export const MENUS = buildMenuStructure({ ]), ], }); + diff --git a/src/app/shared/menu/providers/documentation.menu.ts b/src/app/shared/menu/providers/documentation.menu.ts new file mode 100644 index 00000000000..2362a96996f --- /dev/null +++ b/src/app/shared/menu/providers/documentation.menu.ts @@ -0,0 +1,87 @@ +/** + * 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 { AuthorizationDataService } from '@dspace/core/data/feature-authorization/authorization-data.service'; +import { RootDataService } from '@dspace/core/data/root-data.service'; +import { getFirstSucceededRemoteDataPayload } from '@dspace/core/shared/operators'; +import { + catchError, + map, + Observable, + of, +} from 'rxjs'; + +import { MenuItemType } from '../menu-item-type.model'; +import { + AbstractMenuProvider, + PartialMenuSection, +} from '../menu-provider.model'; + +const DSDOC_BASE_URL = 'https://wiki.lyrasis.org/display/DSDOC'; +const DSDOC_FALLBACK_URL = `${DSDOC_BASE_URL}/`; + +declare const window: any; + +/** + * Menu provider to create the "Documentation" link in the admin sidebar. + * The URL is automatically built from the DSpace backend version. + * Falls back to the generic documentation URL if the version cannot be determined. + */ +@Injectable() +export class DocumentationMenuProvider extends AbstractMenuProvider { + constructor( + protected authorizationService: AuthorizationDataService, + protected rootDataService: RootDataService, + ) { + super(); + } + + public getSections(): Observable { + return this.rootDataService.findRoot().pipe( + getFirstSucceededRemoteDataPayload(), + catchError(() => of(null)), + map((root) => { + const docsUrl = this.buildDocsUrl(root?.dspaceVersion); + return [ + { + visible: true, + model: { + type: MenuItemType.EXTERNAL, + text: 'menu.section.documentation', + href: docsUrl, + }, + icon: 'book', + }, + ] as PartialMenuSection[]; + }), + ); + } + + private buildDocsUrl(dspaceVersion?: string): string { + + const customUrl = window?.documentationUrl; + + if (customUrl) { + return customUrl; + } + + // fallback (lo de Jorge) + if (!dspaceVersion) { + return DSDOC_FALLBACK_URL; + } + + const majorVersion = dspaceVersion.replace(/[^\d.]/g, '').split('.')[0]; + + if (!majorVersion) { + return DSDOC_FALLBACK_URL; + } + + return `${DSDOC_BASE_URL}${majorVersion}x`; + } +} diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index ded851128d4..266c6cecf3c 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -3732,6 +3732,8 @@ "menu.section.health": "Health", + "menu.section.documentation": "Documentation", + "menu.section.registries": "Registries", "menu.section.registries_format": "Format", From 13c0a5a96582a58f88a2757854b93df7d5849d76 Mon Sep 17 00:00:00 2001 From: Guillermo Date: Fri, 7 Aug 2026 17:56:56 -0600 Subject: [PATCH 2/4] fix: move isExternalLink logic to ngOnInit --- .../admin-sidebar-section.component.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) 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 45bf28e65b3..d318fddc544 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 @@ -67,20 +67,16 @@ export class AdminSidebarSectionComponent extends AbstractMenuSectionComponent i injector, themeService, ); - this.isExternalLink = this.section.model.type === MenuItemType.EXTERNAL; - if (this.isExternalLink) { - this.itemModel = this.section.model as ExternalLinkMenuItemModel; - } else { - this.itemModel = this.section.model as LinkMenuItemModel; - } } ngOnInit(): void { + this.isExternalLink = this.section.model.type === MenuItemType.EXTERNAL; if (this.isExternalLink) { - this.isDisabled = this.itemModel?.disabled || isEmpty((this.itemModel as ExternalLinkMenuItemModel)?.href); + this.itemModel = this.section.model as ExternalLinkMenuItemModel; } else { - this.isDisabled = this.itemModel?.disabled || isEmpty(this.itemModel?.link); + this.itemModel = this.section.model as LinkMenuItemModel; } + this.isDisabled = this.itemModel?.disabled || isEmpty(this.isExternalLink ? (this.itemModel as ExternalLinkMenuItemModel)?.href : this.itemModel?.link); super.ngOnInit(); } From 62621cd58990ef2776bc777c50273b2c5fc2c000 Mon Sep 17 00:00:00 2001 From: Guillermo Date: Fri, 7 Aug 2026 18:29:42 -0600 Subject: [PATCH 3/4] fix: add ThemeService provider and section to external link test --- .../admin-sidebar-section.component.spec.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts index b3654806132..06c07d7e972 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts @@ -176,6 +176,7 @@ describe('AdminSidebarSectionComponent', () => { }, { provide: MenuService, useValue: menuService }, { provide: CSSVariableService, useClass: CSSVariableServiceStub }, + { provide: ThemeService, useValue: getMockThemeService() }, ], }).compileComponents(); })); @@ -183,6 +184,13 @@ describe('AdminSidebarSectionComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(AdminSidebarSectionComponent); component = fixture.componentInstance; + component.section = { + model: { + type: MenuItemType.EXTERNAL, + href: 'https://test.com', + }, + icon: iconString, + } as MenuSection; spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent); fixture.detectChanges(); }); From 7825c764a5618f3031490c177f561a9b93ecc279 Mon Sep 17 00:00:00 2001 From: Guillermo Date: Fri, 7 Aug 2026 18:51:11 -0600 Subject: [PATCH 4/4] fix: use Promise.resolve in getMenuItemComponent spy for external link tests --- .../admin-sidebar-section.component.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts index 06c07d7e972..8a1de71eb61 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts @@ -191,7 +191,7 @@ describe('AdminSidebarSectionComponent', () => { }, icon: iconString, } as MenuSection; - spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent); + spyOn(component as any, 'getMenuItemComponent').and.returnValue(Promise.resolve(TestComponent)); fixture.detectChanges(); });