mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 01:54:15 +00:00
462 lines
16 KiB
TypeScript
462 lines
16 KiB
TypeScript
import { Injectable, Inject, Injector } from '@angular/core';
|
|
import { Store, createFeatureSelector, createSelector, select } from '@ngrx/store';
|
|
import { BehaviorSubject, EMPTY, Observable, of as observableOf, from, concatMap } from 'rxjs';
|
|
import { ThemeState } from './theme.reducer';
|
|
import { SetThemeAction, ThemeActionTypes } from './theme.actions';
|
|
import { defaultIfEmpty, expand, filter, map, switchMap, take, toArray } from 'rxjs/operators';
|
|
import { hasNoValue, hasValue, isNotEmpty } from '../empty.util';
|
|
import { RemoteData } from '../../core/data/remote-data';
|
|
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
|
import { getFirstCompletedRemoteData, getFirstSucceededRemoteData, getRemoteDataPayload } from '../../core/shared/operators';
|
|
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, 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');
|
|
|
|
export const currentThemeSelector = createSelector(
|
|
themeStateSelector,
|
|
(state: ThemeState): string => hasValue(state) ? state.currentTheme : BASE_THEME_NAME,
|
|
);
|
|
|
|
@Injectable({
|
|
providedIn: 'root'
|
|
})
|
|
export class ThemeService {
|
|
/**
|
|
* The list of configured themes
|
|
*/
|
|
themes: Theme[];
|
|
|
|
/**
|
|
* True if at least one theme depends on the route
|
|
*/
|
|
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,
|
|
protected injector: Injector,
|
|
@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, injector));
|
|
this.hasDynamicTheme = environment.themes.some((themeConfig: any) =>
|
|
hasValue(themeConfig.regex) ||
|
|
hasValue(themeConfig.handle) ||
|
|
hasValue(themeConfig.uuid)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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(
|
|
select(currentThemeSelector),
|
|
take(1)
|
|
).subscribe((name: string) =>
|
|
currentTheme = name
|
|
);
|
|
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);
|
|
} else {
|
|
// last resort, use fallback favicon.ico
|
|
return [
|
|
this.createHeadTag({
|
|
'tagName': 'link',
|
|
'attributes': {
|
|
'rel': 'icon',
|
|
'href': 'assets/images/favicon.ico',
|
|
'sizes': 'any',
|
|
}
|
|
})
|
|
];
|
|
}
|
|
}
|
|
|
|
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
|
|
* If the snapshot contains a scope parameters, this will be used to match a theme
|
|
* Otherwise the URL is matched against
|
|
* If none of the above find a match, the theme doesn't change
|
|
* @param currentRouteUrl
|
|
* @param activatedRouteSnapshot
|
|
* @return Observable boolean emitting whether or not the theme has been changed
|
|
*/
|
|
updateThemeOnRouteChange$(currentRouteUrl: string, activatedRouteSnapshot: ActivatedRouteSnapshot): Observable<boolean> {
|
|
// and the current theme from the store
|
|
const currentTheme$: Observable<string> = this.store.pipe(select(currentThemeSelector));
|
|
|
|
const action$: Observable<SetThemeAction | NoOpAction> = currentTheme$.pipe(
|
|
switchMap((currentTheme: string) => {
|
|
const snapshotWithData = this.findRouteData(activatedRouteSnapshot);
|
|
if (this.hasDynamicTheme === true && isNotEmpty(this.themes)) {
|
|
if (hasValue(snapshotWithData) && hasValue(snapshotWithData.data) && hasValue(snapshotWithData.data.dso)) {
|
|
const dsoRD: RemoteData<DSpaceObject> = snapshotWithData.data.dso;
|
|
if (dsoRD.hasSucceeded) {
|
|
// Start with the resolved dso and go recursively through its parents until you reach the top-level community
|
|
return observableOf(dsoRD.payload).pipe(
|
|
this.getAncestorDSOs(),
|
|
switchMap((dsos: DSpaceObject[]) => {
|
|
return this.matchThemeToDSOs(dsos, currentRouteUrl);
|
|
}),
|
|
map((dsoMatch: Theme) => {
|
|
return this.getActionForMatch(dsoMatch, currentTheme);
|
|
})
|
|
);
|
|
}
|
|
}
|
|
if (hasValue(activatedRouteSnapshot.queryParams) && hasValue(activatedRouteSnapshot.queryParams.scope)) {
|
|
const dsoFromScope$: Observable<RemoteData<DSpaceObject>> = this.dSpaceObjectDataService.findById(activatedRouteSnapshot.queryParams.scope);
|
|
// Start with the resolved dso and go recursively through its parents until you reach the top-level community
|
|
return dsoFromScope$.pipe(
|
|
getFirstSucceededRemoteData(),
|
|
getRemoteDataPayload(),
|
|
this.getAncestorDSOs(),
|
|
switchMap((dsos: DSpaceObject[]) => {
|
|
return this.matchThemeToDSOs(dsos, currentRouteUrl);
|
|
}),
|
|
map((dsoMatch: Theme) => {
|
|
return this.getActionForMatch(dsoMatch, currentTheme);
|
|
})
|
|
);
|
|
}
|
|
|
|
// check whether the route itself matches
|
|
return from(this.themes).pipe(
|
|
concatMap((theme: Theme) => theme.matches(currentRouteUrl, undefined).pipe(
|
|
filter((result: boolean) => result === true),
|
|
map(() => theme),
|
|
take(1),
|
|
)),
|
|
take(1),
|
|
map((theme: Theme) => this.getActionForMatch(theme, currentTheme))
|
|
);
|
|
} else {
|
|
// If there are no themes configured, do nothing
|
|
return observableOf(new NoOpAction());
|
|
}
|
|
}),
|
|
take(1),
|
|
);
|
|
|
|
action$.pipe(
|
|
filter((action: SetThemeAction | NoOpAction) => action.type !== NO_OP_ACTION_TYPE),
|
|
).subscribe((action: SetThemeAction | NoOpAction) => {
|
|
this.store.dispatch(action);
|
|
});
|
|
|
|
return action$.pipe(
|
|
map((action: SetThemeAction | NoOpAction) => action.type === ThemeActionTypes.SET),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Find a DSpaceObject in one of the provided route snapshots their data
|
|
* Recursively looks for the dso in the routes their child routes until it reaches a dead end or finds one
|
|
* @param routes
|
|
*/
|
|
findRouteData(...routes: ActivatedRouteSnapshot[]) {
|
|
const result = routes.find((route) => hasValue(route.data) && hasValue(route.data.dso));
|
|
if (hasValue(result)) {
|
|
return result;
|
|
} else {
|
|
const nextLevelRoutes = routes
|
|
.map((route: ActivatedRouteSnapshot) => route.children)
|
|
.reduce((combined: ActivatedRouteSnapshot[], current: ActivatedRouteSnapshot[]) => [...combined, ...current]);
|
|
if (isNotEmpty(nextLevelRoutes)) {
|
|
return this.findRouteData(...nextLevelRoutes);
|
|
} else {
|
|
return undefined;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* An rxjs operator that will return an array of all the ancestors of the DSpaceObject used as
|
|
* input. The initial DSpaceObject will be the first element of the output array, followed by
|
|
* its parent, its grandparent etc
|
|
*
|
|
* @private
|
|
*/
|
|
private getAncestorDSOs() {
|
|
return (source: Observable<DSpaceObject>): Observable<DSpaceObject[]> =>
|
|
source.pipe(
|
|
expand((dso: DSpaceObject) => {
|
|
// Check if the dso exists and has a parent link
|
|
if (hasValue(dso) && typeof (dso as any).getParentLinkKey === 'function') {
|
|
const linkName = (dso as any).getParentLinkKey();
|
|
// If it does, retrieve it.
|
|
return this.linkService.resolveLinkWithoutAttaching<DSpaceObject, DSpaceObject>(dso, followLink(linkName)).pipe(
|
|
getFirstCompletedRemoteData(),
|
|
map((rd: RemoteData<DSpaceObject>) => {
|
|
if (hasValue(rd.payload)) {
|
|
// If there's a parent, use it for the next iteration
|
|
return rd.payload;
|
|
} else {
|
|
// If there's no parent, or an error, return null, which will stop recursion
|
|
// in the next iteration
|
|
return null;
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
// The current dso has no value, or no parent. Return EMPTY to stop recursion
|
|
return EMPTY;
|
|
}),
|
|
// only allow through DSOs that have a value
|
|
filter((dso: DSpaceObject) => hasValue(dso)),
|
|
// Wait for recursion to complete, and emit all results at once, in an array
|
|
toArray()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* return the action to dispatch based on the given matching theme
|
|
*
|
|
* @param newTheme The theme to create an action for
|
|
* @param currentThemeName The name of the currently active theme
|
|
* @private
|
|
*/
|
|
private getActionForMatch(newTheme: Theme, currentThemeName: string): SetThemeAction | NoOpAction {
|
|
const newThemeName: string = newTheme?.config.name ?? BASE_THEME_NAME;
|
|
if (newThemeName !== currentThemeName) {
|
|
// If we have a match, and it isn't already the active theme, set it as the new theme
|
|
return new SetThemeAction(newThemeName);
|
|
} else {
|
|
// Otherwise, do nothing
|
|
return new NoOpAction();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check the given DSpaceObjects in order to see if they match the configured themes in order.
|
|
* If a match is found, the matching theme is returned
|
|
*
|
|
* @param dsos The DSpaceObjects to check
|
|
* @param currentRouteUrl The url for the current route
|
|
* @private
|
|
*/
|
|
private matchThemeToDSOs(dsos: DSpaceObject[], currentRouteUrl: string): Observable<Theme> {
|
|
return from(this.themes).pipe(
|
|
concatMap((theme: Theme) => from(dsos).pipe(
|
|
concatMap((dso: DSpaceObject) => theme.matches(currentRouteUrl, dso)),
|
|
filter((result: boolean) => result === true),
|
|
map(() => theme),
|
|
take(1),
|
|
)),
|
|
take(1),
|
|
defaultIfEmpty(undefined),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Searches for a ThemeConfig by its name;
|
|
*/
|
|
getThemeConfigFor(themeName: string): ThemeConfig {
|
|
return this.gtcf(themeName);
|
|
}
|
|
}
|