Merge branch 'main-gh4s' into CST-6782-refactor

This commit is contained in:
Davide Negretti
2022-09-15 15:41:30 +02:00
20 changed files with 285 additions and 240 deletions

View File

@@ -7,8 +7,9 @@ const appConfig: AppConfig = buildAppConfig();
/** /**
* Calls `ng serve` with the following arguments configured for the UI in the app config: host, port, nameSpace, ssl * Calls `ng serve` with the following arguments configured for the UI in the app config: host, port, nameSpace, ssl
* Any CLI arguments given to this script are patched through to `ng serve` as well.
*/ */
child.spawn( child.spawn(
`ng serve --host ${appConfig.ui.host} --port ${appConfig.ui.port} --serve-path ${appConfig.ui.nameSpace} --ssl ${appConfig.ui.ssl}`, `ng serve --host ${appConfig.ui.host} --port ${appConfig.ui.port} --serve-path ${appConfig.ui.nameSpace} --ssl ${appConfig.ui.ssl} ${process.argv.slice(2).join(' ')}`,
{ stdio: 'inherit', shell: true } { stdio: 'inherit', shell: true }
); );

View File

@@ -1,4 +1,4 @@
<nav @slideHorizontal class="navbar navbar-dark p-0" <nav class="navbar navbar-dark p-0"
[ngClass]="{'active': sidebarOpen, 'inactive': sidebarClosed}" [ngClass]="{'active': sidebarOpen, 'inactive': sidebarClosed}"
[@slideSidebar]="{ [@slideSidebar]="{
value: (!(sidebarExpanded | async) ? 'collapsed' : 'expanded'), value: (!(sidebarExpanded | async) ? 'collapsed' : 'expanded'),

View File

@@ -2,7 +2,7 @@ import { Component, HostListener, Injector, OnInit } from '@angular/core';
import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import { debounceTime, distinctUntilChanged, first, map, withLatestFrom } from 'rxjs/operators'; import { debounceTime, distinctUntilChanged, first, map, withLatestFrom } from 'rxjs/operators';
import { AuthService } from '../../core/auth/auth.service'; import { AuthService } from '../../core/auth/auth.service';
import { slideHorizontal, slideSidebar } from '../../shared/animations/slide'; import { slideSidebar } from '../../shared/animations/slide';
import { MenuComponent } from '../../shared/menu/menu.component'; import { MenuComponent } from '../../shared/menu/menu.component';
import { MenuService } from '../../shared/menu/menu.service'; import { MenuService } from '../../shared/menu/menu.service';
import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service'; import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service';
@@ -18,7 +18,7 @@ import { ThemeService } from '../../shared/theme-support/theme.service';
selector: 'ds-admin-sidebar', selector: 'ds-admin-sidebar',
templateUrl: './admin-sidebar.component.html', templateUrl: './admin-sidebar.component.html',
styleUrls: ['./admin-sidebar.component.scss'], styleUrls: ['./admin-sidebar.component.scss'],
animations: [slideHorizontal, slideSidebar] animations: [slideSidebar]
}) })
export class AdminSidebarComponent extends MenuComponent implements OnInit { export class AdminSidebarComponent extends MenuComponent implements OnInit {
/** /**

View File

@@ -7,57 +7,156 @@ import { TestScheduler } from 'rxjs/testing';
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
import { ShortLivedToken } from './models/short-lived-token.model'; import { ShortLivedToken } from './models/short-lived-token.model';
import { RemoteData } from '../data/remote-data'; import { RemoteData } from '../data/remote-data';
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
import objectContaining = jasmine.objectContaining;
import { AuthStatus } from './models/auth-status.model';
import { RestRequestMethod } from '../data/rest-request-method';
describe(`AuthRequestService`, () => { describe(`AuthRequestService`, () => {
let halService: HALEndpointService; let halService: HALEndpointService;
let endpointURL: string; let endpointURL: string;
let requestID: string;
let shortLivedToken: ShortLivedToken; let shortLivedToken: ShortLivedToken;
let shortLivedTokenRD: RemoteData<ShortLivedToken>; let shortLivedTokenRD: RemoteData<ShortLivedToken>;
let requestService: RequestService; let requestService: RequestService;
let rdbService: RemoteDataBuildService; let rdbService: RemoteDataBuildService;
let service: AuthRequestService; let service;
let testScheduler; let testScheduler;
class TestAuthRequestService extends AuthRequestService { const status = new AuthStatus();
constructor(
hes: HALEndpointService,
rs: RequestService,
rdbs: RemoteDataBuildService
) {
super(hes, rs, rdbs);
}
protected createShortLivedTokenRequest(href: string): PostRequest { class TestAuthRequestService extends AuthRequestService {
return new PostRequest(this.requestService.generateRequestId(), href); constructor(
} hes: HALEndpointService,
rs: RequestService,
rdbs: RemoteDataBuildService
) {
super(hes, rs, rdbs);
} }
const init = (cold: typeof TestScheduler.prototype.createColdObservable) => { protected createShortLivedTokenRequest(href: string): PostRequest {
endpointURL = 'https://rest.api/auth'; return new PostRequest(this.requestService.generateRequestId(), href);
shortLivedToken = Object.assign(new ShortLivedToken(), { }
value: 'some-token' }
});
shortLivedTokenRD = createSuccessfulRemoteDataObject(shortLivedToken);
halService = jasmine.createSpyObj('halService', { const init = (cold: typeof TestScheduler.prototype.createColdObservable) => {
'getEndpoint': cold('a', { a: endpointURL }) endpointURL = 'https://rest.api/auth';
}); requestID = 'requestID';
requestService = jasmine.createSpyObj('requestService', { shortLivedToken = Object.assign(new ShortLivedToken(), {
'send': null value: 'some-token'
}); });
rdbService = jasmine.createSpyObj('rdbService', { shortLivedTokenRD = createSuccessfulRemoteDataObject(shortLivedToken);
'buildFromRequestUUID': cold('a', { a: shortLivedTokenRD })
});
service = new TestAuthRequestService(halService, requestService, rdbService); halService = jasmine.createSpyObj('halService', {
}; 'getEndpoint': cold('a', { a: endpointURL })
});
requestService = jasmine.createSpyObj('requestService', {
'generateRequestId': requestID,
'send': null,
});
rdbService = jasmine.createSpyObj('rdbService', {
'buildFromRequestUUID': cold('a', { a: shortLivedTokenRD })
});
service = new TestAuthRequestService(halService, requestService, rdbService);
spyOn(service as any, 'fetchRequest').and.returnValue(cold('a', { a: createSuccessfulRemoteDataObject(status) }));
};
beforeEach(() => {
testScheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
});
describe('REST request methods', () => {
let options: HttpOptions;
beforeEach(() => { beforeEach(() => {
testScheduler = new TestScheduler((actual, expected) => { options = Object.create({});
expect(actual).toEqual(expected); });
describe('GET', () => {
it('should send a GET request to the right endpoint and return the auth status', () => {
testScheduler.run(({ cold, expectObservable, flush }) => {
init(cold);
expectObservable(service.getRequest('method', options)).toBe('a', {
a: objectContaining({ payload: status }),
});
flush();
expect(requestService.send).toHaveBeenCalledWith(objectContaining({
uuid: requestID,
href: endpointURL + '/method',
method: RestRequestMethod.GET,
body: undefined,
options,
}));
expect((service as any).fetchRequest).toHaveBeenCalledWith(requestID);
});
});
it('should send the request even if caller doesn\'t subscribe to the response', () => {
testScheduler.run(({ cold, flush }) => {
init(cold);
service.getRequest('method', options);
flush();
expect(requestService.send).toHaveBeenCalledWith(objectContaining({
uuid: requestID,
href: endpointURL + '/method',
method: RestRequestMethod.GET,
body: undefined,
options,
}));
expect((service as any).fetchRequest).toHaveBeenCalledWith(requestID);
});
}); });
}); });
describe('POST', () => {
it('should send a POST request to the right endpoint and return the auth status', () => {
testScheduler.run(({ cold, expectObservable, flush }) => {
init(cold);
expectObservable(service.postToEndpoint('method', { content: 'something' }, options)).toBe('a', {
a: objectContaining({ payload: status }),
});
flush();
expect(requestService.send).toHaveBeenCalledWith(objectContaining({
uuid: requestID,
href: endpointURL + '/method',
method: RestRequestMethod.POST,
body: { content: 'something' },
options,
}));
expect((service as any).fetchRequest).toHaveBeenCalledWith(requestID);
});
});
it('should send the request even if caller doesn\'t subscribe to the response', () => {
testScheduler.run(({ cold, flush }) => {
init(cold);
service.postToEndpoint('method', { content: 'something' }, options);
flush();
expect(requestService.send).toHaveBeenCalledWith(objectContaining({
uuid: requestID,
href: endpointURL + '/method',
method: RestRequestMethod.POST,
body: { content: 'something' },
options,
}));
expect((service as any).fetchRequest).toHaveBeenCalledWith(requestID);
});
});
});
});
describe(`getShortlivedToken`, () => { describe(`getShortlivedToken`, () => {
it(`should call createShortLivedTokenRequest with the url for the endpoint`, () => { it(`should call createShortLivedTokenRequest with the url for the endpoint`, () => {
testScheduler.run(({ cold, expectObservable, flush }) => { testScheduler.run(({ cold, expectObservable, flush }) => {

View File

@@ -1,5 +1,5 @@
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { distinctUntilChanged, filter, map, mergeMap, switchMap, tap } from 'rxjs/operators'; import { distinctUntilChanged, filter, map, switchMap, tap, take } from 'rxjs/operators';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { isNotEmpty } from '../../shared/empty.util'; import { isNotEmpty } from '../../shared/empty.util';
@@ -27,8 +27,13 @@ export abstract class AuthRequestService {
) { ) {
} }
protected fetchRequest(request: RestRequest, ...linksToFollow: FollowLinkConfig<AuthStatus>[]): Observable<RemoteData<AuthStatus>> { /**
return this.rdbService.buildFromRequestUUID<AuthStatus>(request.uuid, ...linksToFollow).pipe( * Fetch the response to a request from the cache, once it's completed.
* @param requestId the UUID of the request for which to retrieve the response
* @protected
*/
protected fetchRequest(requestId: string, ...linksToFollow: FollowLinkConfig<AuthStatus>[]): Observable<RemoteData<AuthStatus>> {
return this.rdbService.buildFromRequestUUID<AuthStatus>(requestId, ...linksToFollow).pipe(
getFirstCompletedRemoteData(), getFirstCompletedRemoteData(),
); );
} }
@@ -44,28 +49,48 @@ export abstract class AuthRequestService {
return url; return url;
} }
/**
* Send a POST request to an authentication endpoint
* @param method the method to send to (e.g. 'status')
* @param body the data to send (optional)
* @param options the HTTP options for the request
*/
public postToEndpoint(method: string, body?: any, options?: HttpOptions): Observable<RemoteData<AuthStatus>> { public postToEndpoint(method: string, body?: any, options?: HttpOptions): Observable<RemoteData<AuthStatus>> {
return this.halService.getEndpoint(this.linkName).pipe( const requestId = this.requestService.generateRequestId();
this.halService.getEndpoint(this.linkName).pipe(
filter((href: string) => isNotEmpty(href)), filter((href: string) => isNotEmpty(href)),
map((endpointURL) => this.getEndpointByMethod(endpointURL, method)), map((endpointURL) => this.getEndpointByMethod(endpointURL, method)),
distinctUntilChanged(), distinctUntilChanged(),
map((endpointURL: string) => new PostRequest(this.requestService.generateRequestId(), endpointURL, body, options)), map((endpointURL: string) => new PostRequest(requestId, endpointURL, body, options)),
tap((request: PostRequest) => this.requestService.send(request)), take(1)
mergeMap((request: PostRequest) => this.fetchRequest(request)), ).subscribe((request: PostRequest) => {
distinctUntilChanged()); this.requestService.send(request);
});
return this.fetchRequest(requestId);
} }
/**
* Send a GET request to an authentication endpoint
* @param method the method to send to (e.g. 'status')
* @param options the HTTP options for the request
*/
public getRequest(method: string, options?: HttpOptions, ...linksToFollow: FollowLinkConfig<any>[]): Observable<RemoteData<AuthStatus>> { public getRequest(method: string, options?: HttpOptions, ...linksToFollow: FollowLinkConfig<any>[]): Observable<RemoteData<AuthStatus>> {
return this.halService.getEndpoint(this.linkName).pipe( const requestId = this.requestService.generateRequestId();
this.halService.getEndpoint(this.linkName).pipe(
filter((href: string) => isNotEmpty(href)), filter((href: string) => isNotEmpty(href)),
map((endpointURL) => this.getEndpointByMethod(endpointURL, method, ...linksToFollow)), map((endpointURL) => this.getEndpointByMethod(endpointURL, method, ...linksToFollow)),
distinctUntilChanged(), distinctUntilChanged(),
map((endpointURL: string) => new GetRequest(this.requestService.generateRequestId(), endpointURL, undefined, options)), map((endpointURL: string) => new GetRequest(requestId, endpointURL, undefined, options)),
tap((request: GetRequest) => this.requestService.send(request)), take(1)
mergeMap((request: GetRequest) => this.fetchRequest(request, ...linksToFollow)), ).subscribe((request: GetRequest) => {
distinctUntilChanged()); this.requestService.send(request);
} });
return this.fetchRequest(requestId, ...linksToFollow);
}
/** /**
* Factory function to create the request object to send. This needs to be a POST client side and * Factory function to create the request object to send. This needs to be a POST client side and
* a GET server side. Due to CSRF validation, the server isn't allowed to send a POST, so we allow * a GET server side. Due to CSRF validation, the server isn't allowed to send a POST, so we allow

View File

@@ -17,6 +17,7 @@ import {
import { AuthTokenInfo } from './models/auth-token-info.model'; import { AuthTokenInfo } from './models/auth-token-info.model';
import { AuthMethod } from './models/auth.method'; import { AuthMethod } from './models/auth.method';
import { AuthMethodType } from './models/auth.method-type'; import { AuthMethodType } from './models/auth.method-type';
import { StoreActionTypes } from '../../store.actions';
/** /**
* The auth state. * The auth state.
@@ -251,6 +252,11 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
idle: false, idle: false,
}); });
case StoreActionTypes.REHYDRATE:
return Object.assign({}, state, {
blocking: true,
});
default: default:
return state; return state;
} }

View File

@@ -15,10 +15,11 @@ import { RESOURCE_POLICY } from '../resource-policy/models/resource-policy.resou
import { COMMUNITY } from './community.resource-type'; import { COMMUNITY } from './community.resource-type';
import { Community } from './community.model'; import { Community } from './community.model';
import { ChildHALResource } from './child-hal-resource.model'; import { ChildHALResource } from './child-hal-resource.model';
import { HandleObject } from './handle-object.model';
@typedObject @typedObject
@inheritSerialization(DSpaceObject) @inheritSerialization(DSpaceObject)
export class Collection extends DSpaceObject implements ChildHALResource { export class Collection extends DSpaceObject implements ChildHALResource, HandleObject {
static type = COLLECTION; static type = COLLECTION;
/** /**

View File

@@ -11,10 +11,11 @@ import { COMMUNITY } from './community.resource-type';
import { DSpaceObject } from './dspace-object.model'; import { DSpaceObject } from './dspace-object.model';
import { HALLink } from './hal-link.model'; import { HALLink } from './hal-link.model';
import { ChildHALResource } from './child-hal-resource.model'; import { ChildHALResource } from './child-hal-resource.model';
import { HandleObject } from './handle-object.model';
@typedObject @typedObject
@inheritSerialization(DSpaceObject) @inheritSerialization(DSpaceObject)
export class Community extends DSpaceObject implements ChildHALResource { export class Community extends DSpaceObject implements ChildHALResource, HandleObject {
static type = COMMUNITY; static type = COMMUNITY;
/** /**

View File

@@ -0,0 +1,8 @@
/**
* Interface representing an object in DSpace that contains a handle
*/
export interface HandleObject {
handle: string;
}

View File

@@ -23,13 +23,14 @@ import { BITSTREAM } from './bitstream.resource-type';
import { Bitstream } from './bitstream.model'; import { Bitstream } from './bitstream.model';
import { ACCESS_STATUS } from 'src/app/shared/object-list/access-status-badge/access-status.resource-type'; import { ACCESS_STATUS } from 'src/app/shared/object-list/access-status-badge/access-status.resource-type';
import { AccessStatusObject } from 'src/app/shared/object-list/access-status-badge/access-status.model'; import { AccessStatusObject } from 'src/app/shared/object-list/access-status-badge/access-status.model';
import { HandleObject } from './handle-object.model';
/** /**
* Class representing a DSpace Item * Class representing a DSpace Item
*/ */
@typedObject @typedObject
@inheritSerialization(DSpaceObject) @inheritSerialization(DSpaceObject)
export class Item extends DSpaceObject implements ChildHALResource { export class Item extends DSpaceObject implements ChildHALResource, HandleObject {
static type = ITEM; static type = ITEM;
/** /**

View File

@@ -5,7 +5,7 @@ import { inject, TestBed, waitForAsync } from '@angular/core/testing';
import { MetadataService } from './core/metadata/metadata.service'; import { MetadataService } from './core/metadata/metadata.service';
import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { StoreModule } from '@ngrx/store'; import { Store, StoreModule } from '@ngrx/store';
import { authReducer } from './core/auth/auth.reducer'; import { authReducer } from './core/auth/auth.reducer';
import { storeModuleConfig } from './app.reducer'; import { storeModuleConfig } from './app.reducer';
import { AngularticsProviderMock } from './shared/mocks/angulartics-provider.service.mock'; import { AngularticsProviderMock } from './shared/mocks/angulartics-provider.service.mock';
@@ -31,6 +31,7 @@ import { getMockThemeService } from './shared/mocks/theme-service.mock';
import objectContaining = jasmine.objectContaining; import objectContaining = jasmine.objectContaining;
import createSpyObj = jasmine.createSpyObj; import createSpyObj = jasmine.createSpyObj;
import SpyObj = jasmine.SpyObj; import SpyObj = jasmine.SpyObj;
import { getTestScheduler } from 'jasmine-marbles';
let spy: SpyObj<any>; let spy: SpyObj<any>;
@@ -124,6 +125,15 @@ describe('InitService', () => {
let metadataServiceSpy; let metadataServiceSpy;
let breadcrumbsServiceSpy; let breadcrumbsServiceSpy;
const BLOCKING = {
t: { core: { auth: { blocking: true } } },
f: { core: { auth: { blocking: false } } },
};
const BOOLEAN = {
t: true,
f: false,
};
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
correlationIdServiceSpy = jasmine.createSpyObj('correlationIdServiceSpy', [ correlationIdServiceSpy = jasmine.createSpyObj('correlationIdServiceSpy', [
'initCorrelationId', 'initCorrelationId',
@@ -182,6 +192,18 @@ describe('InitService', () => {
expect(breadcrumbsServiceSpy.listenForRouteChanges).toHaveBeenCalledTimes(1); expect(breadcrumbsServiceSpy.listenForRouteChanges).toHaveBeenCalledTimes(1);
})); }));
}); });
describe('authenticationReady', () => {
it('should emit & complete the first time auth is unblocked', () => {
getTestScheduler().run(({ cold, expectObservable }) => {
TestBed.overrideProvider(Store, { useValue: cold('t--t--f--t--f--', BLOCKING) });
const service = TestBed.inject(InitService);
// @ts-ignore
expectObservable(service.authenticationReady$()).toBe('------(f|)', BOOLEAN);
});
});
});
}); });
}); });

View File

@@ -5,7 +5,7 @@
* *
* http://www.dspace.org/license/ * http://www.dspace.org/license/
*/ */
import { Store } from '@ngrx/store'; import { select, Store } from '@ngrx/store';
import { CheckAuthenticationTokenAction } from './core/auth/auth.actions'; import { CheckAuthenticationTokenAction } from './core/auth/auth.actions';
import { CorrelationIdService } from './correlation-id/correlation-id.service'; import { CorrelationIdService } from './correlation-id/correlation-id.service';
import { APP_INITIALIZER, Inject, Provider, Type } from '@angular/core'; import { APP_INITIALIZER, Inject, Provider, Type } from '@angular/core';
@@ -20,6 +20,9 @@ import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider';
import { MetadataService } from './core/metadata/metadata.service'; import { MetadataService } from './core/metadata/metadata.service';
import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service';
import { ThemeService } from './shared/theme-support/theme.service'; import { ThemeService } from './shared/theme-support/theme.service';
import { isAuthenticationBlocking } from './core/auth/selectors';
import { distinctUntilChanged, find } from 'rxjs/operators';
import { Observable } from 'rxjs';
/** /**
* Performs the initialization of the app. * Performs the initialization of the app.
@@ -186,4 +189,16 @@ export abstract class InitService {
this.breadcrumbsService.listenForRouteChanges(); this.breadcrumbsService.listenForRouteChanges();
this.themeService.listenForRouteChanges(); this.themeService.listenForRouteChanges();
} }
/**
* Emits once authentication is ready (no longer blocking)
* @protected
*/
protected authenticationReady$(): Observable<boolean> {
return this.store.pipe(
select(isAuthenticationBlocking),
distinctUntilChanged(),
find((b: boolean) => b === false)
);
}
} }

View File

@@ -1,4 +1,4 @@
import { map } from 'rxjs/operators'; import { map, startWith } from 'rxjs/operators';
import { Component, Inject, Input, OnInit } from '@angular/core'; import { Component, Inject, Input, OnInit } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@@ -71,7 +71,8 @@ export class RootComponent implements OnInit {
const sidebarCollapsed = this.menuService.isMenuCollapsed(MenuID.ADMIN); const sidebarCollapsed = this.menuService.isMenuCollapsed(MenuID.ADMIN);
this.slideSidebarOver = combineLatestObservable([sidebarCollapsed, this.windowService.isXsOrSm()]) this.slideSidebarOver = combineLatestObservable([sidebarCollapsed, this.windowService.isXsOrSm()])
.pipe( .pipe(
map(([collapsed, mobile]) => collapsed || mobile) map(([collapsed, mobile]) => collapsed || mobile),
startWith(true),
); );
if (this.router.url === getPageInternalServerErrorRoute()) { if (this.router.url === getPageInternalServerErrorRoute()) {

View File

@@ -10,13 +10,6 @@ export const slide = trigger('slide', [
transition('expanded <=> collapsed', animate(250)) transition('expanded <=> collapsed', animate(250))
]); ]);
export const slideHorizontal = trigger('slideHorizontal', [
state('void', style({ width: 0 })),
state('*', style({ width: '*' })),
transition(':enter', [animate('200ms')]),
transition(':leave', [animate('200ms')])
]);
export const slideMobileNav = trigger('slideMobileNav', [ export const slideMobileNav = trigger('slideMobileNav', [
state('expanded', style({ height: '100vh' })), state('expanded', style({ height: '100vh' })),

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@angular/core'; import { Injectable, Inject, Injector } from '@angular/core';
import { createFeatureSelector, createSelector, select, Store } from '@ngrx/store'; import { Store, createFeatureSelector, createSelector, select } from '@ngrx/store';
import { BehaviorSubject, EMPTY, Observable, of as observableOf } from 'rxjs'; import { BehaviorSubject, EMPTY, Observable, of as observableOf } from 'rxjs';
import { ThemeState } from './theme.reducer'; import { ThemeState } from './theme.reducer';
import { SetThemeAction, ThemeActionTypes } from './theme.actions'; import { SetThemeAction, ThemeActionTypes } from './theme.actions';
@@ -53,12 +53,13 @@ export class ThemeService {
private store: Store<ThemeState>, private store: Store<ThemeState>,
private linkService: LinkService, private linkService: LinkService,
private dSpaceObjectDataService: DSpaceObjectDataService, private dSpaceObjectDataService: DSpaceObjectDataService,
protected injector: Injector,
@Inject(GET_THEME_CONFIG_FOR_FACTORY) private gtcf: (str) => ThemeConfig, @Inject(GET_THEME_CONFIG_FOR_FACTORY) private gtcf: (str) => ThemeConfig,
private router: Router, private router: Router,
@Inject(DOCUMENT) private document: any, @Inject(DOCUMENT) private document: any,
) { ) {
// Create objects from the theme configs in the environment file // Create objects from the theme configs in the environment file
this.themes = environment.themes.map((themeConfig: ThemeConfig) => themeFactory(themeConfig)); this.themes = environment.themes.map((themeConfig: ThemeConfig) => themeFactory(themeConfig, injector));
this.hasDynamicTheme = environment.themes.some((themeConfig: any) => this.hasDynamicTheme = environment.themes.some((themeConfig: any) =>
hasValue(themeConfig.regex) || hasValue(themeConfig.regex) ||
hasValue(themeConfig.handle) || hasValue(themeConfig.handle) ||

View File

@@ -8,6 +8,7 @@ import { Collection } from '../app/core/shared/collection.model';
import { Item } from '../app/core/shared/item.model'; import { Item } from '../app/core/shared/item.model';
import { ITEM } from '../app/core/shared/item.resource-type'; import { ITEM } from '../app/core/shared/item.resource-type';
import { getItemModuleRoute } from '../app/item-page/item-page-routing-paths'; import { getItemModuleRoute } from '../app/item-page/item-page-routing-paths';
import { HandleService } from '../app/shared/handle.service';
describe('Theme Models', () => { describe('Theme Models', () => {
let theme: Theme; let theme: Theme;
@@ -67,24 +68,40 @@ describe('Theme Models', () => {
}); });
describe('HandleTheme', () => { describe('HandleTheme', () => {
let handleService;
beforeEach(() => {
handleService = new HandleService();
});
it('should return true when the DSO\'s handle matches the theme\'s handle', () => { it('should return true when the DSO\'s handle matches the theme\'s handle', () => {
theme = new HandleTheme({ theme = new HandleTheme({
name: 'matching-handle', name: 'matching-handle',
handle: '1234/5678', handle: '1234/5678',
}); }, handleService);
const dso = Object.assign(new Item(), { const matchingDso = Object.assign(new Item(), {
type: ITEM.value, type: ITEM.value,
uuid: 'item-uuid', uuid: 'item-uuid',
handle: '1234/5678', handle: '1234/5678',
}, handleService);
expect(theme.matches('', matchingDso)).toEqual(true);
});
it('should return false when the DSO\'s handle contains the theme\'s handle as a subpart', () => {
theme = new HandleTheme({
name: 'matching-handle',
handle: '1234/5678',
}, handleService);
const dso = Object.assign(new Item(), {
type: ITEM.value,
uuid: 'item-uuid',
handle: '1234/567891011',
}); });
expect(theme.matches('', dso)).toEqual(true); expect(theme.matches('', dso)).toEqual(false);
}); });
it('should return false when the handles don\'t match', () => { it('should return false when the handles don\'t match', () => {
theme = new HandleTheme({ theme = new HandleTheme({
name: 'no-matching-handle', name: 'no-matching-handle',
handle: '1234/5678', handle: '1234/5678',
}); }, handleService);
const dso = Object.assign(new Item(), { const dso = Object.assign(new Item(), {
type: ITEM.value, type: ITEM.value,
uuid: 'item-uuid', uuid: 'item-uuid',

View File

@@ -3,6 +3,9 @@ import { Config } from './config.interface';
import { hasValue, hasNoValue, isNotEmpty } from '../app/shared/empty.util'; import { hasValue, hasNoValue, isNotEmpty } from '../app/shared/empty.util';
import { DSpaceObject } from '../app/core/shared/dspace-object.model'; import { DSpaceObject } from '../app/core/shared/dspace-object.model';
import { getDSORoute } from '../app/app-routing-paths'; import { getDSORoute } from '../app/app-routing-paths';
import { HandleObject } from '../app/core/shared/handle-object.model';
import { Injector } from '@angular/core';
import { HandleService } from '../app/shared/handle.service';
export interface NamedThemeConfig extends Config { export interface NamedThemeConfig extends Config {
name: string; name: string;
@@ -82,12 +85,20 @@ export class RegExTheme extends Theme {
} }
export class HandleTheme extends Theme { export class HandleTheme extends Theme {
constructor(public config: HandleThemeConfig) {
private normalizedHandle;
constructor(public config: HandleThemeConfig,
protected handleService: HandleService
) {
super(config); super(config);
this.normalizedHandle = this.handleService.normalizeHandle(this.config.handle);
} }
matches(url: string, dso: any): boolean { matches<T extends DSpaceObject & HandleObject>(url: string, dso: T): boolean {
return hasValue(dso) && hasValue(dso.handle) && dso.handle.includes(this.config.handle); return hasValue(dso) && hasValue(dso.handle)
&& this.handleService.normalizeHandle(dso.handle) === this.normalizedHandle;
} }
} }
@@ -101,11 +112,11 @@ export class UUIDTheme extends Theme {
} }
} }
export const themeFactory = (config: ThemeConfig): Theme => { export const themeFactory = (config: ThemeConfig, injector: Injector): Theme => {
if (hasValue((config as RegExThemeConfig).regex)) { if (hasValue((config as RegExThemeConfig).regex)) {
return new RegExTheme(config as RegExThemeConfig); return new RegExTheme(config as RegExThemeConfig);
} else if (hasValue((config as HandleThemeConfig).handle)) { } else if (hasValue((config as HandleThemeConfig).handle)) {
return new HandleTheme(config as HandleThemeConfig); return new HandleTheme(config as HandleThemeConfig, injector.get(HandleService));
} else if (hasValue((config as UUIDThemeConfig).uuid)) { } else if (hasValue((config as UUIDThemeConfig).uuid)) {
return new UUIDTheme(config as UUIDThemeConfig); return new UUIDTheme(config as UUIDThemeConfig);
} else { } else {

View File

@@ -1,155 +0,0 @@
import { InitService } from '../../app/init.service';
import { APP_CONFIG } from 'src/config/app-config.interface';
import { inject, TestBed, waitForAsync } from '@angular/core/testing';
import { GoogleAnalyticsService } from '../../app/statistics/google-analytics.service';
import { MetadataService } from '../../app/core/metadata/metadata.service';
import { BreadcrumbsService } from '../../app/breadcrumbs/breadcrumbs.service';
import { CommonModule } from '@angular/common';
import { Store, StoreModule } from '@ngrx/store';
import { authReducer } from '../../app/core/auth/auth.reducer';
import { storeModuleConfig } from '../../app/app.reducer';
import { AngularticsProviderMock } from '../../app/shared/mocks/angulartics-provider.service.mock';
import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider';
import { AuthService } from '../../app/core/auth/auth.service';
import { AuthServiceMock } from '../../app/shared/mocks/auth.service.mock';
import { ActivatedRoute, Router } from '@angular/router';
import { RouterMock } from '../../app/shared/mocks/router.mock';
import { MockActivatedRoute } from '../../app/shared/mocks/active-router.mock';
import { MenuService } from '../../app/shared/menu/menu.service';
import { LocaleService } from '../../app/core/locale/locale.service';
import { environment } from '../../environments/environment';
import { provideMockStore } from '@ngrx/store/testing';
import { AppComponent } from '../../app/app.component';
import { RouteService } from '../../app/core/services/route.service';
import { getMockLocaleService } from '../../app/app.component.spec';
import { MenuServiceStub } from '../../app/shared/testing/menu-service.stub';
import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service';
import { KlaroService } from '../../app/shared/cookies/klaro.service';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { TranslateLoaderMock } from '../../app/shared/mocks/translate-loader.mock';
import { getTestScheduler } from 'jasmine-marbles';
import { ThemeService } from '../../app/shared/theme-support/theme.service';
import { getMockThemeService } from '../../app/shared/mocks/theme-service.mock';
import { BrowserInitService } from './browser-init.service';
import { TransferState } from '@angular/platform-browser';
const initialState = {
core: {
auth: {
loading: false,
blocking: true,
}
}
};
describe('BrowserInitService', () => {
describe('browser-specific initialization steps', () => {
let correlationIdServiceSpy;
let dspaceTransferStateSpy;
let transferStateSpy;
let metadataServiceSpy;
let breadcrumbsServiceSpy;
let klaroServiceSpy;
let googleAnalyticsSpy;
beforeEach(waitForAsync(() => {
correlationIdServiceSpy = jasmine.createSpyObj('correlationIdServiceSpy', [
'initCorrelationId',
]);
dspaceTransferStateSpy = jasmine.createSpyObj('dspaceTransferStateSpy', [
'transfer',
]);
transferStateSpy = jasmine.createSpyObj('dspaceTransferStateSpy', [
'get', 'hasKey'
]);
breadcrumbsServiceSpy = jasmine.createSpyObj('breadcrumbsServiceSpy', [
'listenForRouteChanges',
]);
metadataServiceSpy = jasmine.createSpyObj('metadataService', [
'listenForRouteChange',
]);
klaroServiceSpy = jasmine.createSpyObj('klaroServiceSpy', [
'initialize',
]);
googleAnalyticsSpy = jasmine.createSpyObj('googleAnalyticsService', [
'addTrackingIdToPage',
]);
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [
CommonModule,
StoreModule.forRoot(authReducer, storeModuleConfig),
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock
}
}),
],
providers: [
{ provide: InitService, useClass: BrowserInitService },
{ provide: CorrelationIdService, useValue: correlationIdServiceSpy },
{ provide: APP_CONFIG, useValue: environment },
{ provide: LocaleService, useValue: getMockLocaleService() },
{ provide: Angulartics2DSpace, useValue: new AngularticsProviderMock() },
{ provide: MetadataService, useValue: metadataServiceSpy },
{ provide: BreadcrumbsService, useValue: breadcrumbsServiceSpy },
{ provide: AuthService, useValue: new AuthServiceMock() },
{ provide: Router, useValue: new RouterMock() },
{ provide: ActivatedRoute, useValue: new MockActivatedRoute() },
{ provide: MenuService, useValue: new MenuServiceStub() },
{ provide: KlaroService, useValue: klaroServiceSpy },
{ provide: GoogleAnalyticsService, useValue: googleAnalyticsSpy },
{ provide: ThemeService, useValue: getMockThemeService() },
provideMockStore({ initialState }),
AppComponent,
RouteService,
{ provide: TransferState, useValue: undefined },
]
});
}));
describe('initGoogleÀnalytics', () => {
it('should call googleAnalyticsService.addTrackingIdToPage()', inject([InitService], (service) => {
// @ts-ignore
service.initGoogleAnalytics();
expect(googleAnalyticsSpy.addTrackingIdToPage).toHaveBeenCalledTimes(1);
}));
});
describe('initKlaro', () => {
const BLOCKING = {
t: { core: { auth: { blocking: true } } },
f: { core: { auth: { blocking: false } } },
};
it('should not initialize Klaro while auth is blocking', () => {
getTestScheduler().run(({ cold, flush}) => {
TestBed.overrideProvider(Store, { useValue: cold('t--t--t--', BLOCKING) });
const service = TestBed.inject(InitService);
// @ts-ignore
service.initKlaro();
flush();
expect(klaroServiceSpy.initialize).not.toHaveBeenCalled();
});
});
it('should only initialize Klaro the first time auth is unblocked', () => {
getTestScheduler().run(({ cold, flush}) => {
TestBed.overrideProvider(Store, { useValue: cold('t--t--f--t--f--', BLOCKING) });
const service = TestBed.inject(InitService);
// @ts-ignore
service.initKlaro();
flush();
expect(klaroServiceSpy.initialize).toHaveBeenCalledTimes(1);
});
});
});
});
});

View File

@@ -6,7 +6,7 @@
* http://www.dspace.org/license/ * http://www.dspace.org/license/
*/ */
import { InitService } from '../../app/init.service'; import { InitService } from '../../app/init.service';
import { select, Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '../../app/app.reducer'; import { AppState } from '../../app/app.reducer';
import { TransferState } from '@angular/platform-browser'; import { TransferState } from '@angular/platform-browser';
import { APP_CONFIG, APP_CONFIG_STATE, AppConfig } from '../../config/app-config.interface'; import { APP_CONFIG, APP_CONFIG_STATE, AppConfig } from '../../config/app-config.interface';
@@ -26,9 +26,8 @@ import { AuthService } from '../../app/core/auth/auth.service';
import { ThemeService } from '../../app/shared/theme-support/theme.service'; import { ThemeService } from '../../app/shared/theme-support/theme.service';
import { StoreAction, StoreActionTypes } from '../../app/store.actions'; import { StoreAction, StoreActionTypes } from '../../app/store.actions';
import { coreSelector } from '../../app/core/core.selectors'; import { coreSelector } from '../../app/core/core.selectors';
import { distinctUntilChanged, filter, find, map, take } from 'rxjs/operators'; import { find, map } from 'rxjs/operators';
import { isNotEmpty } from '../../app/shared/empty.util'; import { isNotEmpty } from '../../app/shared/empty.util';
import { isAuthenticationBlocking } from '../../app/core/auth/selectors';
/** /**
* Performs client-side initialization. * Performs client-side initialization.
@@ -90,6 +89,8 @@ export class BrowserInitService extends InitService {
this.initKlaro(); this.initKlaro();
await this.authenticationReady$().toPromise();
return true; return true;
}; };
} }
@@ -116,16 +117,11 @@ export class BrowserInitService extends InitService {
} }
/** /**
* Initialize Klaro * Initialize Klaro (once authentication is resolved)
* @protected * @protected
*/ */
protected initKlaro() { protected initKlaro() {
this.store.pipe( this.authenticationReady$().subscribe(() => {
select(isAuthenticationBlocking),
distinctUntilChanged(),
filter((isBlocking: boolean) => isBlocking === false),
take(1)
).subscribe(() => {
this.klaroService.initialize(); this.klaroService.initialize();
}); });
} }

View File

@@ -66,6 +66,8 @@ export class ServerInitService extends InitService {
this.initRouteListeners(); this.initRouteListeners();
this.themeService.listenForThemeChanges(false); this.themeService.listenForThemeChanges(false);
await this.authenticationReady$().toPromise();
return true; return true;
}; };
} }