mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 01:54:15 +00:00
Merge branch 'main-gh4s' into CST-6782-refactor
# Conflicts: # src/app/app.component.ts
This commit is contained in:
@@ -32,7 +32,6 @@ import { storeModuleConfig } from './app.reducer';
|
||||
import { LocaleService } from './core/locale/locale.service';
|
||||
import { authReducer } from './core/auth/auth.reducer';
|
||||
import { provideMockStore } from '@ngrx/store/testing';
|
||||
import { GoogleAnalyticsService } from './statistics/google-analytics.service';
|
||||
import { ThemeService } from './shared/theme-support/theme.service';
|
||||
import { getMockThemeService } from './shared/mocks/theme-service.mock';
|
||||
import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service';
|
||||
@@ -46,15 +45,15 @@ const initialState = {
|
||||
core: { auth: { loading: false } }
|
||||
};
|
||||
|
||||
describe('App component', () => {
|
||||
|
||||
let breadcrumbsServiceSpy;
|
||||
|
||||
function getMockLocaleService(): LocaleService {
|
||||
export function getMockLocaleService(): LocaleService {
|
||||
return jasmine.createSpyObj('LocaleService', {
|
||||
setCurrentLanguageCode: jasmine.createSpy('setCurrentLanguageCode')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('App component', () => {
|
||||
|
||||
let breadcrumbsServiceSpy;
|
||||
|
||||
const getDefaultTestBedConf = () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -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 {
|
||||
AfterViewInit,
|
||||
@@ -7,49 +7,31 @@ import {
|
||||
HostListener,
|
||||
Inject,
|
||||
OnInit,
|
||||
Optional,
|
||||
PLATFORM_ID,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
ActivationEnd,
|
||||
NavigationCancel,
|
||||
NavigationEnd,
|
||||
NavigationStart,
|
||||
ResolveEnd,
|
||||
Router,
|
||||
} from '@angular/router';
|
||||
|
||||
import { isEqual } from 'lodash';
|
||||
import { BehaviorSubject, Observable, of } from 'rxjs';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import { select, Store } from '@ngrx/store';
|
||||
import { NgbModal, NgbModalConfig } from '@ng-bootstrap/ng-bootstrap';
|
||||
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 { HostWindowState } from './shared/search/host-window.reducer';
|
||||
import { NativeWindowRef, NativeWindowService } from './core/services/window.service';
|
||||
import { isAuthenticationBlocking } from './core/auth/selectors';
|
||||
import { AuthService } from './core/auth/auth.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 { 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 { 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 { getDefaultThemeConfig } from '../config/config.util';
|
||||
import { APP_CONFIG, AppConfig } from '../config/app-config.interface';
|
||||
import { distinctNext } from './core/shared/distinct-next';
|
||||
import { ModalBeforeDismiss } from './shared/interfaces/modal-before-dismiss.interface';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-app',
|
||||
@@ -58,11 +40,6 @@ import { APP_CONFIG, AppConfig } from '../config/app-config.interface';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
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;
|
||||
models;
|
||||
|
||||
@@ -79,9 +56,7 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||
/**
|
||||
* Whether or not the theme is in the process of being swapped
|
||||
*/
|
||||
isThemeLoading$: BehaviorSubject<boolean> = new BehaviorSubject(false);
|
||||
|
||||
isThemeCSSLoading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
||||
isThemeLoading$: Observable<boolean>;
|
||||
|
||||
/**
|
||||
* 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(DOCUMENT) private document: any,
|
||||
@Inject(PLATFORM_ID) private platformId: any,
|
||||
@Inject(APP_CONFIG) private appConfig: AppConfig,
|
||||
private themeService: ThemeService,
|
||||
private translate: TranslateService,
|
||||
private store: Store<HostWindowState>,
|
||||
private metadata: MetadataService,
|
||||
private angulartics2GoogleAnalytics: Angulartics2GoogleAnalytics,
|
||||
private angulartics2DSpace: Angulartics2DSpace,
|
||||
private authService: AuthService,
|
||||
private router: Router,
|
||||
private cssService: CSSVariableService,
|
||||
private menuService: MenuService,
|
||||
private windowService: HostWindowService,
|
||||
private localeService: LocaleService,
|
||||
private breadcrumbsService: BreadcrumbsService,
|
||||
private modalService: NgbModal,
|
||||
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;
|
||||
|
||||
/* Use models object so all decorators are actually called */
|
||||
this.models = models;
|
||||
|
||||
this.themeService.getThemeName$().subscribe((themeName: string) => {
|
||||
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();
|
||||
}
|
||||
|
||||
// Load all the languages that are defined as active from the config file
|
||||
translate.addLangs(environment.languages.filter((LangConfig) => LangConfig.active === true).map((a) => a.code));
|
||||
this.isThemeLoading$ = this.themeService.isThemeLoading$;
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
@@ -178,18 +101,11 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||
return true;
|
||||
};
|
||||
|
||||
this.isAuthBlocking$ = this.store.pipe(select(isAuthenticationBlocking)).pipe(
|
||||
this.isAuthBlocking$ = this.store.pipe(
|
||||
select(isAuthenticationBlocking),
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -209,54 +125,15 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
let updatingTheme = false;
|
||||
let snapshot: ActivatedRouteSnapshot;
|
||||
|
||||
this.router.events.subscribe((event) => {
|
||||
if (event instanceof NavigationStart) {
|
||||
updatingTheme = false;
|
||||
this.distinctNext(this.isRouteLoading$, true);
|
||||
} else if (event instanceof ResolveEnd) {
|
||||
// this is the earliest point where we have all the information we need
|
||||
// to update the theme, but this event is not emitted on first load
|
||||
this.updateTheme(event.urlAfterRedirects, event.state.root);
|
||||
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;
|
||||
distinctNext(this.isRouteLoading$, true);
|
||||
} else if (
|
||||
event instanceof NavigationEnd ||
|
||||
event instanceof NavigationCancel
|
||||
) {
|
||||
distinctNext(this.isRouteLoading$, false);
|
||||
}
|
||||
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() {
|
||||
const isIdle$ = this.authService.isUserIdle();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,18 +1,14 @@
|
||||
import { APP_BASE_HREF, CommonModule, DOCUMENT } from '@angular/common';
|
||||
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 { BrowserModule } from '@angular/platform-browser';
|
||||
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { EffectsModule } from '@ngrx/effects';
|
||||
import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store';
|
||||
import { MetaReducer, Store, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store';
|
||||
import {
|
||||
DYNAMIC_ERROR_MESSAGES_MATCHER,
|
||||
DYNAMIC_MATCHER_PROVIDERS,
|
||||
DynamicErrorMessagesMatcher
|
||||
} from '@ng-dynamic-forms/core';
|
||||
import { MetaReducer, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store';
|
||||
import { DYNAMIC_ERROR_MESSAGES_MATCHER, DYNAMIC_MATCHER_PROVIDERS, DynamicErrorMessagesMatcher } from '@ng-dynamic-forms/core';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to';
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
@@ -20,7 +16,6 @@ import { AppComponent } from './app.component';
|
||||
import { appEffects } from './app.effects';
|
||||
import { appMetaReducers, debugMetaReducers } from './app.metareducers';
|
||||
import { appReducers, AppState, storeModuleConfig } from './app.reducer';
|
||||
import { CheckAuthenticationTokenAction } from './core/auth/auth.actions';
|
||||
import { CoreModule } from './core/core.module';
|
||||
import { ClientCookieService } from './core/services/client-cookie.service';
|
||||
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 { LogInterceptor } from './core/log/log.interceptor';
|
||||
import { EagerThemesModule } from '../themes/eager-themes.module';
|
||||
|
||||
import { APP_CONFIG, AppConfig } from '../config/app-config.interface';
|
||||
import { NgxMaskModule } from 'ngx-mask';
|
||||
import { StoreDevModules } from '../config/store/devtools';
|
||||
@@ -80,10 +74,6 @@ const IMPORTS = [
|
||||
];
|
||||
|
||||
const PROVIDERS = [
|
||||
{
|
||||
provide: APP_CONFIG,
|
||||
useFactory: getConfig
|
||||
},
|
||||
{
|
||||
provide: APP_BASE_HREF,
|
||||
useFactory: getBaseHref,
|
||||
@@ -99,15 +89,6 @@ const PROVIDERS = [
|
||||
useClass: DSpaceRouterStateSerializer
|
||||
},
|
||||
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
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
|
@@ -4,7 +4,7 @@ import { HttpHeaders } from '@angular/common/http';
|
||||
import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens';
|
||||
|
||||
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 { CookieAttributes } from 'js-cookie';
|
||||
|
||||
@@ -93,6 +93,8 @@ export class AuthService {
|
||||
private translateService: TranslateService
|
||||
) {
|
||||
this.store.pipe(
|
||||
// when this service is constructed the store is not fully initialized yet
|
||||
filter((state: any) => state?.core?.auth !== undefined),
|
||||
select(isAuthenticated),
|
||||
startWith(false)
|
||||
).subscribe((authenticated: boolean) => this._authenticated = authenticated);
|
||||
@@ -346,7 +348,7 @@ export class AuthService {
|
||||
let token: AuthTokenInfo;
|
||||
let currentlyRefreshingToken = false;
|
||||
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) {
|
||||
// Token refresh failed => Error notification => 10 second wait => Page reloads & user logged out
|
||||
this.notificationService.error(this.translateService.get('auth.messages.token-refresh-failed'));
|
||||
|
22
src/app/core/shared/distinct-next.ts
Normal file
22
src/app/core/shared/distinct-next.ts
Normal 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);
|
||||
}
|
||||
}
|
187
src/app/init.service.spec.ts
Normal file
187
src/app/init.service.spec.ts
Normal 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
189
src/app/init.service.ts
Normal 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();
|
||||
}
|
||||
}
|
@@ -16,24 +16,24 @@ import { filter, find, map, take } from 'rxjs/operators';
|
||||
import { hasValue } from './shared/empty.util';
|
||||
import { FeatureID } from './core/data/feature-authorization/feature-id';
|
||||
import {
|
||||
CreateCommunityParentSelectorComponent
|
||||
} from './shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component';
|
||||
ThemedCreateCommunityParentSelectorComponent
|
||||
} from './shared/dso-selector/modal-wrappers/create-community-parent-selector/themed-create-community-parent-selector.component';
|
||||
import { OnClickMenuItemModel } from './shared/menu/menu-item/models/onclick.model';
|
||||
import {
|
||||
CreateCollectionParentSelectorComponent
|
||||
} from './shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component';
|
||||
ThemedCreateCollectionParentSelectorComponent
|
||||
} from './shared/dso-selector/modal-wrappers/create-collection-parent-selector/themed-create-collection-parent-selector.component';
|
||||
import {
|
||||
CreateItemParentSelectorComponent
|
||||
} from './shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component';
|
||||
ThemedCreateItemParentSelectorComponent
|
||||
} from './shared/dso-selector/modal-wrappers/create-item-parent-selector/themed-create-item-parent-selector.component';
|
||||
import {
|
||||
EditCommunitySelectorComponent
|
||||
} from './shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component';
|
||||
ThemedEditCommunitySelectorComponent
|
||||
} from './shared/dso-selector/modal-wrappers/edit-community-selector/themed-edit-community-selector.component';
|
||||
import {
|
||||
EditCollectionSelectorComponent
|
||||
} from './shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component';
|
||||
ThemedEditCollectionSelectorComponent
|
||||
} from './shared/dso-selector/modal-wrappers/edit-collection-selector/themed-edit-collection-selector.component';
|
||||
import {
|
||||
EditItemSelectorComponent
|
||||
} from './shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component';
|
||||
ThemedEditItemSelectorComponent
|
||||
} from './shared/dso-selector/modal-wrappers/edit-item-selector/themed-edit-item-selector.component';
|
||||
import {
|
||||
ExportMetadataSelectorComponent
|
||||
} from './shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component';
|
||||
@@ -188,7 +188,7 @@ export class MenuResolver implements Resolve<boolean> {
|
||||
type: MenuItemType.ONCLICK,
|
||||
text: 'menu.section.new_community',
|
||||
function: () => {
|
||||
this.modalService.open(CreateCommunityParentSelectorComponent);
|
||||
this.modalService.open(ThemedCreateCommunityParentSelectorComponent);
|
||||
}
|
||||
} as OnClickMenuItemModel,
|
||||
},
|
||||
@@ -201,7 +201,7 @@ export class MenuResolver implements Resolve<boolean> {
|
||||
type: MenuItemType.ONCLICK,
|
||||
text: 'menu.section.new_collection',
|
||||
function: () => {
|
||||
this.modalService.open(CreateCollectionParentSelectorComponent);
|
||||
this.modalService.open(ThemedCreateCollectionParentSelectorComponent);
|
||||
}
|
||||
} as OnClickMenuItemModel,
|
||||
},
|
||||
@@ -214,7 +214,7 @@ export class MenuResolver implements Resolve<boolean> {
|
||||
type: MenuItemType.ONCLICK,
|
||||
text: 'menu.section.new_item',
|
||||
function: () => {
|
||||
this.modalService.open(CreateItemParentSelectorComponent);
|
||||
this.modalService.open(ThemedCreateItemParentSelectorComponent);
|
||||
}
|
||||
} as OnClickMenuItemModel,
|
||||
},
|
||||
@@ -263,7 +263,7 @@ export class MenuResolver implements Resolve<boolean> {
|
||||
type: MenuItemType.ONCLICK,
|
||||
text: 'menu.section.edit_community',
|
||||
function: () => {
|
||||
this.modalService.open(EditCommunitySelectorComponent);
|
||||
this.modalService.open(ThemedEditCommunitySelectorComponent);
|
||||
}
|
||||
} as OnClickMenuItemModel,
|
||||
},
|
||||
@@ -276,7 +276,7 @@ export class MenuResolver implements Resolve<boolean> {
|
||||
type: MenuItemType.ONCLICK,
|
||||
text: 'menu.section.edit_collection',
|
||||
function: () => {
|
||||
this.modalService.open(EditCollectionSelectorComponent);
|
||||
this.modalService.open(ThemedEditCollectionSelectorComponent);
|
||||
}
|
||||
} as OnClickMenuItemModel,
|
||||
},
|
||||
@@ -289,7 +289,7 @@ export class MenuResolver implements Resolve<boolean> {
|
||||
type: MenuItemType.ONCLICK,
|
||||
text: 'menu.section.edit_item',
|
||||
function: () => {
|
||||
this.modalService.open(EditItemSelectorComponent);
|
||||
this.modalService.open(ThemedEditItemSelectorComponent);
|
||||
}
|
||||
} as OnClickMenuItemModel,
|
||||
},
|
||||
|
@@ -0,0 +1,28 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {CreateCollectionParentSelectorComponent} from './create-collection-parent-selector.component';
|
||||
import {ThemedComponent} from 'src/app/shared/theme-support/themed.component';
|
||||
|
||||
/**
|
||||
* Themed wrapper for CreateCollectionParentSelectorComponent
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-themed-create-collection-parent-selector',
|
||||
styleUrls: [],
|
||||
templateUrl: '../../../theme-support/themed.component.html'
|
||||
})
|
||||
export class ThemedCreateCollectionParentSelectorComponent
|
||||
extends ThemedComponent<CreateCollectionParentSelectorComponent> {
|
||||
|
||||
protected getComponentName(): string {
|
||||
return 'CreateCollectionParentSelectorComponent';
|
||||
}
|
||||
|
||||
protected importThemedComponent(themeName: string): Promise<any> {
|
||||
return import(`../../../../../themes/${themeName}/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component`);
|
||||
}
|
||||
|
||||
protected importUnthemedComponent(): Promise<any> {
|
||||
return import('./create-collection-parent-selector.component');
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,27 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {CreateCommunityParentSelectorComponent} from './create-community-parent-selector.component';
|
||||
import {ThemedComponent} from 'src/app/shared/theme-support/themed.component';
|
||||
|
||||
/**
|
||||
* Themed wrapper for CreateCommunityParentSelectorComponent
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-themed-create-community-parent-selector',
|
||||
styleUrls: [],
|
||||
templateUrl: '../../../theme-support/themed.component.html'
|
||||
})
|
||||
export class ThemedCreateCommunityParentSelectorComponent
|
||||
extends ThemedComponent<CreateCommunityParentSelectorComponent> {
|
||||
protected getComponentName(): string {
|
||||
return 'CreateCommunityParentSelectorComponent';
|
||||
}
|
||||
|
||||
protected importThemedComponent(themeName: string): Promise<any> {
|
||||
return import(`../../../../../themes/${themeName}/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component`);
|
||||
}
|
||||
|
||||
protected importUnthemedComponent(): Promise<any> {
|
||||
return import('./create-community-parent-selector.component');
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,31 @@
|
||||
import {Component, Input} from '@angular/core';
|
||||
import {CreateItemParentSelectorComponent} from './create-item-parent-selector.component';
|
||||
import {ThemedComponent} from 'src/app/shared/theme-support/themed.component';
|
||||
|
||||
/**
|
||||
* Themed wrapper for CreateItemParentSelectorComponent
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-themed-create-item-parent-selector',
|
||||
styleUrls: [],
|
||||
templateUrl: '../../../theme-support/themed.component.html'
|
||||
})
|
||||
export class ThemedCreateItemParentSelectorComponent
|
||||
extends ThemedComponent<CreateItemParentSelectorComponent> {
|
||||
@Input() entityType: string;
|
||||
|
||||
protected inAndOutputNames: (keyof CreateItemParentSelectorComponent & keyof this)[] = ['entityType'];
|
||||
|
||||
protected getComponentName(): string {
|
||||
return 'CreateItemParentSelectorComponent';
|
||||
}
|
||||
|
||||
protected importThemedComponent(themeName: string): Promise<any> {
|
||||
return import(`../../../../../themes/${themeName}/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component`);
|
||||
}
|
||||
|
||||
protected importUnthemedComponent(): Promise<any> {
|
||||
return import('./create-item-parent-selector.component');
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,27 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {EditCollectionSelectorComponent} from './edit-collection-selector.component';
|
||||
import {ThemedComponent} from 'src/app/shared/theme-support/themed.component';
|
||||
|
||||
/**
|
||||
* Themed wrapper for EditCollectionSelectorComponent
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-themed-edit-collection-selector',
|
||||
styleUrls: [],
|
||||
templateUrl: '../../../theme-support/themed.component.html'
|
||||
})
|
||||
export class ThemedEditCollectionSelectorComponent
|
||||
extends ThemedComponent<EditCollectionSelectorComponent> {
|
||||
protected getComponentName(): string {
|
||||
return 'EditCollectionSelectorComponent';
|
||||
}
|
||||
|
||||
protected importThemedComponent(themeName: string): Promise<any> {
|
||||
return import(`../../../../../themes/${themeName}/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component`);
|
||||
}
|
||||
|
||||
protected importUnthemedComponent(): Promise<any> {
|
||||
return import('./edit-collection-selector.component');
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,27 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {EditCommunitySelectorComponent} from './edit-community-selector.component';
|
||||
import {ThemedComponent} from 'src/app/shared/theme-support/themed.component';
|
||||
|
||||
/**
|
||||
* Themed wrapper for EditCommunitySelectorComponent
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-themed-edit-community-selector',
|
||||
styleUrls: [],
|
||||
templateUrl: '../../../theme-support/themed.component.html'
|
||||
})
|
||||
export class ThemedEditCommunitySelectorComponent
|
||||
extends ThemedComponent<EditCommunitySelectorComponent> {
|
||||
protected getComponentName(): string {
|
||||
return 'EditCommunitySelectorComponent';
|
||||
}
|
||||
|
||||
protected importThemedComponent(themeName: string): Promise<any> {
|
||||
return import(`../../../../../themes/${themeName}/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component`);
|
||||
}
|
||||
|
||||
protected importUnthemedComponent(): Promise<any> {
|
||||
return import('./edit-community-selector.component');
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,27 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {EditItemSelectorComponent} from './edit-item-selector.component';
|
||||
import {ThemedComponent} from 'src/app/shared/theme-support/themed.component';
|
||||
|
||||
/**
|
||||
* Themed wrapper for EditItemSelectorComponent
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-themed-edit-item-selector',
|
||||
styleUrls: [],
|
||||
templateUrl: '../../../theme-support/themed.component.html'
|
||||
})
|
||||
export class ThemedEditItemSelectorComponent
|
||||
extends ThemedComponent<EditItemSelectorComponent> {
|
||||
protected getComponentName(): string {
|
||||
return 'EditItemSelectorComponent';
|
||||
}
|
||||
|
||||
protected importThemedComponent(themeName: string): Promise<any> {
|
||||
return import(`../../../../../themes/${themeName}/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component`);
|
||||
}
|
||||
|
||||
protected importUnthemedComponent(): Promise<any> {
|
||||
return import('./edit-item-selector.component');
|
||||
}
|
||||
|
||||
}
|
@@ -8,6 +8,7 @@ export function getMockThemeService(themeName = 'base', themes?: ThemeConfig[]):
|
||||
getThemeName: themeName,
|
||||
getThemeName$: observableOf(themeName),
|
||||
getThemeConfigFor: undefined,
|
||||
listenForRouteChanges: undefined,
|
||||
});
|
||||
|
||||
if (isNotEmpty(themes)) {
|
||||
|
@@ -124,12 +124,21 @@ import { DSOSelectorComponent } from './dso-selector/dso-selector/dso-selector.c
|
||||
import {
|
||||
CreateCommunityParentSelectorComponent
|
||||
} from './dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component';
|
||||
import {
|
||||
ThemedCreateCommunityParentSelectorComponent
|
||||
} from './dso-selector/modal-wrappers/create-community-parent-selector/themed-create-community-parent-selector.component';
|
||||
import {
|
||||
CreateItemParentSelectorComponent
|
||||
} from './dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component';
|
||||
import {
|
||||
ThemedCreateItemParentSelectorComponent
|
||||
} from './dso-selector/modal-wrappers/create-item-parent-selector/themed-create-item-parent-selector.component';
|
||||
import {
|
||||
CreateCollectionParentSelectorComponent
|
||||
} from './dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component';
|
||||
import {
|
||||
ThemedCreateCollectionParentSelectorComponent
|
||||
} from './dso-selector/modal-wrappers/create-collection-parent-selector/themed-create-collection-parent-selector.component';
|
||||
import {
|
||||
CommunitySearchResultListElementComponent
|
||||
} from './object-list/search-result-list-element/community-search-result/community-search-result-list-element.component';
|
||||
@@ -139,12 +148,21 @@ import {
|
||||
import {
|
||||
EditItemSelectorComponent
|
||||
} from './dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component';
|
||||
import {
|
||||
ThemedEditItemSelectorComponent
|
||||
} from './dso-selector/modal-wrappers/edit-item-selector/themed-edit-item-selector.component';
|
||||
import {
|
||||
EditCommunitySelectorComponent
|
||||
} from './dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component';
|
||||
import {
|
||||
ThemedEditCommunitySelectorComponent
|
||||
} from './dso-selector/modal-wrappers/edit-community-selector/themed-edit-community-selector.component';
|
||||
import {
|
||||
EditCollectionSelectorComponent
|
||||
} from './dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component';
|
||||
import {
|
||||
ThemedEditCollectionSelectorComponent
|
||||
} from './dso-selector/modal-wrappers/edit-collection-selector/themed-edit-collection-selector.component';
|
||||
import {
|
||||
ItemListPreviewComponent
|
||||
} from './object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component';
|
||||
@@ -397,11 +415,17 @@ const COMPONENTS = [
|
||||
DsoInputSuggestionsComponent,
|
||||
DSOSelectorComponent,
|
||||
CreateCommunityParentSelectorComponent,
|
||||
ThemedCreateCommunityParentSelectorComponent,
|
||||
CreateCollectionParentSelectorComponent,
|
||||
ThemedCreateCollectionParentSelectorComponent,
|
||||
CreateItemParentSelectorComponent,
|
||||
ThemedCreateItemParentSelectorComponent,
|
||||
EditCommunitySelectorComponent,
|
||||
ThemedEditCommunitySelectorComponent,
|
||||
EditCollectionSelectorComponent,
|
||||
ThemedEditCollectionSelectorComponent,
|
||||
EditItemSelectorComponent,
|
||||
ThemedEditItemSelectorComponent,
|
||||
CommunitySearchResultListElementComponent,
|
||||
CollectionSearchResultListElementComponent,
|
||||
BrowseByComponent,
|
||||
@@ -493,11 +517,17 @@ const ENTRY_COMPONENTS = [
|
||||
StartsWithDateComponent,
|
||||
StartsWithTextComponent,
|
||||
CreateCommunityParentSelectorComponent,
|
||||
ThemedCreateCommunityParentSelectorComponent,
|
||||
CreateCollectionParentSelectorComponent,
|
||||
ThemedCreateCollectionParentSelectorComponent,
|
||||
CreateItemParentSelectorComponent,
|
||||
ThemedCreateItemParentSelectorComponent,
|
||||
EditCommunitySelectorComponent,
|
||||
ThemedEditCommunitySelectorComponent,
|
||||
EditCollectionSelectorComponent,
|
||||
ThemedEditCollectionSelectorComponent,
|
||||
EditItemSelectorComponent,
|
||||
ThemedEditItemSelectorComponent,
|
||||
PlainTextMetadataListElementComponent,
|
||||
ItemMetadataListElementComponent,
|
||||
MetadataRepresentationListElementComponent,
|
||||
|
@@ -2,7 +2,7 @@ import { of as observableOf } from 'rxjs';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideMockActions } from '@ngrx/effects/testing';
|
||||
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 { Theme } from '../../../config/theme.model';
|
||||
import { provideMockStore } from '@ngrx/store/testing';
|
||||
@@ -21,7 +21,9 @@ import {
|
||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||
import { ThemeService } from './theme.service';
|
||||
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
|
||||
@@ -84,12 +86,16 @@ describe('ThemeService', () => {
|
||||
findById: () => createSuccessfulRemoteDataObject$(mockCommunity)
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
],
|
||||
providers: [
|
||||
ThemeService,
|
||||
{ provide: LinkService, useValue: linkService },
|
||||
provideMockStore({ initialState }),
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -1,10 +1,10 @@
|
||||
import { Injectable, Inject } from '@angular/core';
|
||||
import { Store, createFeatureSelector, createSelector, select } from '@ngrx/store';
|
||||
import { EMPTY, Observable, of as observableOf } from 'rxjs';
|
||||
import { Inject, Injectable } from '@angular/core';
|
||||
import { createFeatureSelector, createSelector, select, Store } from '@ngrx/store';
|
||||
import { BehaviorSubject, EMPTY, Observable, of as observableOf } from 'rxjs';
|
||||
import { ThemeState } from './theme.reducer';
|
||||
import { SetThemeAction, ThemeActionTypes } from './theme.actions';
|
||||
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 { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||
import {
|
||||
@@ -12,14 +12,18 @@ import {
|
||||
getFirstSucceededRemoteData,
|
||||
getRemoteDataPayload
|
||||
} 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 { followLink } from '../utils/follow-link-config.model';
|
||||
import { LinkService } from '../../core/cache/builders/link.service';
|
||||
import { environment } from '../../../environments/environment';
|
||||
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 { 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');
|
||||
|
||||
@@ -42,11 +46,16 @@ export class ThemeService {
|
||||
*/
|
||||
hasDynamicTheme: boolean;
|
||||
|
||||
private _isThemeLoading$ = new BehaviorSubject<boolean>(false);
|
||||
private _isThemeCSSLoading$ = new BehaviorSubject<boolean>(false);
|
||||
|
||||
constructor(
|
||||
private store: Store<ThemeState>,
|
||||
private linkService: LinkService,
|
||||
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
|
||||
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) {
|
||||
this.store.dispatch(new SetThemeAction(newName));
|
||||
}
|
||||
|
||||
/**
|
||||
* The name of the current theme (synchronous)
|
||||
*/
|
||||
getThemeName(): string {
|
||||
let currentTheme: string;
|
||||
this.store.pipe(
|
||||
@@ -72,12 +88,205 @@ export class ThemeService {
|
||||
return currentTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name of the current theme (asynchronous, tracks changes)
|
||||
*/
|
||||
getThemeName$(): Observable<string> {
|
||||
return this.store.pipe(
|
||||
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
|
||||
* If the snapshot contains a dso, this will be used to match a theme
|
||||
|
@@ -40,6 +40,10 @@ interface AppConfig extends Config {
|
||||
info: InfoConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection token for the app configuration.
|
||||
* Provided in {@link InitService.providers}.
|
||||
*/
|
||||
const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG');
|
||||
|
||||
const APP_CONFIG_STATE = makeStateKey('APP_CONFIG_STATE');
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { HttpClient, HttpClientModule } from '@angular/common/http';
|
||||
import { APP_INITIALIZER, NgModule } from '@angular/core';
|
||||
import { BrowserModule, makeStateKey, TransferState } from '@angular/platform-browser';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { BrowserModule, BrowserTransferStateModule, makeStateKey, TransferState } from '@angular/platform-browser';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { REQUEST } from '@nguniversal/express-engine/tokens';
|
||||
|
||||
@@ -12,8 +12,6 @@ import { IdlePreloadModule } from 'angular-idle-preload';
|
||||
import { AppComponent } from '../../app/app.component';
|
||||
|
||||
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 { CookieService } from '../../app/core/services/cookie.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 { KlaroService } from '../../app/shared/cookies/klaro.service';
|
||||
import { HardRedirectService } from '../../app/core/services/hard-redirect.service';
|
||||
import {
|
||||
BrowserHardRedirectService,
|
||||
locationProvider,
|
||||
LocationToken
|
||||
} from '../../app/core/services/browser-hard-redirect.service';
|
||||
import { BrowserHardRedirectService, locationProvider, LocationToken } from '../../app/core/services/browser-hard-redirect.service';
|
||||
import { LocaleService } from '../../app/core/locale/locale.service';
|
||||
import { GoogleAnalyticsService } from '../../app/statistics/google-analytics.service';
|
||||
import { AuthRequestService } from '../../app/core/auth/auth-request.service';
|
||||
import { BrowserAuthRequestService } from '../../app/core/auth/browser-auth-request.service';
|
||||
import { AppConfig, APP_CONFIG_STATE } from '../../config/app-config.interface';
|
||||
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';
|
||||
import { BrowserInitService } from './browser-init.service';
|
||||
|
||||
export const REQ_KEY = makeStateKey<string>('req');
|
||||
|
||||
@@ -61,7 +50,7 @@ export function getRequest(transferState: TransferState): any {
|
||||
StatisticsModule.forRoot(),
|
||||
Angulartics2RouterlessModule.forRoot(),
|
||||
BrowserAnimationsModule,
|
||||
DSpaceBrowserTransferStateModule,
|
||||
BrowserTransferStateModule,
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
@@ -72,27 +61,7 @@ export function getRequest(transferState: TransferState): any {
|
||||
AppModule
|
||||
],
|
||||
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
|
||||
},
|
||||
...BrowserInitService.providers(),
|
||||
{
|
||||
provide: REQUEST,
|
||||
useFactory: getRequest,
|
||||
|
155
src/modules/app/browser-init.service.spec.ts
Normal file
155
src/modules/app/browser-init.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
136
src/modules/app/browser-init.service.ts
Normal file
136
src/modules/app/browser-init.service.ts
Normal 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();
|
||||
}
|
||||
}
|
@@ -1,8 +1,8 @@
|
||||
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 { 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';
|
||||
|
||||
@@ -12,8 +12,6 @@ import { Angulartics2GoogleAnalytics } from 'angulartics2';
|
||||
import { AppComponent } from '../../app/app.component';
|
||||
|
||||
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 { CookieService } from '../../app/core/services/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 { AuthRequestService } from '../../app/core/auth/auth-request.service';
|
||||
import { ServerAuthRequestService } from '../../app/core/auth/server-auth-request.service';
|
||||
import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service';
|
||||
import { AppConfig, APP_CONFIG_STATE } from '../../config/app-config.interface';
|
||||
|
||||
import { environment } from '../../environments/environment';
|
||||
import { ServerInitService } from './server-init.service';
|
||||
|
||||
export function createTranslateLoader(transferState: TransferState) {
|
||||
return new TranslateServerLoader(transferState, 'dist/server/assets/i18n/', '.json5');
|
||||
@@ -47,7 +42,7 @@ export function createTranslateLoader(transferState: TransferState) {
|
||||
appId: 'dspace-angular'
|
||||
}),
|
||||
NoopAnimationsModule,
|
||||
DSpaceServerTransferStateModule,
|
||||
ServerTransferStateModule,
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
@@ -59,22 +54,7 @@ export function createTranslateLoader(transferState: TransferState) {
|
||||
ServerModule,
|
||||
],
|
||||
providers: [
|
||||
// Initialize app config and extend environment
|
||||
{
|
||||
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
|
||||
},
|
||||
...ServerInitService.providers(),
|
||||
{
|
||||
provide: Angulartics2,
|
||||
useClass: Angulartics2Mock
|
||||
|
93
src/modules/app/server-init.service.ts
Normal file
93
src/modules/app/server-init.service.ts
Normal 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);
|
||||
}
|
||||
}
|
@@ -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 {
|
||||
|
||||
}
|
@@ -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();
|
||||
}
|
||||
}
|
@@ -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 {
|
||||
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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>;
|
||||
}
|
@@ -0,0 +1,11 @@
|
||||
<div>
|
||||
<div class="modal-header">{{'dso-selector.'+ action + '.' + objectType.toString().toLowerCase() + '.head' | translate}}
|
||||
<button type="button" class="close" (click)="close()" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h5 *ngIf="header" class="px-2">{{header | translate}}</h5>
|
||||
<ds-dso-selector [currentDSOId]="dsoRD?.payload.uuid" [types]="selectorTypes" (onSelect)="selectObject($event)"></ds-dso-selector>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,13 @@
|
||||
import { Component } from '@angular/core';
|
||||
import {
|
||||
CreateCollectionParentSelectorComponent as BaseComponent
|
||||
} from '../../../../../../../app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-create-collection-parent-selector',
|
||||
// styleUrls: ['./create-collection-parent-selector.component.scss'],
|
||||
// templateUrl: './create-collection-parent-selector.component.html',
|
||||
templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html',
|
||||
})
|
||||
export class CreateCollectionParentSelectorComponent extends BaseComponent {
|
||||
}
|
@@ -0,0 +1,19 @@
|
||||
<div>
|
||||
<div class="modal-header">{{'dso-selector.'+ action + '.' + objectType.toString().toLowerCase() + '.head' | translate}}
|
||||
<button type="button" class="close" (click)="close()" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<button class="btn btn-outline-primary btn-lg btn-block" (click)="selectObject(undefined)">{{'dso-selector.create.community.top-level' | translate}}</button>
|
||||
<h3 class="position-relative py-1 my-3 font-weight-normal">
|
||||
<hr>
|
||||
<div id="create-community-or-separator" class="text-center position-absolute w-100">
|
||||
<span class="px-4 bg-white">or</span>
|
||||
</div>
|
||||
</h3>
|
||||
|
||||
<h5 class="px-2">{{'dso-selector.create.community.sub-level' | translate}}</h5>
|
||||
<ds-dso-selector [currentDSOId]="dsoRD?.payload.uuid" [types]="selectorTypes" (onSelect)="selectObject($event)"></ds-dso-selector>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,3 @@
|
||||
#create-community-or-separator {
|
||||
top: 0;
|
||||
}
|
@@ -0,0 +1,14 @@
|
||||
import { Component } from '@angular/core';
|
||||
import {
|
||||
CreateCommunityParentSelectorComponent as BaseComponent
|
||||
} from '../../../../../../../app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-create-community-parent-selector',
|
||||
// styleUrls: ['./create-community-parent-selector.component.scss'],
|
||||
styleUrls: ['../../../../../../../app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.scss'],
|
||||
// templateUrl: './create-community-parent-selector.component.html',
|
||||
templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html',
|
||||
})
|
||||
export class CreateCommunityParentSelectorComponent extends BaseComponent {
|
||||
}
|
@@ -0,0 +1,15 @@
|
||||
<div>
|
||||
<div class="modal-header">{{'dso-selector.'+ action + '.' + objectType.toString().toLowerCase() + '.head' | translate}}
|
||||
<button type="button" class="close" (click)="close()" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div [innerHTML]="'dso-selector.create.item.intro' | translate"></div>
|
||||
<h5 *ngIf="header" class="px-2">{{header | translate}}</h5>
|
||||
<ds-authorized-collection-selector [currentDSOId]="dsoRD?.payload.uuid"
|
||||
[entityType]="entityType"
|
||||
[types]="selectorTypes"
|
||||
(onSelect)="selectObject($event)"></ds-authorized-collection-selector>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,13 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {
|
||||
CreateItemParentSelectorComponent as BaseComponent
|
||||
} from '../../../../../../../app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-create-item-parent-selector',
|
||||
// styleUrls: ['./create-item-parent-selector.component.scss'],
|
||||
// templateUrl: './create-item-parent-selector.component.html',
|
||||
templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html',
|
||||
})
|
||||
export class CreateItemParentSelectorComponent extends BaseComponent {
|
||||
}
|
@@ -0,0 +1,11 @@
|
||||
<div>
|
||||
<div class="modal-header">{{'dso-selector.'+ action + '.' + objectType.toString().toLowerCase() + '.head' | translate}}
|
||||
<button type="button" class="close" (click)="close()" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h5 *ngIf="header" class="px-2">{{header | translate}}</h5>
|
||||
<ds-dso-selector [currentDSOId]="dsoRD?.payload.uuid" [types]="selectorTypes" (onSelect)="selectObject($event)"></ds-dso-selector>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,13 @@
|
||||
import { Component } from '@angular/core';
|
||||
import {
|
||||
EditCollectionSelectorComponent as BaseComponent
|
||||
} from '../../../../../../../app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-edit-collection-selector',
|
||||
// styleUrls: ['./edit-collection-selector.component.scss'],
|
||||
// templateUrl: './edit-collection-selector.component.html',
|
||||
templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html',
|
||||
})
|
||||
export class EditCollectionSelectorComponent extends BaseComponent {
|
||||
}
|
@@ -0,0 +1,11 @@
|
||||
<div>
|
||||
<div class="modal-header">{{'dso-selector.'+ action + '.' + objectType.toString().toLowerCase() + '.head' | translate}}
|
||||
<button type="button" class="close" (click)="close()" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h5 *ngIf="header" class="px-2">{{header | translate}}</h5>
|
||||
<ds-dso-selector [currentDSOId]="dsoRD?.payload.uuid" [types]="selectorTypes" (onSelect)="selectObject($event)"></ds-dso-selector>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,13 @@
|
||||
import { Component } from '@angular/core';
|
||||
import {
|
||||
EditCommunitySelectorComponent as BaseComponent
|
||||
} from '../../../../../../../app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-edit-item-selector',
|
||||
// styleUrls: ['./edit-community-selector.component.scss'],
|
||||
// templateUrl: './edit-community-selector.component.html',
|
||||
templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html',
|
||||
})
|
||||
export class EditCommunitySelectorComponent extends BaseComponent {
|
||||
}
|
@@ -0,0 +1,11 @@
|
||||
<div>
|
||||
<div class="modal-header">{{'dso-selector.'+ action + '.' + objectType.toString().toLowerCase() + '.head' | translate}}
|
||||
<button type="button" class="close" (click)="close()" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h5 *ngIf="header" class="px-2">{{header | translate}}</h5>
|
||||
<ds-dso-selector [currentDSOId]="dsoRD?.payload.uuid" [types]="selectorTypes" (onSelect)="selectObject($event)"></ds-dso-selector>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,13 @@
|
||||
import { Component } from '@angular/core';
|
||||
import {
|
||||
EditItemSelectorComponent as BaseComponent
|
||||
} from 'src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-edit-item-selector',
|
||||
// styleUrls: ['./edit-item-selector.component.scss'],
|
||||
// templateUrl: './edit-item-selector.component.html',
|
||||
templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html',
|
||||
})
|
||||
export class EditItemSelectorComponent extends BaseComponent {
|
||||
}
|
@@ -21,6 +21,24 @@ import {
|
||||
} from './app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component';
|
||||
import { UntypedItemComponent } from './app/item-page/simple/item-types/untyped-item/untyped-item.component';
|
||||
import { ItemSharedModule } from '../../app/item-page/item-shared.module';
|
||||
import {
|
||||
CreateCollectionParentSelectorComponent
|
||||
} from './app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component';
|
||||
import {
|
||||
CreateCommunityParentSelectorComponent
|
||||
} from './app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component';
|
||||
import {
|
||||
CreateItemParentSelectorComponent
|
||||
} from './app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component';
|
||||
import {
|
||||
EditCollectionSelectorComponent
|
||||
} from './app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component';
|
||||
import {
|
||||
EditCommunitySelectorComponent
|
||||
} from './app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component';
|
||||
import {
|
||||
EditItemSelectorComponent
|
||||
} from './app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component';
|
||||
|
||||
/**
|
||||
* Add components that use a custom decorator to ENTRY_COMPONENTS as well as DECLARATIONS.
|
||||
@@ -41,6 +59,12 @@ const DECLARATIONS = [
|
||||
HeaderNavbarWrapperComponent,
|
||||
NavbarComponent,
|
||||
FooterComponent,
|
||||
CreateCollectionParentSelectorComponent,
|
||||
CreateCommunityParentSelectorComponent,
|
||||
CreateItemParentSelectorComponent,
|
||||
EditCollectionSelectorComponent,
|
||||
EditCommunitySelectorComponent,
|
||||
EditItemSelectorComponent,
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
Reference in New Issue
Block a user