mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 01:54:15 +00:00
93219: Move theme/route subscriptions from AppComponent to ThemeService
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Store, StoreModule } from '@ngrx/store';
|
||||
import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||
import { CommonModule, DOCUMENT } from '@angular/common';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
||||
import { Angulartics2GoogleAnalytics } from 'angulartics2/ga';
|
||||
@@ -129,33 +129,4 @@ describe('App component', () => {
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
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,14 +7,12 @@ import {
|
||||
HostListener,
|
||||
Inject,
|
||||
OnInit,
|
||||
Optional,
|
||||
PLATFORM_ID,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
NavigationCancel,
|
||||
NavigationEnd,
|
||||
NavigationStart, ResolveEnd,
|
||||
NavigationStart,
|
||||
Router,
|
||||
} from '@angular/router';
|
||||
|
||||
@@ -28,14 +26,11 @@ import { NativeWindowRef, NativeWindowService } from './core/services/window.ser
|
||||
import { isAuthenticationBlocking } from './core/auth/selectors';
|
||||
import { AuthService } from './core/auth/auth.service';
|
||||
import { CSSVariableService } from './shared/sass-helper/sass-helper.service';
|
||||
import { HeadTagConfig } from '../config/theme.model';
|
||||
import { environment } from '../environments/environment';
|
||||
import { models } from './core/core.module';
|
||||
import { hasNoValue, hasValue, isNotEmpty } from './shared/empty.util';
|
||||
import { ThemeService } from './shared/theme-support/theme.service';
|
||||
import { BASE_THEME_NAME } from './shared/theme-support/theme.constants';
|
||||
import { IdleModalComponent } from './shared/idle-modal/idle-modal.component';
|
||||
import { getDefaultThemeConfig } from '../config/config.util';
|
||||
import { distinctNext } from './core/shared/distinct-next';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-app',
|
||||
@@ -60,9 +55,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
|
||||
@@ -86,27 +79,12 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||
/* 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.trackIdleModal();
|
||||
}
|
||||
|
||||
this.isThemeLoading$ = this.themeService.isThemeLoading$;
|
||||
|
||||
this.storeCSSVariables();
|
||||
}
|
||||
|
||||
@@ -135,34 +113,14 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
let resolveEndFound = false;
|
||||
this.router.events.subscribe((event) => {
|
||||
if (event instanceof NavigationStart) {
|
||||
resolveEndFound = false;
|
||||
this.distinctNext(this.isRouteLoading$, true);
|
||||
this.distinctNext(this.isThemeLoading$, true);
|
||||
} else if (event instanceof ResolveEnd) {
|
||||
resolveEndFound = true;
|
||||
const activatedRouteSnapShot: ActivatedRouteSnapshot = event.state.root;
|
||||
this.themeService.updateThemeOnRouteChange$(event.urlAfterRedirects, activatedRouteSnapShot).pipe(
|
||||
switchMap((changed) => {
|
||||
if (changed) {
|
||||
return this.isThemeCSSLoading$;
|
||||
} else {
|
||||
return [false];
|
||||
}
|
||||
})
|
||||
).subscribe((changed) => {
|
||||
this.distinctNext(this.isThemeLoading$, changed);
|
||||
});
|
||||
distinctNext(this.isRouteLoading$, true);
|
||||
} else if (
|
||||
event instanceof NavigationEnd ||
|
||||
event instanceof NavigationCancel
|
||||
) {
|
||||
if (!resolveEndFound) {
|
||||
this.distinctNext(this.isThemeLoading$, false);
|
||||
}
|
||||
this.distinctNext(this.isRouteLoading$, false);
|
||||
distinctNext(this.isRouteLoading$, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -178,119 +136,6 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -310,16 +155,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
18
src/app/core/shared/distinct-next.ts
Normal file
18
src/app/core/shared/distinct-next.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* something something atmire
|
||||
*/
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
@@ -33,6 +33,8 @@ import { getTestScheduler } from 'jasmine-marbles';
|
||||
import objectContaining = jasmine.objectContaining;
|
||||
import createSpyObj = jasmine.createSpyObj;
|
||||
import SpyObj = jasmine.SpyObj;
|
||||
import { ThemeService } from './shared/theme-support/theme.service';
|
||||
import { getMockThemeService } from './shared/mocks/theme-service.mock';
|
||||
|
||||
let spy: SpyObj<any>;
|
||||
|
||||
@@ -171,6 +173,7 @@ describe('InitService', () => {
|
||||
{ provide: MenuService, useValue: new MenuServiceStub() },
|
||||
{ provide: KlaroService, useValue: undefined },
|
||||
{ provide: GoogleAnalyticsService, useValue: undefined },
|
||||
{ provide: ThemeService, useValue: getMockThemeService() },
|
||||
provideMockStore({ initialState }),
|
||||
AppComponent,
|
||||
RouteService,
|
||||
|
@@ -25,6 +25,7 @@ import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service';
|
||||
import { distinctUntilChanged, filter, take, tap } from 'rxjs/operators';
|
||||
import { isAuthenticationBlocking } from './core/auth/selectors';
|
||||
import { KlaroService } from './shared/cookies/klaro.service';
|
||||
import { ThemeService } from './shared/theme-support/theme.service';
|
||||
|
||||
/**
|
||||
* Performs the initialization of the app.
|
||||
@@ -49,6 +50,7 @@ export abstract class InitService {
|
||||
protected metadata: MetadataService,
|
||||
protected breadcrumbsService: BreadcrumbsService,
|
||||
@Optional() protected klaroService: KlaroService,
|
||||
protected themeService: ThemeService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -192,11 +194,13 @@ export abstract class InitService {
|
||||
* 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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -6,10 +6,10 @@
|
||||
<div id="collapsingNav">
|
||||
<ul class="navbar-nav mr-auto shadow-none">
|
||||
<ng-container *ngFor="let section of (sections | async)">
|
||||
<ng-container *ngComponentOutlet="(sectionMap$ | async).get(section.id)?.component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
|
||||
<ng-container *ngComponentOutlet="(sectionMap$ | async).get(section.id)?.component; injector: (sectionMap$ | async).get(section.id)?.injector;"></ng-container>
|
||||
</ng-container>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</nav>
|
||||
|
@@ -8,6 +8,7 @@ export function getMockThemeService(themeName = 'base', themes?: ThemeConfig[]):
|
||||
getThemeName: themeName,
|
||||
getThemeName$: observableOf(themeName),
|
||||
getThemeConfigFor: undefined,
|
||||
listenForRouteChanges: undefined,
|
||||
});
|
||||
|
||||
if (isNotEmpty(themes)) {
|
||||
|
@@ -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));
|
||||
@@ -78,6 +87,159 @@ export class ThemeService {
|
||||
);
|
||||
}
|
||||
|
||||
get isThemeLoading$(): Observable<boolean> {
|
||||
return this._isThemeLoading$;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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.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;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
@@ -25,6 +25,7 @@ import { BreadcrumbsService } from '../../app/breadcrumbs/breadcrumbs.service';
|
||||
import { CSSVariableService } from '../../app/shared/sass-helper/sass-helper.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';
|
||||
|
||||
/**
|
||||
* Performs client-side initialization.
|
||||
@@ -46,6 +47,7 @@ export class BrowserInitService extends InitService {
|
||||
protected cssService: CSSVariableService,
|
||||
@Optional() protected klaroService: KlaroService,
|
||||
protected authService: AuthService,
|
||||
protected themeService: ThemeService,
|
||||
) {
|
||||
super(
|
||||
store,
|
||||
@@ -59,6 +61,7 @@ export class BrowserInitService extends InitService {
|
||||
metadata,
|
||||
breadcrumbsService,
|
||||
klaroService,
|
||||
themeService,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,6 +86,7 @@ export class BrowserInitService extends InitService {
|
||||
this.initI18n();
|
||||
this.initAnalytics();
|
||||
this.initRouteListeners();
|
||||
this.themeService.listenForThemeChanges(true);
|
||||
this.trackAuthTokenExpiration();
|
||||
|
||||
this.initKlaro();
|
||||
|
@@ -22,6 +22,7 @@ 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 { KlaroService } from '../../app/shared/cookies/klaro.service';
|
||||
import { ThemeService } from '../../app/shared/theme-support/theme.service';
|
||||
|
||||
/**
|
||||
* Performs server-side initialization.
|
||||
@@ -42,6 +43,7 @@ export class ServerInitService extends InitService {
|
||||
protected breadcrumbsService: BreadcrumbsService,
|
||||
protected cssService: CSSVariableService,
|
||||
@Optional() protected klaroService: KlaroService,
|
||||
protected themeService: ThemeService,
|
||||
) {
|
||||
super(
|
||||
store,
|
||||
@@ -55,6 +57,7 @@ export class ServerInitService extends InitService {
|
||||
metadata,
|
||||
breadcrumbsService,
|
||||
klaroService,
|
||||
themeService,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,6 +72,7 @@ export class ServerInitService extends InitService {
|
||||
this.initI18n();
|
||||
this.initAnalytics();
|
||||
this.initRouteListeners();
|
||||
this.themeService.listenForThemeChanges(false);
|
||||
|
||||
this.initKlaro();
|
||||
|
||||
|
Reference in New Issue
Block a user