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 12b2a97eee6..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 @@ -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 { MenuSection } from '../../../shared/menu/menu-section.model'; import { MenuServiceStub } from '../../../shared/menu/menu-service.stub'; import { CSSVariableService } from '../../../shared/sass-helper/css-variable.service'; @@ -28,8 +29,18 @@ describe('AdminSidebarSectionComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, RouterModule.forRoot([]), TranslateModule.forRoot(), AdminSidebarSectionComponent, TestComponent], + imports: [ + NoopAnimationsModule, + RouterModule.forRoot([]), + TranslateModule.forRoot(), + AdminSidebarSectionComponent, + TestComponent, + ], providers: [ + { + provide: 'sectionDataProvider', + useValue: { model: { link: 'google.com' }, icon: iconString }, + }, { provide: MenuService, useValue: menuService }, { provide: CSSVariableService, useClass: CSSVariableServiceStub }, { provide: ThemeService, useValue: getMockThemeService() }, @@ -56,21 +67,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, RouterModule.forRoot([]), TranslateModule.forRoot(), AdminSidebarSectionComponent, TestComponent], + imports: [ + NoopAnimationsModule, + RouterModule.forRoot([]), + TranslateModule.forRoot(), + AdminSidebarSectionComponent, + TestComponent, + ], providers: [ + { + provide: 'sectionDataProvider', + useValue: { model: { link: 'google.com', disabled: true }, icon: iconString }, + }, { provide: MenuService, useValue: menuService }, { provide: CSSVariableService, useClass: CSSVariableServiceStub }, { provide: ThemeService, useValue: getMockThemeService() }, @@ -98,18 +130,96 @@ 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, + RouterModule.forRoot([]), + 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 }, + { provide: ThemeService, useValue: getMockThemeService() }, + ], + }).compileComponents(); + })); + + 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(Promise.resolve(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: ``, @@ -117,5 +227,4 @@ describe('AdminSidebarSectionComponent', () => { RouterModule, ], }) -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 8770b7c57e3..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 @@ -14,6 +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 { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator'; import { MenuSection } from '../../../shared/menu/menu-section.model'; import { AbstractMenuSectionComponent } from '../../../shared/menu/menu-section/abstract-menu-section.component'; @@ -48,6 +51,11 @@ export class AdminSidebarSectionComponent extends AbstractMenuSectionComponent i */ isDisabled: boolean; + /** + * Whether this section links to an external URL + */ + isExternalLink: boolean; + constructor( protected menuService: MenuService, protected injector: Injector, @@ -62,15 +70,24 @@ export class AdminSidebarSectionComponent extends AbstractMenuSectionComponent i } ngOnInit(): void { - // todo: should support all menu entries? - this.isDisabled = this.itemModel?.disabled || isEmpty(this.itemModel?.link); + 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; + } + this.isDisabled = this.itemModel?.disabled || isEmpty(this.isExternalLink ? (this.itemModel as ExternalLinkMenuItemModel)?.href : 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 73190c57caf..745d1079620 100644 --- a/src/app/app.menus.ts +++ b/src/app/app.menus.ts @@ -19,6 +19,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'; @@ -65,6 +66,8 @@ export const MENUS = buildMenuStructure({ [MenuID.ADMIN]: [ NewMenuProvider, EditMenuProvider, + EditCMSMetadataMenuProvider, + EditUserAgreementMenuProvider, ImportMenuProvider, ExportMenuProvider, NotificationsMenuProvider, @@ -79,8 +82,7 @@ export const MENUS = buildMenuStructure({ SystemWideAlertMenuProvider, CoarNotifyMenuProvider, AuditOverviewMenuProvider, - EditCMSMetadataMenuProvider, - EditUserAgreementMenuProvider, + DocumentationMenuProvider, ], [MenuID.DSO_EDIT]: [ DsoOptionMenuProvider.withSubs([ @@ -119,3 +121,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 6551143ff13..718c5bc01b2 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -3826,6 +3826,8 @@ "menu.section.health": "Health", + "menu.section.documentation": "Documentation", + "menu.section.registries": "Registries", "menu.section.registries_format": "Format",