diff --git a/src/app/root/root.component.spec.ts b/src/app/root/root.component.spec.ts index 504bc34e34..ab148b8ebd 100644 --- a/src/app/root/root.component.spec.ts +++ b/src/app/root/root.component.spec.ts @@ -17,7 +17,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { RouterMock } from '../shared/mocks/router.mock'; import { MockActivatedRoute } from '../shared/mocks/active-router.mock'; import { MenuService } from '../shared/menu/menu.service'; -import { CSSVariableService } from '../shared/sass-helper/sass-helper.service'; +import { CSSVariableService } from '../shared/sass-helper/css-variable.service'; import { CSSVariableServiceStub } from '../shared/testing/css-variable-service.stub'; import { HostWindowService } from '../shared/host-window.service'; import { HostWindowServiceStub } from '../shared/testing/host-window-service.stub'; diff --git a/src/app/root/root.component.ts b/src/app/root/root.component.ts index 472ba440c9..2bbeec6282 100644 --- a/src/app/root/root.component.ts +++ b/src/app/root/root.component.ts @@ -10,7 +10,7 @@ import { MetadataService } from '../core/metadata/metadata.service'; import { HostWindowState } from '../shared/search/host-window.reducer'; import { NativeWindowRef, NativeWindowService } from '../core/services/window.service'; import { AuthService } from '../core/auth/auth.service'; -import { CSSVariableService } from '../shared/sass-helper/sass-helper.service'; +import { CSSVariableService } from '../shared/sass-helper/css-variable.service'; import { MenuService } from '../shared/menu/menu.service'; import { HostWindowService } from '../shared/host-window.service'; import { ThemeConfig } from '../../config/theme.model'; @@ -61,10 +61,10 @@ export class RootComponent implements OnInit { } ngOnInit() { - this.sidebarVisible = this.menuService.isMenuVisible(MenuID.ADMIN); + this.sidebarVisible = this.menuService.isMenuVisibleWithVisibleSections(MenuID.ADMIN); - this.collapsedSidebarWidth = this.cssService.getVariable('collapsedSidebarWidth'); - this.totalSidebarWidth = this.cssService.getVariable('totalSidebarWidth'); + this.collapsedSidebarWidth = this.cssService.getVariable('--ds-collapsed-sidebar-width'); + this.totalSidebarWidth = this.cssService.getVariable('--ds-total-sidebar-width'); const sidebarCollapsed = this.menuService.isMenuCollapsed(MenuID.ADMIN); this.slideSidebarOver = combineLatestObservable([sidebarCollapsed, this.windowService.isXsOrSm()]) diff --git a/src/app/search-navbar/search-navbar.component.scss b/src/app/search-navbar/search-navbar.component.scss index d5bef9bc47..32e369bf48 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; @@ -14,6 +11,11 @@ input[type="text"] { cursor: pointer; position: sticky; top: 0; + + color: var(--ds-header-icon-color); + &:hover, &:focus { + color: var(--ds-header-icon-color-hover); + } } @media screen and (max-width: map-get($grid-breakpoints, md)) { @@ -22,8 +24,5 @@ input[type="text"] { width: 40vw !important; } - a.submit-icon { - color: var(--bs-link-color); - } } diff --git a/src/app/search-page/search-page.component.html b/src/app/search-page/search-page.component.html index 73a952332f..36ba53a885 100644 --- a/src/app/search-page/search-page.component.html +++ b/src/app/search-page/search-page.component.html @@ -1 +1 @@ - + diff --git a/src/app/search-page/search-page.module.ts b/src/app/search-page/search-page.module.ts index 758eca15c0..19fd9bd309 100644 --- a/src/app/search-page/search-page.module.ts +++ b/src/app/search-page/search-page.module.ts @@ -7,7 +7,6 @@ import { ConfigurationSearchPageGuard } from './configuration-search-page.guard' import { SearchTrackerComponent } from './search-tracker.component'; import { StatisticsModule } from '../statistics/statistics.module'; import { SearchPageComponent } from './search-page.component'; -import { SidebarFilterService } from '../shared/sidebar/filter/sidebar-filter.service'; import { SearchFilterService } from '../core/shared/search/search-filter.service'; import { SearchConfigurationService } from '../core/shared/search/search-configuration.service'; import { JournalEntitiesModule } from '../entity-groups/journal-entities/journal-entities.module'; @@ -34,7 +33,6 @@ const components = [ declarations: components, providers: [ SidebarService, - SidebarFilterService, SearchFilterService, ConfigurationSearchPageGuard, SearchConfigurationService diff --git a/src/app/shared/animations/slide.ts b/src/app/shared/animations/slide.ts index b396333fb4..310ddbbfde 100644 --- a/src/app/shared/animations/slide.ts +++ b/src/app/shared/animations/slide.ts @@ -12,7 +12,7 @@ export const slide = trigger('slide', [ export const slideMobileNav = trigger('slideMobileNav', [ - state('expanded', style({ height: '100vh' })), + state('expanded', style({ height: 'auto', 'min-height': '100vh' })), state('collapsed', style({ height: 0 })), diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html index c2b414b6f3..94cbd4368a 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html @@ -2,11 +2,11 @@ diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.scss b/src/app/shared/auth-nav-menu/auth-nav-menu.component.scss index ac51af27bf..36161ff3da 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.scss +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.scss @@ -13,9 +13,9 @@ } .dropdown-toggle { - color: var(--ds-header-icon-color) !important; + color: var(--ds-header-icon-color); - &:hover, &focus { + &:hover, &:focus { color: var(--ds-header-icon-color-hover); } } diff --git a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.html b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.html index 736d39d318..5643f3b9a8 100644 --- a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.html +++ b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.html @@ -1,10 +1,15 @@
- {{(user$ | async)?.name}} ({{(user$ | async)?.email}}) - {{'nav.profile' | translate}} - {{'nav.mydspace' | translate}} + + {{(user$ | async)?.name}}
+ {{(user$ | async)?.email}} +
+ {{'nav.profile' | translate}} + {{'nav.mydspace' | translate}} + {{'nav.subscriptions' | translate}} + - +
diff --git a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.spec.ts b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.spec.ts index 983fe68274..5576b942b3 100644 --- a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.spec.ts +++ b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.spec.ts @@ -162,10 +162,24 @@ describe('UserMenuComponent', () => { }); it('should display user name and email', () => { - const user = 'User Test (test@test.com)'; + const username = 'User Test'; + const email = 'test@test.com'; const span = deUserMenu.query(By.css('.dropdown-item-text')); expect(span).toBeDefined(); - expect(span.nativeElement.innerHTML).toBe(user); + expect(span.nativeElement.innerHTML).toContain(username); + expect(span.nativeElement.innerHTML).toContain(email); + }); + + it('should create logout component', () => { + const components = fixture.debugElement.query(By.css('[data-test="log-out-component"]')); + expect(components).toBeTruthy(); + }); + + it('should not create logout component', () => { + component.inExpandableNavbar = true; + fixture.detectChanges(); + const components = fixture.debugElement.query(By.css('[data-test="log-out-component"]')); + expect(components).toBeFalsy(); }); }); diff --git a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.ts b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.ts index aa78be9749..114c711a9b 100644 --- a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.ts +++ b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, Input, OnInit } from '@angular/core'; import { Observable } from 'rxjs'; import { select, Store } from '@ngrx/store'; @@ -8,7 +8,7 @@ import { AppState } from '../../../app.reducer'; import { isAuthenticationLoading } from '../../../core/auth/selectors'; import { MYDSPACE_ROUTE } from '../../../my-dspace-page/my-dspace-page.component'; import { AuthService } from '../../../core/auth/auth.service'; -import { getProfileModuleRoute } from '../../../app-routing-paths'; +import { getProfileModuleRoute, getSubscriptionsModuleRoute } from '../../../app-routing-paths'; /** * This component represents the user nav menu. @@ -20,6 +20,11 @@ import { getProfileModuleRoute } from '../../../app-routing-paths'; }) export class UserMenuComponent implements OnInit { + /** + * The input flag to show user details in navbar expandable menu + */ + @Input() inExpandableNavbar = false; + /** * True if the authentication is loading. * @type {Observable} @@ -43,6 +48,11 @@ export class UserMenuComponent implements OnInit { */ public profileRoute = getProfileModuleRoute(); + /** + * The profile page route + */ + public subscriptionsRoute = getSubscriptionsModuleRoute(); + constructor(private store: Store, private authService: AuthService) { } diff --git a/src/app/shared/browse-by/browse-by.component.html b/src/app/shared/browse-by/browse-by.component.html index 7d9153b9f8..d6a0005173 100644 --- a/src/app/shared/browse-by/browse-by.component.html +++ b/src/app/shared/browse-by/browse-by.component.html @@ -3,7 +3,7 @@
- +
- +
-
- + - +
+
{{ listItem.collection.name}}
+
+ -
+ diff --git a/src/app/shared/collection-dropdown/themed-collection-dropdown.component.ts b/src/app/shared/collection-dropdown/themed-collection-dropdown.component.ts new file mode 100644 index 0000000000..27c883099d --- /dev/null +++ b/src/app/shared/collection-dropdown/themed-collection-dropdown.component.ts @@ -0,0 +1,33 @@ +import { CollectionDropdownComponent, CollectionListEntry } from './collection-dropdown.component'; +import { ThemedComponent } from '../theme-support/themed.component'; +import { Component, Input, Output, EventEmitter } from '@angular/core'; + +@Component({ + selector: 'ds-themed-collection-dropdown', + styleUrls: [], + templateUrl: '../../shared/theme-support/themed.component.html', +}) +export class ThemedCollectionDropdownComponent extends ThemedComponent { + + @Input() entityType: string; + + @Output() searchComplete = new EventEmitter(); + + @Output() theOnlySelectable = new EventEmitter(); + + @Output() selectionChange = new EventEmitter(); + + protected inAndOutputNames: (keyof CollectionDropdownComponent & keyof this)[] = ['entityType', 'searchComplete', 'theOnlySelectable', 'selectionChange']; + + protected getComponentName(): string { + return 'CollectionDropdownComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/shared/collection-dropdown/collection-dropdown.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./collection-dropdown.component`); + } +} diff --git a/src/app/shared/comcol/comcol-forms/comcol-form/comcol-form.component.ts b/src/app/shared/comcol/comcol-forms/comcol-form/comcol-form.component.ts index 29be240753..23dfca8616 100644 --- a/src/app/shared/comcol/comcol-forms/comcol-form/comcol-form.component.ts +++ b/src/app/shared/comcol/comcol-forms/comcol-form/comcol-form.component.ts @@ -17,8 +17,8 @@ import { MetadataMap, MetadataValue } from '../../../../core/shared/metadata.mod import { ResourceType } from '../../../../core/shared/resource-type'; import { hasValue, isNotEmpty } from '../../../empty.util'; import { NotificationsService } from '../../../notifications/notifications.service'; -import { UploaderOptions } from '../../../uploader/uploader-options.model'; -import { UploaderComponent } from '../../../uploader/uploader.component'; +import { UploaderOptions } from '../../../upload/uploader/uploader-options.model'; +import { UploaderComponent } from '../../../upload/uploader/uploader.component'; import { Operation } from 'fast-json-patch'; import { NoContent } from '../../../../core/shared/NoContent.model'; import { getFirstCompletedRemoteData } from '../../../../core/shared/operators'; diff --git a/src/app/shared/comcol/comcol-forms/delete-comcol-page/delete-comcol-page.component.spec.ts b/src/app/shared/comcol/comcol-forms/delete-comcol-page/delete-comcol-page.component.spec.ts index bc73e4134b..1040e31c57 100644 --- a/src/app/shared/comcol/comcol-forms/delete-comcol-page/delete-comcol-page.component.spec.ts +++ b/src/app/shared/comcol/comcol-forms/delete-comcol-page/delete-comcol-page.component.spec.ts @@ -11,7 +11,6 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; import { DeleteComColPageComponent } from './delete-comcol-page.component'; import { NotificationsService } from '../../../notifications/notifications.service'; import { NotificationsServiceStub } from '../../../testing/notifications-service.stub'; -import { RequestService } from '../../../../core/data/request.service'; import { getTestScheduler } from 'jasmine-marbles'; import { ComColDataService } from '../../../../core/data/comcol-data.service'; import { createFailedRemoteDataObject$, createNoContentRemoteDataObject$ } from '../../../remote-data.utils'; diff --git a/src/app/shared/comcol/comcol.module.ts b/src/app/shared/comcol/comcol.module.ts index 094387929a..efbcedf2c6 100644 --- a/src/app/shared/comcol/comcol.module.ts +++ b/src/app/shared/comcol/comcol.module.ts @@ -15,6 +15,7 @@ import { ThemedComcolPageBrowseByComponent } from './comcol-page-browse-by/theme import { ComcolRoleComponent } from './comcol-forms/edit-comcol-page/comcol-role/comcol-role.component'; import { SharedModule } from '../shared.module'; import { FormModule } from '../form/form.module'; +import { UploadModule } from '../upload/upload.module'; const COMPONENTS = [ ComcolPageContentComponent, @@ -28,9 +29,7 @@ const COMPONENTS = [ ComcolPageBrowseByComponent, ThemedComcolPageBrowseByComponent, ComcolRoleComponent, - ThemedComcolPageHandleComponent - ]; @NgModule({ @@ -40,10 +39,12 @@ const COMPONENTS = [ imports: [ CommonModule, FormModule, - SharedModule + SharedModule, + UploadModule, ], exports: [ - ...COMPONENTS + ...COMPONENTS, + UploadModule, ] }) export class ComcolModule { } diff --git a/src/app/shared/context-help-wrapper/context-help-wrapper.component.html b/src/app/shared/context-help-wrapper/context-help-wrapper.component.html new file mode 100644 index 0000000000..b031d0f42d --- /dev/null +++ b/src/app/shared/context-help-wrapper/context-help-wrapper.component.html @@ -0,0 +1,25 @@ + +
+ + + {{elem.text}} + + + {{ elem }} + + +
+
+ + + diff --git a/src/app/shared/context-help-wrapper/context-help-wrapper.component.scss b/src/app/shared/context-help-wrapper/context-help-wrapper.component.scss new file mode 100644 index 0000000000..71be81ec97 --- /dev/null +++ b/src/app/shared/context-help-wrapper/context-help-wrapper.component.scss @@ -0,0 +1,31 @@ +:host { + position: relative; +} + +.ds-context-help-icon { + position: absolute; + top: 0; + cursor: pointer; + color: var(--bs-info); + background-color: var(--bs-white); + font-size: 16px; // not relative, because we don't want the icon to resize based on the container + line-height: 1; + border-radius: 50%; +} + +.ds-context-help-icon-left { + left: var(--ds-context-x-offset); +} + +.ds-context-help-icon-right { + right: calc(-1 * var(--ds-context-help-icon-size)); +} + +::ng-deep .tooltip-inner { + width: var(--ds-context-help-tooltip-width); + max-width: var(--ds-context-help-tooltip-width); + a { + color: var(--ds-context-help-tooltip-link-color); + text-decoration: underline; + } +} diff --git a/src/app/shared/context-help-wrapper/context-help-wrapper.component.spec.ts b/src/app/shared/context-help-wrapper/context-help-wrapper.component.spec.ts new file mode 100644 index 0000000000..081fb1050f --- /dev/null +++ b/src/app/shared/context-help-wrapper/context-help-wrapper.component.spec.ts @@ -0,0 +1,219 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { of as observableOf, BehaviorSubject } from 'rxjs'; +import { ContextHelpWrapperComponent } from './context-help-wrapper.component'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateService } from '@ngx-translate/core'; +import { ContextHelpService } from '../context-help.service'; +import { ContextHelp } from '../context-help.model'; +import { Component, Input, DebugElement } from '@angular/core'; +import { PlacementArray } from '@ng-bootstrap/ng-bootstrap/util/positioning'; +import { PlacementDir } from './placement-dir.model'; +import { By } from '@angular/platform-browser'; + +@Component({ + template: ` + template + + + ` +}) +class TemplateComponent { + @Input() content: string; + @Input() id: string; + @Input() tooltipPlacement?: PlacementArray; + @Input() iconPlacement?: PlacementDir; + @Input() dontParseLinks?: boolean; +} + +const messages = { + lorem: 'lorem ipsum dolor sit amet', + linkTest: 'This is text, [this](https://dspace.lyrasis.org/) is a link, and [so is this](https://google.com/)' +}; +const exampleContextHelp: ContextHelp = { + id: 'test-tooltip', + isTooltipVisible: false +}; + +describe('ContextHelpWrapperComponent', () => { + let templateComponent: TemplateComponent; + let wrapperComponent: ContextHelpWrapperComponent; + let fixture: ComponentFixture; + let el: DebugElement; + let translateService: any; + let contextHelpService: any; + let getContextHelp$: BehaviorSubject; + let shouldShowIcons$: BehaviorSubject; + + function makeWrappedElement(): HTMLElement { + const wrapped: HTMLElement = document.createElement('div'); + wrapped.innerHTML = 'example element'; + return wrapped; + } + + beforeEach(waitForAsync( () => { + translateService = jasmine.createSpyObj('translateService', ['get']); + contextHelpService = jasmine.createSpyObj('contextHelpService', [ + 'shouldShowIcons$', + 'getContextHelp$', + 'add', + 'remove', + 'toggleIcons', + 'toggleTooltip', + 'showTooltip', + 'hideTooltip' + ]); + + TestBed.configureTestingModule({ + imports: [ NgbTooltipModule ], + providers: [ + { provide: TranslateService, useValue: translateService }, + { provide: ContextHelpService, useValue: contextHelpService }, + ], + declarations: [ TemplateComponent, ContextHelpWrapperComponent ] + }).compileComponents(); + })); + + beforeEach(() => { + // Initializing services. + getContextHelp$ = new BehaviorSubject(exampleContextHelp); + shouldShowIcons$ = new BehaviorSubject(false); + contextHelpService.getContextHelp$.and.returnValue(getContextHelp$); + contextHelpService.shouldShowIcons$.and.returnValue(shouldShowIcons$); + translateService.get.and.callFake((content) => observableOf(messages[content])); + + getContextHelp$.next(exampleContextHelp); + shouldShowIcons$.next(false); + + // Initializing components. + fixture = TestBed.createComponent(TemplateComponent); + el = fixture.debugElement; + templateComponent = fixture.componentInstance; + templateComponent.content = 'lorem'; + templateComponent.id = 'test-tooltip'; + templateComponent.tooltipPlacement = ['bottom']; + templateComponent.iconPlacement = 'left'; + wrapperComponent = el.query(By.css('ds-context-help-wrapper')).componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(templateComponent).toBeDefined(); + expect(wrapperComponent).toBeDefined(); + }); + + it('should not show the context help icon while icon visibility is not turned on', (done) => { + fixture.whenStable().then(() => { + const wrapper = el.query(By.css('ds-context-help-wrapper')).nativeElement; + expect(wrapper.children.length).toBe(0); + done(); + }); + }); + + describe('when icon visibility is turned on', () => { + beforeEach(() => { + shouldShowIcons$.next(true); + fixture.detectChanges(); + spyOn(wrapperComponent.tooltip, 'open').and.callThrough(); + spyOn(wrapperComponent.tooltip, 'close').and.callThrough(); + }); + + it('should show the context help button', (done) => { + fixture.whenStable().then(() => { + const wrapper = el.query(By.css('ds-context-help-wrapper')).nativeElement; + expect(wrapper.children.length).toBe(1); + const [i] = wrapper.children; + expect(i.tagName).toBe('I'); + done(); + }); + }); + + describe('after the icon is clicked', () => { + let i; + beforeEach(() => { + i = el.query(By.css('.ds-context-help-icon')).nativeElement; + i.click(); + fixture.detectChanges(); + }); + + it('should display the tooltip', () => { + expect(contextHelpService.toggleTooltip).toHaveBeenCalledWith('test-tooltip'); + getContextHelp$.next({...exampleContextHelp, isTooltipVisible: true}); + fixture.detectChanges(); + expect(wrapperComponent.tooltip.open).toHaveBeenCalled(); + expect(wrapperComponent.tooltip.close).toHaveBeenCalledTimes(0); + expect(fixture.debugElement.query(By.css('.ds-context-help-content')).nativeElement.textContent) + .toMatch(/\s*lorem ipsum dolor sit amet\s*/); + }); + + it('should correctly display links', () => { + templateComponent.content = 'linkTest'; + getContextHelp$.next({...exampleContextHelp, isTooltipVisible: true}); + fixture.detectChanges(); + const nodeList: NodeList = fixture.debugElement.query(By.css('.ds-context-help-content')) + .nativeElement + .childNodes; + const relevantNodes = Array.from(nodeList).filter(node => node.nodeType !== Node.COMMENT_NODE); + expect(relevantNodes.length).toBe(4); + + const [text1, link1, text2, link2] = relevantNodes; + + expect(text1.nodeType).toBe(Node.TEXT_NODE); + expect(text1.nodeValue).toMatch(/\s* This is text, \s*/); + + expect(link1.nodeName).toBe('A'); + expect((link1 as any).href).toBe('https://dspace.lyrasis.org/'); + expect(link1.textContent).toBe('this'); + + expect(text2.nodeType).toBe(Node.TEXT_NODE); + expect(text2.nodeValue).toMatch(/\s* is a link, and \s*/); + + expect(link2.nodeName).toBe('A'); + expect((link2 as any).href).toBe('https://google.com/'); + expect(link2.textContent).toBe('so is this'); + }); + + it('should not display links if specified not to', () => { + templateComponent.dontParseLinks = true; + templateComponent.content = 'linkTest'; + getContextHelp$.next({...exampleContextHelp, isTooltipVisible: true}); + fixture.detectChanges(); + + + const nodeList: NodeList = fixture.debugElement.query(By.css('.ds-context-help-content')) + .nativeElement + .childNodes; + const relevantNodes = Array.from(nodeList).filter(node => node.nodeType !== Node.COMMENT_NODE); + expect(relevantNodes.length).toBe(1); + + const [text] = relevantNodes; + + expect(text.nodeType).toBe(Node.TEXT_NODE); + expect(text.nodeValue).toMatch( + /\s* This is text, \[this\]\(https:\/\/dspace.lyrasis.org\/\) is a link, and \[so is this\]\(https:\/\/google.com\/\) \s*/); + }); + + describe('after the icon is clicked again', () => { + beforeEach(() => { + i.click(); + fixture.detectChanges(); + spyOn(wrapperComponent.tooltip, 'isOpen').and.returnValue(true); + }); + + it('should close the tooltip', () => { + expect(contextHelpService.toggleTooltip).toHaveBeenCalledWith('test-tooltip'); + getContextHelp$.next({...exampleContextHelp, isTooltipVisible: false}); + fixture.detectChanges(); + expect(wrapperComponent.tooltip.close).toHaveBeenCalled(); + }); + }); + }); + }); +}); 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 new file mode 100644 index 0000000000..e170d522b5 --- /dev/null +++ b/src/app/shared/context-help-wrapper/context-help-wrapper.component.ts @@ -0,0 +1,164 @@ +import { Component, Input, OnInit, TemplateRef, OnDestroy, ViewChild } from '@angular/core'; +import { PlacementArray } from '@ng-bootstrap/ng-bootstrap/util/positioning'; +import { TranslateService } from '@ngx-translate/core'; +import { Observable, Subscription, BehaviorSubject, combineLatest } from 'rxjs'; +import { map, distinctUntilChanged, mergeMap } from 'rxjs/operators'; +import { PlacementDir } from './placement-dir.model'; +import { ContextHelpService } from '../context-help.service'; +import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; +import { hasValueOperator } from '../empty.util'; +import { ContextHelp } from '../context-help.model'; + +type ParsedContent = (string | {href: string, text: string})[]; + +/** + * This component renders an info icon next to the wrapped element which + * produces a tooltip when clicked. + */ +@Component({ + selector: 'ds-context-help-wrapper', + templateUrl: './context-help-wrapper.component.html', + styleUrls: ['./context-help-wrapper.component.scss'], +}) +export class ContextHelpWrapperComponent implements OnInit, OnDestroy { + /** + * Template reference for the wrapped element. + */ + @Input() templateRef: TemplateRef; + + /** + * Identifier for the context help tooltip. + */ + @Input() id: string; + + /** + * Indicate where the tooltip should show up, relative to the info icon. + */ + @Input() tooltipPlacement?: PlacementArray = []; + + /** + * Indicate whether the info icon should appear to the left or to + * the right of the wrapped element. + */ + @Input() iconPlacement?: PlacementDir = 'left'; + + /** + * If true, don't process text to render links. + */ + @Input() set dontParseLinks(dont: boolean) { + this.dontParseLinks$.next(dont); + } + private dontParseLinks$: BehaviorSubject = new BehaviorSubject(false); + + shouldShowIcon$: Observable; + + tooltip: NgbTooltip; + + @Input() set content(translateKey: string) { + this.content$.next(translateKey); + } + private content$: BehaviorSubject = new BehaviorSubject(undefined); + + parsedContent$: Observable; + + private subs: Subscription[] = []; + + constructor( + private translateService: TranslateService, + private contextHelpService: ContextHelpService + ) { } + + ngOnInit() { + this.parsedContent$ = combineLatest([ + this.content$.pipe(distinctUntilChanged(), mergeMap(translateKey => this.translateService.get(translateKey))), + this.dontParseLinks$.pipe(distinctUntilChanged()) + ]).pipe( + map(([text, dontParseLinks]) => + dontParseLinks ? [text] : this.parseLinks(text)) + ); + this.shouldShowIcon$ = this.contextHelpService.shouldShowIcons$(); + } + + @ViewChild('tooltip', { static: false }) set setTooltip(tooltip: NgbTooltip) { + this.tooltip = tooltip; + this.clearSubs(); + if (this.tooltip !== undefined) { + this.subs = [ + this.contextHelpService.getContextHelp$(this.id) + .pipe(hasValueOperator()) + .subscribe((ch: ContextHelp) => { + + if (ch.isTooltipVisible && !this.tooltip.isOpen()) { + this.tooltip.open(); + } else if (!ch.isTooltipVisible && this.tooltip.isOpen()) { + this.tooltip.close(); + } + }), + + this.tooltip.shown.subscribe(() => { + this.contextHelpService.showTooltip(this.id); + }), + + this.tooltip.hidden.subscribe(() => { + this.contextHelpService.hideTooltip(this.id); + }) + ]; + } + } + + ngOnDestroy() { + this.clearSubs(); + } + + onClick() { + this.contextHelpService.toggleTooltip(this.id); + } + + /** + * Parses Markdown-style links, splitting up a given text + * into link-free pieces of text and objects of the form + * {href: string, text: string} (which represent links). + * This function makes no effort to check whether the href is a + * correct URL. Currently, this function does not support escape + * characters: its behavior when given a string containing square + * brackets that do not deliminate a link is undefined. + * Regular parentheses outside of links do work, however. + * + * For example: + * parseLinks("This is text, [this](https://google.com) is a link, and [so is this](https://youtube.com)") + * = + * [ "This is text, ", + * {href: "https://google.com", text: "this"}, + * " is a link, and ", + * {href: "https://youtube.com", text: "so is this"} + * ] + */ + private parseLinks(text: string): ParsedContent { + // Implementation note: due to `matchAll` method on strings not being available for all versions, + // separate "split" and "parse" steps are needed. + + // We use splitRegexp (the outer `match` call) to split the text + // into link-free pieces of text (matched by /[^\[]+/) and pieces + // of text of the form "[some link text](some.link.here)" (matched + // by /\[([^\]]*)\]\(([^\)]*)\)/) + const splitRegexp = /[^\[]+|\[([^\]]*)\]\(([^\)]*)\)/g; + + // Once the array is split up in link-representing strings and + // non-link-representing strings, we use parseRegexp (the inner + // `match` call) to transform the link-representing strings into + // {href: string, text: string} objects. + const parseRegexp = /^\[([^\]]*)\]\(([^\)]*)\)$/; + + return text.match(splitRegexp).map((substring: string) => { + const match = substring.match(parseRegexp); + return match === null + ? substring + : ({href: match[2], text: match[1]}); + }); + } + + private clearSubs() { + this.subs.forEach(sub => sub.unsubscribe()); + this.subs = []; + } +} diff --git a/src/app/shared/context-help-wrapper/placement-dir.model.ts b/src/app/shared/context-help-wrapper/placement-dir.model.ts new file mode 100644 index 0000000000..a02c3e3ec9 --- /dev/null +++ b/src/app/shared/context-help-wrapper/placement-dir.model.ts @@ -0,0 +1 @@ +export type PlacementDir = 'left' | 'right'; diff --git a/src/app/shared/context-help.actions.ts b/src/app/shared/context-help.actions.ts new file mode 100644 index 0000000000..a4892758ff --- /dev/null +++ b/src/app/shared/context-help.actions.ts @@ -0,0 +1,83 @@ +/* eslint-disable max-classes-per-file */ + +import { Action } from '@ngrx/store'; +import { type } from './ngrx/type'; +import { ContextHelp } from './context-help.model'; + +export const ContextHelpActionTypes = { + 'CONTEXT_HELP_TOGGLE_ICONS': type('dspace/context-help/CONTEXT_HELP_TOGGLE_ICONS'), + 'CONTEXT_HELP_ADD': type('dspace/context-help/CONTEXT_HELP_ADD'), + 'CONTEXT_HELP_REMOVE': type('dspace/context-help/CONTEXT_HELP_REMOVE'), + 'CONTEXT_HELP_TOGGLE_TOOLTIP': type('dspace/context-help/CONTEXT_HELP_TOGGLE_TOOLTIP'), + 'CONTEXT_HELP_SHOW_TOOLTIP': type('dspace/context-help/CONTEXT_HELP_SHOW_TOOLTIP'), + 'CONTEXT_HELP_HIDE_TOOLTIP' : type('dspace/context-help/CONTEXT_HELP_HIDE_TOOLTIP'), +}; + +/** + * Toggles the visibility of all context help icons. + */ +export class ContextHelpToggleIconsAction implements Action { + type = ContextHelpActionTypes.CONTEXT_HELP_TOGGLE_ICONS; +} + +/** + * Registers a new context help icon to the store. + */ +export class ContextHelpAddAction implements Action { + type = ContextHelpActionTypes.CONTEXT_HELP_ADD; + model: ContextHelp; + + constructor (model: ContextHelp) { + this.model = model; + } +} + +/** + * Removes a context help icon from the store. + */ +export class ContextHelpRemoveAction implements Action { + type = ContextHelpActionTypes.CONTEXT_HELP_REMOVE; + id: string; + + constructor(id: string) { + this.id = id; + } +} + +export abstract class ContextHelpTooltipAction implements Action { + type; + id: string; + + constructor(id: string) { + this.id = id; + } +} + +/** + * Toggles the tooltip of a single context help icon. + */ +export class ContextHelpToggleTooltipAction extends ContextHelpTooltipAction { + type = ContextHelpActionTypes.CONTEXT_HELP_TOGGLE_TOOLTIP; +} + +/** + * Shows the tooltip of a single context help icon. + */ +export class ContextHelpShowTooltipAction extends ContextHelpTooltipAction { + type = ContextHelpActionTypes.CONTEXT_HELP_SHOW_TOOLTIP; +} + +/** + * Hides the tooltip of a single context help icon. + */ +export class ContextHelpHideTooltipAction extends ContextHelpTooltipAction { + type = ContextHelpActionTypes.CONTEXT_HELP_HIDE_TOOLTIP; +} + +export type ContextHelpAction + = ContextHelpToggleIconsAction + | ContextHelpAddAction + | ContextHelpRemoveAction + | ContextHelpToggleTooltipAction + | ContextHelpShowTooltipAction + | ContextHelpHideTooltipAction; diff --git a/src/app/shared/context-help.directive.spec.ts b/src/app/shared/context-help.directive.spec.ts new file mode 100644 index 0000000000..326251da51 --- /dev/null +++ b/src/app/shared/context-help.directive.spec.ts @@ -0,0 +1,93 @@ +import { Component, Input } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { of as observableOf, BehaviorSubject } from 'rxjs'; +import { ContextHelpDirective, ContextHelpDirectiveInput } from './context-help.directive'; +import { TranslateService } from '@ngx-translate/core'; +import { ContextHelpWrapperComponent } from './context-help-wrapper/context-help-wrapper.component'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { ContextHelpService } from './context-help.service'; +import { ContextHelp } from './context-help.model'; + +@Component({ + template: `
some text
` +}) +class TestComponent { + @Input() content = ''; + @Input() id = ''; + contextHelpParams(): ContextHelpDirectiveInput { + return { + content: this.content, + id: this.id, + iconPlacement: 'left', + tooltipPlacement: ['bottom'] + }; + } +} + +const messages = { + lorem: 'lorem ipsum dolor sit amet', + linkTest: 'This is text, [this](https://dspace.lyrasis.org) is a link, and [so is this](https://google.com)' +}; +const exampleContextHelp: ContextHelp = { + id: 'test-tooltip', + isTooltipVisible: false +}; +describe('ContextHelpDirective', () => { + let component: TestComponent; + let fixture: ComponentFixture; + let translateService: any; + let contextHelpService: any; + let getContextHelp$: BehaviorSubject; + let shouldShowIcons$: BehaviorSubject; + + beforeEach(waitForAsync(() => { + translateService = jasmine.createSpyObj('translateService', ['get']); + contextHelpService = jasmine.createSpyObj('contextHelpService', [ + 'shouldShowIcons$', + 'getContextHelp$', + 'add', + 'remove', + 'toggleIcons', + 'toggleTooltip', + 'showTooltip', + 'hideTooltip' + ]); + + TestBed.configureTestingModule({ + imports: [NgbTooltipModule], + providers: [ + { provide: TranslateService, useValue: translateService }, + { provide: ContextHelpService, useValue: contextHelpService } + ], + declarations: [TestComponent, ContextHelpWrapperComponent, ContextHelpDirective] + }).compileComponents(); + })); + + beforeEach(() => { + // Set up service behavior. + getContextHelp$ = new BehaviorSubject(exampleContextHelp); + shouldShowIcons$ = new BehaviorSubject(false); + contextHelpService.getContextHelp$.and.returnValue(getContextHelp$); + contextHelpService.shouldShowIcons$.and.returnValue(shouldShowIcons$); + translateService.get.and.callFake((content) => observableOf(messages[content])); + + // Set up fixture and component. + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + component.id = 'test-tooltip'; + component.content = 'lorem'; + + fixture.detectChanges(); + }); + + it('should generate the context help wrapper component', (done) => { + fixture.whenStable().then(() => { + expect(fixture.nativeElement.children.length).toBe(1); + const [wrapper] = fixture.nativeElement.children; + expect(component).toBeDefined(); + expect(wrapper.tagName).toBe('DS-CONTEXT-HELP-WRAPPER'); + expect(contextHelpService.add).toHaveBeenCalledWith(exampleContextHelp); + done(); + }); + }); +}); diff --git a/src/app/shared/context-help.directive.ts b/src/app/shared/context-help.directive.ts new file mode 100644 index 0000000000..41d6daec21 --- /dev/null +++ b/src/app/shared/context-help.directive.ts @@ -0,0 +1,76 @@ +import { + ComponentFactoryResolver, + ComponentRef, + Directive, + Input, + OnChanges, + TemplateRef, + ViewContainerRef, + OnDestroy +} from '@angular/core'; +import { PlacementArray } from '@ng-bootstrap/ng-bootstrap/util/positioning'; +import { ContextHelpWrapperComponent } from './context-help-wrapper/context-help-wrapper.component'; +import { PlacementDir } from './context-help-wrapper/placement-dir.model'; +import { ContextHelpService } from './context-help.service'; + +export interface ContextHelpDirectiveInput { + content: string; + id: string; + tooltipPlacement?: PlacementArray; + iconPlacement?: PlacementDir; +} + +/** + * Directive to add a clickable tooltip icon to an element. + * The tooltip icon's position is configurable ('left' or 'right') + * and so is the position of the tooltip itself (PlacementArray). + */ +@Directive({ + selector: '[dsContextHelp]', +}) +export class ContextHelpDirective implements OnChanges, OnDestroy { + /** + * Expects an object with the following fields: + * - content: a string referring to an entry in the i18n files + * - tooltipPlacement: a PlacementArray describing where the tooltip should expand, relative to the tooltip icon + * - iconPlacement: a string 'left' or 'right', describing where the tooltip icon should be placed, relative to the element + */ + @Input() dsContextHelp: ContextHelpDirectiveInput; + mostRecentId: string | undefined = undefined; + + protected wrapper: ComponentRef; + + constructor( + private templateRef: TemplateRef, + private viewContainerRef: ViewContainerRef, + private componentFactoryResolver: ComponentFactoryResolver, + private contextHelpService: ContextHelpService + ) {} + + ngOnChanges() { + this.clearMostRecentId(); + this.mostRecentId = this.dsContextHelp.id; + this.contextHelpService.add({id: this.dsContextHelp.id, isTooltipVisible: false}); + + if (this.wrapper === undefined) { + const factory + = this.componentFactoryResolver.resolveComponentFactory(ContextHelpWrapperComponent); + this.wrapper = this.viewContainerRef.createComponent(factory); + } + this.wrapper.instance.templateRef = this.templateRef; + this.wrapper.instance.content = this.dsContextHelp.content; + this.wrapper.instance.id = this.dsContextHelp.id; + this.wrapper.instance.tooltipPlacement = this.dsContextHelp.tooltipPlacement; + this.wrapper.instance.iconPlacement = this.dsContextHelp.iconPlacement; + } + + ngOnDestroy() { + this.clearMostRecentId(); + } + + private clearMostRecentId(): void { + if (this.mostRecentId !== undefined) { + this.contextHelpService.remove(this.mostRecentId); + } + } +} diff --git a/src/app/shared/context-help.model.ts b/src/app/shared/context-help.model.ts new file mode 100644 index 0000000000..ea6fd036cf --- /dev/null +++ b/src/app/shared/context-help.model.ts @@ -0,0 +1,4 @@ +export class ContextHelp { + id: string; + isTooltipVisible = false; +} diff --git a/src/app/shared/context-help.reducer.ts b/src/app/shared/context-help.reducer.ts new file mode 100644 index 0000000000..3e0d40c25d --- /dev/null +++ b/src/app/shared/context-help.reducer.ts @@ -0,0 +1,48 @@ +import { ContextHelp } from './context-help.model'; +import { ContextHelpAction, ContextHelpActionTypes } from './context-help.actions'; + +export interface ContextHelpModels { + [id: string]: ContextHelp; +} + +export interface ContextHelpState { + allIconsVisible: boolean; + models: ContextHelpModels; +} + +const initialState: ContextHelpState = {allIconsVisible: false, models: {}}; + +export function contextHelpReducer(state: ContextHelpState = initialState, action: ContextHelpAction): ContextHelpState { + switch (action.type) { + case ContextHelpActionTypes.CONTEXT_HELP_TOGGLE_ICONS: { + return {...state, allIconsVisible: !state.allIconsVisible}; + } + case ContextHelpActionTypes.CONTEXT_HELP_ADD: { + const newModels = {...state.models, [action.model.id]: action.model}; + return {...state, models: newModels}; + } + case ContextHelpActionTypes.CONTEXT_HELP_REMOVE: { + const {[action.id]: _, ...remainingModels} = state.models; + return {...state, models: remainingModels}; + } + case ContextHelpActionTypes.CONTEXT_HELP_TOGGLE_TOOLTIP: { + return modifyTooltipVisibility(state, action.id, v => !v); + } + case ContextHelpActionTypes.CONTEXT_HELP_SHOW_TOOLTIP: { + return modifyTooltipVisibility(state, action.id, _ => true); + } + case ContextHelpActionTypes.CONTEXT_HELP_HIDE_TOOLTIP: { + return modifyTooltipVisibility(state, action.id, _ => false); + } + default: { + return state; + } + } +} + +function modifyTooltipVisibility(state: ContextHelpState, id: string, modify: (vis: boolean) => boolean): ContextHelpState { + const {[id]: matchingModel, ...otherModels} = state.models; + const modifiedModel = {...matchingModel, isTooltipVisible: modify(matchingModel.isTooltipVisible)}; + const newModels = {...otherModels, [id]: modifiedModel}; + return {...state, models: newModels}; +} diff --git a/src/app/shared/context-help.service.spec.ts b/src/app/shared/context-help.service.spec.ts new file mode 100644 index 0000000000..f06e700210 --- /dev/null +++ b/src/app/shared/context-help.service.spec.ts @@ -0,0 +1,78 @@ +import { TestBed } from '@angular/core/testing'; + +import { ContextHelpService } from './context-help.service'; +import { StoreModule, Store } from '@ngrx/store'; +import { appReducers, storeModuleConfig } from '../app.reducer'; +import { TestScheduler } from 'rxjs/testing'; + +describe('ContextHelpService', () => { + let service: ContextHelpService; + let store; + let testScheduler; + const booleans = { f: false, t: true }; + const mkContextHelp = (id: string) => ({ 0: {id, isTooltipVisible: false}, 1: {id, isTooltipVisible: true} }); + + beforeEach(async () => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot(appReducers, storeModuleConfig) + ] + }); + }); + + beforeEach(() => { + store = TestBed.inject(Store); + service = new ContextHelpService(store); + testScheduler = new TestScheduler((actual, expected) => expect(actual).toEqual(expected)); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('toggleIcons calls should be observable in shouldShowIcons$', () => { + testScheduler.run(({cold, expectObservable}) => { + const toggles = cold('-xxxxx'); + toggles.subscribe((_) => service.toggleIcons()); + expectObservable(service.shouldShowIcons$()).toBe('ftftft', booleans); + }); + }); + + it('add and remove calls should be observable in getContextHelp$', () => { + testScheduler.run(({cold, expectObservable}) => { + const modifications = cold('-abAcCB', { + a: () => service.add({id: 'a', isTooltipVisible: false}), + b: () => service.add({id: 'b', isTooltipVisible: false}), + c: () => service.add({id: 'c', isTooltipVisible: false}), + A: () => service.remove('a'), B: () => service.remove('b'), C: () => service.remove('c'), + }); + modifications.subscribe(mod => mod()); + const match = (id) => ({ 0: undefined, 1: {id, isTooltipVisible: false} }); + expectObservable(service.getContextHelp$('a')).toBe('01-0---', match('a')); + expectObservable(service.getContextHelp$('b')).toBe('0-1---0', match('b')); + expectObservable(service.getContextHelp$('c')).toBe('0---10-', match('c')); + }); + }); + + it('toggleTooltip calls should be observable in getContextHelp$', () => { + service.add({id: 'a', isTooltipVisible: false}); + service.add({id: 'b', isTooltipVisible: false}); + testScheduler.run(({cold, expectObservable}) => { + const toggles = cold('-aaababbabba'); + toggles.subscribe(id => service.toggleTooltip(id)); + expectObservable(service.getContextHelp$('a')).toBe('0101-0--1--0', mkContextHelp('a')); + expectObservable(service.getContextHelp$('b')).toBe('0---1-01-01-', mkContextHelp('b')); + }); + }); + + it('hideTooltip and showTooltip calls should be observable in getContextHelp$', () => { + service.add({id: 'a', isTooltipVisible: false}); + testScheduler.run(({cold, expectObservable}) => { + const hideShowCalls = cold('-shssshhs', { + s: () => service.showTooltip('a'), h: () => service.hideTooltip('a') + }); + hideShowCalls.subscribe(fn => fn()); + expectObservable(service.getContextHelp$('a')).toBe('010111001', mkContextHelp('a')); + }); + }); +}); diff --git a/src/app/shared/context-help.service.ts b/src/app/shared/context-help.service.ts new file mode 100644 index 0000000000..f10f258be9 --- /dev/null +++ b/src/app/shared/context-help.service.ts @@ -0,0 +1,113 @@ +import { Injectable } from '@angular/core'; +import { ContextHelp } from './context-help.model'; +import { Store, createFeatureSelector, createSelector, select, MemoizedSelector } from '@ngrx/store'; +import { ContextHelpState, ContextHelpModels } from './context-help.reducer'; +import { + ContextHelpToggleIconsAction, + ContextHelpAddAction, + ContextHelpRemoveAction, + ContextHelpShowTooltipAction, + ContextHelpHideTooltipAction, + ContextHelpToggleTooltipAction +} from './context-help.actions'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +const contextHelpStateSelector = + createFeatureSelector('contextHelp'); +const allIconsVisibleSelector = createSelector( + contextHelpStateSelector, + (state: ContextHelpState): boolean => state.allIconsVisible +); +const contextHelpSelector = + (id: string): MemoizedSelector => createSelector( + contextHelpStateSelector, + (state: ContextHelpState) => state.models[id] + ); +const allContextHelpSelector = createSelector( + contextHelpStateSelector, + ((state: ContextHelpState) => state.models) +); + +@Injectable({ + providedIn: 'root' +}) +export class ContextHelpService { + constructor(private store: Store) { } + + /** + * Observable keeping track of whether context help icons should be visible globally. + */ + shouldShowIcons$(): Observable { + return this.store.pipe(select(allIconsVisibleSelector)); + } + + /** + * Observable that tracks the state for a specific context help icon. + * + * @param id: id of the context help icon. + */ + getContextHelp$(id: string): Observable { + return this.store.pipe(select(contextHelpSelector(id))); + } + + /** + * Observable that yields true iff there are currently no context help entries in the store. + */ + tooltipCount$(): Observable { + return this.store.pipe(select(allContextHelpSelector)) + .pipe(map((models: ContextHelpModels) => Object.keys(models).length)); + } + + /** + * Toggles the visibility of all context help icons. + */ + toggleIcons() { + this.store.dispatch(new ContextHelpToggleIconsAction()); + } + + /** + * Registers a new context help icon to the store. + * + * @param contextHelp: the initial state of the new help icon. + */ + add(contextHelp: ContextHelp) { + this.store.dispatch(new ContextHelpAddAction(contextHelp)); + } + + /** + * Removes a context help icon from the store. + * + * @id: the id of the help icon to be removed. + */ + remove(id: string) { + this.store.dispatch(new ContextHelpRemoveAction(id)); + } + + /** + * Toggles the tooltip of a single context help icon. + * + * @id: the id of the help icon for which the visibility will be toggled. + */ + toggleTooltip(id: string) { + this.store.dispatch(new ContextHelpToggleTooltipAction(id)); + } + + /** + * Shows the tooltip of a single context help icon. + * + * @id: the id of the help icon that will be made visible. + */ + showTooltip(id: string) { + this.store.dispatch(new ContextHelpShowTooltipAction(id)); + } + + /** + * Hides the tooltip of a single context help icon. + * + * @id: the id of the help icon that will be made invisible. + */ + hideTooltip(id: string) { + this.store.dispatch(new ContextHelpHideTooltipAction(id)); + } +} diff --git a/src/app/shared/cookies/browser-klaro.service.spec.ts b/src/app/shared/cookies/browser-klaro.service.spec.ts index 9db9caf364..7fd72b54b3 100644 --- a/src/app/shared/cookies/browser-klaro.service.spec.ts +++ b/src/app/shared/cookies/browser-klaro.service.spec.ts @@ -10,7 +10,8 @@ import { AuthService } from '../../core/auth/auth.service'; import { CookieService } from '../../core/services/cookie.service'; import { getTestScheduler } from 'jasmine-marbles'; import { MetadataValue } from '../../core/shared/metadata.models'; -import { clone, cloneDeep } from 'lodash'; +import clone from 'lodash/clone'; +import cloneDeep from 'lodash/cloneDeep'; import { ConfigurationDataService } from '../../core/data/configuration-data.service'; import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; import { ConfigurationProperty } from '../../core/shared/configuration-property.model'; @@ -100,7 +101,7 @@ describe('BrowserKlaroService', () => { mockConfig = { translations: { - en: { + zz: { purposes: {}, test: { testeritis: testKey @@ -158,8 +159,8 @@ describe('BrowserKlaroService', () => { it('addAppMessages', () => { service.addAppMessages(); - expect(mockConfig.translations.en[appName]).toBeDefined(); - expect(mockConfig.translations.en.purposes[purpose]).toBeDefined(); + expect(mockConfig.translations.zz[appName]).toBeDefined(); + expect(mockConfig.translations.zz.purposes[purpose]).toBeDefined(); }); it('translateConfiguration', () => { diff --git a/src/app/shared/cookies/browser-klaro.service.ts b/src/app/shared/cookies/browser-klaro.service.ts index c6819012d9..2b09c0bf15 100644 --- a/src/app/shared/cookies/browser-klaro.service.ts +++ b/src/app/shared/cookies/browser-klaro.service.ts @@ -1,5 +1,4 @@ -import { Injectable } from '@angular/core'; -import * as Klaro from 'klaro'; +import { Inject, Injectable, InjectionToken } from '@angular/core'; import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; import { AuthService } from '../../core/auth/auth.service'; import { TranslateService } from '@ngx-translate/core'; @@ -10,7 +9,8 @@ import { KlaroService } from './klaro.service'; import { hasValue, isEmpty, isNotEmpty } from '../empty.util'; import { CookieService } from '../../core/services/cookie.service'; import { EPersonDataService } from '../../core/eperson/eperson-data.service'; -import { cloneDeep, debounce } from 'lodash'; +import cloneDeep from 'lodash/cloneDeep'; +import debounce from 'lodash/debounce'; import { ANONYMOUS_STORAGE_NAME_KLARO, klaroConfiguration } from './klaro-configuration'; import { Operation } from 'fast-json-patch'; import { getFirstCompletedRemoteData } from '../../core/shared/operators'; @@ -42,6 +42,17 @@ const cookiePurposeMessagePrefix = 'cookies.consent.purpose.'; */ const updateDebounce = 300; +/** + * By using this injection token instead of importing directly we can keep Klaro out of the main bundle + */ +const LAZY_KLARO = new InjectionToken>( + 'Lazily loaded Klaro', + { + providedIn: 'root', + factory: async () => (await import('klaro/dist/klaro-no-translations')), + } +); + /** * Browser implementation for the KlaroService, representing a service for handling Klaro consent preferences and UI */ @@ -64,7 +75,9 @@ export class BrowserKlaroService extends KlaroService { private authService: AuthService, private ePersonService: EPersonDataService, private configService: ConfigurationDataService, - private cookieService: CookieService) { + private cookieService: CookieService, + @Inject(LAZY_KLARO) private lazyKlaro: Promise, + ) { super(); } @@ -78,7 +91,7 @@ export class BrowserKlaroService extends KlaroService { initialize() { if (!environment.info.enablePrivacyStatement) { delete this.klaroConfig.privacyPolicy; - this.klaroConfig.translations.en.consentNotice.description = 'cookies.consent.content-notice.description.no-privacy'; + this.klaroConfig.translations.zz.consentNotice.description = 'cookies.consent.content-notice.description.no-privacy'; } const hideGoogleAnalytics$ = this.configService.findByPropertyName(this.GOOGLE_ANALYTICS_KEY).pipe( @@ -102,7 +115,6 @@ export class BrowserKlaroService extends KlaroService { if (hideRegistrationVerification) { servicesToHideArray.push(CAPTCHA_NAME); } - console.log(servicesToHideArray); return servicesToHideArray; }) ); @@ -134,8 +146,7 @@ export class BrowserKlaroService extends KlaroService { this.translateConfiguration(); this.klaroConfig.services = this.filterConfigServices(servicesToHide); - - Klaro.setup(this.klaroConfig); + this.lazyKlaro.then(({ setup }) => setup(this.klaroConfig)); }); } @@ -219,7 +230,7 @@ export class BrowserKlaroService extends KlaroService { * Show the cookie consent form */ showSettings() { - Klaro.show(this.klaroConfig); + this.lazyKlaro.then(({show}) => show(this.klaroConfig)); } /** @@ -227,12 +238,12 @@ export class BrowserKlaroService extends KlaroService { */ addAppMessages() { this.klaroConfig.services.forEach((app) => { - this.klaroConfig.translations.en[app.name] = { + this.klaroConfig.translations.zz[app.name] = { title: this.getTitleTranslation(app.name), description: this.getDescriptionTranslation(app.name) }; app.purposes.forEach((purpose) => { - this.klaroConfig.translations.en.purposes[purpose] = this.getPurposeTranslation(purpose); + this.klaroConfig.translations.zz.purposes[purpose] = this.getPurposeTranslation(purpose); }); }); } @@ -246,7 +257,7 @@ export class BrowserKlaroService extends KlaroService { */ this.translateService.setDefaultLang(environment.defaultLanguage); - this.translate(this.klaroConfig.translations.en); + this.translate(this.klaroConfig.translations.zz); } /** diff --git a/src/app/shared/cookies/klaro-configuration.ts b/src/app/shared/cookies/klaro-configuration.ts index 8a9855bd89..a41b641dec 100644 --- a/src/app/shared/cookies/klaro-configuration.ts +++ b/src/app/shared/cookies/klaro-configuration.ts @@ -54,10 +54,46 @@ export const klaroConfiguration: any = { https://github.com/KIProtect/klaro/tree/master/src/translations */ translations: { - en: { + /* + The `zz` key contains default translations that will be used as fallback values. + This can e.g. be useful for defining a fallback privacy policy URL. + FOR DSPACE: We use 'zz' to map to our own i18n translations for klaro, see + translateConfiguration() in browser-klaro.service.ts. All the below i18n keys are specified + in your /src/assets/i18n/*.json5 translation pack. + */ + zz: { acceptAll: 'cookies.consent.accept-all', acceptSelected: 'cookies.consent.accept-selected', - app: { + close: 'cookies.consent.close', + consentModal: { + title: 'cookies.consent.content-modal.title', + description: 'cookies.consent.content-modal.description' + }, + consentNotice: { + changeDescription: 'cookies.consent.update', + title: 'cookies.consent.content-notice.title', + description: 'cookies.consent.content-notice.description', + learnMore: 'cookies.consent.content-notice.learnMore', + }, + decline: 'cookies.consent.decline', + ok: 'cookies.consent.ok', + poweredBy: 'Powered by Klaro!', + privacyPolicy: { + name: 'cookies.consent.content-modal.privacy-policy.name', + text: 'cookies.consent.content-modal.privacy-policy.text' + }, + purposeItem: { + service: 'cookies.consent.content-modal.service', + services: 'cookies.consent.content-modal.services' + }, + purposes: { + }, + save: 'cookies.consent.save', + service: { + disableAll: { + description: 'cookies.consent.app.disable-all.description', + title: 'cookies.consent.app.disable-all.title' + }, optOut: { description: 'cookies.consent.app.opt-out.description', title: 'cookies.consent.app.opt-out.title' @@ -65,26 +101,10 @@ export const klaroConfiguration: any = { purpose: 'cookies.consent.app.purpose', purposes: 'cookies.consent.app.purposes', required: { - description: 'cookies.consent.app.required.description', - title: 'cookies.consent.app.required.title' + title: 'cookies.consent.app.required.title', + description: 'cookies.consent.app.required.description' } - }, - close: 'cookies.consent.close', - decline: 'cookies.consent.decline', - changeDescription: 'cookies.consent.update', - consentNotice: { - description: 'cookies.consent.content-notice.description', - learnMore: 'cookies.consent.content-notice.learnMore' - }, - consentModal: { - description: 'cookies.consent.content-modal.description', - privacyPolicy: { - name: 'cookies.consent.content-modal.privacy-policy.name', - text: 'cookies.consent.content-modal.privacy-policy.text' - }, - title: 'cookies.consent.content-modal.title' - }, - purposes: {} + } } }, services: [ diff --git a/src/app/shared/date.util.spec.ts b/src/app/shared/date.util.spec.ts new file mode 100644 index 0000000000..4576ea497c --- /dev/null +++ b/src/app/shared/date.util.spec.ts @@ -0,0 +1,107 @@ +import { dateToString, dateToNgbDateStruct, dateToISOFormat, isValidDate, yearFromString } from './date.util'; + +describe('Date Utils', () => { + + describe('dateToISOFormat', () => { + it('should convert Date to YYYY-MM-DDThh:mm:ssZ string', () => { + // NOTE: month is zero indexed which is why it increases by one + expect(dateToISOFormat(new Date(Date.UTC(2022, 5, 3)))).toEqual('2022-06-03T00:00:00Z'); + }); + it('should convert Date string to YYYY-MM-DDThh:mm:ssZ string', () => { + expect(dateToISOFormat('2022-06-03')).toEqual('2022-06-03T00:00:00Z'); + }); + it('should convert Month string to YYYY-MM-DDThh:mm:ssZ string', () => { + expect(dateToISOFormat('2022-06')).toEqual('2022-06-01T00:00:00Z'); + }); + it('should convert Year string to YYYY-MM-DDThh:mm:ssZ string', () => { + expect(dateToISOFormat('2022')).toEqual('2022-01-01T00:00:00Z'); + }); + it('should convert ISO Date string to YYYY-MM-DDThh:mm:ssZ string', () => { + // NOTE: Time is always zeroed out as proven by this test. + expect(dateToISOFormat('2022-06-03T03:24:04Z')).toEqual('2022-06-03T00:00:00Z'); + }); + it('should convert NgbDateStruct to YYYY-MM-DDThh:mm:ssZ string', () => { + // NOTE: month is zero indexed which is why it increases by one + const date = new Date(Date.UTC(2022, 5, 3)); + expect(dateToISOFormat(dateToNgbDateStruct(date))).toEqual('2022-06-03T00:00:00Z'); + }); + }); + + describe('dateToString', () => { + it('should convert Date to YYYY-MM-DD string', () => { + // NOTE: month is zero indexed which is why it increases by one + expect(dateToString(new Date(Date.UTC(2022, 5, 3)))).toEqual('2022-06-03'); + }); + it('should convert Date with time to YYYY-MM-DD string', () => { + // NOTE: month is zero indexed which is why it increases by one + expect(dateToString(new Date(Date.UTC(2022, 5, 3, 3, 24, 0)))).toEqual('2022-06-03'); + }); + it('should convert Month only to YYYY-MM-DD string', () => { + // NOTE: month is zero indexed which is why it increases by one + expect(dateToString(new Date(Date.UTC(2022, 5)))).toEqual('2022-06-01'); + }); + it('should convert ISO Date to YYYY-MM-DD string', () => { + expect(dateToString(new Date('2022-06-03T03:24:00Z'))).toEqual('2022-06-03'); + }); + it('should convert NgbDateStruct to YYYY-MM-DD string', () => { + // NOTE: month is zero indexed which is why it increases by one + const date = new Date(Date.UTC(2022, 5, 3)); + expect(dateToString(dateToNgbDateStruct(date))).toEqual('2022-06-03'); + }); + }); + + + describe('isValidDate', () => { + it('should return false for null', () => { + expect(isValidDate(null)).toBe(false); + }); + it('should return false for empty string', () => { + expect(isValidDate('')).toBe(false); + }); + it('should return false for text', () => { + expect(isValidDate('test')).toBe(false); + }); + it('should return true for YYYY', () => { + expect(isValidDate('2022')).toBe(true); + }); + it('should return true for YYYY-MM', () => { + expect(isValidDate('2022-12')).toBe(true); + }); + it('should return true for YYYY-MM-DD', () => { + expect(isValidDate('2022-06-03')).toBe(true); + }); + it('should return true for YYYY-MM-DDTHH:MM:SS', () => { + expect(isValidDate('2022-06-03T10:20:30')).toBe(true); + }); + it('should return true for YYYY-MM-DDTHH:MM:SSZ', () => { + expect(isValidDate('2022-06-03T10:20:30Z')).toBe(true); + }); + it('should return false for a month that does not exist', () => { + expect(isValidDate('2022-13')).toBe(false); + }); + it('should return false for a day that does not exist', () => { + expect(isValidDate('2022-02-60')).toBe(false); + }); + it('should return false for a time that does not exist', () => { + expect(isValidDate('2022-02-60T10:60:20')).toBe(false); + }); + }); + + describe('yearFromString', () => { + it('should return year from YYYY string', () => { + expect(yearFromString('2022')).toEqual(2022); + }); + it('should return year from YYYY-MM string', () => { + expect(yearFromString('1970-06')).toEqual(1970); + }); + it('should return year from YYYY-MM-DD string', () => { + expect(yearFromString('1914-10-23')).toEqual(1914); + }); + it('should return year from YYYY-MM-DDTHH:MM:SSZ string', () => { + expect(yearFromString('1914-10-23T10:20:30Z')).toEqual(1914); + }); + it('should return null if invalid date', () => { + expect(yearFromString('test')).toBeNull(); + }); + }); +}); diff --git a/src/app/shared/date.util.ts b/src/app/shared/date.util.ts index 5f7ccb2438..5b74ed02d2 100644 --- a/src/app/shared/date.util.ts +++ b/src/app/shared/date.util.ts @@ -1,9 +1,8 @@ import { NgbDateStruct } from '@ng-bootstrap/ng-bootstrap'; - -import { isObject } from 'lodash'; -import * as moment from 'moment'; - -import { isNull, isUndefined } from './empty.util'; +import { formatInTimeZone } from 'date-fns-tz'; +import { isValid } from 'date-fns'; +import isObject from 'lodash/isObject'; +import { hasNoValue } from './empty.util'; /** * Returns true if the passed value is a NgbDateStruct. @@ -31,21 +30,7 @@ export function dateToISOFormat(date: Date | NgbDateStruct | string): string { const dateObj: Date = (date instanceof Date) ? date : ((typeof date === 'string') ? ngbDateStructToDate(stringToNgbDateStruct(date)) : ngbDateStructToDate(date)); - let year = dateObj.getUTCFullYear().toString(); - let month = (dateObj.getUTCMonth() + 1).toString(); - let day = dateObj.getUTCDate().toString(); - let hour = dateObj.getHours().toString(); - let min = dateObj.getMinutes().toString(); - let sec = dateObj.getSeconds().toString(); - - year = (year.length === 1) ? '0' + year : year; - month = (month.length === 1) ? '0' + month : month; - day = (day.length === 1) ? '0' + day : day; - hour = (hour.length === 1) ? '0' + hour : hour; - min = (min.length === 1) ? '0' + min : min; - sec = (sec.length === 1) ? '0' + sec : sec; - const dateStr = `${year}${month}${day}${hour}${min}${sec}`; - return moment.utc(dateStr, 'YYYYMMDDhhmmss').format(); + return formatInTimeZone(dateObj, 'UTC', "yyyy-MM-dd'T'HH:mm:ss'Z'"); } /** @@ -81,7 +66,7 @@ export function stringToNgbDateStruct(date: string): NgbDateStruct { * the NgbDateStruct object */ export function dateToNgbDateStruct(date?: Date): NgbDateStruct { - if (isNull(date) || isUndefined(date)) { + if (hasNoValue(date)) { date = new Date(); } @@ -102,16 +87,7 @@ export function dateToNgbDateStruct(date?: Date): NgbDateStruct { */ export function dateToString(date: Date | NgbDateStruct): string { const dateObj: Date = (date instanceof Date) ? date : ngbDateStructToDate(date); - - let year = dateObj.getUTCFullYear().toString(); - let month = (dateObj.getUTCMonth() + 1).toString(); - let day = dateObj.getUTCDate().toString(); - - year = (year.length === 1) ? '0' + year : year; - month = (month.length === 1) ? '0' + month : month; - day = (day.length === 1) ? '0' + day : day; - const dateStr = `${year}-${month}-${day}`; - return moment.utc(dateStr, 'YYYYMMDD').format('YYYY-MM-DD'); + return formatInTimeZone(dateObj, 'UTC', 'yyyy-MM-dd'); } /** @@ -119,5 +95,15 @@ export function dateToString(date: Date | NgbDateStruct): string { * @param date the string to be checked */ export function isValidDate(date: string) { - return moment(date).isValid(); + return (hasNoValue(date)) ? false : isValid(new Date(date)); } + +/** + * Parse given date string to a year number based on expected formats + * @param date the string to be parsed + * @param formats possible formats the string may align with. MUST be valid date-fns formats + */ +export function yearFromString(date: string) { + return isValidDate(date) ? new Date(date).getUTCFullYear() : null; +} + 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/shared/resource-policies/form/eperson-group-list/eperson-group-list.component.scss b/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu.component.scss similarity index 100% rename from src/app/shared/resource-policies/form/eperson-group-list/eperson-group-list.component.scss 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.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.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-subscription-button/dso-page-subscription-button.component.html b/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.html new file mode 100644 index 0000000000..15135009fc --- /dev/null +++ b/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.html @@ -0,0 +1,8 @@ + diff --git a/src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.html b/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.scss similarity index 100% rename from src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.html rename to src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.scss diff --git a/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.spec.ts b/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.spec.ts new file mode 100644 index 0000000000..726854778d --- /dev/null +++ b/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.spec.ts @@ -0,0 +1,83 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DsoPageSubscriptionButtonComponent } from './dso-page-subscription-button.component'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { of as observableOf } from 'rxjs'; +import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'; +import { By } from '@angular/platform-browser'; +import { DebugElement } from '@angular/core'; +import { Item } from '../../../core/shared/item.model'; +import { ITEM } from '../../../core/shared/item.resource-type'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../../mocks/translate-loader.mock'; + +describe('DsoPageSubscriptionButtonComponent', () => { + let component: DsoPageSubscriptionButtonComponent; + let fixture: ComponentFixture; + let de: DebugElement; + + const authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: jasmine.createSpy('isAuthorized') // observableOf(true) + }); + + const mockItem = Object.assign(new Item(), { + id: 'fake-id', + uuid: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + type: ITEM, + _links: { + self: { + href: 'https://localhost:8000/items/fake-id' + } + } + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + NgbModalModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + declarations: [ DsoPageSubscriptionButtonComponent ], + providers: [ + { provide: AuthorizationDataService, useValue: authorizationService }, + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DsoPageSubscriptionButtonComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + component.dso = mockItem; + }); + + describe('when is authorized', () => { + beforeEach(() => { + authorizationService.isAuthorized.and.returnValue(observableOf(true)); + fixture.detectChanges(); + }); + + it('should display subscription button', () => { + expect(de.query(By.css(' [data-test="subscription-button"]'))).toBeTruthy(); + }); + }); + + describe('when is not authorized', () => { + beforeEach(() => { + authorizationService.isAuthorized.and.returnValue(observableOf(false)); + fixture.detectChanges(); + }); + + it('should not display subscription button', () => { + expect(de.query(By.css(' [data-test="subscription-button"]'))).toBeNull(); + }); + }); +}); diff --git a/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.ts b/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.ts new file mode 100644 index 0000000000..54cd9e6bb0 --- /dev/null +++ b/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.ts @@ -0,0 +1,57 @@ +import { Component, Input, OnInit } from '@angular/core'; + +import { Observable, of } from 'rxjs'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; + +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { SubscriptionModalComponent } from '../../subscriptions/subscription-modal/subscription-modal.component'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; + +@Component({ + selector: 'ds-dso-page-subscription-button', + templateUrl: './dso-page-subscription-button.component.html', + styleUrls: ['./dso-page-subscription-button.component.scss'] +}) +/** + * Display a button that opens the modal to manage subscriptions + */ +export class DsoPageSubscriptionButtonComponent implements OnInit { + + /** + * Whether the current user is authorized to edit the DSpaceObject + */ + isAuthorized$: Observable = of(false); + + /** + * Reference to NgbModal + */ + public modalRef: NgbModalRef; + + /** + * DSpaceObject that is being viewed + */ + @Input() dso: DSpaceObject; + + constructor( + protected authorizationService: AuthorizationDataService, + private modalService: NgbModal, + ) { + } + + /** + * check if the current DSpaceObject can be subscribed by the user + */ + ngOnInit(): void { + this.isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanSubscribe, this.dso.self); + } + + /** + * Open the modal to subscribe to the related DSpaceObject + */ + public openSubscriptionModal() { + this.modalRef = this.modalService.open(SubscriptionModalComponent); + this.modalRef.componentInstance.dso = this.dso; + } + +} diff --git a/src/app/shared/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..d700b34e77 --- /dev/null +++ b/src/app/shared/dso-page/dso-page.module.ts @@ -0,0 +1,56 @@ +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 { NgbDropdownModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; + +const COMPONENTS = [ + DsoEditMenuComponent, + DsoEditMenuSectionComponent, + DsoEditMenuExpandableSectionComponent, +]; + +const ENTRY_COMPONENTS = [ +]; + +const MODULES = [ + TranslateModule, + RouterModule, + CommonModule, + NgbTooltipModule, + NgbDropdownModule, +]; +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.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/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts b/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts index ab48d058ca..cc1f9822d6 100644 --- a/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts +++ b/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts @@ -53,8 +53,9 @@ export class AuthorizedCollectionSelectorComponent extends DSOSelectorComponent * Perform a search for authorized collections with the current query and page * @param query Query to search objects for * @param page Page to retrieve + * @param useCache Whether or not to use the cache */ - search(query: string, page: number): Observable>>> { + search(query: string, page: number, useCache: boolean = true): Observable>>> { let searchListService$: Observable>> = null; const findOptions: FindListOptions = { currentPage: page, @@ -69,7 +70,7 @@ export class AuthorizedCollectionSelectorComponent extends DSOSelectorComponent findOptions); } else { searchListService$ = this.collectionDataService - .getAuthorizedCollection(query, findOptions, true, false, followLink('parentCommunity')); + .getAuthorizedCollection(query, findOptions, useCache, false, followLink('parentCommunity')); } return searchListService$.pipe( getFirstCompletedRemoteData(), diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.html b/src/app/shared/dso-selector/dso-selector/dso-selector.component.html index 8abb8ad558..c4f5dbc4cd 100644 --- a/src/app/shared/dso-selector/dso-selector/dso-selector.component.html +++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.html @@ -21,12 +21,12 @@