diff --git a/src/app/search-navbar/search-navbar.component.scss b/src/app/search-navbar/search-navbar.component.scss index 40433fc619..d5f3d8d615 100644 --- a/src/app/search-navbar/search-navbar.component.scss +++ b/src/app/search-navbar/search-navbar.component.scss @@ -1,9 +1,6 @@ input[type="text"] { margin-top: calc(-0.5 * var(--bs-font-size-base)); - - &:focus { - background-color: rgba(255, 255, 255, 0.5) !important; - } + background-color: #fff !important; &.collapsed { opacity: 0; diff --git a/src/app/shared/context-help-wrapper/context-help-wrapper.component.ts b/src/app/shared/context-help-wrapper/context-help-wrapper.component.ts index c5bb40cf1b..e170d522b5 100644 --- a/src/app/shared/context-help-wrapper/context-help-wrapper.component.ts +++ b/src/app/shared/context-help-wrapper/context-help-wrapper.component.ts @@ -61,8 +61,7 @@ export class ContextHelpWrapperComponent implements OnInit, OnDestroy { parsedContent$: Observable; - private subs: {always: Subscription[], tooltipBound: Subscription[]} - = {always: [], tooltipBound: []}; + private subs: Subscription[] = []; constructor( private translateService: TranslateService, @@ -78,14 +77,13 @@ export class ContextHelpWrapperComponent implements OnInit, OnDestroy { dontParseLinks ? [text] : this.parseLinks(text)) ); this.shouldShowIcon$ = this.contextHelpService.shouldShowIcons$(); - this.subs.always = [this.parsedContent$.subscribe(), this.shouldShowIcon$.subscribe()]; } @ViewChild('tooltip', { static: false }) set setTooltip(tooltip: NgbTooltip) { this.tooltip = tooltip; - this.clearSubs('tooltipBound'); + this.clearSubs(); if (this.tooltip !== undefined) { - this.subs.tooltipBound = [ + this.subs = [ this.contextHelpService.getContextHelp$(this.id) .pipe(hasValueOperator()) .subscribe((ch: ContextHelp) => { @@ -159,13 +157,8 @@ export class ContextHelpWrapperComponent implements OnInit, OnDestroy { }); } - private clearSubs(filter: null | 'tooltipBound' = null) { - if (filter === null) { - [].concat(...Object.values(this.subs)).forEach(sub => sub.unsubscribe()); - this.subs = {always: [], tooltipBound: []}; - } else { - this.subs[filter].forEach(sub => sub.unsubscribe()); - this.subs[filter] = []; - } + private clearSubs() { + this.subs.forEach(sub => sub.unsubscribe()); + this.subs = []; } } 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 new file mode 100644 index 0000000000..abfe618174 --- /dev/null +++ b/src/app/shared/dso-page/dso-edit-menu.resolver.spec.ts @@ -0,0 +1,259 @@ +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 new file mode 100644 index 0000000000..749d5580a4 --- /dev/null +++ b/src/app/shared/dso-page/dso-edit-menu.resolver.ts @@ -0,0 +1,224 @@ +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; + } + 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.html b/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.html new file mode 100644 index 0000000000..cb725e7d70 --- /dev/null +++ b/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.html @@ -0,0 +1,23 @@ +
+
+ +
    + +
+
+
+ + + + diff --git a/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.scss b/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.scss new file mode 100644 index 0000000000..b37f1be746 --- /dev/null +++ b/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.scss @@ -0,0 +1,30 @@ +.btn-dark { + background-color: var(--ds-admin-sidebar-bg); +} + +.dso-button-menu { + .dropdown-toggle::after { + display: none; + } +} + +ul.dropdown-menu { + background-color: var(--ds-admin-sidebar-bg); + color: white; + + ::ng-deep a { + color: white; + + &.disabled { + color: var(--bs-btn-link-disabled-color); + } + } + + .disabled { + color: var(--bs-btn-link-disabled-color); + } +} + +.dso-edit-menu-dropdown { + max-width: calc(min(600px, 75vw)); +} 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 new file mode 100644 index 0000000000..79ab35bd28 --- /dev/null +++ b/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.spec.ts @@ -0,0 +1,75 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { MenuServiceStub } from '../../../testing/menu-service.stub'; +import { TranslateModule } from '@ngx-translate/core'; +import { MenuService } from '../../../menu/menu.service'; +import { CSSVariableService } from '../../../sass-helper/css-variable.service'; +import { CSSVariableServiceStub } from '../../../testing/css-variable-service.stub'; +import { Router } from '@angular/router'; +import { RouterStub } from '../../../testing/router.stub'; +import { of as observableOf } from 'rxjs'; +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'; + +describe('DsoEditMenuExpandableSectionComponent', () => { + let component: DsoEditMenuExpandableSectionComponent; + let fixture: ComponentFixture; + const menuService = new MenuServiceStub(); + const iconString = 'test'; + + const dummySection = { + id: 'dummy', + active: false, + visible: true, + model: { + type: MenuItemType.TEXT, + disabled: false, + text: 'text' + }, + 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(); + })); + + 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 show a button with the icon', () => { + const button = fixture.debugElement.query(By.css('.btn-dark')); + expect(button.nativeElement.innerHTML).toContain('fa-' + iconString); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { +} diff --git a/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.ts b/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.ts new file mode 100644 index 0000000000..8e4a7008af --- /dev/null +++ b/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.ts @@ -0,0 +1,49 @@ +import { Component, Inject, Injector } from '@angular/core'; +import { rendersSectionForMenu } from 'src/app/shared/menu/menu-section.decorator'; +import { MenuSectionComponent } from 'src/app/shared/menu/menu-section/menu-section.component'; +import { MenuService } from '../../../menu/menu.service'; +import { Router } from '@angular/router'; +import { MenuID } from 'src/app/shared/menu/menu-id.model'; +import { MenuSection } from 'src/app/shared/menu/menu-section.model'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { hasValue } from '../../../empty.util'; + +/** + * Represents an expandable section in the dso edit menus + */ +@Component({ + /* tslint:disable:component-selector */ + selector: 'ds-dso-edit-menu-expandable-section', + templateUrl: './dso-edit-menu-expandable-section.component.html', + styleUrls: ['./dso-edit-menu-expandable-section.component.scss'], +}) +@rendersSectionForMenu(MenuID.DSO_EDIT, true) +export class DsoEditMenuExpandableSectionComponent extends MenuSectionComponent { + + menuID: MenuID = MenuID.DSO_EDIT; + itemModel; + + renderIcons$: Observable; + + constructor( + @Inject('sectionDataProvider') menuSection: MenuSection, + protected menuService: MenuService, + protected injector: Injector, + protected router: Router, + ) { + super(menuSection, menuService, injector); + this.itemModel = menuSection.model; + } + + ngOnInit(): void { + this.menuService.activateSection(this.menuID, this.section.id); + super.ngOnInit(); + + this.renderIcons$ = this.subSections$.pipe( + map((sections: MenuSection[]) => { + return sections.some(section => hasValue(section.icon)); + }), + ); + } +} diff --git a/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component.html b/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component.html new file mode 100644 index 0000000000..16fda1caa8 --- /dev/null +++ b/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component.html @@ -0,0 +1,25 @@ +
+ + + +
+ +
+
+ +
+ +
diff --git a/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component.scss b/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component.scss new file mode 100644 index 0000000000..cf0e81c553 --- /dev/null +++ b/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component.scss @@ -0,0 +1,3 @@ +.btn-dark { + background-color: var(--ds-admin-sidebar-bg); +} diff --git a/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component.spec.ts b/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component.spec.ts new file mode 100644 index 0000000000..f0815c5415 --- /dev/null +++ b/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component.spec.ts @@ -0,0 +1,173 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { MenuServiceStub } from '../../../testing/menu-service.stub'; +import { TranslateModule } from '@ngx-translate/core'; +import { MenuService } from '../../../menu/menu.service'; +import { CSSVariableService } from '../../../sass-helper/css-variable.service'; +import { CSSVariableServiceStub } from '../../../testing/css-variable-service.stub'; +import { Router } from '@angular/router'; +import { RouterStub } from '../../../testing/router.stub'; +import { of as observableOf } from 'rxjs'; +import { Component } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { DsoEditMenuSectionComponent } from './dso-edit-menu-section.component'; +import { OnClickMenuItemModel } from '../../../menu/menu-item/models/onclick.model'; +import { MenuItemType } from 'src/app/shared/menu/menu-item-type.model'; + +function initAsync(dummySectionText: { visible: boolean; icon: string; active: boolean; model: { disabled: boolean; text: string; type: MenuItemType }; id: string }, menuService: MenuServiceStub) { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [DsoEditMenuSectionComponent, TestComponent], + providers: [ + {provide: 'sectionDataProvider', useValue: dummySectionText}, + {provide: MenuService, useValue: menuService}, + {provide: CSSVariableService, useClass: CSSVariableServiceStub}, + {provide: Router, useValue: new RouterStub()}, + ] + }).overrideComponent(DsoEditMenuSectionComponent, { + set: { + entryComponents: [TestComponent] + } + }) + .compileComponents(); + })); +} + +describe('DsoEditMenuSectionComponent', () => { + let component: DsoEditMenuSectionComponent; + let fixture: ComponentFixture; + const menuService = new MenuServiceStub(); + const iconString = 'test'; + + const dummySectionText = { + id: 'dummy', + active: false, + visible: true, + model: { + type: MenuItemType.TEXT, + disabled: false, + text: 'text' + }, + icon: iconString + }; + const dummySectionLink = { + id: 'dummy', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + disabled: false, + text: 'text', + link: 'link' + }, + icon: iconString + }; + + const dummySectionClick = { + id: 'dummy', + active: false, + visible: true, + model: { + type: MenuItemType.ONCLICK, + disabled: false, + text: 'text', + function: () => 'test' + }, + icon: iconString + }; + + describe('text model', () => { + initAsync(dummySectionText, menuService); + beforeEach(() => { + spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([])); + fixture = TestBed.createComponent(DsoEditMenuSectionComponent); + component = fixture.componentInstance; + spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show a button with the icon', () => { + const button = fixture.debugElement.query(By.css('.btn-dark')); + expect(button.nativeElement.innerHTML).toContain('fa-' + iconString); + }); + describe('when the section model in a disabled link or text', () => { + it('should show just the button', () => { + const textButton = fixture.debugElement.query(By.css('div div button')); + expect(textButton.nativeElement.innerHTML).toContain('fa-' + iconString); + }); + }); + }); + describe('on click model', () => { + initAsync(dummySectionClick, menuService); + beforeEach(() => { + spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([])); + fixture = TestBed.createComponent(DsoEditMenuSectionComponent); + component = fixture.componentInstance; + spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent); + fixture.detectChanges(); + }); + + describe('when the section model in an on click menu', () => { + it('should call the activate method when clicking the button', () => { + spyOn(component, 'activate'); + + const button = fixture.debugElement.query(By.css('.btn-dark')); + button.triggerEventHandler('click', null); + + expect(component.activate).toHaveBeenCalled(); + }); + }); + describe('activate', () => { + const mockEvent = jasmine.createSpyObj('event', { + preventDefault: jasmine.createSpy('preventDefault'), + stopPropagation: jasmine.createSpy('stopPropagation'), + }); + it('should call the item model function when not disabled', () => { + spyOn(component.section.model as OnClickMenuItemModel, 'function'); + component.activate(mockEvent); + + expect((component.section.model as OnClickMenuItemModel).function).toHaveBeenCalled(); + }); + it('should call not the item model function when disabled', () => { + spyOn(component.section.model as OnClickMenuItemModel, 'function'); + component.itemModel.disabled = true; + component.activate(mockEvent); + + expect((component.section.model as OnClickMenuItemModel).function).not.toHaveBeenCalled(); + component.itemModel.disabled = false; + }); + }); + + }); + + describe('link model', () => { + initAsync(dummySectionLink, menuService); + beforeEach(() => { + spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([])); + fixture = TestBed.createComponent(DsoEditMenuSectionComponent); + component = fixture.componentInstance; + spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent); + fixture.detectChanges(); + }); + + describe('when the section model in a non disabled link', () => { + it('should show a link element with the button in it', () => { + const link = fixture.debugElement.query(By.css('a')); + expect(link.nativeElement.innerHTML).toContain('button'); + }); + }); + + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { +} diff --git a/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component.ts b/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component.ts new file mode 100644 index 0000000000..af3381ef71 --- /dev/null +++ b/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component.ts @@ -0,0 +1,51 @@ +import { Component, Inject, Injector, OnInit } from '@angular/core'; +import { rendersSectionForMenu } from 'src/app/shared/menu/menu-section.decorator'; +import { MenuSectionComponent } from 'src/app/shared/menu/menu-section/menu-section.component'; +import { MenuService } from '../../../menu/menu.service'; +import { isNotEmpty } from '../../../empty.util'; +import { MenuID } from '../../../menu/menu-id.model'; +import { MenuSection } from '../../../menu/menu-section.model'; + +/** + * Represents a non-expandable section in the dso edit menus + */ +@Component({ + /* tslint:disable:component-selector */ + selector: 'ds-dso-edit-menu-section', + templateUrl: './dso-edit-menu-section.component.html', + styleUrls: ['./dso-edit-menu-section.component.scss'] +}) +@rendersSectionForMenu(MenuID.DSO_EDIT, false) +export class DsoEditMenuSectionComponent extends MenuSectionComponent implements OnInit { + + menuID: MenuID = MenuID.DSO_EDIT; + itemModel; + hasLink: boolean; + canActivate: boolean; + + constructor( + @Inject('sectionDataProvider') menuSection: MenuSection, + protected menuService: MenuService, + protected injector: Injector, + ) { + super(menuSection, menuService, injector); + this.itemModel = menuSection.model; + } + + ngOnInit(): void { + this.hasLink = isNotEmpty(this.itemModel?.link); + this.canActivate = isNotEmpty(this.itemModel?.function); + super.ngOnInit(); + } + + /** + * Activate the section's model funtion + */ + public activate(event: any) { + event.preventDefault(); + if (!this.itemModel.disabled) { + this.itemModel.function(); + } + event.stopPropagation(); + } +} diff --git a/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu.component.html b/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu.component.html new file mode 100644 index 0000000000..000da1678e --- /dev/null +++ b/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu.component.html @@ -0,0 +1,6 @@ +
+
+ +
+
diff --git a/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.html b/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu.component.scss similarity index 100% rename from src/app/item-page/simple/item-types/versioned-item/versioned-item.component.html rename to src/app/shared/dso-page/dso-edit-menu/dso-edit-menu.component.scss 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 new file mode 100644 index 0000000000..5616e8ea10 --- /dev/null +++ b/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu.component.spec.ts @@ -0,0 +1,76 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { Injector, NO_ERRORS_SCHEMA } from '@angular/core'; +import { of as observableOf } from 'rxjs'; +import { RouterTestingModule } from '@angular/router/testing'; +import { ActivatedRoute } from '@angular/router'; +import { DsoEditMenuComponent } from './dso-edit-menu.component'; +import { MenuServiceStub } from '../../testing/menu-service.stub'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +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'; + + +import { DsoPageModule } from '../dso-page.module'; + +describe('DsoEditMenuComponent', () => { + let comp: DsoEditMenuComponent; + let fixture: ComponentFixture; + const menuService = new MenuServiceStub(); + let authorizationService: AuthorizationDataService; + + const routeStub = { + children: [] + }; + + const section = { + id: 'edit-dso', + active: false, + visible: true, + model: { + type: null, + disabled: false, + } as MenuItemModel, + icon: 'pencil-alt', + index: 1 + }; + + + beforeEach(waitForAsync(() => { + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(true) + }); + spyOn(menuService, 'getMenuTopSections').and.returnValue(observableOf([section])); + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), RouterTestingModule, DsoPageModule], + declarations: [DsoEditMenuComponent], + providers: [ + Injector, + {provide: MenuService, useValue: menuService}, + {provide: AuthService, useClass: AuthServiceStub}, + {provide: ActivatedRoute, useValue: routeStub}, + {provide: AuthorizationDataService, useValue: authorizationService}, + {provide: ThemeService, useValue: getMockThemeService()}, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DsoEditMenuComponent); + comp = fixture.componentInstance; + comp.sections = observableOf([]); + fixture.detectChanges(); + }); + + describe('onInit', () => { + it('should create', () => { + expect(comp).toBeTruthy(); + }); + }); +}); + diff --git a/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu.component.ts b/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu.component.ts new file mode 100644 index 0000000000..ff4f957314 --- /dev/null +++ b/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu.component.ts @@ -0,0 +1,34 @@ +import { Component, Injector } from '@angular/core'; +import { AuthorizationDataService } from 'src/app/core/data/feature-authorization/authorization-data.service'; +import { MenuComponent } from '../../menu/menu.component'; +import { MenuService } from '../../menu/menu.service'; +import { ActivatedRoute } from '@angular/router'; +import { ThemeService } from '../../theme-support/theme.service'; +import { MenuID } from '../../menu/menu-id.model'; + +/** + * Component representing the edit menu and other menus on the dspace object pages + */ +@Component({ + selector: 'ds-dso-edit-menu', + styleUrls: ['./dso-edit-menu.component.scss'], + templateUrl: './dso-edit-menu.component.html', +}) +export class DsoEditMenuComponent extends MenuComponent { + /** + * The menu ID of this component is DSO_EDIT + * @type {MenuID.DSO_EDIT} + */ + menuID = MenuID.DSO_EDIT; + + + constructor(protected menuService: MenuService, + protected injector: Injector, + public authorizationService: AuthorizationDataService, + public route: ActivatedRoute, + protected themeService: ThemeService + ) { + super(menuService, injector, authorizationService, route, themeService); + } + +} diff --git a/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.html b/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.html deleted file mode 100644 index d680c140d8..0000000000 --- a/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.html +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.scss b/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.scss deleted file mode 100644 index 8b13789179..0000000000 --- a/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.scss +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.spec.ts b/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.spec.ts deleted file mode 100644 index 5949a98f71..0000000000 --- a/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { DsoPageEditButtonComponent } from './dso-page-edit-button.component'; -import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { Item } from '../../../core/shared/item.model'; -import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; -import { of as observableOf } from 'rxjs'; -import { TranslateModule } from '@ngx-translate/core'; -import { RouterTestingModule } from '@angular/router/testing'; -import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; -import { By } from '@angular/platform-browser'; -import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; - -describe('DsoPageEditButtonComponent', () => { - let component: DsoPageEditButtonComponent; - let fixture: ComponentFixture; - - let authorizationService: AuthorizationDataService; - let dso: DSpaceObject; - - beforeEach(waitForAsync(() => { - dso = Object.assign(new Item(), { - id: 'test-item', - _links: { - self: { href: 'test-item-selflink' } - } - }); - authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(true) - }); - TestBed.configureTestingModule({ - declarations: [DsoPageEditButtonComponent], - imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NgbModule], - providers: [ - { provide: AuthorizationDataService, useValue: authorizationService } - ] - }).compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(DsoPageEditButtonComponent); - component = fixture.componentInstance; - component.dso = dso; - component.pageRoute = 'test'; - fixture.detectChanges(); - }); - - it('should check the authorization of the current user', () => { - expect(authorizationService.isAuthorized).toHaveBeenCalledWith(FeatureID.CanEditMetadata, dso.self); - }); - - describe('when the user is authorized', () => { - beforeEach(() => { - (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(true)); - component.ngOnInit(); - fixture.detectChanges(); - }); - - it('should render a link', () => { - const link = fixture.debugElement.query(By.css('a')); - expect(link).not.toBeNull(); - }); - }); - - describe('when the user is not authorized', () => { - beforeEach(() => { - (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false)); - component.ngOnInit(); - fixture.detectChanges(); - }); - - it('should not render a link', () => { - const link = fixture.debugElement.query(By.css('a')); - expect(link).toBeNull(); - }); - }); -}); diff --git a/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.ts b/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.ts deleted file mode 100644 index 1879581d23..0000000000 --- a/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Component, Input, OnInit } from '@angular/core'; -import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; -import { Observable } from 'rxjs'; -import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; - -@Component({ - selector: 'ds-dso-page-edit-button', - templateUrl: './dso-page-edit-button.component.html', - styleUrls: ['./dso-page-edit-button.component.scss'] -}) -/** - * Display a button linking to the edit page of a DSpaceObject - */ -export class DsoPageEditButtonComponent implements OnInit { - /** - * The DSpaceObject to display a button to the edit page for - */ - @Input() dso: DSpaceObject; - - /** - * The prefix of the route to the edit page (before the object's UUID, e.g. "items") - */ - @Input() pageRoute: string; - - /** - * A message for the tooltip on the button - * Supports i18n keys - */ - @Input() tooltipMsg: string; - - /** - * Whether or not the current user is authorized to edit the DSpaceObject - */ - isAuthorized$: Observable; - - constructor(protected authorizationService: AuthorizationDataService) { } - - ngOnInit() { - this.isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanEditMetadata, this.dso.self); - } - -} diff --git a/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.html b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.html deleted file mode 100644 index 305900ae33..0000000000 --- a/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.html +++ /dev/null @@ -1,6 +0,0 @@ - - diff --git a/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.scss b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.scss deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.spec.ts b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.spec.ts deleted file mode 100644 index c70ec4b808..0000000000 --- a/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { Item } from '../../../core/shared/item.model'; -import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; -import { of as observableOf } from 'rxjs'; -import { TranslateModule } from '@ngx-translate/core'; -import { RouterTestingModule } from '@angular/router/testing'; -import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; -import { By } from '@angular/platform-browser'; -import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; -import { DsoPageOrcidButtonComponent } from './dso-page-orcid-button.component'; - -describe('DsoPageOrcidButtonComponent', () => { - let component: DsoPageOrcidButtonComponent; - let fixture: ComponentFixture; - - let authorizationService: AuthorizationDataService; - let dso: DSpaceObject; - - beforeEach(waitForAsync(() => { - dso = Object.assign(new Item(), { - id: 'test-item', - _links: { - self: { href: 'test-item-selflink' } - } - }); - authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(true) - }); - TestBed.configureTestingModule({ - declarations: [DsoPageOrcidButtonComponent], - imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NgbModule], - providers: [ - { provide: AuthorizationDataService, useValue: authorizationService } - ] - }).compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(DsoPageOrcidButtonComponent); - component = fixture.componentInstance; - component.dso = dso; - component.pageRoute = 'test'; - fixture.detectChanges(); - }); - - it('should check the authorization of the current user', () => { - expect(authorizationService.isAuthorized).toHaveBeenCalledWith(FeatureID.CanSynchronizeWithORCID, dso.self); - }); - - describe('when the user is authorized', () => { - beforeEach(() => { - (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(true)); - component.ngOnInit(); - fixture.detectChanges(); - }); - - it('should render a link', () => { - const link = fixture.debugElement.query(By.css('a')); - expect(link).not.toBeNull(); - }); - }); - - describe('when the user is not authorized', () => { - beforeEach(() => { - (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false)); - component.ngOnInit(); - fixture.detectChanges(); - }); - - it('should not render a link', () => { - const link = fixture.debugElement.query(By.css('a')); - expect(link).toBeNull(); - }); - }); -}); diff --git a/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.ts b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.ts deleted file mode 100644 index c345d8cbdc..0000000000 --- a/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Component, Input, OnInit } from '@angular/core'; - -import { BehaviorSubject } from 'rxjs'; -import { take } from 'rxjs/operators'; - -import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; -import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; -import { DSpaceObject } from '../../../core/shared/dspace-object.model'; - -@Component({ - selector: 'ds-dso-page-orcid-button', - templateUrl: './dso-page-orcid-button.component.html', - styleUrls: ['./dso-page-orcid-button.component.scss'] -}) -export class DsoPageOrcidButtonComponent implements OnInit { - /** - * The DSpaceObject to display a button to the edit page for - */ - @Input() dso: DSpaceObject; - - /** - * The prefix of the route to the edit page (before the object's UUID, e.g. "items") - */ - @Input() pageRoute: string; - - /** - * Whether or not the current user is authorized to edit the DSpaceObject - */ - isAuthorized: BehaviorSubject = new BehaviorSubject(false); - - constructor(protected authorizationService: AuthorizationDataService) { } - - ngOnInit() { - this.authorizationService.isAuthorized(FeatureID.CanSynchronizeWithORCID, this.dso.self).pipe(take(1)).subscribe((isAuthorized: boolean) => { - this.isAuthorized.next(isAuthorized); - }); - } - -} diff --git a/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.html b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.html deleted file mode 100644 index 0e2e35dcb7..0000000000 --- a/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.html +++ /dev/null @@ -1,8 +0,0 @@ - diff --git a/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.scss b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.scss deleted file mode 100644 index e8b7d689a3..0000000000 --- a/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.scss +++ /dev/null @@ -1,3 +0,0 @@ -.btn-dark { - background-color: var(--ds-admin-sidebar-bg); -} diff --git a/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.spec.ts b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.spec.ts deleted file mode 100644 index 9839507d57..0000000000 --- a/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { DsoPageVersionButtonComponent } from './dso-page-version-button.component'; -import { Item } from '../../../core/shared/item.model'; -import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; -import { Observable, of, of as observableOf } from 'rxjs'; -import { TranslateModule } from '@ngx-translate/core'; -import { RouterTestingModule } from '@angular/router/testing'; -import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; -import { By } from '@angular/platform-browser'; -import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; -import { VersionHistoryDataService } from '../../../core/data/version-history-data.service'; - -describe('DsoPageVersionButtonComponent', () => { - let component: DsoPageVersionButtonComponent; - let fixture: ComponentFixture; - - let authorizationService: AuthorizationDataService; - let versionHistoryService: VersionHistoryDataService; - - let dso: Item; - let tooltipMsg: Observable; - - const authorizationServiceSpy = jasmine.createSpyObj('authorizationService', ['isAuthorized']); - - const versionHistoryServiceSpy = jasmine.createSpyObj('versionHistoryService', - ['getVersions', 'getLatestVersionFromHistory$', 'isLatest$', 'hasDraftVersion$'] - ); - - beforeEach(waitForAsync(() => { - dso = Object.assign(new Item(), { - id: 'test-item', - _links: { - self: { href: 'test-item-selflink' }, - version: { href: 'test-item-version-selflink' }, - }, - }); - tooltipMsg = of('tooltip-msg'); - - TestBed.configureTestingModule({ - declarations: [DsoPageVersionButtonComponent], - imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NgbModule], - providers: [ - { provide: AuthorizationDataService, useValue: authorizationServiceSpy }, - { provide: VersionHistoryDataService, useValue: versionHistoryServiceSpy }, - ] - }).compileComponents(); - - authorizationService = TestBed.inject(AuthorizationDataService); - versionHistoryService = TestBed.inject(VersionHistoryDataService); - - versionHistoryServiceSpy.hasDraftVersion$.and.returnValue(observableOf(true)); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(DsoPageVersionButtonComponent); - component = fixture.componentInstance; - component.dso = dso; - component.tooltipMsg$ = tooltipMsg; - fixture.detectChanges(); - }); - - it('should check the authorization of the current user', () => { - expect(authorizationService.isAuthorized).toHaveBeenCalledWith(FeatureID.CanCreateVersion, dso.self); - }); - - it('should check if the item has a draft version', () => { - expect(versionHistoryServiceSpy.hasDraftVersion$).toHaveBeenCalledWith(dso._links.version.href); - }); - - describe('when the user is authorized', () => { - beforeEach(() => { - authorizationServiceSpy.isAuthorized.and.returnValue(observableOf(true)); - component.ngOnInit(); - fixture.detectChanges(); - }); - - it('should render a button', () => { - const button = fixture.debugElement.query(By.css('button')); - expect(button).not.toBeNull(); - }); - }); - - describe('when the user is not authorized', () => { - beforeEach(() => { - authorizationServiceSpy.isAuthorized.and.returnValue(observableOf(false)); - component.ngOnInit(); - fixture.detectChanges(); - }); - - it('should render a button', () => { - const button = fixture.debugElement.query(By.css('button')); - expect(button).toBeNull(); - }); - }); - -}); diff --git a/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.ts b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.ts deleted file mode 100644 index cf07953c75..0000000000 --- a/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; -import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; -import { Observable, of } from 'rxjs'; -import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; -import { VersionHistoryDataService } from '../../../core/data/version-history-data.service'; -import { Item } from '../../../core/shared/item.model'; -import { map, startWith, switchMap } from 'rxjs/operators'; - -@Component({ - selector: 'ds-dso-page-version-button', - templateUrl: './dso-page-version-button.component.html', - styleUrls: ['./dso-page-version-button.component.scss'] -}) -/** - * Display a button linking to the edit page of a DSpaceObject - */ -export class DsoPageVersionButtonComponent implements OnInit { - /** - * The item for which display a button to create a new version - */ - @Input() dso: Item; - - /** - * A message for the tooltip on the button - * Supports i18n keys - */ - @Input() tooltipMsgCreate: string; - - /** - * A message for the tooltip on the button (when is disabled) - * Supports i18n keys - */ - @Input() tooltipMsgHasDraft: string; - - /** - * Emits an event that triggers the creation of the new version - */ - @Output() newVersionEvent = new EventEmitter(); - - /** - * Whether or not the current user is authorized to create a new version of the DSpaceObject - */ - isAuthorized$: Observable; - - disableNewVersionButton$: Observable; - - tooltipMsg$: Observable; - - constructor( - protected authorizationService: AuthorizationDataService, - protected versionHistoryService: VersionHistoryDataService, - ) { - } - - /** - * Creates a new version for the current item - */ - createNewVersion() { - this.newVersionEvent.emit(); - } - - ngOnInit() { - this.isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanCreateVersion, this.dso.self); - - this.disableNewVersionButton$ = this.versionHistoryService.hasDraftVersion$(this.dso._links.version.href).pipe( - // button is disabled if hasDraftVersion = true, and enabled if hasDraftVersion = false or null - // (hasDraftVersion is null when a version history does not exist) - map((res) => Boolean(res)), - startWith(true), - ); - - this.tooltipMsg$ = this.disableNewVersionButton$.pipe( - switchMap((hasDraftVersion) => of(hasDraftVersion ? this.tooltipMsgHasDraft : this.tooltipMsgCreate)), - ); - } - -} diff --git a/src/app/shared/dso-page/dso-page.module.ts b/src/app/shared/dso-page/dso-page.module.ts new file mode 100644 index 0000000000..6820e8eb53 --- /dev/null +++ b/src/app/shared/dso-page/dso-page.module.ts @@ -0,0 +1,55 @@ +import { NgModule } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterModule } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { DsoEditMenuComponent } from '../dso-page/dso-edit-menu/dso-edit-menu.component'; +import { + DsoEditMenuSectionComponent +} from '../dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component'; +import { + DsoEditMenuExpandableSectionComponent +} from '../dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; + +const COMPONENTS = [ + DsoEditMenuComponent, + DsoEditMenuSectionComponent, + DsoEditMenuExpandableSectionComponent, +]; + +const ENTRY_COMPONENTS = [ +]; + +const MODULES = [ + TranslateModule, + RouterModule, + CommonModule, + NgbTooltipModule, +]; +const PROVIDERS = [ + +]; + +@NgModule({ + imports: [ + ...MODULES + ], + declarations: [ + ...COMPONENTS, + ...ENTRY_COMPONENTS, + ], + providers: [ + ...PROVIDERS, + ...ENTRY_COMPONENTS, + ], + exports: [ + ...COMPONENTS + ] +}) + +/** + * This module handles all components, providers and modules that are needed for the menu + */ +export class DsoPageModule { + +} diff --git a/src/app/shared/dso-page/dso-versioning-modal-service/dso-versioning-modal.service.spec.ts b/src/app/shared/dso-page/dso-versioning-modal-service/dso-versioning-modal.service.spec.ts new file mode 100644 index 0000000000..fc5c1dafc9 --- /dev/null +++ b/src/app/shared/dso-page/dso-versioning-modal-service/dso-versioning-modal.service.spec.ts @@ -0,0 +1,92 @@ +import { DsoVersioningModalService } from './dso-versioning-modal.service'; +import { waitForAsync } from '@angular/core/testing'; +import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; +import { Version } from '../../../core/shared/version.model'; +import { Item } from '../../../core/shared/item.model'; +import { MetadataMap } from '../../../core/shared/metadata.models'; +import { createRelationshipsObservable } from '../../../item-page/simple/item-types/shared/item.component.spec'; +import { buildPaginatedList } from '../../../core/data/paginated-list.model'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { EMPTY, of as observableOf } from 'rxjs'; + +describe('DsoVersioningModalService', () => { + let service: DsoVersioningModalService; + let modalService; + let versionService; + let versionHistoryService; + let itemVersionShared; + let router; + let workspaceItemDataService; + let itemService; + + const mockItem: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), + metadata: new MetadataMap(), + relationships: createRelationshipsObservable(), + _links: { + self: { + href: 'item-href' + }, + version: { + href: 'version-href' + } + } + }); + + beforeEach(waitForAsync(() => { + modalService = jasmine.createSpyObj('modalService', { + open: {componentInstance: {firstVersion: {}, versionNumber: {}, createVersionEvent: EMPTY}} + }); + versionService = jasmine.createSpyObj('versionService', { + findByHref: createSuccessfulRemoteDataObject$(new Version()), + }); + versionHistoryService = jasmine.createSpyObj('versionHistoryService', { + createVersion: createSuccessfulRemoteDataObject$(new Version()), + hasDraftVersion$: observableOf(false) + }); + itemVersionShared = jasmine.createSpyObj('itemVersionShared', ['notifyCreateNewVersion']); + router = jasmine.createSpyObj('router', ['navigateByUrl']); + workspaceItemDataService = jasmine.createSpyObj('workspaceItemDataService', ['findByItem']); + itemService = jasmine.createSpyObj('itemService', ['findByHref']); + + service = new DsoVersioningModalService( + modalService, + versionService, + versionHistoryService, + itemVersionShared, + router, + workspaceItemDataService, + itemService + ); + })); + describe('when onCreateNewVersion() is called', () => { + it('should call versionService.findByHref', () => { + service.openCreateVersionModal(mockItem); + expect(versionService.findByHref).toHaveBeenCalledWith('version-href'); + }); + }); + + describe('isNewVersionButtonDisabled', () => { + it('should call versionHistoryService.hasDraftVersion$', () => { + service.isNewVersionButtonDisabled(mockItem); + expect(versionHistoryService.hasDraftVersion$).toHaveBeenCalledWith(mockItem._links.version.href); + }); + }); + + describe('getVersioningTooltipMessage', () => { + it('should return the create message when isNewVersionButtonDisabled returns false', (done) => { + spyOn(service, 'isNewVersionButtonDisabled').and.returnValue(observableOf(false)); + service.getVersioningTooltipMessage(mockItem, 'draft-message', 'create-message').subscribe((message) => { + expect(message).toEqual('create-message'); + done(); + }); + }); + it('should return the draft message when isNewVersionButtonDisabled returns true', (done) => { + spyOn(service, 'isNewVersionButtonDisabled').and.returnValue(observableOf(true)); + service.getVersioningTooltipMessage(mockItem, 'draft-message', 'create-message').subscribe((message) => { + expect(message).toEqual('draft-message'); + done(); + }); + }); + }); +}); diff --git a/src/app/shared/dso-page/dso-versioning-modal-service/dso-versioning-modal.service.ts b/src/app/shared/dso-page/dso-versioning-modal-service/dso-versioning-modal.service.ts new file mode 100644 index 0000000000..46792294dd --- /dev/null +++ b/src/app/shared/dso-page/dso-versioning-modal-service/dso-versioning-modal.service.ts @@ -0,0 +1,101 @@ +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Version } from '../../../core/shared/version.model'; +import { map, startWith, switchMap, tap } from 'rxjs/operators'; +import { Item } from '../../../core/shared/item.model'; +import { WorkspaceItem } from '../../../core/submission/models/workspaceitem.model'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { VersionDataService } from '../../../core/data/version-data.service'; +import { VersionHistoryDataService } from '../../../core/data/version-history-data.service'; +import { Router } from '@angular/router'; +import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { Injectable } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { ItemVersionsSharedService } from '../../../item-page/versions/item-versions-shared.service'; +import { + ItemVersionsSummaryModalComponent +} from '../../../item-page/versions/item-versions-summary-modal/item-versions-summary-modal.component'; + +/** + * Service to take care of all the functionality related to the version creation modal + */ +@Injectable({ + providedIn: 'root' +}) +export class DsoVersioningModalService { + + + constructor( + protected modalService: NgbModal, + protected versionService: VersionDataService, + protected versionHistoryService: VersionHistoryDataService, + protected itemVersionShared: ItemVersionsSharedService, + protected router: Router, + protected workspaceItemDataService: WorkspaceitemDataService, + protected itemService: ItemDataService, + ) { + } + + /** + * Open the create version modal for the provided dso + */ + openCreateVersionModal(dso): void { + + const item = dso; + const versionHref = item._links.version.href; + + // Open modal + const activeModal = this.modalService.open(ItemVersionsSummaryModalComponent); + + // Show current version in modal + this.versionService.findByHref(versionHref).pipe(getFirstCompletedRemoteData()).subscribe((res: RemoteData) => { + // if res.hasNoContent then the item is unversioned + activeModal.componentInstance.firstVersion = res.hasNoContent; + activeModal.componentInstance.versionNumber = (res.hasNoContent ? undefined : res.payload.version); + }); + + // On createVersionEvent emitted create new version and notify + activeModal.componentInstance.createVersionEvent.pipe( + switchMap((summary: string) => this.versionHistoryService.createVersion(item._links.self.href, summary)), + getFirstCompletedRemoteData(), + // close model (should be displaying loading/waiting indicator) when version creation failed/succeeded + tap(() => activeModal.close()), + // show success/failure notification + tap((res: RemoteData) => { + this.itemVersionShared.notifyCreateNewVersion(res); + }), + // get workspace item + getFirstSucceededRemoteDataPayload(), + switchMap((newVersion: Version) => this.itemService.findByHref(newVersion._links.item.href)), + getFirstSucceededRemoteDataPayload(), + switchMap((newVersionItem: Item) => this.workspaceItemDataService.findByItem(newVersionItem.uuid, true, false)), + getFirstSucceededRemoteDataPayload(), + ).subscribe((wsItem) => { + const wsiId = wsItem.id; + const route = 'workspaceitems/' + wsiId + '/edit'; + this.router.navigateByUrl(route); + }); + } + + /** + * Checks if the new version button should be disabled for the provided dso + */ + isNewVersionButtonDisabled(dso): Observable { + return this.versionHistoryService.hasDraftVersion$(dso._links.version.href).pipe( + // button is disabled if hasDraftVersion = true, and enabled if hasDraftVersion = false or null + // (hasDraftVersion is null when a version history does not exist) + map((res) => Boolean(res)), + startWith(true), + ); + } + + /** + * Checks and returns the tooltip that needs to be used for the create version button tooltip + */ + getVersioningTooltipMessage(dso, tooltipMsgHasDraft, tooltipMsgCreate): Observable { + return this.isNewVersionButtonDisabled(dso).pipe( + switchMap((hasDraftVersion) => of(hasDraftVersion ? tooltipMsgHasDraft : tooltipMsgCreate)), + ); + } +} diff --git a/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.html b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.html deleted file mode 100644 index c4bba286bf..0000000000 --- a/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.html +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.scss b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.scss deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.spec.ts b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.spec.ts deleted file mode 100644 index 5d589187b9..0000000000 --- a/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.spec.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; - -import { of as observableOf } from 'rxjs'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; - -import { PersonPageClaimButtonComponent } from './person-page-claim-button.component'; -import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; -import { NotificationsService } from '../../notifications/notifications.service'; -import { NotificationsServiceStub } from '../../testing/notifications-service.stub'; -import { TranslateLoaderMock } from '../../mocks/translate-loader.mock'; -import { ResearcherProfileDataService } from '../../../core/profile/researcher-profile-data.service'; -import { RouteService } from '../../../core/services/route.service'; -import { routeServiceStub } from '../../testing/route-service.stub'; -import { Item } from '../../../core/shared/item.model'; -import { ResearcherProfile } from '../../../core/profile/model/researcher-profile.model'; -import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; -import { getTestScheduler } from 'jasmine-marbles'; -import { TestScheduler } from 'rxjs/testing'; - -describe('PersonPageClaimButtonComponent', () => { - let scheduler: TestScheduler; - let component: PersonPageClaimButtonComponent; - let fixture: ComponentFixture; - - const mockItem: Item = Object.assign(new Item(), { - metadata: { - 'person.email': [ - { - language: 'en_US', - value: 'fake@email.com' - } - ], - 'person.birthDate': [ - { - language: 'en_US', - value: '1993' - } - ], - 'person.jobTitle': [ - { - language: 'en_US', - value: 'Developer' - } - ], - 'person.familyName': [ - { - language: 'en_US', - value: 'Doe' - } - ], - 'person.givenName': [ - { - language: 'en_US', - value: 'John' - } - ] - }, - _links: { - self: { - href: 'item-href' - } - } - }); - - const mockResearcherProfile: ResearcherProfile = Object.assign(new ResearcherProfile(), { - id: 'test-id', - visible: true, - type: 'profile', - _links: { - item: { - href: 'https://rest.api/rest/api/profiles/test-id/item' - }, - self: { - href: 'https://rest.api/rest/api/profiles/test-id' - }, - } - }); - - const notificationsService = new NotificationsServiceStub(); - - const authorizationDataService = jasmine.createSpyObj('authorizationDataService', { - isAuthorized: jasmine.createSpy('isAuthorized') - }); - - const researcherProfileService = jasmine.createSpyObj('researcherProfileService', { - createFromExternalSource: jasmine.createSpy('createFromExternalSource'), - findRelatedItemId: jasmine.createSpy('findRelatedItemId'), - }); - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock - } - }) - ], - declarations: [PersonPageClaimButtonComponent], - providers: [ - { provide: AuthorizationDataService, useValue: authorizationDataService }, - { provide: NotificationsService, useValue: notificationsService }, - { provide: ResearcherProfileDataService, useValue: researcherProfileService }, - { provide: RouteService, useValue: routeServiceStub }, - ] - }) - .compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(PersonPageClaimButtonComponent); - component = fixture.componentInstance; - component.object = mockItem; - }); - - describe('when item can be claimed', () => { - beforeEach(() => { - authorizationDataService.isAuthorized.and.returnValue(observableOf(true)); - researcherProfileService.createFromExternalSource.calls.reset(); - researcherProfileService.findRelatedItemId.calls.reset(); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should create claim button', () => { - const btn = fixture.debugElement.query(By.css('[data-test="item-claim"]')); - expect(btn).toBeTruthy(); - }); - - describe('claim', () => { - describe('when successfully', () => { - beforeEach(() => { - scheduler = getTestScheduler(); - researcherProfileService.createFromExternalSource.and.returnValue(createSuccessfulRemoteDataObject$(mockResearcherProfile)); - researcherProfileService.findRelatedItemId.and.returnValue(observableOf('test-id')); - }); - - it('should display success notification', () => { - scheduler.schedule(() => component.claim()); - scheduler.flush(); - - expect(researcherProfileService.findRelatedItemId).toHaveBeenCalled(); - expect(notificationsService.success).toHaveBeenCalled(); - }); - }); - - describe('when not successfully', () => { - beforeEach(() => { - scheduler = getTestScheduler(); - researcherProfileService.createFromExternalSource.and.returnValue(createFailedRemoteDataObject$()); - }); - - it('should display success notification', () => { - scheduler.schedule(() => component.claim()); - scheduler.flush(); - - expect(researcherProfileService.findRelatedItemId).not.toHaveBeenCalled(); - expect(notificationsService.error).toHaveBeenCalled(); - }); - }); - }); - - }); - - describe('when item cannot be claimed', () => { - beforeEach(() => { - authorizationDataService.isAuthorized.and.returnValue(observableOf(false)); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should create claim button', () => { - const btn = fixture.debugElement.query(By.css('[data-test="item-claim"]')); - expect(btn).toBeFalsy(); - }); - - }); -}); diff --git a/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.ts b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.ts deleted file mode 100644 index f0071d0a41..0000000000 --- a/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Component, Input, OnInit } from '@angular/core'; - -import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; -import { mergeMap, take } from 'rxjs/operators'; -import { TranslateService } from '@ngx-translate/core'; - -import { RouteService } from '../../../core/services/route.service'; -import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; -import { NotificationsService } from '../../notifications/notifications.service'; -import { ResearcherProfileDataService } from '../../../core/profile/researcher-profile-data.service'; -import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; -import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; -import { RemoteData } from '../../../core/data/remote-data'; -import { ResearcherProfile } from '../../../core/profile/model/researcher-profile.model'; -import { isNotEmpty } from '../../empty.util'; -import { DSpaceObject } from '../../../core/shared/dspace-object.model'; - -@Component({ - selector: 'ds-person-page-claim-button', - templateUrl: './person-page-claim-button.component.html', - styleUrls: ['./person-page-claim-button.component.scss'] -}) -export class PersonPageClaimButtonComponent implements OnInit { - - /** - * The target person item to claim - */ - @Input() object: DSpaceObject; - - /** - * A boolean representing if item can be claimed or not - */ - claimable$: BehaviorSubject = new BehaviorSubject(false); - - constructor(protected routeService: RouteService, - protected authorizationService: AuthorizationDataService, - protected notificationsService: NotificationsService, - protected translate: TranslateService, - protected researcherProfileService: ResearcherProfileDataService) { - } - - ngOnInit(): void { - this.authorizationService.isAuthorized(FeatureID.CanClaimItem, this.object._links.self.href, null, false).pipe( - take(1) - ).subscribe((isAuthorized: boolean) => { - this.claimable$.next(isAuthorized); - }); - - } - - /** - * Create a new researcher profile claiming the current item. - */ - claim() { - this.researcherProfileService.createFromExternalSource(this.object._links.self.href).pipe( - getFirstCompletedRemoteData(), - mergeMap((rd: RemoteData) => { - if (rd.hasSucceeded) { - return this.researcherProfileService.findRelatedItemId(rd.payload); - } else { - return observableOf(null); - } - })) - .subscribe((id: string) => { - if (isNotEmpty(id)) { - this.notificationsService.success(this.translate.get('researcherprofile.success.claim.title'), - this.translate.get('researcherprofile.success.claim.body')); - this.claimable$.next(false); - } else { - this.notificationsService.error( - this.translate.get('researcherprofile.error.claim.title'), - this.translate.get('researcherprofile.error.claim.body')); - } - }); - } - - /** - * Returns true if the item is claimable, false otherwise. - */ - isClaimable(): Observable { - return this.claimable$; - } - -} diff --git a/src/app/shared/menu/initial-menus-state.ts b/src/app/shared/menu/initial-menus-state.ts index d684afc3e7..4759a5712e 100644 --- a/src/app/shared/menu/initial-menus-state.ts +++ b/src/app/shared/menu/initial-menus-state.ts @@ -22,5 +22,14 @@ export const initialMenusState: MenusState = { visible: true, sections: {}, sectionToSubsectionIndex: {} - } + }, + [MenuID.DSO_EDIT]: + { + id: MenuID.DSO_EDIT, + collapsed: true, + previewCollapsed: true, + visible: false, + sections: {}, + sectionToSubsectionIndex: {} + }, }; diff --git a/src/app/shared/menu/menu-id.model.ts b/src/app/shared/menu/menu-id.model.ts index 50ce924a1b..4e7d31f467 100644 --- a/src/app/shared/menu/menu-id.model.ts +++ b/src/app/shared/menu/menu-id.model.ts @@ -3,5 +3,6 @@ */ export enum MenuID { ADMIN = 'admin-sidebar', - PUBLIC = 'public' + PUBLIC = 'public', + DSO_EDIT = 'dso-edit' } diff --git a/src/app/shared/menu/menu-item/link-menu-item.component.html b/src/app/shared/menu/menu-item/link-menu-item.component.html index b3d0059217..91e23ad886 100644 --- a/src/app/shared/menu/menu-item/link-menu-item.component.html +++ b/src/app/shared/menu/menu-item/link-menu-item.component.html @@ -1,7 +1,7 @@ {}; } diff --git a/src/app/shared/menu/menu-item/models/search.model.ts b/src/app/shared/menu/menu-item/models/search.model.ts index eac281da51..1c2b5a6456 100644 --- a/src/app/shared/menu/menu-item/models/search.model.ts +++ b/src/app/shared/menu/menu-item/models/search.model.ts @@ -6,6 +6,7 @@ import { MenuItemType } from '../../menu-item-type.model'; */ export class SearchMenuItemModel implements MenuItemModel { type = MenuItemType.SEARCH; + disabled: boolean; placeholder: string; action: string; } diff --git a/src/app/shared/menu/menu-item/models/text.model.ts b/src/app/shared/menu/menu-item/models/text.model.ts index 70dc7c1203..4fe8a41cc9 100644 --- a/src/app/shared/menu/menu-item/models/text.model.ts +++ b/src/app/shared/menu/menu-item/models/text.model.ts @@ -6,5 +6,6 @@ import { MenuItemType } from '../../menu-item-type.model'; */ export class TextMenuItemModel implements MenuItemModel { type = MenuItemType.TEXT; + disabled: boolean; text: string; } diff --git a/src/app/shared/menu/menu-item/onclick-menu-item.component.html b/src/app/shared/menu/menu-item/onclick-menu-item.component.html index fd0192ad5f..5e8aecbd44 100644 --- a/src/app/shared/menu/menu-item/onclick-menu-item.component.html +++ b/src/app/shared/menu/menu-item/onclick-menu-item.component.html @@ -1,4 +1,5 @@ -{{item.text | translate}} +{{item.text | translate}} diff --git a/src/app/shared/menu/menu-item/onclick-menu-item.component.ts b/src/app/shared/menu/menu-item/onclick-menu-item.component.ts index 8df65a35c1..68e477b1fb 100644 --- a/src/app/shared/menu/menu-item/onclick-menu-item.component.ts +++ b/src/app/shared/menu/menu-item/onclick-menu-item.component.ts @@ -14,13 +14,16 @@ import { MenuItemType } from '../menu-item-type.model'; @rendersMenuItemForType(MenuItemType.ONCLICK) export class OnClickMenuItemComponent { item: OnClickMenuItemModel; + constructor(@Inject('itemModelProvider') item: OnClickMenuItemModel) { this.item = item; } public activate(event: any) { - event.preventDefault(); - this.item.function(); - event.stopPropagation(); + if (!this.item.disabled) { + event.preventDefault(); + this.item.function(); + event.stopPropagation(); + } } } diff --git a/src/app/shared/menu/menu-item/text-menu-item.component.html b/src/app/shared/menu/menu-item/text-menu-item.component.html index 7ba353e5e7..11c4420402 100644 --- a/src/app/shared/menu/menu-item/text-menu-item.component.html +++ b/src/app/shared/menu/menu-item/text-menu-item.component.html @@ -1 +1 @@ -{{item.text | translate}} \ No newline at end of file +{{item.text | translate}} diff --git a/src/app/shared/menu/menu-section/menu-section.component.ts b/src/app/shared/menu/menu-section/menu-section.component.ts index bef90b21c3..9b5bd499e1 100644 --- a/src/app/shared/menu/menu-section/menu-section.component.ts +++ b/src/app/shared/menu/menu-section/menu-section.component.ts @@ -65,7 +65,9 @@ export class MenuSectionComponent implements OnInit, OnDestroy { */ toggleSection(event: Event) { event.preventDefault(); - this.menuService.toggleActiveSection(this.menuID, this.section.id); + if (!this.section.model?.disabled) { + this.menuService.toggleActiveSection(this.menuID, this.section.id); + } } /** @@ -74,7 +76,9 @@ export class MenuSectionComponent implements OnInit, OnDestroy { */ activateSection(event: Event) { event.preventDefault(); - this.menuService.activateSection(this.menuID, this.section.id); + if (!this.section.model?.disabled) { + this.menuService.activateSection(this.menuID, this.section.id); + } } /** diff --git a/src/app/shared/menu/menu.effects.spec.ts b/src/app/shared/menu/menu.effects.spec.ts deleted file mode 100644 index 72319e06b8..0000000000 --- a/src/app/shared/menu/menu.effects.spec.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { LinkMenuItemModel } from './menu-item/models/link.model'; -import { TestBed } from '@angular/core/testing'; -import { MenuService } from './menu.service'; -import { ActivatedRoute } from '@angular/router'; -import { Observable, of as observableOf } from 'rxjs'; -import { provideMockActions } from '@ngrx/effects/testing'; -import { cold, hot } from 'jasmine-marbles'; -import { ROUTER_NAVIGATED } from '@ngrx/router-store'; -import { MenuEffects } from './menu.effects'; -import { MenuSection } from './menu-section.model'; -import { MenuID } from './menu-id.model'; -import { MenuItemType } from './menu-item-type.model'; - -describe('MenuEffects', () => { - let menuEffects: MenuEffects; - let routeDataMenuSection: MenuSection; - let routeDataMenuSectionResolved: MenuSection; - let routeDataMenuChildSection: MenuSection; - let toBeRemovedMenuSection: MenuSection; - let alreadyPresentMenuSection: MenuSection; - let route; - let menuService; - let actions: Observable; - - function init() { - routeDataMenuSection = { - id: 'mockSection_:idparam', - active: false, - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.mockSection', - 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', - active: false, - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.mockChildSection', - 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, - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.alreadyPresentSection', - link: '' - } as LinkMenuItemModel - }; - route = { - root: { - snapshot: { - data: { - menu: { - [MenuID.PUBLIC]: [routeDataMenuSection, alreadyPresentMenuSection] - } - }, - params: { - idparam: 'id_param_resolved', - linkparam: 'link_param_resolved', - } - }, - firstChild: { - snapshot: { - data: { - menu: { - [MenuID.PUBLIC]: routeDataMenuChildSection - } - } - } - } - } - }; - - menuService = jasmine.createSpyObj('menuService', { - getNonPersistentMenuSections: observableOf([toBeRemovedMenuSection, alreadyPresentMenuSection]), - addSection: {}, - removeSection: {} - }); - } - - beforeEach(() => { - init(); - TestBed.configureTestingModule({ - providers: [ - MenuEffects, - { provide: MenuService, useValue: menuService }, - { provide: ActivatedRoute, useValue: route }, - provideMockActions(() => actions) - ] - }); - - menuEffects = TestBed.inject(MenuEffects); - }); - - describe('buildRouteMenuSections$', () => { - it('should add and remove menu sections depending on the current route', () => { - actions = hot('--a-', { - a: { - type: ROUTER_NAVIGATED - } - }); - - const expected = cold('--b-', { - b: { - type: ROUTER_NAVIGATED - } - }); - - expect(menuEffects.buildRouteMenuSections$).toBeObservable(expected); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, routeDataMenuSectionResolved); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, routeDataMenuChildSection); - expect(menuService.addSection).not.toHaveBeenCalledWith(MenuID.PUBLIC, alreadyPresentMenuSection); - expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.PUBLIC, toBeRemovedMenuSection.id); - }); - }); -}); diff --git a/src/app/shared/menu/menu.effects.ts b/src/app/shared/menu/menu.effects.ts deleted file mode 100644 index d566545352..0000000000 --- a/src/app/shared/menu/menu.effects.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { ActivatedRoute } from '@angular/router'; -import { hasNoValue, hasValue } from '../empty.util'; -import { Actions, createEffect, ofType } from '@ngrx/effects'; -import { MenuService } from './menu.service'; -import { Observable } from 'rxjs'; -import { Action } from '@ngrx/store'; -import { ROUTER_NAVIGATED } from '@ngrx/router-store'; -import { Injectable } from '@angular/core'; -import { map, take, tap } from 'rxjs/operators'; -import { MenuSection } from './menu-section.model'; -import { MenuID } from './menu-id.model'; - -/** - * Effects modifying the state of menus - */ -@Injectable() -export class MenuEffects { - - /** - * On route change, build menu sections for every menu type depending on the current route data - */ - public buildRouteMenuSections$: Observable = createEffect(() => this.actions$ - .pipe( - ofType(ROUTER_NAVIGATED), - tap(() => { - Object.values(MenuID).forEach((menuID) => { - this.buildRouteMenuSections(menuID); - }); - }) - ), { dispatch: false }); - - constructor(private actions$: Actions, - private menuService: MenuService, - private route: ActivatedRoute) { - } - - /** - * 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.menuService.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.menuService.addSection(menuID, section); - } - }); - shouldNotPersistIDs.forEach((id) => { - this.menuService.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) : []; - } - - private resolveSubstitutions(object, params) { - - let resolved; - if (typeof object === 'string') { - resolved = object; - let match: RegExpMatchArray; - do { - match = resolved.match(/:(\w+)/); - if (match) { - const substitute = params[match[1]]; - if (hasValue(substitute)) { - resolved = resolved.replace(match[0], `${substitute}`); - } - } - } while (match); - } 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.module.ts b/src/app/shared/menu/menu.module.ts index c007af517d..28bdab9987 100644 --- a/src/app/shared/menu/menu.module.ts +++ b/src/app/shared/menu/menu.module.ts @@ -24,7 +24,7 @@ const ENTRY_COMPONENTS = [ const MODULES = [ TranslateModule, RouterModule, - CommonModule + CommonModule, ]; const PROVIDERS = [ diff --git a/src/app/shared/menu/menu.service.spec.ts b/src/app/shared/menu/menu.service.spec.ts index 589b5ebf6b..f4b0fe3db8 100644 --- a/src/app/shared/menu/menu.service.spec.ts +++ b/src/app/shared/menu/menu.service.spec.ts @@ -24,6 +24,9 @@ import { menusReducer } from './menu.reducer'; import { storeModuleConfig } from '../../app.reducer'; import { MenuSection } from './menu-section.model'; import { MenuID } from './menu-id.model'; +import { MenuItemType } from './menu-item-type.model'; +import { LinkMenuItemModel } from './menu-item/models/link.model'; +import { NavigationEnd } from '@angular/router'; describe('MenuService', () => { let service: MenuService; @@ -35,6 +38,14 @@ describe('MenuService', () => { let subSection4; let topSections; let initialState; + let routeDataMenuSection: MenuSection; + let routeDataMenuSectionResolved: MenuSection; + let routeDataMenuChildSection: MenuSection; + let toBeRemovedMenuSection: MenuSection; + let alreadyPresentMenuSection: MenuSection; + let route; + let router; + function init() { @@ -85,24 +96,103 @@ describe('MenuService', () => { } }; + routeDataMenuSection = { + id: 'mockSection_:idparam', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.mockSection', + 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', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.mockChildSection', + 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, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.alreadyPresentSection', + link: '' + } as LinkMenuItemModel + }; + route = { + root: { + snapshot: { + data: { + menu: { + [MenuID.PUBLIC]: [routeDataMenuSection, alreadyPresentMenuSection] + } + }, + params: { + idparam: 'id_param_resolved', + linkparam: 'link_param_resolved', + } + }, + firstChild: { + snapshot: { + data: { + menu: { + [MenuID.PUBLIC]: routeDataMenuChildSection + } + } + } + } + } + }; + + router = { + events: observableOf(new NavigationEnd(1, 'test-url', 'test-url')) + }; } beforeEach(waitForAsync(() => { init(); TestBed.configureTestingModule({ imports: [ - StoreModule.forRoot({ menus: menusReducer }, storeModuleConfig) + StoreModule.forRoot({menus: menusReducer}, storeModuleConfig) ], providers: [ - provideMockStore({ initialState }), - { provide: MenuService, useValue: service } + provideMockStore({initialState}), + {provide: MenuService, useValue: service} ] }).compileComponents(); })); beforeEach(() => { store = TestBed.inject(Store); - service = new MenuService(store); + service = new MenuService(store, route, router); spyOn(store, 'dispatch'); }); @@ -449,4 +539,32 @@ 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); + }); + }); + }); diff --git a/src/app/shared/menu/menu.service.ts b/src/app/shared/menu/menu.service.ts index 087145ae82..2253e9ce09 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 { map, switchMap } from 'rxjs/operators'; +import { filter, map, switchMap, take } from 'rxjs/operators'; import { ActivateMenuSectionAction, AddMenuSectionAction, @@ -11,9 +11,9 @@ import { DeactivateMenuSectionAction, ExpandMenuAction, ExpandMenuPreviewAction, - HideMenuAction, + HideMenuAction, HideMenuSectionAction, RemoveMenuSectionAction, - ShowMenuAction, + ShowMenuAction, ShowMenuSectionAction, ToggleActiveMenuSectionAction, ToggleMenuAction, } from './menu.actions'; @@ -22,6 +22,7 @@ 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'; export function menuKeySelector(key: string, selector): MemoizedSelector { return createSelector(selector, (state) => { @@ -54,7 +55,11 @@ const getSubSectionsFromSectionSelector = (id: string): MemoizedSelector) { + constructor( + protected store: Store, + protected route: ActivatedRoute, + protected router: Router, + ) { } /** @@ -266,6 +271,15 @@ export class MenuService { this.store.dispatch(new ShowMenuAction(menuID)); } + /** + * Show a given menu section + * @param {MenuID} menuID The ID of the menu + * @param id The ID of the section + */ + showMenuSection(menuID: MenuID, id: string): void { + this.store.dispatch(new ShowMenuSectionAction(menuID, id)); + } + /** * Hide a given menu * @param {MenuID} menuID The ID of the menu @@ -274,6 +288,15 @@ export class MenuService { this.store.dispatch(new HideMenuAction(menuID)); } + /** + * Hide a given menu section + * @param {MenuID} menuID The ID of the menu + * @param id The ID of the section + */ + hideMenuSection(menuID: MenuID, id: string): void { + this.store.dispatch(new HideMenuSectionAction(menuID, id)); + } + /** * Activate a given menu section when it's currently inactive or deactivate it when it's currently active * @param {MenuID} menuID The ID of the menu @@ -321,4 +344,100 @@ 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 (typeof object === 'string') { + resolved = object; + let match: RegExpMatchArray; + do { + match = resolved.match(/:(\w+)/); + if (match) { + const substitute = params[match[1]]; + if (hasValue(substitute)) { + resolved = resolved.replace(match[0], `${substitute}`); + } + } + } while (match); + } 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/object-list/identifier-data/identifier-data.component.html b/src/app/shared/object-list/identifier-data/identifier-data.component.html new file mode 100644 index 0000000000..91470628c4 --- /dev/null +++ b/src/app/shared/object-list/identifier-data/identifier-data.component.html @@ -0,0 +1,5 @@ + +
+ {{ identifiers[0].value | translate }} +
+
diff --git a/src/app/shared/object-list/identifier-data/identifier-data.component.ts b/src/app/shared/object-list/identifier-data/identifier-data.component.ts new file mode 100644 index 0000000000..cb6d1d97e5 --- /dev/null +++ b/src/app/shared/object-list/identifier-data/identifier-data.component.ts @@ -0,0 +1,47 @@ +import { Component, Input } from '@angular/core'; +import { map } from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import { hasValue } from '../../empty.util'; +import { Item } from 'src/app/core/shared/item.model'; +import { IdentifierData } from './identifier-data.model'; +import { IdentifierDataService } from '../../../core/data/identifier-data.service'; + +@Component({ + selector: 'ds-identifier-data', + templateUrl: './identifier-data.component.html' +}) +/** + * Component rendering an identifier, eg. DOI or handle + */ +export class IdentifierDataComponent { + + @Input() item: Item; + identifiers$: Observable; + + /** + * Initialize instance variables + * + * @param {IdentifierDataService} identifierDataService + */ + constructor(private identifierDataService: IdentifierDataService) { } + + ngOnInit(): void { + if (this.item == null) { + // Do not show the identifier if the feature is inactive or if the item is null. + return; + } + if (this.item.identifiers == null) { + // In case the identifier has not been loaded, do it individually. + this.item.identifiers = this.identifierDataService.getIdentifierDataFor(this.item); + } + this.identifiers$ = this.item.identifiers.pipe( + map((identifierRD) => { + if (identifierRD.statusCode !== 401 && hasValue(identifierRD.payload)) { + return identifierRD.payload; + } else { + return null; + } + }), + ); + } +} diff --git a/src/app/shared/object-list/identifier-data/identifier-data.model.ts b/src/app/shared/object-list/identifier-data/identifier-data.model.ts new file mode 100644 index 0000000000..e707f396e4 --- /dev/null +++ b/src/app/shared/object-list/identifier-data/identifier-data.model.ts @@ -0,0 +1,33 @@ +import { autoserialize, deserialize } from 'cerialize'; +import { typedObject } from 'src/app/core/cache/builders/build-decorators'; +import { CacheableObject } from 'src/app/core/cache/cacheable-object.model'; +import { HALLink } from 'src/app/core/shared/hal-link.model'; +import { ResourceType } from 'src/app/core/shared/resource-type'; +import { excludeFromEquals } from 'src/app/core/utilities/equals.decorators'; +import { IDENTIFIERS } from './identifier-data.resource-type'; +import {Identifier} from './identifier.model'; + +@typedObject +export class IdentifierData implements CacheableObject { + static type = IDENTIFIERS; + /** + * The type for this IdentifierData + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The + */ + @autoserialize + identifiers: Identifier[]; + + /** + * The {@link HALLink}s for this IdentifierData + */ + @deserialize + _links: { + self: HALLink; + }; +} diff --git a/src/app/shared/object-list/identifier-data/identifier-data.resource-type.ts b/src/app/shared/object-list/identifier-data/identifier-data.resource-type.ts new file mode 100644 index 0000000000..823a43eff9 --- /dev/null +++ b/src/app/shared/object-list/identifier-data/identifier-data.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from 'src/app/core/shared/resource-type'; + +/** + * The resource type for Identifiers + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const IDENTIFIERS = new ResourceType('identifiers'); diff --git a/src/app/shared/object-list/identifier-data/identifier.model.ts b/src/app/shared/object-list/identifier-data/identifier.model.ts new file mode 100644 index 0000000000..f528824b36 --- /dev/null +++ b/src/app/shared/object-list/identifier-data/identifier.model.ts @@ -0,0 +1,30 @@ +import { autoserialize } from 'cerialize'; + +/** + * Identifier model. Identifiers using this model are returned in lists from the /item/{id}/identifiers endpoint + * + * @author Kim Shepherd + */ +export class Identifier { + /** + * The value of the identifier, eg. http://hdl.handle.net/123456789/123 or https://doi.org/test/doi/1234 + */ + @autoserialize + value: string; + /** + * The type of identiifer, eg. "doi", or "handle", or "other" + */ + @autoserialize + identifierType: string; + /** + * The status of the identifier. Some schemes, like DOI, will have a different status based on whether it is + * queued for remote registration, reservation, or update, or has been registered, simply minted locally, etc. + */ + @autoserialize + identifierStatus: string; + /** + * The type of resource, in this case Identifier + */ + @autoserialize + type: string; +} diff --git a/src/app/shared/object-list/metadata-representation-list-element/browse-link/browse-link-metadata-list-element.component.html b/src/app/shared/object-list/metadata-representation-list-element/browse-link/browse-link-metadata-list-element.component.html new file mode 100644 index 0000000000..8d3afea273 --- /dev/null +++ b/src/app/shared/object-list/metadata-representation-list-element/browse-link/browse-link-metadata-list-element.component.html @@ -0,0 +1,9 @@ + diff --git a/src/app/shared/object-list/metadata-representation-list-element/browse-link/browse-link-metadata-list-element.component.spec.ts b/src/app/shared/object-list/metadata-representation-list-element/browse-link/browse-link-metadata-list-element.component.spec.ts new file mode 100644 index 0000000000..32919d9758 --- /dev/null +++ b/src/app/shared/object-list/metadata-representation-list-element/browse-link/browse-link-metadata-list-element.component.spec.ts @@ -0,0 +1,62 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { BrowseLinkMetadataListElementComponent } from './browse-link-metadata-list-element.component'; +import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; + +const mockMetadataRepresentation = Object.assign(new MetadatumRepresentation('type'), { + key: 'dc.contributor.author', + value: 'Test Author' +}); + +const mockMetadataRepresentationWithUrl = Object.assign(new MetadatumRepresentation('type'), { + key: 'dc.subject', + value: 'http://purl.org/test/subject' +}); + +describe('BrowseLinkMetadataListElementComponent', () => { + let comp: BrowseLinkMetadataListElementComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [], + declarations: [BrowseLinkMetadataListElementComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(BrowseLinkMetadataListElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(BrowseLinkMetadataListElementComponent); + comp = fixture.componentInstance; + comp.metadataRepresentation = mockMetadataRepresentation; + fixture.detectChanges(); + })); + + waitForAsync(() => { + it('should contain the value as a browse link', () => { + expect(fixture.debugElement.nativeElement.textContent).toContain(mockMetadataRepresentation.value); + }); + it('should NOT match isLink', () => { + expect(comp.isLink).toBe(false); + }); + }); + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(BrowseLinkMetadataListElementComponent); + comp = fixture.componentInstance; + comp.metadataRepresentation = mockMetadataRepresentationWithUrl; + fixture.detectChanges(); + })); + + waitForAsync(() => { + it('should contain the value expected', () => { + expect(fixture.debugElement.nativeElement.textContent).toContain(mockMetadataRepresentationWithUrl.value); + }); + it('should match isLink', () => { + expect(comp.isLink).toBe(true); + }); + }); + +}); diff --git a/src/app/shared/object-list/metadata-representation-list-element/browse-link/browse-link-metadata-list-element.component.ts b/src/app/shared/object-list/metadata-representation-list-element/browse-link/browse-link-metadata-list-element.component.ts new file mode 100644 index 0000000000..0eb0ce05b0 --- /dev/null +++ b/src/app/shared/object-list/metadata-representation-list-element/browse-link/browse-link-metadata-list-element.component.ts @@ -0,0 +1,29 @@ +import { MetadataRepresentationType } from '../../../../core/shared/metadata-representation/metadata-representation.model'; +import { Component } from '@angular/core'; +import { MetadataRepresentationListElementComponent } from '../metadata-representation-list-element.component'; +import { metadataRepresentationComponent } from '../../../metadata-representation/metadata-representation.decorator'; +//@metadataRepresentationComponent('Publication', MetadataRepresentationType.PlainText) +// For now, authority controlled fields are rendered the same way as plain text fields +//@metadataRepresentationComponent('Publication', MetadataRepresentationType.AuthorityControlled) +@metadataRepresentationComponent('Publication', MetadataRepresentationType.BrowseLink) +@Component({ + selector: 'ds-browse-link-metadata-list-element', + templateUrl: './browse-link-metadata-list-element.component.html' +}) +/** + * A component for displaying MetadataRepresentation objects in the form of plain text + * It will simply use the value retrieved from MetadataRepresentation.getValue() to display as plain text + */ +export class BrowseLinkMetadataListElementComponent extends MetadataRepresentationListElementComponent { + /** + * Get the appropriate query parameters for this browse link, depending on whether the browse definition + * expects 'startsWith' (eg browse by date) or 'value' (eg browse by title) + */ + getQueryParams() { + let queryParams = {startsWith: this.metadataRepresentation.getValue()}; + if (this.metadataRepresentation.browseDefinition.metadataBrowse) { + return {value: this.metadataRepresentation.getValue()}; + } + return queryParams; + } +} diff --git a/src/app/shared/object-list/metadata-representation-list-element/metadata-representation-list-element.component.spec.ts b/src/app/shared/object-list/metadata-representation-list-element/metadata-representation-list-element.component.spec.ts new file mode 100644 index 0000000000..f0cc150b3e --- /dev/null +++ b/src/app/shared/object-list/metadata-representation-list-element/metadata-representation-list-element.component.spec.ts @@ -0,0 +1,59 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { MetadatumRepresentation } from '../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; +import { mockData } from '../../testing/browse-definition-data-service.stub'; +import { MetadataRepresentationListElementComponent } from './metadata-representation-list-element.component'; + +// Mock metadata representation values +const mockMetadataRepresentation = Object.assign(new MetadatumRepresentation('type', mockData[1]), { + key: 'dc.contributor.author', + value: 'Test Author' +}); +const mockMetadataRepresentationUrl = Object.assign(new MetadatumRepresentation('type', mockData[1]), { + key: 'dc.subject', + value: 'https://www.google.com' +}); + +describe('MetadataRepresentationListElementComponent', () => { + let comp: MetadataRepresentationListElementComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [], + declarations: [MetadataRepresentationListElementComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(MetadataRepresentationListElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(MetadataRepresentationListElementComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + })); + + describe('when the value is not a URL', () => { + beforeEach(() => { + comp.metadataRepresentation = mockMetadataRepresentation; + }); + it('isLink correctly detects a non-URL string as false', () => { + waitForAsync(() => { + expect(comp.isLink()).toBe(false); + }); + }); + }); + + describe('when the value is a URL', () => { + beforeEach(() => { + comp.metadataRepresentation = mockMetadataRepresentationUrl; + }); + it('isLink correctly detects a URL string as true', () => { + waitForAsync(() => { + expect(comp.isLink()).toBe(true); + }); + }); + }); + +}); diff --git a/src/app/shared/object-list/metadata-representation-list-element/metadata-representation-list-element.component.ts b/src/app/shared/object-list/metadata-representation-list-element/metadata-representation-list-element.component.ts index 2e14485fbb..b69f6b37dc 100644 --- a/src/app/shared/object-list/metadata-representation-list-element/metadata-representation-list-element.component.ts +++ b/src/app/shared/object-list/metadata-representation-list-element/metadata-representation-list-element.component.ts @@ -13,4 +13,14 @@ export class MetadataRepresentationListElementComponent { * The metadata representation of this component */ metadataRepresentation: MetadataRepresentation; + + /** + * Returns true if this component's value matches a basic regex "Is this an HTTP URL" test + */ + isLink(): boolean { + // Match any string that begins with http:// or https:// + const linkPattern = new RegExp(/^https?\/\/.*/); + return linkPattern.test(this.metadataRepresentation.getValue()); + } + } diff --git a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.html b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.html index 31b670b1a3..7b611a7d1f 100644 --- a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.html +++ b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.html @@ -1,3 +1,17 @@
- {{metadataRepresentation.getValue()}} + + + {{metadataRepresentation.getValue()}} + + + {{metadataRepresentation.getValue()}} + + {{metadataRepresentation.getValue()}} + + {{metadataRepresentation.getValue()}} +
diff --git a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.spec.ts b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.spec.ts index af09d3c204..cfb812a475 100644 --- a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.spec.ts +++ b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.spec.ts @@ -2,8 +2,12 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { PlainTextMetadataListElementComponent } from './plain-text-metadata-list-element.component'; import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; +import { By } from '@angular/platform-browser'; +import { mockData } from '../../../testing/browse-definition-data-service.stub'; -const mockMetadataRepresentation = Object.assign(new MetadatumRepresentation('type'), { +// Render the mock representation with the default mock author browse definition so it is also rendered as a link +// without affecting other tests +const mockMetadataRepresentation = Object.assign(new MetadatumRepresentation('type', mockData[1]), { key: 'dc.contributor.author', value: 'Test Author' }); @@ -33,4 +37,8 @@ describe('PlainTextMetadataListElementComponent', () => { expect(fixture.debugElement.nativeElement.textContent).toContain(mockMetadataRepresentation.value); }); + it('should contain the browse link as plain text', () => { + expect(fixture.debugElement.query(By.css('a.ds-browse-link')).nativeElement.innerHTML).toContain(mockMetadataRepresentation.value); + }); + }); diff --git a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.ts b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.ts index 198c3712d9..2d21a7afe8 100644 --- a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.ts +++ b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.ts @@ -15,4 +15,15 @@ import { metadataRepresentationComponent } from '../../../metadata-representatio * It will simply use the value retrieved from MetadataRepresentation.getValue() to display as plain text */ export class PlainTextMetadataListElementComponent extends MetadataRepresentationListElementComponent { + /** + * Get the appropriate query parameters for this browse link, depending on whether the browse definition + * expects 'startsWith' (eg browse by date) or 'value' (eg browse by title) + */ + getQueryParams() { + let queryParams = {startsWith: this.metadataRepresentation.getValue()}; + if (this.metadataRepresentation.browseDefinition.metadataBrowse) { + return {value: this.metadataRepresentation.getValue()}; + } + return queryParams; + } } diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html index 3c15eff127..9a4bffadb8 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html @@ -2,13 +2,12 @@ [tabIndex]="-1" [routerLink]="[searchLink]" [queryParams]="addQueryParams" queryParamsHandling="merge"> -