diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 8ef686a5..12984108 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -32,12 +32,18 @@ import { import { ComponentHarness, setupTestModuleForComponent } from './testing/testing.helpers'; import { createDataElementSearchServiceStub } from './testing/stubs/data-element-search.stub'; import { DataElementSearchService } from './data-explorer/data-element-search.service'; +import { createThemeServiceStub } from './testing/stubs/theme.stub'; +import { defaultTheme, ThemeService } from './shared/theme.service'; +import { of } from 'rxjs'; describe('AppComponent', () => { let harness: ComponentHarness; const dataRequestsStub = createDataRequestsServiceStub(); const endpointsStub: MdmEndpointsServiceStub = createMdmEndpointsStub(); const dataElementSearchStub = createDataElementSearchServiceStub(); + const themesStub = createThemeServiceStub(); + + themesStub.loadTheme.mockImplementation(() => of(defaultTheme)); beforeEach(async () => { harness = await setupTestModuleForComponent(AppComponent, { @@ -63,6 +69,10 @@ describe('AppComponent', () => { provide: DataElementSearchService, useValue: dataElementSearchStub, }, + { + provide: ThemeService, + useValue: themesStub, + }, ], }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 8c64fa94..81f33c2e 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -178,7 +178,10 @@ export class AppComponent implements OnInit, OnDestroy { private userIdle: UserIdleService, private error: ErrorService, private themes: ThemeService - ) {} + ) { + // Load the theme into the DOM as the first thing to do + this.themes.loadTheme().subscribe((theme) => this.themes.applyTheme(theme)); + } @HostListener('window:mousemove', ['$event']) onMouseMove() { @@ -186,8 +189,6 @@ export class AppComponent implements OnInit, OnDestroy { } ngOnInit(): void { - this.themes.loadTheme().subscribe((theme) => this.themes.applyTheme(theme)); - this.broadcast .on('http-application-offline') .pipe(takeUntil(this.unsubscribe$)) diff --git a/src/app/mauro/mauro.types.ts b/src/app/mauro/mauro.types.ts index 10b39125..97be2065 100644 --- a/src/app/mauro/mauro.types.ts +++ b/src/app/mauro/mauro.types.ts @@ -50,3 +50,17 @@ export const isDataClass = ( export const isDataElement = (item: DataClass | DataElement): item is DataElement => item.domainType === CatalogueItemDomainType.DataElement; + +export interface KeyValueIdentifier { + id: Uuid; + key: string; + value: string; +} + +export const getKviValue = ( + items: KeyValueIdentifier[], + key: string, + defaultValue: string +) => { + return items.find((i) => i.key === key)?.value ?? defaultValue; +}; diff --git a/src/app/mauro/plugins/plugin-research.resource.ts b/src/app/mauro/plugins/plugin-research.resource.ts index 735c480e..4da7f8b6 100644 --- a/src/app/mauro/plugins/plugin-research.resource.ts +++ b/src/app/mauro/plugins/plugin-research.resource.ts @@ -112,4 +112,18 @@ export class MdmPluginResearchResource extends MdmResource { const url = `${this.apiEndpoint}/explorer/rootDataModel`; return this.simpleGet(url, query, options); } + + /** + * `HTTP GET` - Gets the theme settings configured via API properties. + * + * @param query Optional query parameters, if required. + * @param options Optional REST handler parameters, if required. + * @returns The result of the `GET` request: + * + * `200 OK` - will return a paged set of key/value pairs. + */ + theme(query?: QueryParameters, options?: RequestSettings) { + const url = `${this.apiEndpoint}/explorer/theme`; + return this.simpleGet(url, query, options); + } } diff --git a/src/app/mauro/research-plugin.service.ts b/src/app/mauro/research-plugin.service.ts index 7934246f..3ab69861 100644 --- a/src/app/mauro/research-plugin.service.ts +++ b/src/app/mauro/research-plugin.service.ts @@ -22,9 +22,11 @@ import { DataModelDetailResponse, FolderDetail, FolderDetailResponse, + MdmIndexResponse, Uuid, } from '@maurodatamapper/mdm-resources'; import { map, Observable, switchMap } from 'rxjs'; +import { KeyValueIdentifier } from './mauro.types'; import { MdmEndpointsService } from './mdm-endpoints.service'; import { PluginResearchContactPayload, @@ -67,4 +69,10 @@ export class ResearchPluginService { .rootDataModel() .pipe(map((response: FolderDetailResponse) => response.body)); } + + theme(): Observable { + return this.endpoints.pluginResearch + .theme() + .pipe(map((response: MdmIndexResponse) => response.body.items)); + } } diff --git a/src/app/shared/theme.service.spec.ts b/src/app/shared/theme.service.spec.ts index 184b9f46..da1e5e73 100644 --- a/src/app/shared/theme.service.spec.ts +++ b/src/app/shared/theme.service.spec.ts @@ -18,7 +18,10 @@ SPDX-License-Identifier: Apache-2.0 */ import { setupTestModuleForService } from '../testing/testing.helpers'; import { expect } from '@jest/globals'; -import { ThemeService } from './theme.service'; +import { defaultTheme, ThemeService } from './theme.service'; +import { createResearchPluginServiceStub } from '../testing/stubs/research-plugin.stub'; +import { ResearchPluginService } from '../mauro/research-plugin.service'; +import { cold, ObservableWithSubscriptions } from 'jest-marbles'; const toHaveCssVariable = ( style: CSSStyleDeclaration, @@ -44,93 +47,118 @@ expect.extend({ toHaveCssVariable }); declare module 'expect' { interface Matchers { toHaveCssVariable(name: string, expected: string): R; + toBeObservable(observable: ObservableWithSubscriptions): void; } } describe('ThemeService', () => { let service: ThemeService; + const researchPluginStub = createResearchPluginServiceStub(); beforeEach(() => { - service = setupTestModuleForService(ThemeService); + service = setupTestModuleForService(ThemeService, { + providers: [ + { + provide: ResearchPluginService, + useValue: researchPluginStub, + }, + ], + }); }); it('should be created', () => { expect(service).toBeTruthy(); }); - // TODO: write tests for loadTheme() when actively loading theme data from server + describe('load theme', () => { + it('should return the default theme if unable to fetch from server', () => { + researchPluginStub.theme.mockImplementationOnce(() => + cold('#', null, new Error('Server error')) + ); - const expectCssVariable = (name: string, expected: string) => { - expect(document.documentElement.style).toHaveCssVariable(name, expected); - }; + const expected$ = cold('(a|)', { a: defaultTheme }); + const actual$ = service.loadTheme(); + expect(actual$).toBeObservable(expected$); + }); - describe('apply theme', () => { - const theme = { - material: { - colors: { - primary: '#19381f', - accent: '#cdb980', - warn: '#a5122a', - }, - // Default typography settings taken from - // https://github.com/angular/components/blob/main/src/material/core/typography/_typography.scss - typography: { - fontFamily: 'Roboto, "Helvetica Neue", sans-serif', - body1: { - fontSize: '14px', - lineHeight: '20px', - fontWeight: 400, - }, - body2: { - fontSize: '14px', - lineHeight: '24px', - fontWeight: 500, - }, - headline: { - fontSize: '24px', - lineHeight: '32px', - fontWeight: 400, - }, - title: { - fontSize: '20px', - lineHeight: '32px', - fontWeight: 500, - }, - subheading2: { - fontSize: '16px', - lineHeight: '28px', - fontWeight: 400, - }, - subheading1: { - fontSize: '15px', - lineHeight: '24px', - fontWeight: 400, - }, - button: { - fontSize: '14px', - lineHeight: '14px', - fontWeight: 500, + it('should return a non-default theme from the server and merged with defaults', () => { + // Arrange + const primaryColor = '#ff0000'; + const accentColor = '#00ff00'; + const warnColor = '#0000ff'; + const fontFamily = 'Comic Sans'; + const buttonTypeSettings = ['24px', '16px', '700']; + const body1TypeSettings = ['32px', '12px', '500']; + const pageColor = '#f0f0f0'; + + const changes = { + 'material.colors.primary': primaryColor, + 'material.colors.accent': accentColor, + 'material.colors.warn': warnColor, + 'material.typography.fontfamily': fontFamily, + 'material.typography.button': buttonTypeSettings.join(', '), // Intentional: test with ", " separator + 'material.typography.bodyone': body1TypeSettings.join(','), // Intentional: test with "," separator (without space) + 'contrastcolors.page': pageColor, + }; + + researchPluginStub.theme.mockImplementationOnce(() => { + const items = Object.entries(changes).map(([key, value]) => { + return { + id: '123', + key, + value, + }; + }); + return cold('--a|', { a: items }); + }); + + // Build an expected theme, starting with the default as a base and + // making some adjustments to show it's not entirely the same + const expectedTheme = { + ...defaultTheme, + material: { + colors: { + primary: primaryColor, + accent: accentColor, + warn: warnColor, }, - input: { - fontSize: 'inherit', - fontWeight: 400, + typography: { + ...defaultTheme.material.typography, + fontFamily, + button: { + fontSize: buttonTypeSettings[0], + lineHeight: buttonTypeSettings[1], + fontWeight: buttonTypeSettings[2], + }, + body1: { + fontSize: body1TypeSettings[0], + lineHeight: body1TypeSettings[1], + fontWeight: body1TypeSettings[2], + }, }, }, - }, - regularColors: { - hyperlink: '#003752', - requestCount: '#ffe603', - }, - contrastColors: { - page: '#fff', - unsentRequest: '#68d4ca', - submittedRequest: '#008bce', - classRow: '#c4c4c4', - }, - }; + contrastColors: { + ...defaultTheme.contrastColors, + page: pageColor, + }, + }; + + // Act + const actual$ = service.loadTheme(); + + // Assert + const expected$ = cold('--a|', { a: expectedTheme }); + expect(actual$).toBeObservable(expected$); + }); + }); + const expectCssVariable = (name: string, expected: string) => { + expect(document.documentElement.style).toHaveCssVariable(name, expected); + }; + + describe('apply theme', () => { beforeEach(() => { - service.applyTheme(theme); + service.applyTheme(defaultTheme); }); it('should set the Material "primary" color palette', () => { @@ -274,9 +302,9 @@ describe('ThemeService', () => { expectCssVariable('--theme-color-page', '#ffffff'); expectCssVariable('--theme-color-page-contrast', 'rgba(black, 0.87)'); - expectCssVariable('--theme-color-unsentRequest', '#68d4ca'); - expectCssVariable('--theme-color-unsentRequest-contrast', 'rgba(black, 0.87)'); - expectCssVariable('--theme-color-submittedRequest', '#008bce'); + expectCssVariable('--theme-color-unsentRequest', '#008bce'); + expectCssVariable('--theme-color-unsentRequest-contrast', 'white'); + expectCssVariable('--theme-color-submittedRequest', '#0e8f77'); expectCssVariable('--theme-color-submittedRequest-contrast', 'white'); expectCssVariable('--theme-color-classRow', '#c4c4c4'); expectCssVariable('--theme-color-classRow-contrast', 'rgba(black, 0.87)'); diff --git a/src/app/shared/theme.service.ts b/src/app/shared/theme.service.ts index 1f489f26..94338a6e 100644 --- a/src/app/shared/theme.service.ts +++ b/src/app/shared/theme.service.ts @@ -17,8 +17,10 @@ limitations under the License. SPDX-License-Identifier: Apache-2.0 */ import { Injectable } from '@angular/core'; -import { Observable, of } from 'rxjs'; +import { catchError, map, Observable, of } from 'rxjs'; import tinycolor, { ColorFormats } from 'tinycolor2'; +import { getKviValue, KeyValueIdentifier } from '../mauro/mauro.types'; +import { ResearchPluginService } from '../mauro/research-plugin.service'; /** * Represents the core colours to use for the colour palettes in an Angular Material theme. @@ -84,7 +86,7 @@ export interface Theme { contrastColors: ThemeContrastColors; } -const defaultTheme: Theme = { +export const defaultTheme: Theme = { material: { colors: { primary: '#19381f', @@ -148,6 +150,33 @@ const defaultTheme: Theme = { }, }; +const mapKviToTypographyLevel = ( + items: KeyValueIdentifier[], + key: string, + defaultValue: ThemeMaterialTypographyLevel +) => { + const value = getKviValue(items, key, ''); + if (value === '') { + return defaultValue; + } + + // Remove unecessary whitespace before comma split + // Format should be "fontSize, lineHeight, fontWeight" + const parts = value.replace(/\s/g, '').split(','); + if (parts.length !== 3) { + console.warn( + `Theme value "${key}" does not have 3 values. Got "${value}", expected "fontSize, lineHeight, fontWeight"` + ); + return defaultValue; + } + + return { + fontSize: parts[0], + lineHeight: parts[1], + fontWeight: parts[2], + }; +}; + const lightContrastTextColor = 'rgba(black, 0.87)'; const darkContrastTextColor = 'white'; @@ -164,14 +193,29 @@ interface Color { providedIn: 'root', }) export class ThemeService { + constructor(private researchPlugin: ResearchPluginService) {} + + private static multiply(rgb1: ColorFormats.RGBA, rgb2: ColorFormats.RGBA) { + rgb1.b = Math.floor((rgb1.b * rgb2.b) / 255); + rgb1.g = Math.floor((rgb1.g * rgb2.g) / 255); + rgb1.r = Math.floor((rgb1.r * rgb2.r) / 255); + return tinycolor(`rgb ${rgb1.r} ${rgb1.g} ${rgb1.b}`); + } + /** * Load the theme data required to apply to the application. * * @returns A {@link Theme} object containing all color and typography information. */ loadTheme(): Observable { - // TODO: hardcoded for now, a later task must load this from the server - return of(defaultTheme); + return this.researchPlugin.theme().pipe( + catchError((error) => { + // Ensure that there is always a theme returned + console.error(error); + return of(undefined); + }), + map((props) => (props ? this.mapValuesToTheme(props) : defaultTheme)) + ); } /** @@ -190,6 +234,104 @@ export class ThemeService { this.applyAppColorsToCss(theme); } + private mapValuesToTheme(props: KeyValueIdentifier[]): Theme { + return { + material: { + colors: { + primary: getKviValue( + props, + 'material.colors.primary', + defaultTheme.material.colors.primary + ), + accent: getKviValue( + props, + 'material.colors.accent', + defaultTheme.material.colors.accent + ), + warn: getKviValue( + props, + 'material.colors.warn', + defaultTheme.material.colors.warn + ), + }, + typography: { + fontFamily: getKviValue( + props, + 'material.typography.fontfamily', + defaultTheme.material.typography.fontFamily + ), + body1: mapKviToTypographyLevel( + props, + 'material.typography.bodyone', + defaultTheme.material.typography.body1 + ), + body2: mapKviToTypographyLevel( + props, + 'material.typography.bodytwo', + defaultTheme.material.typography.body2 + ), + headline: mapKviToTypographyLevel( + props, + 'material.typography.headline', + defaultTheme.material.typography.headline + ), + title: mapKviToTypographyLevel( + props, + 'material.typography.title', + defaultTheme.material.typography.title + ), + subheading1: mapKviToTypographyLevel( + props, + 'material.typography.subheadingone', + defaultTheme.material.typography.subheading1 + ), + subheading2: mapKviToTypographyLevel( + props, + 'material.typography.subheadingtwo', + defaultTheme.material.typography.subheading2 + ), + button: mapKviToTypographyLevel( + props, + 'material.typography.button', + defaultTheme.material.typography.button + ), + // Intentionally use hardcoded values for input typography, required for Angular Material theme to compile + input: defaultTheme.material.typography.input, + }, + }, + regularColors: { + hyperlink: getKviValue( + props, + 'regularcolors.hyperlink', + defaultTheme.regularColors.hyperlink + ), + requestCount: getKviValue( + props, + 'regularcolors.requestcount', + defaultTheme.regularColors.requestCount + ), + }, + contrastColors: { + page: getKviValue(props, 'contrastcolors.page', defaultTheme.contrastColors.page), + unsentRequest: getKviValue( + props, + 'contrastcolors.unsentrequest', + defaultTheme.contrastColors.unsentRequest + ), + submittedRequest: getKviValue( + props, + 'contrastcolors.submittedrequest', + defaultTheme.contrastColors.submittedRequest + ), + classRow: getKviValue( + props, + 'contrastcolors.classrow', + defaultTheme.contrastColors.classRow + ), + }, + }; + } + /** * Auto-generates a full Angular Material colour palette from a single colour and applies it to the DOM. * @@ -265,7 +407,7 @@ export class ThemeService { this.applyCss(warn); const contrastColors = Object.entries(theme.contrastColors) - .map(([property, value]) => this.computeContrastColors(property, value)) + .map(([property, value]) => this.computeContrastColors(property, value as string)) .reduce((prev, current) => { return { ...prev, @@ -279,7 +421,7 @@ export class ThemeService { (prev, [property, value]) => { return { ...prev, - [`--theme-color-${property}`]: tinycolor(value).toHexString(), + [`--theme-color-${property}`]: tinycolor(value as string).toHexString(), }; }, {} @@ -394,11 +536,4 @@ export class ThemeService { [`--theme-mat-typography-${name}-font-weight`]: level.fontWeight, }; } - - private static multiply(rgb1: ColorFormats.RGBA, rgb2: ColorFormats.RGBA) { - rgb1.b = Math.floor((rgb1.b * rgb2.b) / 255); - rgb1.g = Math.floor((rgb1.g * rgb2.g) / 255); - rgb1.r = Math.floor((rgb1.r * rgb2.r) / 255); - return tinycolor(`rgb ${rgb1.r} ${rgb1.g} ${rgb1.b}`); - } } diff --git a/src/app/testing/stubs/mdm-resources/plugin-research-resource.stub.ts b/src/app/testing/stubs/mdm-resources/plugin-research-resource.stub.ts index 9a95b4e9..a5e6f4ed 100644 --- a/src/app/testing/stubs/mdm-resources/plugin-research-resource.stub.ts +++ b/src/app/testing/stubs/mdm-resources/plugin-research-resource.stub.ts @@ -16,8 +16,14 @@ limitations under the License. SPDX-License-Identifier: Apache-2.0 */ -import { FolderDetail, MdmResponse, Uuid } from '@maurodatamapper/mdm-resources'; +import { + FolderDetail, + MdmIndexResponse, + MdmResponse, + Uuid, +} from '@maurodatamapper/mdm-resources'; import { Observable } from 'rxjs'; +import { KeyValueIdentifier } from 'src/app/mauro/mauro.types'; import { PluginResearchContactPayload, PluginResearchContactResponse, @@ -34,6 +40,7 @@ export interface MdmPluginResearchResourceStub { submitRequest: jest.MockedFunction; userFolder: jest.MockedFunction; templateFolder: jest.MockedFunction<() => Observable>; + theme: jest.MockedFunction<() => Observable>>; } export const createPluginResearchStub = (): MdmPluginResearchResourceStub => { @@ -42,5 +49,6 @@ export const createPluginResearchStub = (): MdmPluginResearchResourceStub => { submitRequest: jest.fn() as jest.MockedFunction, userFolder: jest.fn() as jest.MockedFunction, templateFolder: jest.fn(), + theme: jest.fn(), }; }; diff --git a/src/app/testing/stubs/research-plugin.stub.ts b/src/app/testing/stubs/research-plugin.stub.ts index 024745d5..0caa7a35 100644 --- a/src/app/testing/stubs/research-plugin.stub.ts +++ b/src/app/testing/stubs/research-plugin.stub.ts @@ -18,6 +18,7 @@ SPDX-License-Identifier: Apache-2.0 */ import { DataModelDetail, FolderDetail, Uuid } from '@maurodatamapper/mdm-resources'; import { Observable } from 'rxjs'; +import { KeyValueIdentifier } from 'src/app/mauro/mauro.types'; import { PluginResearchContactPayload } from 'src/app/mauro/plugins/plugin-research.resource'; export type ResearchPluginContactFn = ( @@ -32,6 +33,7 @@ export interface ResearchPluginServiceStub { userFolder: jest.MockedFunction; templateFolder: jest.MockedFunction<() => Observable>; rootDataModel: jest.MockedFunction<() => Observable>; + theme: jest.MockedFunction<() => Observable>; } export const createResearchPluginServiceStub = (): ResearchPluginServiceStub => { @@ -41,5 +43,6 @@ export const createResearchPluginServiceStub = (): ResearchPluginServiceStub => userFolder: jest.fn() as jest.MockedFunction, templateFolder: jest.fn(), rootDataModel: jest.fn(), + theme: jest.fn(), }; }; diff --git a/src/app/testing/stubs/theme.stub.ts b/src/app/testing/stubs/theme.stub.ts new file mode 100644 index 00000000..8721c0b7 --- /dev/null +++ b/src/app/testing/stubs/theme.stub.ts @@ -0,0 +1,32 @@ +/* +Copyright 2022-2023 University of Oxford +and Health and Social Care Information Centre, also known as NHS Digital + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +*/ +import { Observable } from 'rxjs'; +import { Theme } from 'src/app/shared/theme.service'; + +export interface ThemeServiceStub { + loadTheme: jest.MockedFunction<() => Observable>; + applyTheme: jest.MockedFunction<(theme: Theme) => void>; +} + +export const createThemeServiceStub = (): ThemeServiceStub => { + return { + loadTheme: jest.fn(), + applyTheme: jest.fn(), + }; +};