From bdc004f64dbeae554e97cb45158e90bf5ac7b1f3 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Fri, 22 Jul 2022 12:12:21 +0200 Subject: [PATCH] 93219: Move theme/route subscriptions from AppComponent to ThemeService --- src/app/app.component.spec.ts | 31 +-- src/app/app.component.ts | 183 +----------------- src/app/core/shared/distinct-next.ts | 18 ++ src/app/init.service.spec.ts | 3 + src/app/init.service.ts | 4 + src/app/navbar/navbar.component.html | 4 +- src/app/shared/mocks/theme-service.mock.ts | 1 + .../theme-support/theme.service.spec.ts | 57 +++++- src/app/shared/theme-support/theme.service.ts | 176 ++++++++++++++++- src/modules/app/browser-init.service.ts | 4 + src/modules/app/server-init.service.ts | 4 + 11 files changed, 268 insertions(+), 217 deletions(-) create mode 100644 src/app/core/shared/distinct-next.ts diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 4cf3f3b6e7..0e86beeedb 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -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); - }); - }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index c7b99b42a8..98e91c9c31 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -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 = new BehaviorSubject(false); - - isThemeCSSLoading$: BehaviorSubject = new BehaviorSubject(false); + isThemeLoading$: Observable; /** * 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 - * - * @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(bs: BehaviorSubject, nextValue: T): void { - if (bs.getValue() !== nextValue) { - bs.next(nextValue); - } - } } diff --git a/src/app/core/shared/distinct-next.ts b/src/app/core/shared/distinct-next.ts new file mode 100644 index 0000000000..0ee3d237d4 --- /dev/null +++ b/src/app/core/shared/distinct-next.ts @@ -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(bs: BehaviorSubject, nextValue: T): void { + if (bs.getValue() !== nextValue) { + bs.next(nextValue); + } +} diff --git a/src/app/init.service.spec.ts b/src/app/init.service.spec.ts index 7fb555f23c..c046ad5715 100644 --- a/src/app/init.service.spec.ts +++ b/src/app/init.service.spec.ts @@ -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; @@ -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, diff --git a/src/app/init.service.ts b/src/app/init.service.ts index e5b04163c0..62461212d2 100644 --- a/src/app/init.service.ts +++ b/src/app/init.service.ts @@ -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(); } /** diff --git a/src/app/navbar/navbar.component.html b/src/app/navbar/navbar.component.html index fc5d1a2ef3..8531543361 100644 --- a/src/app/navbar/navbar.component.html +++ b/src/app/navbar/navbar.component.html @@ -6,10 +6,10 @@
- \ No newline at end of file + diff --git a/src/app/shared/mocks/theme-service.mock.ts b/src/app/shared/mocks/theme-service.mock.ts index 058ba993bc..e3c2960e51 100644 --- a/src/app/shared/mocks/theme-service.mock.ts +++ b/src/app/shared/mocks/theme-service.mock.ts @@ -8,6 +8,7 @@ export function getMockThemeService(themeName = 'base', themes?: ThemeConfig[]): getThemeName: themeName, getThemeName$: observableOf(themeName), getThemeConfigFor: undefined, + listenForRouteChanges: undefined, }); if (isNotEmpty(themes)) { diff --git a/src/app/shared/theme-support/theme.service.spec.ts b/src/app/shared/theme-support/theme.service.spec.ts index 84043369c0..43b5964b8c 100644 --- a/src/app/shared/theme-support/theme.service.spec.ts +++ b/src/app/shared/theme-support/theme.service.spec.ts @@ -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); + }); + }); }); diff --git a/src/app/shared/theme-support/theme.service.ts b/src/app/shared/theme-support/theme.service.ts index 4a4f6ae986..7642b7097e 100644 --- a/src/app/shared/theme-support/theme.service.ts +++ b/src/app/shared/theme-support/theme.service.ts @@ -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('theme'); @@ -42,11 +46,16 @@ export class ThemeService { */ hasDynamicTheme: boolean; + private _isThemeLoading$ = new BehaviorSubject(false); + private _isThemeCSSLoading$ = new BehaviorSubject(false); + constructor( private store: Store, 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 { + 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 + * + * @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 diff --git a/src/modules/app/browser-init.service.ts b/src/modules/app/browser-init.service.ts index f675c55718..e5718045c6 100644 --- a/src/modules/app/browser-init.service.ts +++ b/src/modules/app/browser-init.service.ts @@ -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(); diff --git a/src/modules/app/server-init.service.ts b/src/modules/app/server-init.service.ts index fc7fc66bf7..9e23bbeef3 100644 --- a/src/modules/app/server-init.service.ts +++ b/src/modules/app/server-init.service.ts @@ -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();