mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-13 04:53:06 +00:00
83628: Dynamic theme fixes
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { delay, distinctUntilChanged, filter, take, withLatestFrom } from 'rxjs/operators';
|
||||
import { delay, distinctUntilChanged, filter, switchMap, take, withLatestFrom } from 'rxjs/operators';
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
@@ -9,7 +9,14 @@ import {
|
||||
Optional,
|
||||
PLATFORM_ID,
|
||||
} from '@angular/core';
|
||||
import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router';
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
NavigationCancel,
|
||||
NavigationEnd,
|
||||
NavigationStart,
|
||||
Router,
|
||||
RoutesRecognized
|
||||
} from '@angular/router';
|
||||
|
||||
import { BehaviorSubject, Observable, of } from 'rxjs';
|
||||
import { select, Store } from '@ngrx/store';
|
||||
@@ -71,6 +78,7 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||
*/
|
||||
isThemeLoading$: BehaviorSubject<boolean> = new BehaviorSubject(false);
|
||||
|
||||
isThemeCSSLoading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
||||
|
||||
/**
|
||||
* Whether or not the idle modal is is currently open
|
||||
@@ -106,6 +114,7 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||
if (isPlatformBrowser(this.platformId)) {
|
||||
// the theme css will never download server side, so this should only happen on the browser
|
||||
this.isThemeLoading$.next(true);
|
||||
this.isThemeCSSLoading$.next(true);
|
||||
}
|
||||
if (hasValue(themeName)) {
|
||||
this.setThemeCss(themeName);
|
||||
@@ -184,6 +193,19 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||
).subscribe((event) => {
|
||||
if (event instanceof NavigationStart) {
|
||||
this.isRouteLoading$.next(true);
|
||||
} else if (event instanceof RoutesRecognized) {
|
||||
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.isThemeLoading$.next(changed);
|
||||
});
|
||||
} else if (
|
||||
event instanceof NavigationEnd ||
|
||||
event instanceof NavigationCancel
|
||||
@@ -237,7 +259,7 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||
});
|
||||
}
|
||||
// the fact that this callback is used, proves we're on the browser.
|
||||
this.isThemeLoading$.next(false);
|
||||
this.isThemeCSSLoading$.next(false);
|
||||
};
|
||||
head.appendChild(link);
|
||||
}
|
||||
|
@@ -1,75 +1,17 @@
|
||||
import { ThemeEffects } from './theme.effects';
|
||||
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 { ROOT_EFFECTS_INIT } from '@ngrx/effects';
|
||||
import { SetThemeAction } from './theme.actions';
|
||||
import { Theme } from '../../../config/theme.model';
|
||||
import { provideMockStore } from '@ngrx/store/testing';
|
||||
import { ROUTER_NAVIGATED } from '@ngrx/router-store';
|
||||
import { ResolverActionTypes } from '../../core/resolving/resolver.actions';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
import { COMMUNITY } from '../../core/shared/community.resource-type';
|
||||
import { NoOpAction } from '../ngrx/no-op.action';
|
||||
import { ITEM } from '../../core/shared/item.resource-type';
|
||||
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
import { Collection } from '../../core/shared/collection.model';
|
||||
import { COLLECTION } from '../../core/shared/collection.resource-type';
|
||||
import {
|
||||
createNoContentRemoteDataObject$,
|
||||
createSuccessfulRemoteDataObject$
|
||||
} from '../remote-data.utils';
|
||||
import { BASE_THEME_NAME } from './theme.constants';
|
||||
|
||||
/**
|
||||
* LinkService able to mock recursively resolving DSO parent links
|
||||
* Every time resolveLinkWithoutAttaching is called, it returns the next object in the array of ancestorDSOs until
|
||||
* none are left, after which it returns a no-content remote-date
|
||||
*/
|
||||
class MockLinkService {
|
||||
index = -1;
|
||||
|
||||
constructor(private ancestorDSOs: DSpaceObject[]) {
|
||||
}
|
||||
|
||||
resolveLinkWithoutAttaching() {
|
||||
if (this.index >= this.ancestorDSOs.length - 1) {
|
||||
return createNoContentRemoteDataObject$();
|
||||
} else {
|
||||
this.index++;
|
||||
return createSuccessfulRemoteDataObject$(this.ancestorDSOs[this.index]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('ThemeEffects', () => {
|
||||
let themeEffects: ThemeEffects;
|
||||
let linkService: LinkService;
|
||||
let initialState;
|
||||
|
||||
let ancestorDSOs: DSpaceObject[];
|
||||
|
||||
function init() {
|
||||
ancestorDSOs = [
|
||||
Object.assign(new Collection(), {
|
||||
type: COLLECTION.value,
|
||||
uuid: 'collection-uuid',
|
||||
_links: { owningCommunity: { href: 'owning-community-link' } }
|
||||
}),
|
||||
Object.assign(new Community(), {
|
||||
type: COMMUNITY.value,
|
||||
uuid: 'sub-community-uuid',
|
||||
_links: { parentCommunity: { href: 'parent-community-link' } }
|
||||
}),
|
||||
Object.assign(new Community(), {
|
||||
type: COMMUNITY.value,
|
||||
uuid: 'top-community-uuid',
|
||||
}),
|
||||
];
|
||||
linkService = new MockLinkService(ancestorDSOs) as any;
|
||||
initialState = {
|
||||
theme: {
|
||||
currentTheme: 'custom',
|
||||
@@ -82,7 +24,6 @@ describe('ThemeEffects', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ThemeEffects,
|
||||
{ provide: LinkService, useValue: linkService },
|
||||
provideMockStore({ initialState }),
|
||||
provideMockActions(() => mockActions)
|
||||
]
|
||||
@@ -110,205 +51,4 @@ describe('ThemeEffects', () => {
|
||||
expect(themeEffects.initTheme$).toBeObservable(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateThemeOnRouteChange$', () => {
|
||||
const url = '/test/route';
|
||||
const dso = Object.assign(new Community(), {
|
||||
type: COMMUNITY.value,
|
||||
uuid: '0958c910-2037-42a9-81c7-dca80e3892b4',
|
||||
});
|
||||
|
||||
function spyOnPrivateMethods() {
|
||||
spyOn((themeEffects as any), 'getAncestorDSOs').and.returnValue(() => observableOf([dso]));
|
||||
spyOn((themeEffects as any), 'matchThemeToDSOs').and.returnValue(new Theme({ name: 'custom' }));
|
||||
spyOn((themeEffects as any), 'getActionForMatch').and.returnValue(new SetThemeAction('custom'));
|
||||
}
|
||||
|
||||
describe('when a resolved action is present', () => {
|
||||
beforeEach(() => {
|
||||
setupEffectsWithActions(
|
||||
hot('--ab-', {
|
||||
a: {
|
||||
type: ROUTER_NAVIGATED,
|
||||
payload: { routerState: { url } },
|
||||
},
|
||||
b: {
|
||||
type: ResolverActionTypes.RESOLVED,
|
||||
payload: { url, dso },
|
||||
}
|
||||
})
|
||||
);
|
||||
spyOnPrivateMethods();
|
||||
});
|
||||
|
||||
it('should set the theme it receives from the DSO', () => {
|
||||
const expected = cold('--b-', {
|
||||
b: new SetThemeAction('custom')
|
||||
});
|
||||
|
||||
expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when no resolved action is present', () => {
|
||||
beforeEach(() => {
|
||||
setupEffectsWithActions(
|
||||
hot('--a-', {
|
||||
a: {
|
||||
type: ROUTER_NAVIGATED,
|
||||
payload: { routerState: { url } },
|
||||
},
|
||||
})
|
||||
);
|
||||
spyOnPrivateMethods();
|
||||
});
|
||||
|
||||
it('should set the theme it receives from the route url', () => {
|
||||
const expected = cold('--b-', {
|
||||
b: new SetThemeAction('custom')
|
||||
});
|
||||
|
||||
expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when no themes are present', () => {
|
||||
beforeEach(() => {
|
||||
setupEffectsWithActions(
|
||||
hot('--a-', {
|
||||
a: {
|
||||
type: ROUTER_NAVIGATED,
|
||||
payload: { routerState: { url } },
|
||||
},
|
||||
})
|
||||
);
|
||||
(themeEffects as any).themes = [];
|
||||
});
|
||||
|
||||
it('should return an empty action', () => {
|
||||
const expected = cold('--b-', {
|
||||
b: new NoOpAction()
|
||||
});
|
||||
|
||||
expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('private functions', () => {
|
||||
beforeEach(() => {
|
||||
setupEffectsWithActions(hot('-', {}));
|
||||
});
|
||||
|
||||
describe('getActionForMatch', () => {
|
||||
it('should return a SET action if the new theme differs from the current theme', () => {
|
||||
const theme = new Theme({ name: 'new-theme' });
|
||||
expect((themeEffects as any).getActionForMatch(theme, 'old-theme')).toEqual(new SetThemeAction('new-theme'));
|
||||
});
|
||||
|
||||
it('should return an empty action if the new theme equals the current theme', () => {
|
||||
const theme = new Theme({ name: 'old-theme' });
|
||||
expect((themeEffects as any).getActionForMatch(theme, 'old-theme')).toEqual(new NoOpAction());
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchThemeToDSOs', () => {
|
||||
let themes: Theme[];
|
||||
let nonMatchingTheme: Theme;
|
||||
let itemMatchingTheme: Theme;
|
||||
let communityMatchingTheme: Theme;
|
||||
let dsos: DSpaceObject[];
|
||||
|
||||
beforeEach(() => {
|
||||
nonMatchingTheme = Object.assign(new Theme({ name: 'non-matching-theme' }), {
|
||||
matches: () => false
|
||||
});
|
||||
itemMatchingTheme = Object.assign(new Theme({ name: 'item-matching-theme' }), {
|
||||
matches: (url, dso) => (dso as any).type === ITEM.value
|
||||
});
|
||||
communityMatchingTheme = Object.assign(new Theme({ name: 'community-matching-theme' }), {
|
||||
matches: (url, dso) => (dso as any).type === COMMUNITY.value
|
||||
});
|
||||
dsos = [
|
||||
Object.assign(new Item(), {
|
||||
type: ITEM.value,
|
||||
uuid: 'item-uuid',
|
||||
}),
|
||||
Object.assign(new Collection(), {
|
||||
type: COLLECTION.value,
|
||||
uuid: 'collection-uuid',
|
||||
}),
|
||||
Object.assign(new Community(), {
|
||||
type: COMMUNITY.value,
|
||||
uuid: 'community-uuid',
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
||||
describe('when no themes match any of the DSOs', () => {
|
||||
beforeEach(() => {
|
||||
themes = [ nonMatchingTheme ];
|
||||
themeEffects.themes = themes;
|
||||
});
|
||||
|
||||
it('should return undefined', () => {
|
||||
expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when one of the themes match a DSOs', () => {
|
||||
beforeEach(() => {
|
||||
themes = [ nonMatchingTheme, itemMatchingTheme ];
|
||||
themeEffects.themes = themes;
|
||||
});
|
||||
|
||||
it('should return the matching theme', () => {
|
||||
expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when multiple themes match some of the DSOs', () => {
|
||||
it('should return the first matching theme', () => {
|
||||
themes = [ nonMatchingTheme, itemMatchingTheme, communityMatchingTheme ];
|
||||
themeEffects.themes = themes;
|
||||
expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme);
|
||||
|
||||
themes = [ nonMatchingTheme, communityMatchingTheme, itemMatchingTheme ];
|
||||
themeEffects.themes = themes;
|
||||
expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(communityMatchingTheme);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAncestorDSOs', () => {
|
||||
it('should return an array of the provided DSO and its ancestors', (done) => {
|
||||
const dso = Object.assign(new Item(), {
|
||||
type: ITEM.value,
|
||||
uuid: 'item-uuid',
|
||||
_links: { owningCollection: { href: 'owning-collection-link' } },
|
||||
});
|
||||
|
||||
observableOf(dso).pipe(
|
||||
(themeEffects as any).getAncestorDSOs()
|
||||
).subscribe((result) => {
|
||||
expect(result).toEqual([dso, ...ancestorDSOs]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an array of just the provided DSO if it doesn\'t have any parents', (done) => {
|
||||
const dso = {
|
||||
type: ITEM.value,
|
||||
uuid: 'item-uuid',
|
||||
};
|
||||
|
||||
observableOf(dso).pipe(
|
||||
(themeEffects as any).getAncestorDSOs()
|
||||
).subscribe((result) => {
|
||||
expect(result).toEqual([dso]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -1,22 +1,9 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { createEffect, Actions, ofType, ROOT_EFFECTS_INIT } from '@ngrx/effects';
|
||||
import { ROUTER_NAVIGATED, RouterNavigatedAction } from '@ngrx/router-store';
|
||||
import { map, withLatestFrom, expand, switchMap, toArray, startWith, filter } from 'rxjs/operators';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { SetThemeAction } from './theme.actions';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { ThemeConfig, themeFactory, Theme, } from '../../../config/theme.model';
|
||||
import { hasValue, isNotEmpty, hasNoValue } from '../empty.util';
|
||||
import { NoOpAction } from '../ngrx/no-op.action';
|
||||
import { Store, select } from '@ngrx/store';
|
||||
import { ThemeState } from './theme.reducer';
|
||||
import { currentThemeSelector } from './theme.service';
|
||||
import { of as observableOf, EMPTY, Observable } from 'rxjs';
|
||||
import { ResolverActionTypes, ResolvedAction } from '../../core/resolving/resolver.actions';
|
||||
import { followLink } from '../utils/follow-link-config.model';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
|
||||
import { LinkService } from '../../core/cache/builders/link.service';
|
||||
import { hasValue, hasNoValue } from '../empty.util';
|
||||
import { BASE_THEME_NAME } from './theme.constants';
|
||||
|
||||
export const DEFAULT_THEME_CONFIG = environment.themes.find((themeConfig: any) =>
|
||||
@@ -27,16 +14,6 @@ export const DEFAULT_THEME_CONFIG = environment.themes.find((themeConfig: any) =
|
||||
|
||||
@Injectable()
|
||||
export class ThemeEffects {
|
||||
/**
|
||||
* The list of configured themes
|
||||
*/
|
||||
themes: Theme[];
|
||||
|
||||
/**
|
||||
* True if at least one theme depends on the route
|
||||
*/
|
||||
hasDynamicTheme: boolean;
|
||||
|
||||
/**
|
||||
* Initialize with a theme that doesn't depend on the route.
|
||||
*/
|
||||
@@ -53,133 +30,8 @@ export class ThemeEffects {
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* An effect that fires when a route change completes,
|
||||
* and determines whether or not the theme should change
|
||||
*/
|
||||
updateThemeOnRouteChange$ = createEffect(() => this.actions$.pipe(
|
||||
// Listen for when a route change ends
|
||||
ofType(ROUTER_NAVIGATED),
|
||||
withLatestFrom(
|
||||
// Pull in the latest resolved action, or undefined if none was dispatched yet
|
||||
this.actions$.pipe(ofType(ResolverActionTypes.RESOLVED), startWith(undefined)),
|
||||
// and the current theme from the store
|
||||
this.store.pipe(select(currentThemeSelector))
|
||||
),
|
||||
switchMap(([navigatedAction, resolvedAction, currentTheme]: [RouterNavigatedAction, ResolvedAction, string]) => {
|
||||
if (this.hasDynamicTheme === true && isNotEmpty(this.themes)) {
|
||||
const currentRouteUrl = navigatedAction.payload.routerState.url;
|
||||
// If resolvedAction exists, and deals with the current url
|
||||
if (hasValue(resolvedAction) && resolvedAction.payload.url === currentRouteUrl) {
|
||||
// Start with the resolved dso and go recursively through its parents until you reach the top-level community
|
||||
return observableOf(resolvedAction.payload.dso).pipe(
|
||||
this.getAncestorDSOs(),
|
||||
map((dsos: DSpaceObject[]) => {
|
||||
const dsoMatch = this.matchThemeToDSOs(dsos, currentRouteUrl);
|
||||
return this.getActionForMatch(dsoMatch, currentTheme);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// check whether the route itself matches
|
||||
const routeMatch = this.themes.find((theme: Theme) => theme.matches(currentRouteUrl, undefined));
|
||||
|
||||
return [this.getActionForMatch(routeMatch, currentTheme)];
|
||||
}
|
||||
|
||||
// If there are no themes configured, do nothing
|
||||
return [new NoOpAction()];
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (hasValue(newTheme) && newTheme.config.name !== currentThemeName) {
|
||||
// If we have a match, and it isn't already the active theme, set it as the new theme
|
||||
return new SetThemeAction(newTheme.config.name);
|
||||
} 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): Theme {
|
||||
// iterate over the themes in order, and return the first one that matches
|
||||
return this.themes.find((theme: Theme) => {
|
||||
// iterate over the dsos's in order (most specific one first, so Item, Collection,
|
||||
// Community), and return the first one that matches the current theme
|
||||
const match = dsos.find((dso: DSpaceObject) => theme.matches(currentRouteUrl, dso));
|
||||
return hasValue(match);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private actions$: Actions,
|
||||
private store: Store<ThemeState>,
|
||||
private linkService: LinkService,
|
||||
) {
|
||||
// Create objects from the theme configs in the environment file
|
||||
this.themes = environment.themes.map((themeConfig: ThemeConfig) => themeFactory(themeConfig));
|
||||
this.hasDynamicTheme = environment.themes.some((themeConfig: any) =>
|
||||
hasValue(themeConfig.regex) ||
|
||||
hasValue(themeConfig.handle) ||
|
||||
hasValue(themeConfig.uuid)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -1,10 +1,27 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Store, createFeatureSelector, createSelector, select } from '@ngrx/store';
|
||||
import { Store, createFeatureSelector, createSelector, select, Action } from '@ngrx/store';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { ThemeState } from './theme.reducer';
|
||||
import { SetThemeAction } from './theme.actions';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { hasValue } from '../empty.util';
|
||||
import { SetThemeAction, ThemeActionTypes } from './theme.actions';
|
||||
import { expand, filter, map, startWith, switchMap, take, toArray } from 'rxjs/operators';
|
||||
import { hasValue, isNotEmpty } from '../empty.util';
|
||||
import { Actions, ofType } from '@ngrx/effects';
|
||||
import { ResolvedAction, ResolverActionTypes } from '../../core/resolving/resolver.actions';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||
import {
|
||||
getFirstCompletedRemoteData,
|
||||
getFirstSucceededRemoteData,
|
||||
getRemoteDataPayload
|
||||
} from '../../core/shared/operators';
|
||||
import { combineLatest as observableCombineLatest, EMPTY, of as observableOf } from 'rxjs';
|
||||
import { 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';
|
||||
|
||||
export const themeStateSelector = createFeatureSelector<ThemeState>('theme');
|
||||
|
||||
@@ -17,9 +34,29 @@ export const currentThemeSelector = createSelector(
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ThemeService {
|
||||
/**
|
||||
* The list of configured themes
|
||||
*/
|
||||
themes: Theme[];
|
||||
|
||||
/**
|
||||
* True if at least one theme depends on the route
|
||||
*/
|
||||
hasDynamicTheme: boolean;
|
||||
|
||||
constructor(
|
||||
private store: Store<ThemeState>,
|
||||
private actions$: Actions,
|
||||
private linkService: LinkService,
|
||||
private dSpaceObjectDataService: DSpaceObjectDataService,
|
||||
) {
|
||||
// Create objects from the theme configs in the environment file
|
||||
this.themes = environment.themes.map((themeConfig: ThemeConfig) => themeFactory(themeConfig));
|
||||
this.hasDynamicTheme = environment.themes.some((themeConfig: any) =>
|
||||
hasValue(themeConfig.regex) ||
|
||||
hasValue(themeConfig.handle) ||
|
||||
hasValue(themeConfig.uuid)
|
||||
);
|
||||
}
|
||||
|
||||
setTheme(newName: string) {
|
||||
@@ -43,4 +80,136 @@ export class ThemeService {
|
||||
);
|
||||
}
|
||||
|
||||
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$ = currentTheme$.pipe(
|
||||
switchMap((currentTheme: string) => {
|
||||
if (this.hasDynamicTheme === true && isNotEmpty(this.themes)) {
|
||||
if (hasValue(activatedRouteSnapshot) && hasValue(activatedRouteSnapshot.data) && hasValue(activatedRouteSnapshot.data.dso)) {
|
||||
const dsoRD: RemoteData<DSpaceObject> = activatedRouteSnapshot.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(),
|
||||
map((dsos: DSpaceObject[]) => {
|
||||
const dsoMatch = this.matchThemeToDSOs(dsos, currentRouteUrl);
|
||||
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(),
|
||||
map((dsos: DSpaceObject[]) => {
|
||||
const dsoMatch = this.matchThemeToDSOs(dsos, currentRouteUrl);
|
||||
return this.getActionForMatch(dsoMatch, currentTheme);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// check whether the route itself matches
|
||||
const routeMatch = this.themes.find((theme: Theme) => theme.matches(currentRouteUrl, undefined));
|
||||
|
||||
return [this.getActionForMatch(routeMatch, currentTheme)];
|
||||
}
|
||||
|
||||
// If there are no themes configured, do nothing
|
||||
return [new NoOpAction()];
|
||||
}),
|
||||
);
|
||||
|
||||
action$.pipe(
|
||||
take(1),
|
||||
filter((action) => action.type !== NO_OP_ACTION_TYPE),
|
||||
).subscribe((action) => {
|
||||
this.store.dispatch(action);
|
||||
});
|
||||
|
||||
return action$.pipe(
|
||||
map((action) => action.type === ThemeActionTypes.SET),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (hasValue(newTheme) && newTheme.config.name !== currentThemeName) {
|
||||
// If we have a match, and it isn't already the active theme, set it as the new theme
|
||||
return new SetThemeAction(newTheme.config.name);
|
||||
} 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): Theme {
|
||||
// iterate over the themes in order, and return the first one that matches
|
||||
return this.themes.find((theme: Theme) => {
|
||||
// iterate over the dsos's in order (most specific one first, so Item, Collection,
|
||||
// Community), and return the first one that matches the current theme
|
||||
const match = dsos.find((dso: DSpaceObject) => theme.matches(currentRouteUrl, dso));
|
||||
return hasValue(match);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
Reference in New Issue
Block a user