From 2d638a738e9951ade099e54d537a2e99e5989b79 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 22 Sep 2021 11:51:05 +0200 Subject: [PATCH] 83628: Dynamic theme fixes --- src/app/app.component.ts | 28 +- .../theme-support/theme.effects.spec.ts | 260 ------------------ src/app/shared/theme-support/theme.effects.ts | 152 +--------- src/app/shared/theme-support/theme.service.ts | 177 +++++++++++- 4 files changed, 200 insertions(+), 417 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 356025da9e..d3668fad5a 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -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 = new BehaviorSubject(false); + isThemeCSSLoading$: BehaviorSubject = new BehaviorSubject(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); } diff --git a/src/app/shared/theme-support/theme.effects.spec.ts b/src/app/shared/theme-support/theme.effects.spec.ts index 7a0e9c8f19..43727df8d6 100644 --- a/src/app/shared/theme-support/theme.effects.spec.ts +++ b/src/app/shared/theme-support/theme.effects.spec.ts @@ -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(); - }); - }); - }); - }); }); diff --git a/src/app/shared/theme-support/theme.effects.ts b/src/app/shared/theme-support/theme.effects.ts index 894cfeca75..e120257728 100644 --- a/src/app/shared/theme-support/theme.effects.ts +++ b/src/app/shared/theme-support/theme.effects.ts @@ -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): Observable => - 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(dso, followLink(linkName)).pipe( - getFirstCompletedRemoteData(), - map((rd: RemoteData) => { - 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, - 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) - ); } } diff --git a/src/app/shared/theme-support/theme.service.ts b/src/app/shared/theme-support/theme.service.ts index 7b0af93e04..f351f320d7 100644 --- a/src/app/shared/theme-support/theme.service.ts +++ b/src/app/shared/theme-support/theme.service.ts @@ -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('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, + 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 { + // and the current theme from the store + const currentTheme$: Observable = 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 = 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> = 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): Observable => + 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(dso, followLink(linkName)).pipe( + getFirstCompletedRemoteData(), + map((rd: RemoteData) => { + 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); + }); + } + }