83631: Add the ability to extend a theme

This commit is contained in:
Yura
2021-09-17 15:11:49 +02:00
parent 9fc7b57157
commit 0b62144d97
9 changed files with 381 additions and 60 deletions

View File

@@ -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'
);
});
});
});
});

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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'
);
});
});
});
});

View File

@@ -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);
}
}
}
};

View File

@@ -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);
}
}

View File

@@ -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 */

View 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);
}
}
}

View File

@@ -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 {