mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-15 22:13:02 +00:00
83631: Add the ability to extend a theme
This commit is contained in:
@@ -7,12 +7,17 @@ import {
|
||||
import { MetadataRepresentationType } from '../../core/shared/metadata-representation/metadata-representation.model';
|
||||
import { Context } from '../../core/shared/context.model';
|
||||
import * as uuidv4 from 'uuid/v4';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
let ogEnvironmentThemes;
|
||||
|
||||
describe('MetadataRepresentation decorator function', () => {
|
||||
const type1 = 'TestType';
|
||||
const type2 = 'TestType2';
|
||||
const type3 = 'TestType3';
|
||||
const type4 = 'RandomType';
|
||||
const typeHier1 = 'TestTypeHier1';
|
||||
const typeHier2 = 'TestTypeHier2';
|
||||
let prefix;
|
||||
|
||||
/* tslint:disable:max-classes-per-file */
|
||||
@@ -31,6 +36,12 @@ describe('MetadataRepresentation decorator function', () => {
|
||||
class Test3ItemSubmission {
|
||||
}
|
||||
|
||||
class TestHier1Ancestor {
|
||||
}
|
||||
|
||||
class TestHier2Unthemed {
|
||||
}
|
||||
|
||||
/* tslint:enable:max-classes-per-file */
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -46,8 +57,17 @@ describe('MetadataRepresentation decorator function', () => {
|
||||
metadataRepresentationComponent(key + type2, MetadataRepresentationType.Item, Context.Workspace)(Test2ItemSubmission);
|
||||
|
||||
metadataRepresentationComponent(key + type3, MetadataRepresentationType.Item, Context.Workspace)(Test3ItemSubmission);
|
||||
|
||||
metadataRepresentationComponent(key + typeHier1, MetadataRepresentationType.Item, Context.Any, 'ancestor')(TestHier1Ancestor);
|
||||
metadataRepresentationComponent(key + typeHier2, MetadataRepresentationType.Item, Context.Any)(TestHier2Unthemed);
|
||||
|
||||
ogEnvironmentThemes = environment.themes;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
environment.themes = ogEnvironmentThemes;
|
||||
});
|
||||
|
||||
describe('If there\'s an exact match', () => {
|
||||
it('should return the matching class', () => {
|
||||
const component = getMetadataRepresentationComponent(prefix + type3, MetadataRepresentationType.Item, Context.Workspace);
|
||||
@@ -76,4 +96,44 @@ describe('MetadataRepresentation decorator function', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('With theme extensions', () => {
|
||||
describe('If requested theme has no match', () => {
|
||||
beforeEach(() => {
|
||||
environment.themes = [
|
||||
{ name: 'requested', extends: 'intermediate' },
|
||||
{ name: 'intermediate', extends: 'ancestor' },
|
||||
];
|
||||
});
|
||||
|
||||
it('should return component from ancestor theme if it has a match', () => {
|
||||
const component = getMetadataRepresentationComponent(prefix + typeHier1, MetadataRepresentationType.Item, Context.Any, 'requested');
|
||||
expect(component).toEqual(TestHier1Ancestor);
|
||||
});
|
||||
|
||||
it('should return default component if ancestor theme has no match', () => {
|
||||
const component = getMetadataRepresentationComponent(prefix + typeHier2, MetadataRepresentationType.Item, Context.Any, 'requested');
|
||||
expect(component).toEqual(TestHier2Unthemed);
|
||||
});
|
||||
});
|
||||
|
||||
describe('If there is a theme extension cycle', () => {
|
||||
beforeEach(() => {
|
||||
environment.themes = [
|
||||
{ name: 'extension-cycle', extends: 'broken1' },
|
||||
{ name: 'broken1', extends: 'broken2' },
|
||||
{ name: 'broken2', extends: 'broken3' },
|
||||
{ name: 'broken3', extends: 'broken1' },
|
||||
];
|
||||
});
|
||||
|
||||
it('should throw an error', () => {
|
||||
expect(() => {
|
||||
getMetadataRepresentationComponent(prefix + typeHier1, MetadataRepresentationType.Item, Context.Any, 'extension-cycle');
|
||||
}).toThrowError(
|
||||
'Theme extension cycle detected: extension-cycle -> broken1 -> broken2 -> broken3 -> broken1'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -3,6 +3,7 @@ import { hasNoValue, hasValue } from '../empty.util';
|
||||
import { Context } from '../../core/shared/context.model';
|
||||
import { InjectionToken } from '@angular/core';
|
||||
import { GenericConstructor } from '../../core/shared/generic-constructor';
|
||||
import { resolveTheme } from '../object-collection/shared/listable-object/listable-object.decorator';
|
||||
|
||||
export const METADATA_REPRESENTATION_COMPONENT_FACTORY = new InjectionToken<(entityType: string, mdRepresentationType: MetadataRepresentationType, context: Context, theme: string) => GenericConstructor<any>>('getMetadataRepresentationComponent', {
|
||||
providedIn: 'root',
|
||||
@@ -57,8 +58,9 @@ export function getMetadataRepresentationComponent(entityType: string, mdReprese
|
||||
if (hasValue(entityAndMDRepMap)) {
|
||||
const contextMap = entityAndMDRepMap.get(context);
|
||||
if (hasValue(contextMap)) {
|
||||
if (hasValue(contextMap.get(theme))) {
|
||||
return contextMap.get(theme);
|
||||
const match = resolveTheme(contextMap, theme);
|
||||
if (hasValue(match)) {
|
||||
return match;
|
||||
}
|
||||
if (hasValue(contextMap.get(DEFAULT_THEME))) {
|
||||
return contextMap.get(DEFAULT_THEME);
|
||||
|
@@ -1,9 +1,18 @@
|
||||
import { ThemeService } from '../theme-support/theme.service';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { ThemeConfig } from '../../../config/theme.model';
|
||||
import { isNotEmpty } from '../empty.util';
|
||||
|
||||
export function getMockThemeService(themeName = 'base'): ThemeService {
|
||||
return jasmine.createSpyObj('themeService', {
|
||||
export function getMockThemeService(themeName = 'base', themes?: ThemeConfig[]): ThemeService {
|
||||
const spy = jasmine.createSpyObj('themeService', {
|
||||
getThemeName: themeName,
|
||||
getThemeName$: observableOf(themeName)
|
||||
getThemeName$: observableOf(themeName),
|
||||
getThemeConfigFor: undefined,
|
||||
});
|
||||
|
||||
if (isNotEmpty(themes)) {
|
||||
spy.getThemeConfigFor.and.callFake((name: string) => themes.find(theme => theme.name === name));
|
||||
}
|
||||
|
||||
return spy;
|
||||
}
|
||||
|
@@ -2,11 +2,16 @@ import { Item } from '../../../../core/shared/item.model';
|
||||
import { ViewMode } from '../../../../core/shared/view-mode.model';
|
||||
import { getListableObjectComponent, listableObjectComponent } from './listable-object.decorator';
|
||||
import { Context } from '../../../../core/shared/context.model';
|
||||
import { environment } from '../../../../../environments/environment';
|
||||
|
||||
let ogEnvironmentThemes;
|
||||
|
||||
describe('ListableObject decorator function', () => {
|
||||
const type1 = 'TestType';
|
||||
const type2 = 'TestType2';
|
||||
const type3 = 'TestType3';
|
||||
const typeHier1 = 'TestTypeHier1';
|
||||
const typeHier2 = 'TestTypeHier2';
|
||||
|
||||
/* tslint:disable:max-classes-per-file */
|
||||
class Test1List {
|
||||
@@ -27,6 +32,12 @@ describe('ListableObject decorator function', () => {
|
||||
class Test3DetailedSubmission {
|
||||
}
|
||||
|
||||
class TestHier1Ancestor {
|
||||
}
|
||||
|
||||
class TestHier2Unthemed {
|
||||
}
|
||||
|
||||
/* tslint:enable:max-classes-per-file */
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -38,6 +49,15 @@ describe('ListableObject decorator function', () => {
|
||||
|
||||
listableObjectComponent(type3, ViewMode.ListElement)(Test3List);
|
||||
listableObjectComponent(type3, ViewMode.DetailedListElement, Context.Workspace)(Test3DetailedSubmission);
|
||||
|
||||
listableObjectComponent(typeHier1, ViewMode.ListElement, Context.Any, 'ancestor')(TestHier1Ancestor);
|
||||
listableObjectComponent(typeHier2, ViewMode.ListElement, Context.Any)(TestHier2Unthemed);
|
||||
|
||||
ogEnvironmentThemes = environment.themes;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
environment.themes = ogEnvironmentThemes;
|
||||
});
|
||||
|
||||
const gridDecorator = listableObjectComponent('Item', ViewMode.GridElement);
|
||||
@@ -80,4 +100,44 @@ describe('ListableObject decorator function', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('With theme extensions', () => {
|
||||
describe('If requested theme has no match', () => {
|
||||
beforeEach(() => {
|
||||
environment.themes = [
|
||||
{ name: 'requested', extends: 'intermediate' },
|
||||
{ name: 'intermediate', extends: 'ancestor' },
|
||||
];
|
||||
});
|
||||
|
||||
it('should return component from ancestor theme if it has a match', () => {
|
||||
const component = getListableObjectComponent([typeHier1], ViewMode.ListElement, Context.Any, 'requested');
|
||||
expect(component).toEqual(TestHier1Ancestor);
|
||||
});
|
||||
|
||||
it('should return default component if ancestor theme has no match', () => {
|
||||
const component = getListableObjectComponent([typeHier2], ViewMode.ListElement, Context.Any, 'requested');
|
||||
expect(component).toEqual(TestHier2Unthemed);
|
||||
});
|
||||
});
|
||||
|
||||
describe('If there is a theme extension cycle', () => {
|
||||
beforeEach(() => {
|
||||
environment.themes = [
|
||||
{ name: 'extension-cycle', extends: 'broken1' },
|
||||
{ name: 'broken1', extends: 'broken2' },
|
||||
{ name: 'broken2', extends: 'broken3' },
|
||||
{ name: 'broken3', extends: 'broken1' },
|
||||
];
|
||||
});
|
||||
|
||||
it('should throw an error', () => {
|
||||
expect(() => {
|
||||
getListableObjectComponent([typeHier1], ViewMode.ListElement, Context.Any, 'extension-cycle');
|
||||
}).toThrowError(
|
||||
'Theme extension cycle detected: extension-cycle -> broken1 -> broken2 -> broken3 -> broken1'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -1,15 +1,26 @@
|
||||
import { ViewMode } from '../../../../core/shared/view-mode.model';
|
||||
import { Context } from '../../../../core/shared/context.model';
|
||||
import { hasNoValue, hasValue } from '../../../empty.util';
|
||||
import { hasNoValue, hasValue, isNotEmpty } from '../../../empty.util';
|
||||
import {
|
||||
DEFAULT_CONTEXT,
|
||||
DEFAULT_THEME
|
||||
} from '../../../metadata-representation/metadata-representation.decorator';
|
||||
import { GenericConstructor } from '../../../../core/shared/generic-constructor';
|
||||
import { ListableObject } from '../listable-object.model';
|
||||
import { environment } from '../../../../../environments/environment';
|
||||
import { ThemeConfig } from '../../../../../config/theme.model';
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
export const DEFAULT_VIEW_MODE = ViewMode.ListElement;
|
||||
|
||||
/**
|
||||
* Factory to allow us to inject getThemeConfigFor so we can mock it in tests
|
||||
*/
|
||||
export const GET_THEME_CONFIG_FOR_FACTORY = new InjectionToken<(str) => ThemeConfig>('getThemeConfigFor', {
|
||||
providedIn: 'root',
|
||||
factory: () => getThemeConfigFor
|
||||
});
|
||||
|
||||
const map = new Map();
|
||||
|
||||
/**
|
||||
@@ -54,8 +65,9 @@ export function getListableObjectComponent(types: (string | GenericConstructor<L
|
||||
if (hasValue(typeModeMap)) {
|
||||
const contextMap = typeModeMap.get(context);
|
||||
if (hasValue(contextMap)) {
|
||||
if (hasValue(contextMap.get(theme))) {
|
||||
return contextMap.get(theme);
|
||||
const match = resolveTheme(contextMap, theme);
|
||||
if (hasValue(match)) {
|
||||
return match;
|
||||
}
|
||||
if (bestMatchValue < 3 && hasValue(contextMap.get(DEFAULT_THEME))) {
|
||||
bestMatchValue = 3;
|
||||
@@ -80,3 +92,35 @@ export function getListableObjectComponent(types: (string | GenericConstructor<L
|
||||
}
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for a ThemeConfig by its name;
|
||||
*/
|
||||
export const getThemeConfigFor = (themeName: string): ThemeConfig => {
|
||||
return environment.themes.find(theme => theme.name === themeName);
|
||||
};
|
||||
|
||||
/**
|
||||
* Find a match in the given map for the given theme name, taking theme extension into account
|
||||
*
|
||||
* @param contextMap A map of theme names to components
|
||||
* @param themeName The name of the theme to check
|
||||
* @param checkedThemeNames The list of theme names that are already checked
|
||||
*/
|
||||
export const resolveTheme = (contextMap: Map<any, any>, themeName: string, checkedThemeNames: string[] = []): any => {
|
||||
const match = contextMap.get(themeName);
|
||||
if (hasValue(match)) {
|
||||
return match;
|
||||
} else {
|
||||
const cfg = getThemeConfigFor(themeName);
|
||||
if (hasValue(cfg) && isNotEmpty(cfg.extends)) {
|
||||
const nextTheme = cfg.extends;
|
||||
const nextCheckedThemeNames = [...checkedThemeNames, themeName];
|
||||
if (checkedThemeNames.includes(nextTheme)) {
|
||||
throw new Error('Theme extension cycle detected: ' + [...nextCheckedThemeNames, nextTheme].join(' -> '));
|
||||
} else {
|
||||
return resolveTheme(contextMap, nextTheme, nextCheckedThemeNames);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@@ -1,10 +1,16 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, Inject } from '@angular/core';
|
||||
import { Store, createFeatureSelector, createSelector, select } 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 { ThemeConfig } from '../../../config/theme.model';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import {
|
||||
GET_THEME_CONFIG_FOR_FACTORY,
|
||||
getThemeConfigFor
|
||||
} from '../object-collection/shared/listable-object/listable-object.decorator';
|
||||
|
||||
export const themeStateSelector = createFeatureSelector<ThemeState>('theme');
|
||||
|
||||
@@ -19,6 +25,7 @@ export const currentThemeSelector = createSelector(
|
||||
export class ThemeService {
|
||||
constructor(
|
||||
private store: Store<ThemeState>,
|
||||
@Inject(GET_THEME_CONFIG_FOR_FACTORY) private gtcf: (str) => ThemeConfig
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -43,4 +50,10 @@ export class ThemeService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for a ThemeConfig by its name;
|
||||
*/
|
||||
getThemeConfigFor(themeName: string): ThemeConfig {
|
||||
return this.gtcf(themeName);
|
||||
}
|
||||
}
|
||||
|
@@ -5,6 +5,7 @@ import { VarDirective } from '../utils/var.directive';
|
||||
import { ThemeService } from './theme.service';
|
||||
import { getMockThemeService } from '../mocks/theme-service.mock';
|
||||
import { TestComponent } from './test/test.component.spec';
|
||||
import { ThemeConfig } from '../../../config/theme.model';
|
||||
|
||||
/* tslint:disable:max-classes-per-file */
|
||||
@Component({
|
||||
@@ -32,8 +33,8 @@ describe('ThemedComponent', () => {
|
||||
let fixture: ComponentFixture<TestThemedComponent>;
|
||||
let themeService: ThemeService;
|
||||
|
||||
function setupTestingModuleForTheme(theme: string) {
|
||||
themeService = getMockThemeService(theme);
|
||||
function setupTestingModuleForTheme(theme: string, themes?: ThemeConfig[]) {
|
||||
themeService = getMockThemeService(theme, themes);
|
||||
TestBed.configureTestingModule({
|
||||
imports: [],
|
||||
declarations: [TestThemedComponent, VarDirective],
|
||||
@@ -44,17 +45,20 @@ describe('ThemedComponent', () => {
|
||||
}).compileComponents();
|
||||
}
|
||||
|
||||
function initComponent() {
|
||||
fixture = TestBed.createComponent(TestThemedComponent);
|
||||
component = fixture.componentInstance;
|
||||
spyOn(component as any, 'importThemedComponent').and.callThrough();
|
||||
component.testInput = 'changed';
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
describe('when the current theme matches a themed component', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
setupTestingModuleForTheme('custom');
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(TestThemedComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.testInput = 'changed';
|
||||
fixture.detectChanges();
|
||||
});
|
||||
beforeEach(initComponent);
|
||||
|
||||
it('should set compRef to the themed component', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
@@ -70,16 +74,12 @@ describe('ThemedComponent', () => {
|
||||
});
|
||||
|
||||
describe('when the current theme doesn\'t match a themed component', () => {
|
||||
describe('and it doesn\'t extend another theme', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
setupTestingModuleForTheme('non-existing-theme');
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(TestThemedComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.testInput = 'changed';
|
||||
fixture.detectChanges();
|
||||
});
|
||||
beforeEach(initComponent);
|
||||
|
||||
it('should set compRef to the default component', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
@@ -93,5 +93,108 @@ describe('ThemedComponent', () => {
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('and it extends another theme', () => {
|
||||
describe('that doesn\'t match it either', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
setupTestingModuleForTheme('current-theme', [
|
||||
{ name: 'current-theme', extends: 'non-existing-theme' },
|
||||
]);
|
||||
}));
|
||||
|
||||
beforeEach(initComponent);
|
||||
|
||||
it('should set compRef to the default component', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme');
|
||||
expect((component as any).importThemedComponent).toHaveBeenCalledWith('non-existing-theme');
|
||||
expect((component as any).compRef.instance.type).toEqual('default');
|
||||
});
|
||||
}));
|
||||
|
||||
it('should sync up this component\'s input with the default component', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect((component as any).compRef.instance.testInput).toEqual('changed');
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('that does match it', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
setupTestingModuleForTheme('current-theme', [
|
||||
{ name: 'current-theme', extends: 'custom' },
|
||||
]);
|
||||
}));
|
||||
|
||||
beforeEach(initComponent);
|
||||
|
||||
it('should set compRef to the themed component', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme');
|
||||
expect((component as any).importThemedComponent).toHaveBeenCalledWith('custom');
|
||||
expect((component as any).compRef.instance.type).toEqual('themed');
|
||||
});
|
||||
}));
|
||||
|
||||
it('should sync up this component\'s input with the themed component', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect((component as any).compRef.instance.testInput).toEqual('changed');
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('that extends another theme that doesn\'t match it either', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
setupTestingModuleForTheme('current-theme', [
|
||||
{ name: 'current-theme', extends: 'parent-theme' },
|
||||
{ name: 'parent-theme', extends: 'non-existing-theme' },
|
||||
]);
|
||||
}));
|
||||
|
||||
beforeEach(initComponent);
|
||||
|
||||
it('should set compRef to the default component', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme');
|
||||
expect((component as any).importThemedComponent).toHaveBeenCalledWith('parent-theme');
|
||||
expect((component as any).importThemedComponent).toHaveBeenCalledWith('non-existing-theme');
|
||||
expect((component as any).compRef.instance.type).toEqual('default');
|
||||
});
|
||||
}));
|
||||
|
||||
it('should sync up this component\'s input with the default component', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect((component as any).compRef.instance.testInput).toEqual('changed');
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('that extends another theme that does match it', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
setupTestingModuleForTheme('current-theme', [
|
||||
{ name: 'current-theme', extends: 'parent-theme' },
|
||||
{ name: 'parent-theme', extends: 'custom' },
|
||||
]);
|
||||
}));
|
||||
|
||||
beforeEach(initComponent);
|
||||
|
||||
it('should set compRef to the themed component', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme');
|
||||
expect((component as any).importThemedComponent).toHaveBeenCalledWith('parent-theme');
|
||||
expect((component as any).importThemedComponent).toHaveBeenCalledWith('custom');
|
||||
expect((component as any).compRef.instance.type).toEqual('themed');
|
||||
});
|
||||
}));
|
||||
|
||||
it('should sync up this component\'s input with the themed component', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect((component as any).compRef.instance.testInput).toEqual('changed');
|
||||
});
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
/* tslint:enable:max-classes-per-file */
|
||||
|
@@ -11,7 +11,7 @@ import {
|
||||
OnChanges
|
||||
} from '@angular/core';
|
||||
import { hasValue, isNotEmpty } from '../empty.util';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { Observable, of as observableOf, Subscription } from 'rxjs';
|
||||
import { ThemeService } from './theme.service';
|
||||
import { fromPromise } from 'rxjs/internal-compatibility';
|
||||
import { catchError, switchMap, map } from 'rxjs/operators';
|
||||
@@ -69,11 +69,7 @@ export abstract class ThemedComponent<T> implements OnInit, OnDestroy, OnChanges
|
||||
this.lazyLoadSub.unsubscribe();
|
||||
}
|
||||
|
||||
this.lazyLoadSub =
|
||||
fromPromise(this.importThemedComponent(this.themeService.getThemeName())).pipe(
|
||||
// if there is no themed version of the component an exception is thrown,
|
||||
// catch it and return null instead
|
||||
catchError(() => [null]),
|
||||
this.lazyLoadSub = this.resolveThemedComponent(this.themeService.getThemeName()).pipe(
|
||||
switchMap((themedFile: any) => {
|
||||
if (hasValue(themedFile) && hasValue(themedFile[this.getComponentName()])) {
|
||||
// if the file is not null, and exports a component with the specified name,
|
||||
@@ -113,4 +109,32 @@ export abstract class ThemedComponent<T> implements OnInit, OnDestroy, OnChanges
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to import this component from the current theme or a theme it {@link NamedThemeConfig.extends}.
|
||||
* Recurse until we succeed or when until we run out of themes to fall back to.
|
||||
*
|
||||
* @param themeName The name of the theme to check
|
||||
* @param checkedThemeNames The list of theme names that are already checked
|
||||
* @private
|
||||
*/
|
||||
private resolveThemedComponent(themeName?: string, checkedThemeNames: string[] = []): Observable<any> {
|
||||
if (isNotEmpty(themeName)) {
|
||||
return fromPromise(this.importThemedComponent(themeName)).pipe(
|
||||
catchError(() => {
|
||||
// Try the next ancestor theme instead
|
||||
const nextTheme = this.themeService.getThemeConfigFor(themeName)?.extends;
|
||||
const nextCheckedThemeNames = [...checkedThemeNames, themeName];
|
||||
if (checkedThemeNames.includes(nextTheme)) {
|
||||
throw new Error('Theme extension cycle detected: ' + [...nextCheckedThemeNames, nextTheme].join(' -> '));
|
||||
} else {
|
||||
return this.resolveThemedComponent(nextTheme, nextCheckedThemeNames);
|
||||
}
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// If we got here, we've failed to import this component from any ancestor theme → fall back to unthemed
|
||||
return observableOf(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -6,6 +6,12 @@ import { getDSORoute } from '../app/app-routing-paths';
|
||||
// tslint:disable:max-classes-per-file
|
||||
export interface NamedThemeConfig extends Config {
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Specify another theme to build upon: whenever a themed component is not found in the current theme,
|
||||
* its ancestor theme(s) will be checked recursively before falling back to the default theme.
|
||||
*/
|
||||
extends?: string;
|
||||
}
|
||||
|
||||
export interface RegExThemeConfig extends NamedThemeConfig {
|
||||
|
Reference in New Issue
Block a user