diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index bd2d832c67..c29b1fb2fb 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -40,6 +40,7 @@ import { CSSVariableServiceStub } from './shared/testing/css-variable-service-st import { MenuServiceStub } from './shared/testing/menu-service-stub'; import { HostWindowService } from './shared/host-window.service'; import { HostWindowServiceStub } from './shared/testing/host-window-service-stub'; +import { ThemeService } from './core/theme/theme.service'; import { ActivatedRoute, Router } from '@angular/router'; import { RouteService } from './shared/services/route.service'; import { MockActivatedRoute } from './shared/mocks/mock-active-router'; @@ -78,6 +79,7 @@ describe('App component', () => { { provide: MenuService, useValue: menuService }, { provide: CSSVariableService, useClass: CSSVariableServiceStub }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, + { provide: ThemeService, useValue: {getCurrentTheme: () => {/* No implementation */}} }, AppComponent, RouteService ], diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 65f59f49a8..7abc1aab97 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -34,7 +34,6 @@ import { combineLatest as combineLatestObservable, of } from 'rxjs'; import { HostWindowService } from './shared/host-window.service'; import { ThemeService } from './core/theme/theme.service'; import { Theme } from '../config/theme.inferface'; -import { isNotEmpty } from './shared/empty.util'; @Component({ selector: 'ds-app', @@ -92,10 +91,6 @@ export class AppComponent implements OnInit, AfterViewInit { } ngOnInit() { - const availableThemes: Theme[] = this.config.themes; - if (isNotEmpty(availableThemes)) { - this.themeService.setCurrentTheme(availableThemes[0]); - } this.theme = this.themeService.getCurrentTheme(); const env: string = this.config.production ? 'Production' : 'Development'; diff --git a/src/app/core/core.effects.ts b/src/app/core/core.effects.ts index 94669cac31..6f4eed018b 100644 --- a/src/app/core/core.effects.ts +++ b/src/app/core/core.effects.ts @@ -5,7 +5,6 @@ import { AuthEffects } from './auth/auth.effects'; import { JsonPatchOperationsEffects } from './json-patch/json-patch-operations.effects'; import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects'; import { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects'; -import { ThemeEffects } from './theme/theme.effects'; export const coreEffects = [ RequestEffects, @@ -15,5 +14,4 @@ export const coreEffects = [ JsonPatchOperationsEffects, ServerSyncBufferEffects, ObjectUpdatesEffects, - ThemeEffects ]; diff --git a/src/app/core/theme/theme.effects.ts b/src/app/core/theme/theme.effects.ts deleted file mode 100644 index f03b356b78..0000000000 --- a/src/app/core/theme/theme.effects.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Inject, Injectable } from '@angular/core'; -import { Action, Store } from '@ngrx/store'; -import { Actions, Effect } from '@ngrx/effects'; -import { CoreState } from '../core.reducers'; -import { SetThemeAction } from './theme.actions'; -import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; -import { Theme } from '../../../config/theme.inferface'; -import { isNotEmpty } from '../../shared/empty.util'; -import { asyncScheduler, defer, Observable, of as observableOf } from 'rxjs'; - -@Injectable() -export class ThemeEffects { - - // @Effect() setInitialTheme: Observable = defer(() => { - // console.log('set theme'); - // const availableThemes: Theme[] = this.config.themes; - // if (isNotEmpty(availableThemes)) { - // return observableOf(new SetThemeAction(availableThemes[0]), asyncScheduler); - // } - // }); - // - // constructor(private actions: Actions, private store: Store, @Inject(GLOBAL_CONFIG) public config: GlobalConfig) { - // console.log("theme effects"); - // } -} diff --git a/src/app/core/theme/theme.reducer.spec.ts b/src/app/core/theme/theme.reducer.spec.ts new file mode 100644 index 0000000000..662f9bb852 --- /dev/null +++ b/src/app/core/theme/theme.reducer.spec.ts @@ -0,0 +1,46 @@ +import * as deepFreeze from 'deep-freeze'; +import { SetThemeAction } from './theme.actions'; +import { themeReducer } from './theme.reducer'; +import { Theme } from '../../../config/theme.inferface'; + +class NullAction extends SetThemeAction { + type = null; + payload = null; + + constructor() { + super(null); + } +} + +const newTheme: Theme = { name: 'New theme', cssClass: 'new-class' }; +describe('themeReducer', () => { + const testState = { theme: { name: 'test theme', cssClass: 'test-class' } }; + deepFreeze(testState); + + it('should return the current state when no valid actions have been made', () => { + const action = new NullAction(); + const newState = themeReducer(testState, action); + + expect(newState).toEqual(testState); + }); + + it('should start with an empty object', () => { + const action = new NullAction(); + const initialState = themeReducer(undefined, action); + + expect(initialState).toEqual({} as any); + }); + + it('should perform the SET action without affecting the previous state', () => { + const action = new SetThemeAction(newTheme); + // testState has already been frozen above + themeReducer(testState, action); + }); + + it('should return a new state with the new theme when calling the SET action with this new theme', () => { + const action = new SetThemeAction(newTheme); + + const newState = themeReducer(testState, action); + expect(newState.theme).toEqual(newTheme); + }); +}); diff --git a/src/app/core/theme/theme.reducer.ts b/src/app/core/theme/theme.reducer.ts index fb3e0eb7b8..26f717872d 100644 --- a/src/app/core/theme/theme.reducer.ts +++ b/src/app/core/theme/theme.reducer.ts @@ -1,6 +1,9 @@ import { Theme } from '../../../config/theme.inferface'; import { ThemeAction, ThemeActionTypes } from './theme.actions'; +/** + * Represents the state of the current theme in the store + */ export interface ThemeState { theme: Theme } @@ -8,12 +11,16 @@ export interface ThemeState { // Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) const initialState = Object.create(null); +/** + * Reducer the current theme state to the new state depending on a given action + * @param state The previous state, equal to the initial state when it was not defined before + * @param action The action to perform on the current theme state + */ export function themeReducer(state = initialState, action: ThemeAction): ThemeState { switch (action.type) { case ThemeActionTypes.SET: { - const newState = Object.assign({}, state, { theme: action.payload.theme }); - console.log(newState); - return newState; + return Object.assign({}, state, { theme: action.payload.theme }); } } + return state; } diff --git a/src/app/core/theme/theme.service.spec.ts b/src/app/core/theme/theme.service.spec.ts new file mode 100644 index 0000000000..3edbc764a3 --- /dev/null +++ b/src/app/core/theme/theme.service.spec.ts @@ -0,0 +1,60 @@ +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { ThemeService } from './theme.service'; +import { SetThemeAction } from './theme.actions'; +import * as ngrx from '@ngrx/store'; +import { Theme } from '../../../config/theme.inferface'; +import { of as observableOf } from 'rxjs'; +import { first } from 'rxjs/operators'; + +describe('ThemeService', () => { + let service: ThemeService; + let store: Store; + const initialTheme: Theme = { + name: 'test theme', + cssClass: 'test-class' + }; + const config = { + themes: [initialTheme] + } as any; + beforeEach(() => { + store = new Store(undefined, undefined, undefined); + spyOn(store, 'dispatch'); + service = new ThemeService(store, config); + }); + + describe('when the service is created', () => { + beforeEach(() => { + spyOn(ThemeService.prototype, 'setCurrentTheme'); + service = new ThemeService(store, config); + }); + + it('should call setCurrentTheme action on itself with the theme from the configuration', () => { + expect(service.setCurrentTheme).toHaveBeenCalledWith(initialTheme); + }); + }); + + describe('when setCurrentTheme op the service is called', () => { + it('should dispatch a SET action on the store', () => { + service.setCurrentTheme(initialTheme); + expect(store.dispatch).toHaveBeenCalledWith(new SetThemeAction(initialTheme)); + }); + }); + + describe('when getCurrentTheme op the service is called', () => { + beforeEach(() => { + spyOnProperty(ngrx, 'select').and.callFake(() => { + return () => { + return () => observableOf({ theme: initialTheme }); + }; + }); + }); + + it('should select the current theme from the store', () => { + const theme = service.getCurrentTheme(); + theme.pipe(first()).subscribe((newTheme) => { + expect(newTheme).toEqual(initialTheme); + }); + }); + }); +}); diff --git a/src/app/core/theme/theme.service.ts b/src/app/core/theme/theme.service.ts index 998fe6b856..2da6a28f38 100644 --- a/src/app/core/theme/theme.service.ts +++ b/src/app/core/theme/theme.service.ts @@ -6,9 +6,11 @@ import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { ThemeState } from './theme.reducer'; import { SetThemeAction } from './theme.actions'; +import { isNotEmpty } from '../../shared/empty.util'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; function themeStateSelector(): MemoizedSelector { - return createSelector(coreSelector, (state: CoreState) => state['theme']); + return createSelector(coreSelector, (state: CoreState) => state.theme); } /** @@ -16,10 +18,21 @@ function themeStateSelector(): MemoizedSelector { */ @Injectable() export class ThemeService { - constructor(private store: Store) { - + /** + * Sets the initial theme read from configuration + * @param store The current store for the core state + * @param config The global configuration + */ + constructor(private store: Store, @Inject(GLOBAL_CONFIG) public config: GlobalConfig) { + const availableThemes: Theme[] = this.config.themes; + if (isNotEmpty(availableThemes)) { + this.setCurrentTheme(availableThemes[0]); + } } + /** + * Returns the current theme from the store + */ public getCurrentTheme(): Observable { return this.store.pipe( select(themeStateSelector()), @@ -27,7 +40,11 @@ export class ThemeService { ); } + /** + * Sets the current theme in the store + * @param theme The new theme + */ public setCurrentTheme(theme: Theme): void { return this.store.dispatch(new SetThemeAction(theme)); } -} \ No newline at end of file +} diff --git a/webpack/webpack.test.js b/webpack/webpack.test.js index 8c6760e377..233fb2be57 100644 --- a/webpack/webpack.test.js +++ b/webpack/webpack.test.js @@ -165,7 +165,8 @@ module.exports = function (options) { options: { sourceMap: true } - } + }, + 'webpack-import-glob-loader' ], exclude: [root('src/index.html')] },