Merge pull request #1741 from atmire/w2p-93219_Consolidate-all-initialization-in-a-single-Service_PR

Consolidate all initialization in a single Service
This commit is contained in:
Tim Donohue
2022-09-07 09:25:51 -05:00
committed by GitHub
21 changed files with 1095 additions and 522 deletions

View File

@@ -32,7 +32,6 @@ import { storeModuleConfig } from './app.reducer';
import { LocaleService } from './core/locale/locale.service'; import { LocaleService } from './core/locale/locale.service';
import { authReducer } from './core/auth/auth.reducer'; import { authReducer } from './core/auth/auth.reducer';
import { provideMockStore } from '@ngrx/store/testing'; import { provideMockStore } from '@ngrx/store/testing';
import { GoogleAnalyticsService } from './statistics/google-analytics.service';
import { ThemeService } from './shared/theme-support/theme.service'; import { ThemeService } from './shared/theme-support/theme.service';
import { getMockThemeService } from './shared/mocks/theme-service.mock'; import { getMockThemeService } from './shared/mocks/theme-service.mock';
import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service';
@@ -46,15 +45,15 @@ const initialState = {
core: { auth: { loading: false } } core: { auth: { loading: false } }
}; };
describe('App component', () => { export function getMockLocaleService(): LocaleService {
let breadcrumbsServiceSpy;
function getMockLocaleService(): LocaleService {
return jasmine.createSpyObj('LocaleService', { return jasmine.createSpyObj('LocaleService', {
setCurrentLanguageCode: jasmine.createSpy('setCurrentLanguageCode') setCurrentLanguageCode: jasmine.createSpy('setCurrentLanguageCode')
}); });
} }
describe('App component', () => {
let breadcrumbsServiceSpy;
const getDefaultTestBedConf = () => { const getDefaultTestBedConf = () => {
breadcrumbsServiceSpy = jasmine.createSpyObj(['listenForRouteChanges']); breadcrumbsServiceSpy = jasmine.createSpyObj(['listenForRouteChanges']);
@@ -130,66 +129,4 @@ describe('App component', () => {
}); });
}); });
describe('the constructor', () => {
it('should call breadcrumbsService.listenForRouteChanges', () => {
expect(breadcrumbsServiceSpy.listenForRouteChanges).toHaveBeenCalledTimes(1);
});
});
describe('when GoogleAnalyticsService is provided', () => {
let googleAnalyticsSpy;
beforeEach(() => {
// NOTE: Cannot override providers once components have been compiled, so TestBed needs to be reset
TestBed.resetTestingModule();
TestBed.configureTestingModule(getDefaultTestBedConf());
googleAnalyticsSpy = jasmine.createSpyObj('googleAnalyticsService', [
'addTrackingIdToPage',
]);
TestBed.overrideProvider(GoogleAnalyticsService, {useValue: googleAnalyticsSpy});
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
it('should create component', () => {
expect(comp).toBeTruthy();
});
describe('the constructor', () => {
it('should call googleAnalyticsService.addTrackingIdToPage()', () => {
expect(googleAnalyticsSpy.addTrackingIdToPage).toHaveBeenCalledTimes(1);
});
});
});
describe('when ThemeService returns a custom theme', () => {
let document;
let headSpy;
beforeEach(() => {
// NOTE: Cannot override providers once components have been compiled, so TestBed needs to be reset
TestBed.resetTestingModule();
TestBed.configureTestingModule(getDefaultTestBedConf());
TestBed.overrideProvider(ThemeService, {useValue: getMockThemeService('custom')});
document = TestBed.inject(DOCUMENT);
headSpy = jasmine.createSpyObj('head', ['appendChild', 'getElementsByClassName']);
headSpy.getElementsByClassName.and.returnValue([]);
spyOn(document, 'getElementsByTagName').and.returnValue([headSpy]);
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
it('should append a link element with the correct attributes to the head element', () => {
const link = document.createElement('link');
link.setAttribute('rel', 'stylesheet');
link.setAttribute('type', 'text/css');
link.setAttribute('class', 'theme-css');
link.setAttribute('href', 'custom-theme.css');
expect(headSpy.appendChild).toHaveBeenCalledWith(link);
});
});
}); });

View File

@@ -1,4 +1,4 @@
import { distinctUntilChanged, filter, switchMap, take, withLatestFrom } from 'rxjs/operators'; import { distinctUntilChanged, take, withLatestFrom } from 'rxjs/operators';
import { DOCUMENT, isPlatformBrowser } from '@angular/common'; import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { import {
AfterViewInit, AfterViewInit,
@@ -7,48 +7,30 @@ import {
HostListener, HostListener,
Inject, Inject,
OnInit, OnInit,
Optional,
PLATFORM_ID, PLATFORM_ID,
} from '@angular/core'; } from '@angular/core';
import { import {
ActivatedRouteSnapshot,
ActivationEnd,
NavigationCancel, NavigationCancel,
NavigationEnd, NavigationEnd,
NavigationStart, ResolveEnd, NavigationStart,
Router, Router,
} from '@angular/router'; } from '@angular/router';
import { isEqual } from 'lodash'; import { BehaviorSubject, Observable } from 'rxjs';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { select, Store } from '@ngrx/store'; import { select, Store } from '@ngrx/store';
import { NgbModal, NgbModalConfig } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal, NgbModalConfig } from '@ng-bootstrap/ng-bootstrap';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { Angulartics2GoogleAnalytics } from 'angulartics2';
import { MetadataService } from './core/metadata/metadata.service';
import { HostWindowResizeAction } from './shared/host-window.actions'; import { HostWindowResizeAction } from './shared/host-window.actions';
import { HostWindowState } from './shared/search/host-window.reducer'; import { HostWindowState } from './shared/search/host-window.reducer';
import { NativeWindowRef, NativeWindowService } from './core/services/window.service'; import { NativeWindowRef, NativeWindowService } from './core/services/window.service';
import { isAuthenticationBlocking } from './core/auth/selectors'; import { isAuthenticationBlocking } from './core/auth/selectors';
import { AuthService } from './core/auth/auth.service'; import { AuthService } from './core/auth/auth.service';
import { CSSVariableService } from './shared/sass-helper/sass-helper.service'; import { CSSVariableService } from './shared/sass-helper/sass-helper.service';
import { MenuService } from './shared/menu/menu.service';
import { HostWindowService } from './shared/host-window.service';
import { HeadTagConfig, ThemeConfig } from '../config/theme.model';
import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider';
import { environment } from '../environments/environment'; import { environment } from '../environments/environment';
import { models } from './core/core.module'; import { models } from './core/core.module';
import { LocaleService } from './core/locale/locale.service';
import { hasNoValue, hasValue, isNotEmpty } from './shared/empty.util';
import { KlaroService } from './shared/cookies/klaro.service';
import { GoogleAnalyticsService } from './statistics/google-analytics.service';
import { ThemeService } from './shared/theme-support/theme.service'; import { ThemeService } from './shared/theme-support/theme.service';
import { BASE_THEME_NAME } from './shared/theme-support/theme.constants';
import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service';
import { IdleModalComponent } from './shared/idle-modal/idle-modal.component'; import { IdleModalComponent } from './shared/idle-modal/idle-modal.component';
import { getDefaultThemeConfig } from '../config/config.util'; import { distinctNext } from './core/shared/distinct-next';
import { AppConfig, APP_CONFIG } from 'src/config/app-config.interface';
import { ModalBeforeDismiss } from './shared/interfaces/modal-before-dismiss.interface'; import { ModalBeforeDismiss } from './shared/interfaces/modal-before-dismiss.interface';
@Component({ @Component({
@@ -58,11 +40,6 @@ import { ModalBeforeDismiss } from './shared/interfaces/modal-before-dismiss.int
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class AppComponent implements OnInit, AfterViewInit { export class AppComponent implements OnInit, AfterViewInit {
sidebarVisible: Observable<boolean>;
slideSidebarOver: Observable<boolean>;
collapsedSidebarWidth: Observable<string>;
totalSidebarWidth: Observable<string>;
theme: Observable<ThemeConfig> = of({} as any);
notificationOptions; notificationOptions;
models; models;
@@ -79,9 +56,7 @@ export class AppComponent implements OnInit, AfterViewInit {
/** /**
* Whether or not the theme is in the process of being swapped * Whether or not the theme is in the process of being swapped
*/ */
isThemeLoading$: BehaviorSubject<boolean> = new BehaviorSubject(false); isThemeLoading$: Observable<boolean>;
isThemeCSSLoading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
/** /**
* Whether or not the idle modal is is currently open * Whether or not the idle modal is is currently open
@@ -92,78 +67,26 @@ export class AppComponent implements OnInit, AfterViewInit {
@Inject(NativeWindowService) private _window: NativeWindowRef, @Inject(NativeWindowService) private _window: NativeWindowRef,
@Inject(DOCUMENT) private document: any, @Inject(DOCUMENT) private document: any,
@Inject(PLATFORM_ID) private platformId: any, @Inject(PLATFORM_ID) private platformId: any,
@Inject(APP_CONFIG) private appConfig: AppConfig,
private themeService: ThemeService, private themeService: ThemeService,
private translate: TranslateService, private translate: TranslateService,
private store: Store<HostWindowState>, private store: Store<HostWindowState>,
private metadata: MetadataService,
private angulartics2GoogleAnalytics: Angulartics2GoogleAnalytics,
private angulartics2DSpace: Angulartics2DSpace,
private authService: AuthService, private authService: AuthService,
private router: Router, private router: Router,
private cssService: CSSVariableService, private cssService: CSSVariableService,
private menuService: MenuService,
private windowService: HostWindowService,
private localeService: LocaleService,
private breadcrumbsService: BreadcrumbsService,
private modalService: NgbModal, private modalService: NgbModal,
private modalConfig: NgbModalConfig, private modalConfig: NgbModalConfig,
@Optional() private cookiesService: KlaroService,
@Optional() private googleAnalyticsService: GoogleAnalyticsService,
) { ) {
if (!isEqual(environment, this.appConfig)) {
throw new Error('environment does not match app config!');
}
this.notificationOptions = environment.notifications; this.notificationOptions = environment.notifications;
/* Use models object so all decorators are actually called */ /* Use models object so all decorators are actually called */
this.models = models; this.models = models;
this.themeService.getThemeName$().subscribe((themeName: string) => {
if (isPlatformBrowser(this.platformId)) { if (isPlatformBrowser(this.platformId)) {
// the theme css will never download server side, so this should only happen on the browser
this.distinctNext(this.isThemeCSSLoading$, true);
}
if (hasValue(themeName)) {
this.loadGlobalThemeConfig(themeName);
} else {
const defaultThemeConfig = getDefaultThemeConfig();
if (hasValue(defaultThemeConfig)) {
this.loadGlobalThemeConfig(defaultThemeConfig.name);
} else {
this.loadGlobalThemeConfig(BASE_THEME_NAME);
}
}
});
if (isPlatformBrowser(this.platformId)) {
this.authService.trackTokenExpiration();
this.trackIdleModal(); this.trackIdleModal();
} }
// Load all the languages that are defined as active from the config file this.isThemeLoading$ = this.themeService.isThemeLoading$;
translate.addLangs(environment.languages.filter((LangConfig) => LangConfig.active === true).map((a) => a.code));
// Load the default language from the config file
// translate.setDefaultLang(environment.defaultLanguage);
// set the current language code
this.localeService.setCurrentLanguageCode();
// analytics
if (hasValue(googleAnalyticsService)) {
googleAnalyticsService.addTrackingIdToPage();
}
angulartics2DSpace.startTracking();
metadata.listenForRouteChange();
breadcrumbsService.listenForRouteChanges();
if (environment.debug) {
console.info(environment);
}
this.storeCSSVariables(); this.storeCSSVariables();
} }
@@ -178,18 +101,11 @@ export class AppComponent implements OnInit, AfterViewInit {
return true; return true;
}; };
this.isAuthBlocking$ = this.store.pipe(select(isAuthenticationBlocking)).pipe( this.isAuthBlocking$ = this.store.pipe(
select(isAuthenticationBlocking),
distinctUntilChanged() distinctUntilChanged()
); );
this.isAuthBlocking$
.pipe(
filter((isBlocking: boolean) => isBlocking === false),
take(1)
).subscribe(() => this.initializeKlaro());
const env: string = environment.production ? 'Production' : 'Development';
const color: string = environment.production ? 'red' : 'green';
console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`);
this.dispatchWindowSize(this._window.nativeWindow.innerWidth, this._window.nativeWindow.innerHeight); this.dispatchWindowSize(this._window.nativeWindow.innerWidth, this._window.nativeWindow.innerHeight);
} }
@@ -209,54 +125,15 @@ export class AppComponent implements OnInit, AfterViewInit {
} }
ngAfterViewInit() { ngAfterViewInit() {
let updatingTheme = false;
let snapshot: ActivatedRouteSnapshot;
this.router.events.subscribe((event) => { this.router.events.subscribe((event) => {
if (event instanceof NavigationStart) { if (event instanceof NavigationStart) {
updatingTheme = false; distinctNext(this.isRouteLoading$, true);
this.distinctNext(this.isRouteLoading$, true); } else if (
} else if (event instanceof ResolveEnd) { event instanceof NavigationEnd ||
// this is the earliest point where we have all the information we need event instanceof NavigationCancel
// to update the theme, but this event is not emitted on first load ) {
this.updateTheme(event.urlAfterRedirects, event.state.root); distinctNext(this.isRouteLoading$, false);
updatingTheme = true;
} else if (!updatingTheme && event instanceof ActivationEnd) {
// if there was no ResolveEnd, keep track of the snapshot...
snapshot = event.snapshot;
} else if (event instanceof NavigationEnd) {
if (!updatingTheme) {
// ...and use it to update the theme on NavigationEnd instead
this.updateTheme(event.urlAfterRedirects, snapshot);
updatingTheme = true;
} }
this.distinctNext(this.isRouteLoading$, false);
} else if (event instanceof NavigationCancel) {
if (!updatingTheme) {
this.distinctNext(this.isThemeLoading$, false);
}
this.distinctNext(this.isRouteLoading$, false);
}
});
}
/**
* Update the theme according to the current route, if applicable.
* @param urlAfterRedirects the current URL after redirects
* @param snapshot the current route snapshot
* @private
*/
private updateTheme(urlAfterRedirects: string, snapshot: ActivatedRouteSnapshot): void {
this.themeService.updateThemeOnRouteChange$(urlAfterRedirects, snapshot).pipe(
switchMap((changed) => {
if (changed) {
return this.isThemeCSSLoading$;
} else {
return [false];
}
})
).subscribe((changed) => {
this.distinctNext(this.isThemeLoading$, changed);
}); });
} }
@@ -271,125 +148,6 @@ export class AppComponent implements OnInit, AfterViewInit {
); );
} }
private initializeKlaro() {
if (hasValue(this.cookiesService)) {
this.cookiesService.initialize();
}
}
private loadGlobalThemeConfig(themeName: string): void {
this.setThemeCss(themeName);
this.setHeadTags(themeName);
}
/**
* Update the theme css file in <head>
*
* @param themeName The name of the new theme
* @private
*/
private setThemeCss(themeName: string): void {
const head = this.document.getElementsByTagName('head')[0];
if (hasNoValue(head)) {
return;
}
// Array.from to ensure we end up with an array, not an HTMLCollection, which would be
// automatically updated if we add nodes later
const currentThemeLinks = Array.from(head.getElementsByClassName('theme-css'));
const link = this.document.createElement('link');
link.setAttribute('rel', 'stylesheet');
link.setAttribute('type', 'text/css');
link.setAttribute('class', 'theme-css');
link.setAttribute('href', `${encodeURIComponent(themeName)}-theme.css`);
// wait for the new css to download before removing the old one to prevent a
// flash of unstyled content
link.onload = () => {
if (isNotEmpty(currentThemeLinks)) {
currentThemeLinks.forEach((currentThemeLink: any) => {
if (hasValue(currentThemeLink)) {
currentThemeLink.remove();
}
});
}
// the fact that this callback is used, proves we're on the browser.
this.distinctNext(this.isThemeCSSLoading$, false);
};
head.appendChild(link);
}
private setHeadTags(themeName: string): void {
const head = this.document.getElementsByTagName('head')[0];
if (hasNoValue(head)) {
return;
}
// clear head tags
const currentHeadTags = Array.from(head.getElementsByClassName('theme-head-tag'));
if (hasValue(currentHeadTags)) {
currentHeadTags.forEach((currentHeadTag: any) => currentHeadTag.remove());
}
// create new head tags (not yet added to DOM)
const headTagFragment = this.document.createDocumentFragment();
this.createHeadTags(themeName)
.forEach(newHeadTag => headTagFragment.appendChild(newHeadTag));
// add new head tags to DOM
head.appendChild(headTagFragment);
}
private createHeadTags(themeName: string): HTMLElement[] {
const themeConfig = this.themeService.getThemeConfigFor(themeName);
const headTagConfigs = themeConfig?.headTags;
if (hasNoValue(headTagConfigs)) {
const parentThemeName = themeConfig?.extends;
if (hasValue(parentThemeName)) {
// inherit the head tags of the parent theme
return this.createHeadTags(parentThemeName);
}
const defaultThemeConfig = getDefaultThemeConfig();
const defaultThemeName = defaultThemeConfig.name;
if (
hasNoValue(defaultThemeName) ||
themeName === defaultThemeName ||
themeName === BASE_THEME_NAME
) {
// last resort, use fallback favicon.ico
return [
this.createHeadTag({
'tagName': 'link',
'attributes': {
'rel': 'icon',
'href': 'assets/images/favicon.ico',
'sizes': 'any',
}
})
];
}
// inherit the head tags of the default theme
return this.createHeadTags(defaultThemeConfig.name);
}
return headTagConfigs.map(this.createHeadTag.bind(this));
}
private createHeadTag(headTagConfig: HeadTagConfig): HTMLElement {
const tag = this.document.createElement(headTagConfig.tagName);
if (hasValue(headTagConfig.attributes)) {
Object.entries(headTagConfig.attributes)
.forEach(([key, value]) => tag.setAttribute(key, value));
}
// 'class' attribute should always be 'theme-head-tag' for removal
tag.setAttribute('class', 'theme-head-tag');
return tag;
}
private trackIdleModal() { private trackIdleModal() {
const isIdle$ = this.authService.isUserIdle(); const isIdle$ = this.authService.isUserIdle();
const isAuthenticated$ = this.authService.isAuthenticated(); const isAuthenticated$ = this.authService.isAuthenticated();
@@ -409,16 +167,4 @@ export class AppComponent implements OnInit, AfterViewInit {
}); });
} }
/**
* Use nextValue to update a given BehaviorSubject, only if it differs from its current value
*
* @param bs a BehaviorSubject
* @param nextValue the next value for that BehaviorSubject
* @protected
*/
protected distinctNext<T>(bs: BehaviorSubject<T>, nextValue: T): void {
if (bs.getValue() !== nextValue) {
bs.next(nextValue);
}
}
} }

View File

@@ -1,18 +1,14 @@
import { APP_BASE_HREF, CommonModule, DOCUMENT } from '@angular/common'; import { APP_BASE_HREF, CommonModule, DOCUMENT } from '@angular/common';
import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http'; import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http';
import { APP_INITIALIZER, NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { AbstractControl } from '@angular/forms'; import { AbstractControl } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule } from '@angular/platform-browser';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { EffectsModule } from '@ngrx/effects'; import { EffectsModule } from '@ngrx/effects';
import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store'; import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store';
import { MetaReducer, Store, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store'; import { MetaReducer, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store';
import { import { DYNAMIC_ERROR_MESSAGES_MATCHER, DYNAMIC_MATCHER_PROVIDERS, DynamicErrorMessagesMatcher } from '@ng-dynamic-forms/core';
DYNAMIC_ERROR_MESSAGES_MATCHER,
DYNAMIC_MATCHER_PROVIDERS,
DynamicErrorMessagesMatcher
} from '@ng-dynamic-forms/core';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to'; import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to';
import { AppRoutingModule } from './app-routing.module'; import { AppRoutingModule } from './app-routing.module';
@@ -20,7 +16,6 @@ import { AppComponent } from './app.component';
import { appEffects } from './app.effects'; import { appEffects } from './app.effects';
import { appMetaReducers, debugMetaReducers } from './app.metareducers'; import { appMetaReducers, debugMetaReducers } from './app.metareducers';
import { appReducers, AppState, storeModuleConfig } from './app.reducer'; import { appReducers, AppState, storeModuleConfig } from './app.reducer';
import { CheckAuthenticationTokenAction } from './core/auth/auth.actions';
import { CoreModule } from './core/core.module'; import { CoreModule } from './core/core.module';
import { ClientCookieService } from './core/services/client-cookie.service'; import { ClientCookieService } from './core/services/client-cookie.service';
import { NavbarModule } from './navbar/navbar.module'; import { NavbarModule } from './navbar/navbar.module';
@@ -32,7 +27,6 @@ import { LocaleInterceptor } from './core/locale/locale.interceptor';
import { XsrfInterceptor } from './core/xsrf/xsrf.interceptor'; import { XsrfInterceptor } from './core/xsrf/xsrf.interceptor';
import { LogInterceptor } from './core/log/log.interceptor'; import { LogInterceptor } from './core/log/log.interceptor';
import { EagerThemesModule } from '../themes/eager-themes.module'; import { EagerThemesModule } from '../themes/eager-themes.module';
import { APP_CONFIG, AppConfig } from '../config/app-config.interface'; import { APP_CONFIG, AppConfig } from '../config/app-config.interface';
import { NgxMaskModule } from 'ngx-mask'; import { NgxMaskModule } from 'ngx-mask';
import { StoreDevModules } from '../config/store/devtools'; import { StoreDevModules } from '../config/store/devtools';
@@ -80,10 +74,6 @@ const IMPORTS = [
]; ];
const PROVIDERS = [ const PROVIDERS = [
{
provide: APP_CONFIG,
useFactory: getConfig
},
{ {
provide: APP_BASE_HREF, provide: APP_BASE_HREF,
useFactory: getBaseHref, useFactory: getBaseHref,
@@ -99,15 +89,6 @@ const PROVIDERS = [
useClass: DSpaceRouterStateSerializer useClass: DSpaceRouterStateSerializer
}, },
ClientCookieService, ClientCookieService,
// Check the authentication token when the app initializes
{
provide: APP_INITIALIZER,
useFactory: (store: Store<AppState>,) => {
return () => store.dispatch(new CheckAuthenticationTokenAction());
},
deps: [Store],
multi: true
},
// register AuthInterceptor as HttpInterceptor // register AuthInterceptor as HttpInterceptor
{ {
provide: HTTP_INTERCEPTORS, provide: HTTP_INTERCEPTORS,

View File

@@ -4,7 +4,7 @@ import { HttpHeaders } from '@angular/common/http';
import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens';
import { Observable, of as observableOf } from 'rxjs'; import { Observable, of as observableOf } from 'rxjs';
import { map, startWith, switchMap, take } from 'rxjs/operators'; import { filter, map, startWith, switchMap, take } from 'rxjs/operators';
import { select, Store } from '@ngrx/store'; import { select, Store } from '@ngrx/store';
import { CookieAttributes } from 'js-cookie'; import { CookieAttributes } from 'js-cookie';
@@ -93,6 +93,8 @@ export class AuthService {
private translateService: TranslateService private translateService: TranslateService
) { ) {
this.store.pipe( this.store.pipe(
// when this service is constructed the store is not fully initialized yet
filter((state: any) => state?.core?.auth !== undefined),
select(isAuthenticated), select(isAuthenticated),
startWith(false) startWith(false)
).subscribe((authenticated: boolean) => this._authenticated = authenticated); ).subscribe((authenticated: boolean) => this._authenticated = authenticated);
@@ -346,7 +348,7 @@ export class AuthService {
let token: AuthTokenInfo; let token: AuthTokenInfo;
let currentlyRefreshingToken = false; let currentlyRefreshingToken = false;
this.store.pipe(select(getAuthenticationToken)).subscribe((authTokenInfo: AuthTokenInfo) => { this.store.pipe(select(getAuthenticationToken)).subscribe((authTokenInfo: AuthTokenInfo) => {
// If new token is undefined an it wasn't previously => Refresh failed // If new token is undefined and it wasn't previously => Refresh failed
if (currentlyRefreshingToken && token !== undefined && authTokenInfo === undefined) { if (currentlyRefreshingToken && token !== undefined && authTokenInfo === undefined) {
// Token refresh failed => Error notification => 10 second wait => Page reloads & user logged out // Token refresh failed => Error notification => 10 second wait => Page reloads & user logged out
this.notificationService.error(this.translateService.get('auth.messages.token-refresh-failed')); this.notificationService.error(this.translateService.get('auth.messages.token-refresh-failed'));

View File

@@ -0,0 +1,22 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { BehaviorSubject } from 'rxjs';
/**
* Use nextValue to update a given BehaviorSubject, only if it differs from its current value
*
* @param bs a BehaviorSubject
* @param nextValue the next value for that BehaviorSubject
* @protected
*/
export function distinctNext<T>(bs: BehaviorSubject<T>, nextValue: T): void {
if (bs.getValue() !== nextValue) {
bs.next(nextValue);
}
}

View File

@@ -0,0 +1,187 @@
import { InitService } from './init.service';
import { APP_CONFIG } from 'src/config/app-config.interface';
import { APP_INITIALIZER, Injectable } from '@angular/core';
import { inject, TestBed, waitForAsync } from '@angular/core/testing';
import { MetadataService } from './core/metadata/metadata.service';
import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service';
import { CommonModule } from '@angular/common';
import { StoreModule } from '@ngrx/store';
import { authReducer } from './core/auth/auth.reducer';
import { storeModuleConfig } from './app.reducer';
import { AngularticsProviderMock } from './shared/mocks/angulartics-provider.service.mock';
import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider';
import { AuthService } from './core/auth/auth.service';
import { AuthServiceMock } from './shared/mocks/auth.service.mock';
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 { LocaleService } from './core/locale/locale.service';
import { environment } from '../environments/environment';
import { provideMockStore } from '@ngrx/store/testing';
import { AppComponent } from './app.component';
import { RouteService } from './core/services/route.service';
import { getMockLocaleService } from './app.component.spec';
import { MenuServiceStub } from './shared/testing/menu-service.stub';
import { CorrelationIdService } from './correlation-id/correlation-id.service';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { TranslateLoaderMock } from './shared/mocks/translate-loader.mock';
import { ThemeService } from './shared/theme-support/theme.service';
import { getMockThemeService } from './shared/mocks/theme-service.mock';
import objectContaining = jasmine.objectContaining;
import createSpyObj = jasmine.createSpyObj;
import SpyObj = jasmine.SpyObj;
let spy: SpyObj<any>;
@Injectable()
export class ConcreteInitServiceMock extends InitService {
protected static resolveAppConfig() {
spy.resolveAppConfig();
}
protected init(): () => Promise<boolean> {
spy.init();
return async () => true;
}
}
const initialState = {
core: {
auth: {
loading: false,
blocking: true,
}
}
};
describe('InitService', () => {
describe('providers', () => {
beforeEach(() => {
spy = createSpyObj('ConcreteInitServiceMock', {
resolveAppConfig: null,
init: null,
});
});
it('should throw error when called on abstract InitService', () => {
expect(() => InitService.providers()).toThrow();
});
it('should correctly set up provider dependencies', () => {
const providers = ConcreteInitServiceMock.providers();
expect(providers).toContain(objectContaining({
provide: InitService,
useClass: ConcreteInitServiceMock
}));
expect(providers).toContain(objectContaining({
provide: APP_CONFIG,
}));
expect(providers).toContain(objectContaining({
provide: APP_INITIALIZER,
deps: [ InitService ],
multi: true,
}));
});
it('should call resolveAppConfig() in APP_CONFIG factory', () => {
const factory = (
ConcreteInitServiceMock.providers()
.find((p: any) => p.provide === APP_CONFIG) as any
).useFactory;
// this factory is called _before_ InitService is instantiated
factory();
expect(spy.resolveAppConfig).toHaveBeenCalled();
expect(spy.init).not.toHaveBeenCalled();
});
it('should defer to init() in APP_INITIALIZER factory', () => {
const factory = (
ConcreteInitServiceMock.providers()
.find((p: any) => p.provide === APP_INITIALIZER) as any
).useFactory;
// we don't care about the dependencies here
// @ts-ignore
const instance = new ConcreteInitServiceMock(null, null, null);
// provider ensures that the right concrete instance is passed to the factory
factory(instance);
expect(spy.resolveAppConfig).not.toHaveBeenCalled();
expect(spy.init).toHaveBeenCalled();
});
});
describe('common initialization steps', () => {
let correlationIdServiceSpy;
let dspaceTransferStateSpy;
let transferStateSpy;
let metadataServiceSpy;
let breadcrumbsServiceSpy;
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',
]);
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [
CommonModule,
StoreModule.forRoot(authReducer, storeModuleConfig),
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock
}
}),
],
providers: [
{ provide: InitService, useClass: ConcreteInitServiceMock },
{ 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: ThemeService, useValue: getMockThemeService() },
provideMockStore({ initialState }),
AppComponent,
RouteService,
]
});
}));
describe('initRouteListeners', () => {
it('should call listenForRouteChanges', inject([InitService], (service) => {
// @ts-ignore
service.initRouteListeners();
expect(metadataServiceSpy.listenForRouteChange).toHaveBeenCalledTimes(1);
expect(breadcrumbsServiceSpy.listenForRouteChanges).toHaveBeenCalledTimes(1);
}));
});
});
});

189
src/app/init.service.ts Normal file
View File

@@ -0,0 +1,189 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Store } from '@ngrx/store';
import { CheckAuthenticationTokenAction } from './core/auth/auth.actions';
import { CorrelationIdService } from './correlation-id/correlation-id.service';
import { APP_INITIALIZER, Inject, Provider, Type } from '@angular/core';
import { makeStateKey, TransferState } from '@angular/platform-browser';
import { APP_CONFIG, AppConfig } from '../config/app-config.interface';
import { environment } from '../environments/environment';
import { AppState } from './app.reducer';
import { isEqual } from 'lodash';
import { TranslateService } from '@ngx-translate/core';
import { LocaleService } from './core/locale/locale.service';
import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider';
import { MetadataService } from './core/metadata/metadata.service';
import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service';
import { ThemeService } from './shared/theme-support/theme.service';
/**
* Performs the initialization of the app.
*
* Should be extended to implement server- & browser-specific functionality.
* Initialization steps shared between the server and brower implementations
* can be included in this class.
*
* Note that the service cannot (indirectly) depend on injection tokens that are only available _after_ APP_INITIALIZER.
* For example, NgbModal depends on ApplicationRef and can therefore not be used during initialization.
*/
export abstract class InitService {
/**
* The state transfer key to use for the NgRx store state
* @protected
*/
protected static NGRX_STATE = makeStateKey('NGRX_STATE');
protected constructor(
protected store: Store<AppState>,
protected correlationIdService: CorrelationIdService,
@Inject(APP_CONFIG) protected appConfig: AppConfig,
protected translate: TranslateService,
protected localeService: LocaleService,
protected angulartics2DSpace: Angulartics2DSpace,
protected metadata: MetadataService,
protected breadcrumbsService: BreadcrumbsService,
protected themeService: ThemeService,
) {
}
/**
* The initialization providers to use in `*AppModule`
* - this concrete {@link InitService}
* - {@link APP_CONFIG} with optional pre-initialization hook
* - {@link APP_INITIALIZER}
* <br>
* Should only be called on concrete subclasses of InitService for the initialization hooks to work
*/
public static providers(): Provider[] {
if (!InitService.isPrototypeOf(this)) {
throw new Error(
'Initalization providers should only be generated from concrete subclasses of InitService'
);
}
return [
{
provide: InitService,
useClass: this as unknown as Type<InitService>,
},
{
provide: APP_CONFIG,
useFactory: (transferState: TransferState) => {
this.resolveAppConfig(transferState);
return environment;
},
deps: [ TransferState ]
},
{
provide: APP_INITIALIZER,
useFactory: (initService: InitService) => initService.init(),
deps: [ InitService ],
multi: true,
},
];
}
/**
* Optional pre-initialization method to ensure that {@link APP_CONFIG} is fully resolved before {@link init} is called.
*
* For example, Router depends on APP_BASE_HREF, which in turn depends on APP_CONFIG.
* In production mode, APP_CONFIG is resolved from the TransferState when the app is initialized.
* If we want to use Router within APP_INITIALIZER, we have to make sure APP_BASE_HREF is resolved beforehand.
* In this case that means that we must transfer the configuration from the SSR state during pre-initialization.
* @protected
*/
protected static resolveAppConfig(
transferState: TransferState
): void {
// overriden in subclasses if applicable
}
/**
* Main initialization method.
* @protected
*/
protected abstract init(): () => Promise<boolean>;
// Common initialization steps
/**
* Dispatch a {@link CheckAuthenticationTokenAction} to start off the chain of
* actions used to determine whether a user is already logged in.
* @protected
*/
protected checkAuthenticationToken(): void {
this.store.dispatch(new CheckAuthenticationTokenAction());
}
/**
* Initialize the correlation ID (from cookie, NgRx store or random)
* @protected
*/
protected initCorrelationId(): void {
this.correlationIdService.initCorrelationId();
}
/**
* Make sure the {@link environment} matches {@link APP_CONFIG} and print
* some information about it to the console
* @protected
*/
protected checkEnvironment(): void {
if (!isEqual(environment, this.appConfig)) {
throw new Error('environment does not match app config!');
}
if (environment.debug) {
console.info(environment);
}
const env: string = environment.production ? 'Production' : 'Development';
const color: string = environment.production ? 'red' : 'green';
console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`);
}
/**
* Initialize internationalization services
* - Specify the active languages
* - Set the current locale
* @protected
*/
protected initI18n(): void {
// Load all the languages that are defined as active from the config file
this.translate.addLangs(
environment.languages
.filter((LangConfig) => LangConfig.active === true)
.map((a) => a.code)
);
// Load the default language from the config file
// translate.setDefaultLang(environment.defaultLanguage);
this.localeService.setCurrentLanguageCode();
}
/**
* Initialize Angulartics
* @protected
*/
protected initAngulartics(): void {
this.angulartics2DSpace.startTracking();
}
/**
* Start route-listening subscriptions
* - {@link MetadataService.listenForRouteChange}
* - {@link BreadcrumbsService.listenForRouteChanges}
* - {@link ThemeService.listenForRouteChanges}
* @protected
*/
protected initRouteListeners(): void {
this.metadata.listenForRouteChange();
this.breadcrumbsService.listenForRouteChanges();
this.themeService.listenForRouteChanges();
}
}

View File

@@ -8,6 +8,7 @@ export function getMockThemeService(themeName = 'base', themes?: ThemeConfig[]):
getThemeName: themeName, getThemeName: themeName,
getThemeName$: observableOf(themeName), getThemeName$: observableOf(themeName),
getThemeConfigFor: undefined, getThemeConfigFor: undefined,
listenForRouteChanges: undefined,
}); });
if (isNotEmpty(themes)) { if (isNotEmpty(themes)) {

View File

@@ -2,7 +2,7 @@ import { of as observableOf } from 'rxjs';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing'; import { provideMockActions } from '@ngrx/effects/testing';
import { LinkService } from '../../core/cache/builders/link.service'; import { LinkService } from '../../core/cache/builders/link.service';
import { cold, hot } from 'jasmine-marbles'; import { hot } from 'jasmine-marbles';
import { SetThemeAction } from './theme.actions'; import { SetThemeAction } from './theme.actions';
import { Theme } from '../../../config/theme.model'; import { Theme } from '../../../config/theme.model';
import { provideMockStore } from '@ngrx/store/testing'; import { provideMockStore } from '@ngrx/store/testing';
@@ -21,7 +21,9 @@ import {
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { ThemeService } from './theme.service'; import { ThemeService } from './theme.service';
import { ROUTER_NAVIGATED } from '@ngrx/router-store'; import { ROUTER_NAVIGATED } from '@ngrx/router-store';
import { ActivatedRouteSnapshot } from '@angular/router'; import { ActivatedRouteSnapshot, Router } from '@angular/router';
import { CommonModule, DOCUMENT } from '@angular/common';
import { RouterMock } from '../mocks/router.mock';
/** /**
* LinkService able to mock recursively resolving DSO parent links * LinkService able to mock recursively resolving DSO parent links
@@ -84,12 +86,16 @@ describe('ThemeService', () => {
findById: () => createSuccessfulRemoteDataObject$(mockCommunity) findById: () => createSuccessfulRemoteDataObject$(mockCommunity)
}; };
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [
CommonModule,
],
providers: [ providers: [
ThemeService, ThemeService,
{ provide: LinkService, useValue: linkService }, { provide: LinkService, useValue: linkService },
provideMockStore({ initialState }), provideMockStore({ initialState }),
provideMockActions(() => mockActions), provideMockActions(() => mockActions),
{ provide: DSpaceObjectDataService, useValue: mockDsoService } { provide: DSpaceObjectDataService, useValue: mockDsoService },
{ provide: Router, useValue: new RouterMock() },
] ]
}); });
@@ -367,4 +373,49 @@ describe('ThemeService', () => {
}); });
}); });
}); });
describe('listenForThemeChanges', () => {
let document;
let headSpy;
beforeEach(() => {
const mockDsoService = {
findById: () => createSuccessfulRemoteDataObject$(mockCommunity)
};
TestBed.configureTestingModule({
imports: [
CommonModule,
],
providers: [
ThemeService,
{ provide: LinkService, useValue: linkService },
provideMockStore({ initialState }),
{ provide: DSpaceObjectDataService, useValue: mockDsoService },
{ provide: Router, useValue: new RouterMock() },
]
});
document = TestBed.inject(DOCUMENT);
headSpy = jasmine.createSpyObj('head', ['appendChild', 'getElementsByClassName']);
headSpy.getElementsByClassName.and.returnValue([]);
spyOn(document, 'getElementsByTagName').and.returnValue([headSpy]);
themeService = TestBed.inject(ThemeService);
spyOn(themeService, 'getThemeName').and.returnValue('custom');
spyOn(themeService, 'getThemeName$').and.returnValue(observableOf('custom'));
});
it('should append a link element with the correct attributes to the head element', () => {
themeService.listenForThemeChanges(true);
const link = document.createElement('link');
link.setAttribute('rel', 'stylesheet');
link.setAttribute('type', 'text/css');
link.setAttribute('class', 'theme-css');
link.setAttribute('href', '/custom-theme.css');
expect(headSpy.appendChild).toHaveBeenCalledWith(link);
});
});
}); });

View File

@@ -1,10 +1,10 @@
import { Injectable, Inject } from '@angular/core'; import { Inject, Injectable } from '@angular/core';
import { Store, createFeatureSelector, createSelector, select } from '@ngrx/store'; import { createFeatureSelector, createSelector, select, Store } from '@ngrx/store';
import { 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';
import { expand, filter, map, switchMap, take, toArray } from 'rxjs/operators'; import { expand, filter, map, switchMap, take, toArray } from 'rxjs/operators';
import { hasValue, isNotEmpty } from '../empty.util'; import { hasNoValue, hasValue, isNotEmpty } from '../empty.util';
import { RemoteData } from '../../core/data/remote-data'; import { RemoteData } from '../../core/data/remote-data';
import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { import {
@@ -12,14 +12,18 @@ import {
getFirstSucceededRemoteData, getFirstSucceededRemoteData,
getRemoteDataPayload getRemoteDataPayload
} from '../../core/shared/operators'; } from '../../core/shared/operators';
import { Theme, ThemeConfig, themeFactory } from '../../../config/theme.model'; import { HeadTagConfig, Theme, ThemeConfig, themeFactory } from '../../../config/theme.model';
import { NO_OP_ACTION_TYPE, NoOpAction } from '../ngrx/no-op.action'; import { NO_OP_ACTION_TYPE, NoOpAction } from '../ngrx/no-op.action';
import { followLink } from '../utils/follow-link-config.model'; import { followLink } from '../utils/follow-link-config.model';
import { LinkService } from '../../core/cache/builders/link.service'; import { LinkService } from '../../core/cache/builders/link.service';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { ActivatedRouteSnapshot } from '@angular/router'; import { ActivatedRouteSnapshot, ResolveEnd, Router } from '@angular/router';
import { GET_THEME_CONFIG_FOR_FACTORY } from '../object-collection/shared/listable-object/listable-object.decorator'; import { GET_THEME_CONFIG_FOR_FACTORY } from '../object-collection/shared/listable-object/listable-object.decorator';
import { distinctNext } from 'src/app/core/shared/distinct-next';
import { DOCUMENT } from '@angular/common';
import { getDefaultThemeConfig } from '../../../config/config.util';
import { BASE_THEME_NAME } from './theme.constants';
export const themeStateSelector = createFeatureSelector<ThemeState>('theme'); export const themeStateSelector = createFeatureSelector<ThemeState>('theme');
@@ -42,11 +46,16 @@ export class ThemeService {
*/ */
hasDynamicTheme: boolean; hasDynamicTheme: boolean;
private _isThemeLoading$ = new BehaviorSubject<boolean>(false);
private _isThemeCSSLoading$ = new BehaviorSubject<boolean>(false);
constructor( constructor(
private store: Store<ThemeState>, private store: Store<ThemeState>,
private linkService: LinkService, private linkService: LinkService,
private dSpaceObjectDataService: DSpaceObjectDataService, private dSpaceObjectDataService: DSpaceObjectDataService,
@Inject(GET_THEME_CONFIG_FOR_FACTORY) private gtcf: (str) => ThemeConfig @Inject(GET_THEME_CONFIG_FOR_FACTORY) private gtcf: (str) => ThemeConfig,
private router: Router,
@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));
@@ -57,10 +66,17 @@ export class ThemeService {
); );
} }
/**
* Set the current theme
* @param newName
*/
setTheme(newName: string) { setTheme(newName: string) {
this.store.dispatch(new SetThemeAction(newName)); this.store.dispatch(new SetThemeAction(newName));
} }
/**
* The name of the current theme (synchronous)
*/
getThemeName(): string { getThemeName(): string {
let currentTheme: string; let currentTheme: string;
this.store.pipe( this.store.pipe(
@@ -72,12 +88,205 @@ export class ThemeService {
return currentTheme; return currentTheme;
} }
/**
* The name of the current theme (asynchronous, tracks changes)
*/
getThemeName$(): Observable<string> { getThemeName$(): Observable<string> {
return this.store.pipe( return this.store.pipe(
select(currentThemeSelector) select(currentThemeSelector)
); );
} }
/**
* Whether the theme is currently loading
*/
get isThemeLoading$(): Observable<boolean> {
return this._isThemeLoading$;
}
/**
* Every time the theme is changed
* - if the theme name is valid, load it (CSS + <head> tags)
* - otherwise fall back to {@link getDefaultThemeConfig} or {@link BASE_THEME_NAME}
* Should be called when initializing the app.
* @param isBrowser
*/
listenForThemeChanges(isBrowser: boolean): void {
this.getThemeName$().subscribe((themeName: string) => {
if (isBrowser) {
// the theme css will never download server side, so this should only happen on the browser
distinctNext(this._isThemeCSSLoading$, true);
}
if (hasValue(themeName)) {
this.loadGlobalThemeConfig(themeName);
} else {
const defaultThemeConfig = getDefaultThemeConfig();
if (hasValue(defaultThemeConfig)) {
this.loadGlobalThemeConfig(defaultThemeConfig.name);
} else {
this.loadGlobalThemeConfig(BASE_THEME_NAME);
}
}
});
}
/**
* For every resolved route, check if it matches a dynamic theme. If it does, load that theme.
* Should be called when initializing the app.
*/
listenForRouteChanges(): void {
this.router.events.pipe(
filter(event => event instanceof ResolveEnd),
switchMap((event: ResolveEnd) => this.updateThemeOnRouteChange$(event.urlAfterRedirects, event.state.root)),
switchMap((changed) => {
if (changed) {
return this._isThemeCSSLoading$;
} else {
return [false];
}
})
).subscribe((changed) => {
distinctNext(this._isThemeLoading$, changed);
});
}
/**
* Load a theme's configuration
* - CSS
* - <head> tags
* @param themeName
* @private
*/
private loadGlobalThemeConfig(themeName: string): void {
this.setThemeCss(themeName);
this.setHeadTags(themeName);
}
/**
* Update the theme css file in <head>
*
* @param themeName The name of the new theme
* @private
*/
private setThemeCss(themeName: string): void {
const head = this.document.getElementsByTagName('head')[0];
if (hasNoValue(head)) {
return;
}
// Array.from to ensure we end up with an array, not an HTMLCollection, which would be
// automatically updated if we add nodes later
const currentThemeLinks = Array.from(head.getElementsByClassName('theme-css'));
const link = this.document.createElement('link');
link.setAttribute('rel', 'stylesheet');
link.setAttribute('type', 'text/css');
link.setAttribute('class', 'theme-css');
link.setAttribute('href', `/${encodeURIComponent(themeName)}-theme.css`);
// wait for the new css to download before removing the old one to prevent a
// flash of unstyled content
link.onload = () => {
if (isNotEmpty(currentThemeLinks)) {
currentThemeLinks.forEach((currentThemeLink: any) => {
if (hasValue(currentThemeLink)) {
currentThemeLink.remove();
}
});
}
// the fact that this callback is used, proves we're on the browser.
distinctNext(this._isThemeCSSLoading$, false);
};
head.appendChild(link);
}
/**
* Update the page to add a theme's <head> tags
* @param themeName the theme in question
* @private
*/
private setHeadTags(themeName: string): void {
const head = this.document.getElementsByTagName('head')[0];
if (hasNoValue(head)) {
return;
}
// clear head tags
const currentHeadTags = Array.from(head.getElementsByClassName('theme-head-tag'));
if (hasValue(currentHeadTags)) {
currentHeadTags.forEach((currentHeadTag: any) => currentHeadTag.remove());
}
// create new head tags (not yet added to DOM)
const headTagFragment = this.document.createDocumentFragment();
this.createHeadTags(themeName)
.forEach(newHeadTag => headTagFragment.appendChild(newHeadTag));
// add new head tags to DOM
head.appendChild(headTagFragment);
}
/**
* Create HTML elements for a theme's <head> tags
* (including those defined in the parent theme, if applicable)
* @param themeName the theme in question
* @private
*/
private createHeadTags(themeName: string): HTMLElement[] {
const themeConfig = this.getThemeConfigFor(themeName);
const headTagConfigs = themeConfig?.headTags;
if (hasNoValue(headTagConfigs)) {
const parentThemeName = themeConfig?.extends;
if (hasValue(parentThemeName)) {
// inherit the head tags of the parent theme
return this.createHeadTags(parentThemeName);
}
const defaultThemeConfig = getDefaultThemeConfig();
const defaultThemeName = defaultThemeConfig.name;
if (
hasNoValue(defaultThemeName) ||
themeName === defaultThemeName ||
themeName === BASE_THEME_NAME
) {
// last resort, use fallback favicon.ico
return [
this.createHeadTag({
'tagName': 'link',
'attributes': {
'rel': 'icon',
'href': 'assets/images/favicon.ico',
'sizes': 'any',
}
})
];
}
// inherit the head tags of the default theme
return this.createHeadTags(defaultThemeConfig.name);
}
return headTagConfigs.map(this.createHeadTag.bind(this));
}
/**
* Create a single <head> tag element
* @param headTagConfig the configuration for this <head> tag
* @private
*/
private createHeadTag(headTagConfig: HeadTagConfig): HTMLElement {
const tag = this.document.createElement(headTagConfig.tagName);
if (hasValue(headTagConfig.attributes)) {
Object.entries(headTagConfig.attributes)
.forEach(([key, value]) => tag.setAttribute(key, value));
}
// 'class' attribute should always be 'theme-head-tag' for removal
tag.setAttribute('class', 'theme-head-tag');
return tag;
}
/** /**
* Determine whether or not the theme needs to change depending on the current route's URL and snapshot data * Determine whether or not the theme needs to change depending on the current route's URL and snapshot data
* If the snapshot contains a dso, this will be used to match a theme * If the snapshot contains a dso, this will be used to match a theme

View File

@@ -40,6 +40,10 @@ interface AppConfig extends Config {
info: InfoConfig; info: InfoConfig;
} }
/**
* Injection token for the app configuration.
* Provided in {@link InitService.providers}.
*/
const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG'); const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG');
const APP_CONFIG_STATE = makeStateKey('APP_CONFIG_STATE'); const APP_CONFIG_STATE = makeStateKey('APP_CONFIG_STATE');

View File

@@ -1,6 +1,6 @@
import { HttpClient, HttpClientModule } from '@angular/common/http'; import { HttpClient, HttpClientModule } from '@angular/common/http';
import { APP_INITIALIZER, NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { BrowserModule, makeStateKey, TransferState } from '@angular/platform-browser'; import { BrowserModule, BrowserTransferStateModule, makeStateKey, TransferState } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { REQUEST } from '@nguniversal/express-engine/tokens'; import { REQUEST } from '@nguniversal/express-engine/tokens';
@@ -12,8 +12,6 @@ import { IdlePreloadModule } from 'angular-idle-preload';
import { AppComponent } from '../../app/app.component'; import { AppComponent } from '../../app/app.component';
import { AppModule } from '../../app/app.module'; import { AppModule } from '../../app/app.module';
import { DSpaceBrowserTransferStateModule } from '../transfer-state/dspace-browser-transfer-state.module';
import { DSpaceTransferState } from '../transfer-state/dspace-transfer-state.service';
import { ClientCookieService } from '../../app/core/services/client-cookie.service'; import { ClientCookieService } from '../../app/core/services/client-cookie.service';
import { CookieService } from '../../app/core/services/cookie.service'; import { CookieService } from '../../app/core/services/cookie.service';
import { AuthService } from '../../app/core/auth/auth.service'; import { AuthService } from '../../app/core/auth/auth.service';
@@ -23,21 +21,12 @@ import { StatisticsModule } from '../../app/statistics/statistics.module';
import { BrowserKlaroService } from '../../app/shared/cookies/browser-klaro.service'; import { BrowserKlaroService } from '../../app/shared/cookies/browser-klaro.service';
import { KlaroService } from '../../app/shared/cookies/klaro.service'; import { KlaroService } from '../../app/shared/cookies/klaro.service';
import { HardRedirectService } from '../../app/core/services/hard-redirect.service'; import { HardRedirectService } from '../../app/core/services/hard-redirect.service';
import { import { BrowserHardRedirectService, locationProvider, LocationToken } from '../../app/core/services/browser-hard-redirect.service';
BrowserHardRedirectService,
locationProvider,
LocationToken
} from '../../app/core/services/browser-hard-redirect.service';
import { LocaleService } from '../../app/core/locale/locale.service'; import { LocaleService } from '../../app/core/locale/locale.service';
import { GoogleAnalyticsService } from '../../app/statistics/google-analytics.service'; import { GoogleAnalyticsService } from '../../app/statistics/google-analytics.service';
import { AuthRequestService } from '../../app/core/auth/auth-request.service'; import { AuthRequestService } from '../../app/core/auth/auth-request.service';
import { BrowserAuthRequestService } from '../../app/core/auth/browser-auth-request.service'; import { BrowserAuthRequestService } from '../../app/core/auth/browser-auth-request.service';
import { AppConfig, APP_CONFIG_STATE } from '../../config/app-config.interface'; import { BrowserInitService } from './browser-init.service';
import { DefaultAppConfig } from '../../config/default-app-config';
import { extendEnvironmentWithAppConfig } from '../../config/config.util';
import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service';
import { environment } from '../../environments/environment';
export const REQ_KEY = makeStateKey<string>('req'); export const REQ_KEY = makeStateKey<string>('req');
@@ -61,7 +50,7 @@ export function getRequest(transferState: TransferState): any {
StatisticsModule.forRoot(), StatisticsModule.forRoot(),
Angulartics2RouterlessModule.forRoot(), Angulartics2RouterlessModule.forRoot(),
BrowserAnimationsModule, BrowserAnimationsModule,
DSpaceBrowserTransferStateModule, BrowserTransferStateModule,
TranslateModule.forRoot({ TranslateModule.forRoot({
loader: { loader: {
provide: TranslateLoader, provide: TranslateLoader,
@@ -72,27 +61,7 @@ export function getRequest(transferState: TransferState): any {
AppModule AppModule
], ],
providers: [ providers: [
{ ...BrowserInitService.providers(),
provide: APP_INITIALIZER,
useFactory: (
transferState: TransferState,
dspaceTransferState: DSpaceTransferState,
correlationIdService: CorrelationIdService
) => {
if (transferState.hasKey<AppConfig>(APP_CONFIG_STATE)) {
const appConfig = transferState.get<AppConfig>(APP_CONFIG_STATE, new DefaultAppConfig());
// extend environment with app config for browser
extendEnvironmentWithAppConfig(environment, appConfig);
}
return () =>
dspaceTransferState.transfer().then((b: boolean) => {
correlationIdService.initCorrelationId();
return b;
});
},
deps: [TransferState, DSpaceTransferState, CorrelationIdService],
multi: true
},
{ {
provide: REQUEST, provide: REQUEST,
useFactory: getRequest, useFactory: getRequest,

View File

@@ -0,0 +1,155 @@
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

@@ -0,0 +1,136 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { InitService } from '../../app/init.service';
import { select, Store } from '@ngrx/store';
import { AppState } from '../../app/app.reducer';
import { TransferState } from '@angular/platform-browser';
import { APP_CONFIG, APP_CONFIG_STATE, AppConfig } from '../../config/app-config.interface';
import { DefaultAppConfig } from '../../config/default-app-config';
import { extendEnvironmentWithAppConfig } from '../../config/config.util';
import { environment } from '../../environments/environment';
import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service';
import { Inject, Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { LocaleService } from '../../app/core/locale/locale.service';
import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider';
import { GoogleAnalyticsService } from '../../app/statistics/google-analytics.service';
import { MetadataService } from '../../app/core/metadata/metadata.service';
import { BreadcrumbsService } from '../../app/breadcrumbs/breadcrumbs.service';
import { KlaroService } from '../../app/shared/cookies/klaro.service';
import { AuthService } from '../../app/core/auth/auth.service';
import { ThemeService } from '../../app/shared/theme-support/theme.service';
import { StoreAction, StoreActionTypes } from '../../app/store.actions';
import { coreSelector } from '../../app/core/core.selectors';
import { distinctUntilChanged, filter, find, map, take } from 'rxjs/operators';
import { isNotEmpty } from '../../app/shared/empty.util';
import { isAuthenticationBlocking } from '../../app/core/auth/selectors';
/**
* Performs client-side initialization.
*/
@Injectable()
export class BrowserInitService extends InitService {
constructor(
protected store: Store<AppState>,
protected correlationIdService: CorrelationIdService,
protected transferState: TransferState,
@Inject(APP_CONFIG) protected appConfig: AppConfig,
protected translate: TranslateService,
protected localeService: LocaleService,
protected angulartics2DSpace: Angulartics2DSpace,
protected googleAnalyticsService: GoogleAnalyticsService,
protected metadata: MetadataService,
protected breadcrumbsService: BreadcrumbsService,
protected klaroService: KlaroService,
protected authService: AuthService,
protected themeService: ThemeService,
) {
super(
store,
correlationIdService,
appConfig,
translate,
localeService,
angulartics2DSpace,
metadata,
breadcrumbsService,
themeService,
);
}
protected static resolveAppConfig(
transferState: TransferState,
) {
if (transferState.hasKey<AppConfig>(APP_CONFIG_STATE)) {
const appConfig = transferState.get<AppConfig>(APP_CONFIG_STATE, new DefaultAppConfig());
// extend environment with app config for browser
extendEnvironmentWithAppConfig(environment, appConfig);
}
}
protected init(): () => Promise<boolean> {
return async () => {
await this.loadAppState();
this.checkAuthenticationToken();
this.initCorrelationId();
this.checkEnvironment();
this.initI18n();
this.initAngulartics();
this.initGoogleAnalytics();
this.initRouteListeners();
this.themeService.listenForThemeChanges(true);
this.trackAuthTokenExpiration();
this.initKlaro();
return true;
};
}
// Browser-only initialization steps
/**
* Retrieve server-side application state from the {@link NGRX_STATE} key and rehydrate the store.
* Resolves once the store is no longer empty.
* @private
*/
private async loadAppState(): Promise<boolean> {
const state = this.transferState.get<any>(InitService.NGRX_STATE, null);
this.transferState.remove(InitService.NGRX_STATE);
this.store.dispatch(new StoreAction(StoreActionTypes.REHYDRATE, state));
return this.store.select(coreSelector).pipe(
find((core: any) => isNotEmpty(core)),
map(() => true)
).toPromise();
}
private trackAuthTokenExpiration(): void {
this.authService.trackTokenExpiration();
}
/**
* Initialize Klaro
* @protected
*/
protected initKlaro() {
this.store.pipe(
select(isAuthenticationBlocking),
distinctUntilChanged(),
filter((isBlocking: boolean) => isBlocking === false),
take(1)
).subscribe(() => {
this.klaroService.initialize();
});
}
protected initGoogleAnalytics() {
this.googleAnalyticsService.addTrackingIdToPage();
}
}

View File

@@ -1,8 +1,8 @@
import { HTTP_INTERCEPTORS } from '@angular/common/http'; import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { APP_INITIALIZER, NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { BrowserModule, TransferState } from '@angular/platform-browser'; import { BrowserModule, TransferState } from '@angular/platform-browser';
import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ServerModule } from '@angular/platform-server'; import { ServerModule, ServerTransferStateModule } from '@angular/platform-server';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
@@ -12,8 +12,6 @@ import { Angulartics2GoogleAnalytics } from 'angulartics2';
import { AppComponent } from '../../app/app.component'; import { AppComponent } from '../../app/app.component';
import { AppModule } from '../../app/app.module'; import { AppModule } from '../../app/app.module';
import { DSpaceServerTransferStateModule } from '../transfer-state/dspace-server-transfer-state.module';
import { DSpaceTransferState } from '../transfer-state/dspace-transfer-state.service';
import { TranslateServerLoader } from '../../ngx-translate-loaders/translate-server.loader'; import { TranslateServerLoader } from '../../ngx-translate-loaders/translate-server.loader';
import { CookieService } from '../../app/core/services/cookie.service'; import { CookieService } from '../../app/core/services/cookie.service';
import { ServerCookieService } from '../../app/core/services/server-cookie.service'; import { ServerCookieService } from '../../app/core/services/server-cookie.service';
@@ -31,10 +29,7 @@ import { ServerHardRedirectService } from '../../app/core/services/server-hard-r
import { Angulartics2Mock } from '../../app/shared/mocks/angulartics2.service.mock'; import { Angulartics2Mock } from '../../app/shared/mocks/angulartics2.service.mock';
import { AuthRequestService } from '../../app/core/auth/auth-request.service'; import { AuthRequestService } from '../../app/core/auth/auth-request.service';
import { ServerAuthRequestService } from '../../app/core/auth/server-auth-request.service'; import { ServerAuthRequestService } from '../../app/core/auth/server-auth-request.service';
import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service'; import { ServerInitService } from './server-init.service';
import { AppConfig, APP_CONFIG_STATE } from '../../config/app-config.interface';
import { environment } from '../../environments/environment';
export function createTranslateLoader(transferState: TransferState) { export function createTranslateLoader(transferState: TransferState) {
return new TranslateServerLoader(transferState, 'dist/server/assets/i18n/', '.json5'); return new TranslateServerLoader(transferState, 'dist/server/assets/i18n/', '.json5');
@@ -47,7 +42,7 @@ export function createTranslateLoader(transferState: TransferState) {
appId: 'dspace-angular' appId: 'dspace-angular'
}), }),
NoopAnimationsModule, NoopAnimationsModule,
DSpaceServerTransferStateModule, ServerTransferStateModule,
TranslateModule.forRoot({ TranslateModule.forRoot({
loader: { loader: {
provide: TranslateLoader, provide: TranslateLoader,
@@ -59,22 +54,7 @@ export function createTranslateLoader(transferState: TransferState) {
ServerModule, ServerModule,
], ],
providers: [ providers: [
// Initialize app config and extend environment ...ServerInitService.providers(),
{
provide: APP_INITIALIZER,
useFactory: (
transferState: TransferState,
dspaceTransferState: DSpaceTransferState,
correlationIdService: CorrelationIdService,
) => {
transferState.set<AppConfig>(APP_CONFIG_STATE, environment as AppConfig);
dspaceTransferState.transfer();
correlationIdService.initCorrelationId();
return () => true;
},
deps: [TransferState, DSpaceTransferState, CorrelationIdService],
multi: true
},
{ {
provide: Angulartics2, provide: Angulartics2,
useClass: Angulartics2Mock useClass: Angulartics2Mock

View File

@@ -0,0 +1,93 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { InitService } from '../../app/init.service';
import { Store } from '@ngrx/store';
import { AppState } from '../../app/app.reducer';
import { TransferState } from '@angular/platform-browser';
import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service';
import { APP_CONFIG, APP_CONFIG_STATE, AppConfig } from '../../config/app-config.interface';
import { environment } from '../../environments/environment';
import { Inject, Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { LocaleService } from '../../app/core/locale/locale.service';
import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider';
import { MetadataService } from '../../app/core/metadata/metadata.service';
import { BreadcrumbsService } from '../../app/breadcrumbs/breadcrumbs.service';
import { CSSVariableService } from '../../app/shared/sass-helper/sass-helper.service';
import { ThemeService } from '../../app/shared/theme-support/theme.service';
import { take } from 'rxjs/operators';
/**
* Performs server-side initialization.
*/
@Injectable()
export class ServerInitService extends InitService {
constructor(
protected store: Store<AppState>,
protected correlationIdService: CorrelationIdService,
protected transferState: TransferState,
@Inject(APP_CONFIG) protected appConfig: AppConfig,
protected translate: TranslateService,
protected localeService: LocaleService,
protected angulartics2DSpace: Angulartics2DSpace,
protected metadata: MetadataService,
protected breadcrumbsService: BreadcrumbsService,
protected cssService: CSSVariableService,
protected themeService: ThemeService,
) {
super(
store,
correlationIdService,
appConfig,
translate,
localeService,
angulartics2DSpace,
metadata,
breadcrumbsService,
themeService,
);
}
protected init(): () => Promise<boolean> {
return async () => {
this.checkAuthenticationToken();
this.saveAppConfigForCSR();
this.saveAppState();
this.initCorrelationId();
this.checkEnvironment();
this.initI18n();
this.initAngulartics();
this.initRouteListeners();
this.themeService.listenForThemeChanges(false);
return true;
};
}
// Server-only initialization steps
/**
* Set the {@link NGRX_STATE} key when state is serialized to be transfered
* @private
*/
private saveAppState() {
this.transferState.onSerialize(InitService.NGRX_STATE, () => {
let state;
this.store.pipe(take(1)).subscribe((saveState: any) => {
state = saveState;
});
return state;
});
}
private saveAppConfigForCSR(): void {
this.transferState.set<AppConfig>(APP_CONFIG_STATE, environment as AppConfig);
}
}

View File

@@ -1,16 +0,0 @@
import { NgModule } from '@angular/core';
import { BrowserTransferStateModule } from '@angular/platform-browser';
import { DSpaceBrowserTransferState } from './dspace-browser-transfer-state.service';
import { DSpaceTransferState } from './dspace-transfer-state.service';
@NgModule({
imports: [
BrowserTransferStateModule
],
providers: [
{ provide: DSpaceTransferState, useClass: DSpaceBrowserTransferState }
]
})
export class DSpaceBrowserTransferStateModule {
}

View File

@@ -1,19 +0,0 @@
import { Injectable } from '@angular/core';
import { coreSelector } from 'src/app/core/core.selectors';
import { StoreAction, StoreActionTypes } from '../../app/store.actions';
import { DSpaceTransferState } from './dspace-transfer-state.service';
import { find, map } from 'rxjs/operators';
import { isNotEmpty } from '../../app/shared/empty.util';
@Injectable()
export class DSpaceBrowserTransferState extends DSpaceTransferState {
transfer(): Promise<boolean> {
const state = this.transferState.get<any>(DSpaceTransferState.NGRX_STATE, null);
this.transferState.remove(DSpaceTransferState.NGRX_STATE);
this.store.dispatch(new StoreAction(StoreActionTypes.REHYDRATE, state));
return this.store.select(coreSelector).pipe(
find((core: any) => isNotEmpty(core)),
map(() => true)
).toPromise();
}
}

View File

@@ -1,16 +0,0 @@
import { NgModule } from '@angular/core';
import { ServerTransferStateModule } from '@angular/platform-server';
import { DSpaceServerTransferState } from './dspace-server-transfer-state.service';
import { DSpaceTransferState } from './dspace-transfer-state.service';
@NgModule({
imports: [
ServerTransferStateModule
],
providers: [
{ provide: DSpaceTransferState, useClass: DSpaceServerTransferState }
]
})
export class DSpaceServerTransferStateModule {
}

View File

@@ -1,20 +0,0 @@
import {take} from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { DSpaceTransferState } from './dspace-transfer-state.service';
@Injectable()
export class DSpaceServerTransferState extends DSpaceTransferState {
transfer(): Promise<boolean> {
this.transferState.onSerialize(DSpaceTransferState.NGRX_STATE, () => {
let state;
this.store.pipe(take(1)).subscribe((saveState: any) => {
state = saveState;
});
return state;
});
return new Promise<boolean>(() => true);
}
}

View File

@@ -1,18 +0,0 @@
import { Injectable } from '@angular/core';
import { makeStateKey, TransferState } from '@angular/platform-browser';
import { Store } from '@ngrx/store';
import { AppState } from '../../app/app.reducer';
@Injectable()
export abstract class DSpaceTransferState {
protected static NGRX_STATE = makeStateKey('NGRX_STATE');
constructor(
protected transferState: TransferState,
protected store: Store<AppState>
) {
}
abstract transfer(): Promise<boolean>;
}