From f50671a036de3f39bb86f79ab63d17f60706ddf4 Mon Sep 17 00:00:00 2001 From: Daniel Durak Date: Thu, 11 May 2023 01:25:31 +0200 Subject: [PATCH] feat: load extra configuration from Configuration CMS component for dynamic storefront configuration (#1427) * configure application and locale specific logos, styling, features, generic JSON (e.g. service token) * support feature toggle configuration in extra server configuration * file reference configuration parameter mapper documentation + JSON parsing * DOMService rename 'setCssVariable' to 'setCssCustomProperty' * configure additional style definitions or style file reference * optimization to not redo the extra configuration changes on the client side if they were already done in SSR * add a feature toggle for 'extraConfiguration' Co-authored-by: Silke Co-authored-by: Stefan Hauke --- src/app/core/facades/app.facade.ts | 12 ++- .../content-configuration-parameter.mapper.ts | 20 +++- .../configuration.service.spec.ts | 11 ++- .../configuration/configuration.service.ts | 93 ++++++++++++++++++- .../server-config/server-config.actions.ts | 7 ++ .../server-config.effects.spec.ts | 6 +- .../server-config/server-config.effects.ts | 81 ++++++++++++++-- .../server-config/server-config.reducer.ts | 14 ++- .../server-config/server-config.selectors.ts | 13 +++ src/app/core/utils/dom/dom.service.ts | 29 ++++-- src/environments/environment.model.ts | 1 + 11 files changed, 262 insertions(+), 25 deletions(-) diff --git a/src/app/core/facades/app.facade.ts b/src/app/core/facades/app.facade.ts index c0ebe699cf..0c353cbc36 100644 --- a/src/app/core/facades/app.facade.ts +++ b/src/app/core/facades/app.facade.ts @@ -15,7 +15,7 @@ import { } from 'ish-core/store/core/configuration'; import { businessError, getGeneralError, getGeneralErrorType } from 'ish-core/store/core/error'; import { selectPath } from 'ish-core/store/core/router'; -import { getServerConfigParameter } from 'ish-core/store/core/server-config'; +import { getExtraConfigParameter, getServerConfigParameter } from 'ish-core/store/core/server-config'; import { getBreadcrumbData, getHeaderType, getWrapperClass, isStickyHeader } from 'ish-core/store/core/viewconf'; import { getLoggedInCustomer } from 'ish-core/store/customer/user'; import { getAllCountries, loadCountries } from 'ish-core/store/general/countries'; @@ -113,6 +113,16 @@ export class AppFacade { return this.store.pipe(select(getServerConfigParameter(path))); } + // not-dead-code + /** + * extracts a specific extra server setting from the store (intended for custom ConfigurationJSON) + * + * @param path the path to the server setting, starting from the serverConfig/extra store + */ + extraSetting$(path: string) { + return this.store.pipe(select(getExtraConfigParameter(path))); + } + /** * returns the currency symbol for the currency parameter in the current locale. * If no parameter is given, the the default currency is taken instead of it. diff --git a/src/app/core/models/content-configuration-parameter/content-configuration-parameter.mapper.ts b/src/app/core/models/content-configuration-parameter/content-configuration-parameter.mapper.ts index b2a00d4267..62d64c1cfa 100644 --- a/src/app/core/models/content-configuration-parameter/content-configuration-parameter.mapper.ts +++ b/src/app/core/models/content-configuration-parameter/content-configuration-parameter.mapper.ts @@ -37,25 +37,37 @@ export class ContentConfigurationParameterMapper { case 'bc_pmc:types.pagelet2-ImageFileRef': case 'bc_pmc:types.pagelet2-FileRef': if (Array.isArray(data.value)) { - return data.value.map(x => this.resolveStaticURL(x)); + return data.value.map(x => this.processFileReferences(x)); } else { - return this.resolveStaticURL(data.value.toString()); + return this.processFileReferences(data.value.toString()); } default: + // parse values of configuration parameters that end in 'JSON' to JSON objects + if (data.definitionQualifiedName.endsWith('JSON')) { + return JSON.parse(data.value as string); + } return data.value; } } - // convert ICM file references to full server URLs - private resolveStaticURL(value: string): string { + // process file reference values according to their type + private processFileReferences(value: string): string { + // absolute URL references - keep them as they are (http:// and https://) if (value.startsWith('http')) { return value; } + // relative URL references, e.g. to asset files are prefixed with 'file://' + if (value.startsWith('file://')) { + return value.split('file://')[1]; + } + + // everything else that does not include ':/' is not an ICM file reference and is left as it is if (!value.includes(':/')) { return value; } + // convert ICM file references to full server URLs const split = value.split(':'); return encodeURI(`${this.staticURL}/${split[0]}/${this.lang}${split[1]}`); } diff --git a/src/app/core/services/configuration/configuration.service.spec.ts b/src/app/core/services/configuration/configuration.service.spec.ts index fc55b40891..f25e9a7904 100644 --- a/src/app/core/services/configuration/configuration.service.spec.ts +++ b/src/app/core/services/configuration/configuration.service.spec.ts @@ -2,18 +2,27 @@ import { TestBed } from '@angular/core/testing'; import { of } from 'rxjs'; import { anything, instance, mock, verify, when } from 'ts-mockito'; +import { ContentConfigurationParameterMapper } from 'ish-core/models/content-configuration-parameter/content-configuration-parameter.mapper'; import { ApiService } from 'ish-core/services/api/api.service'; import { ConfigurationService } from './configuration.service'; describe('Configuration Service', () => { let apiServiceMock: ApiService; + let contentConfigurationParameterMapperMock: ContentConfigurationParameterMapper; let configurationService: ConfigurationService; beforeEach(() => { apiServiceMock = mock(ApiService); + contentConfigurationParameterMapperMock = mock(ContentConfigurationParameterMapper); TestBed.configureTestingModule({ - providers: [{ provide: ApiService, useFactory: () => instance(apiServiceMock) }], + providers: [ + { + provide: ContentConfigurationParameterMapper, + useFactory: () => instance(contentConfigurationParameterMapperMock), + }, + { provide: ApiService, useFactory: () => instance(apiServiceMock) }, + ], }); configurationService = TestBed.inject(ConfigurationService); }); diff --git a/src/app/core/services/configuration/configuration.service.ts b/src/app/core/services/configuration/configuration.service.ts index 0644adac2b..16cb71f3e5 100644 --- a/src/app/core/services/configuration/configuration.service.ts +++ b/src/app/core/services/configuration/configuration.service.ts @@ -1,15 +1,24 @@ +import { DOCUMENT } from '@angular/common'; import { HttpHeaders } from '@angular/common/http'; -import { Injectable } from '@angular/core'; +import { Inject, Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; +import { ContentConfigurationParameterMapper } from 'ish-core/models/content-configuration-parameter/content-configuration-parameter.mapper'; +import { ContentPageletEntryPointData } from 'ish-core/models/content-pagelet-entry-point/content-pagelet-entry-point.interface'; import { ServerConfigMapper } from 'ish-core/models/server-config/server-config.mapper'; import { ServerConfig } from 'ish-core/models/server-config/server-config.model'; import { ApiService } from 'ish-core/services/api/api.service'; +import { DomService } from 'ish-core/utils/dom/dom.service'; @Injectable({ providedIn: 'root' }) export class ConfigurationService { - constructor(private apiService: ApiService) {} + constructor( + private apiService: ApiService, + private domService: DomService, + private contentConfigurationParameterMapper: ContentConfigurationParameterMapper, + @Inject(DOCUMENT) private document: Document + ) {} private configHeaders = new HttpHeaders({ 'content-type': 'application/json', @@ -30,4 +39,84 @@ export class ConfigurationService { }) .pipe(map(ServerConfigMapper.fromData)); } + + /** + * Gets additional storefront configuration parameters managed via CMS configuration include. + * + * @returns The configuration object. + */ + getExtraConfiguration(): Observable { + return this.apiService + .get(`cms/includes/include.configuration.pagelet2-Include`, { + skipApiErrorHandling: true, + sendPGID: true, + sendLocale: true, + sendCurrency: false, + }) + .pipe( + map(data => + data?.pagelets?.length + ? (this.contentConfigurationParameterMapper.fromData( + data?.pagelets[0].configurationParameters + ) as ServerConfig) + : undefined + ) + ); + } + + /** + * Sets the theme configuration from additional storefront configuration parameters. + */ + setThemeConfiguration(config: ServerConfig) { + // Logo + if (config?.Logo) { + this.domService.setCssCustomProperty('logo', `url(${config.Logo.toString()})`); + } + + // Logo Mobile + if (config?.LogoMobile) { + this.domService.setCssCustomProperty('logo-mobile', `url(${config.LogoMobile.toString()})`); + } + + // Favicon + if (config?.Favicon) { + this.domService.setAttributeForSelector('link[rel="icon"]', 'href', config.Favicon.toString()); + } + + // CSS Custom Properties + if (config?.CSSProperties) { + config.CSSProperties.toString() + .split(/\r?\n/) + .filter(Boolean) + .forEach(property => { + const propertyKeyValue = property.split(':'); + this.domService.setCssCustomProperty(propertyKeyValue[0].trim(), propertyKeyValue[1].trim()); + }); + } + + // CSS Fonts embedding + if (config?.CSSFonts) { + config.CSSFonts.toString() + .split(/\r?\n/) + .filter(Boolean) + .forEach(font => { + const link = this.domService.createElement('link', this.document.head); + this.domService.setProperty(link, 'rel', 'stylesheet'); + this.domService.setProperty(link, 'href', font.toString()); + }); + } + + // CSS File + if (config?.CSSFile) { + const link = this.domService.createElement('link', this.document.head); + this.domService.setProperty(link, 'rel', 'stylesheet'); + this.domService.setProperty(link, 'href', config.CSSFile.toString()); + } + + // CSS Styling + if (config?.CSSStyling) { + const style = this.domService.createElement('style', this.document.head); + this.domService.createTextNode(config.CSSStyling.toString(), style); + } + } } diff --git a/src/app/core/store/core/server-config/server-config.actions.ts b/src/app/core/store/core/server-config/server-config.actions.ts index 23585b5a6f..df7d93df4d 100644 --- a/src/app/core/store/core/server-config/server-config.actions.ts +++ b/src/app/core/store/core/server-config/server-config.actions.ts @@ -11,3 +11,10 @@ export const loadServerConfigSuccess = createAction( ); export const loadServerConfigFail = createAction('[Configuration API] Get the ICM configuration Fail', httpError()); + +export const loadExtraConfigSuccess = createAction( + '[CMS API] Get extra ICM configuration from CMS Success', + payload<{ extra: ServerConfig }>() +); + +export const loadExtraConfigFail = createAction('[CMS API] Get extra ICM configuration from CMS Fail', httpError()); diff --git a/src/app/core/store/core/server-config/server-config.effects.spec.ts b/src/app/core/store/core/server-config/server-config.effects.spec.ts index 541b36771c..ff2dd4ba99 100644 --- a/src/app/core/store/core/server-config/server-config.effects.spec.ts +++ b/src/app/core/store/core/server-config/server-config.effects.spec.ts @@ -6,6 +6,7 @@ import { cold, hot } from 'jasmine-marbles'; import { Observable, of, throwError } from 'rxjs'; import { instance, mock, when } from 'ts-mockito'; +import { FeatureToggleModule } from 'ish-core/feature-toggle.module'; import { ConfigurationService } from 'ish-core/services/configuration/configuration.service'; import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; import { serverConfigError } from 'ish-core/store/core/error'; @@ -25,7 +26,10 @@ describe('Server Config Effects', () => { when(configurationServiceMock.getServerConfiguration()).thenReturn(of({})); TestBed.configureTestingModule({ - imports: [CoreStoreModule.forTesting(['serverConfig'], [ServerConfigEffects])], + imports: [ + CoreStoreModule.forTesting(['serverConfig'], [ServerConfigEffects]), + FeatureToggleModule.forTesting('extraConfiguration'), + ], providers: [ { provide: ConfigurationService, useFactory: () => instance(configurationServiceMock) }, provideStoreSnapshots(), diff --git a/src/app/core/store/core/server-config/server-config.effects.ts b/src/app/core/store/core/server-config/server-config.effects.ts index 44cd6bdcc3..393f62f252 100644 --- a/src/app/core/store/core/server-config/server-config.effects.ts +++ b/src/app/core/store/core/server-config/server-config.effects.ts @@ -2,19 +2,35 @@ import { Injectable } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { routerNavigationAction } from '@ngrx/router-store'; import { Store, select } from '@ngrx/store'; -import { identity } from 'rxjs'; -import { concatMap, first, map, switchMap } from 'rxjs/operators'; +import { EMPTY, identity } from 'rxjs'; +import { concatMap, first, map, switchMap, takeWhile } from 'rxjs/operators'; +import { FeatureToggleService } from 'ish-core/feature-toggle.module'; +import { ServerConfig } from 'ish-core/models/server-config/server-config.model'; import { ConfigurationService } from 'ish-core/services/configuration/configuration.service'; +import { applyConfiguration } from 'ish-core/store/core/configuration'; +import { ConfigurationState } from 'ish-core/store/core/configuration/configuration.reducer'; import { serverConfigError } from 'ish-core/store/core/error'; -import { mapErrorToAction, mapToPayloadProperty, whenFalsy } from 'ish-core/utils/operators'; +import { personalizationStatusDetermined } from 'ish-core/store/customer/user'; +import { delayUntil, mapErrorToAction, mapToPayloadProperty, whenFalsy, whenTruthy } from 'ish-core/utils/operators'; -import { loadServerConfig, loadServerConfigFail, loadServerConfigSuccess } from './server-config.actions'; -import { isServerConfigurationLoaded } from './server-config.selectors'; +import { + loadExtraConfigFail, + loadExtraConfigSuccess, + loadServerConfig, + loadServerConfigFail, + loadServerConfigSuccess, +} from './server-config.actions'; +import { isExtraConfigurationLoaded, isServerConfigurationLoaded } from './server-config.selectors'; @Injectable() export class ServerConfigEffects { - constructor(private actions$: Actions, private store: Store, private configService: ConfigurationService) {} + constructor( + private actions$: Actions, + private store: Store, + private configService: ConfigurationService, + private featureToggleService: FeatureToggleService + ) {} /** * get server configuration on routing event, if it is not already loaded @@ -41,6 +57,47 @@ export class ServerConfigEffects { ) ); + loadExtraServerConfig$ = createEffect(() => + this.actions$.pipe( + ofType(loadServerConfig), + takeWhile(() => this.featureToggleService.enabled('extraConfiguration')), + switchMap(() => this.store.pipe(select(isExtraConfigurationLoaded))), + whenFalsy(), + delayUntil(this.actions$.pipe(ofType(personalizationStatusDetermined))), + concatMap(() => + this.configService.getExtraConfiguration().pipe( + map(extra => loadExtraConfigSuccess({ extra })), + mapErrorToAction(loadExtraConfigFail) + ) + ) + ) + ); + + setThemeConfiguration$ = createEffect( + () => + this.actions$.pipe( + ofType(loadExtraConfigSuccess), + mapToPayloadProperty('extra'), + whenTruthy(), + concatMap(config => { + this.configService.setThemeConfiguration(config); + return EMPTY; + }) + ), + { dispatch: false } + ); + + setFeatureConfiguration$ = createEffect(() => + this.actions$.pipe( + ofType(loadExtraConfigSuccess), + mapToPayloadProperty('extra'), + whenTruthy(), + map(config => this.mapFeatures(config)), + whenTruthy(), + map(config => applyConfiguration(config)) + ) + ); + mapToServerConfigError$ = createEffect(() => this.actions$.pipe( ofType(loadServerConfigFail), @@ -48,4 +105,16 @@ export class ServerConfigEffects { map(error => serverConfigError({ error })) ) ); + + // mapping extra configuration feature toggle overrides to features/addFeatures state used by the feature toggle functionality + private mapFeatures(config: ServerConfig): Partial { + const featureConfig: Partial = {}; + if (config.Features) { + featureConfig.features = (config.Features as string).split(','); + } + if (config.AddFeatures) { + featureConfig.addFeatures = (config.AddFeatures as string).split(','); + } + return featureConfig.features?.length || featureConfig.addFeatures?.length ? featureConfig : undefined; + } } diff --git a/src/app/core/store/core/server-config/server-config.reducer.ts b/src/app/core/store/core/server-config/server-config.reducer.ts index 23ad275925..c9de2f3f66 100644 --- a/src/app/core/store/core/server-config/server-config.reducer.ts +++ b/src/app/core/store/core/server-config/server-config.reducer.ts @@ -2,22 +2,32 @@ import { createReducer, on } from '@ngrx/store'; import { ServerConfig } from 'ish-core/models/server-config/server-config.model'; -import { loadServerConfigSuccess } from './server-config.actions'; +import { loadExtraConfigSuccess, loadServerConfigSuccess } from './server-config.actions'; export interface ServerConfigState { _config: ServerConfig; + extra: ServerConfig; } const initialState: ServerConfigState = { _config: undefined, + extra: undefined, }; export const serverConfigReducer = createReducer( initialState, on( loadServerConfigSuccess, - (_, action): ServerConfigState => ({ + (state, action): ServerConfigState => ({ + ...state, _config: action.payload.config, }) + ), + on( + loadExtraConfigSuccess, + (state, action): ServerConfigState => ({ + ...state, + extra: action.payload.extra, + }) ) ); diff --git a/src/app/core/store/core/server-config/server-config.selectors.ts b/src/app/core/store/core/server-config/server-config.selectors.ts index ad1ff00e6c..9c97673abc 100644 --- a/src/app/core/store/core/server-config/server-config.selectors.ts +++ b/src/app/core/store/core/server-config/server-config.selectors.ts @@ -16,3 +16,16 @@ export const getServerConfigParameter = (path: string) => .split('.') .reduce((obj, key) => (obj?.[key] !== undefined ? obj[key] : undefined), serverConfig) as unknown as T ); + +const getExtraConfig = createSelector(getServerConfigState, state => state.extra); + +export const isExtraConfigurationLoaded = createSelector(getExtraConfig, extraConfig => !!extraConfig); + +export const getExtraConfigParameter = (path: string) => + createSelector( + getExtraConfig, + (extraConfig): T => + path + .split('.') + .reduce((obj, key) => (obj?.[key] !== undefined ? obj[key] : undefined), extraConfig) as unknown as T + ); diff --git a/src/app/core/utils/dom/dom.service.ts b/src/app/core/utils/dom/dom.service.ts index 12a7890704..e132e45ce9 100644 --- a/src/app/core/utils/dom/dom.service.ts +++ b/src/app/core/utils/dom/dom.service.ts @@ -62,6 +62,21 @@ export class DomService { return el ? el : this.createElement(tagName, parent); } + /** + * Creates a text node with the given text and if a parent is given appends it to a parent element. + * + * @param text The text string. + * @param parent The parent element. + * @returns The created element. + */ + createTextNode(text: string, parent?: HTMLElement) { + const textNode = this.renderer.createText(text); + if (parent) { + this.appendChild(parent, textNode); + } + return textNode; + } + /** * Sets an attribute value for an element in the DOM. * @@ -98,19 +113,17 @@ export class DomService { return this.renderer.setProperty(el, name, value); } - // not-dead-code /** - * Sets the value of a css variable. - * ToDo: remove the notDeadCode comment as soon as setCssVariable method is used + * Sets the value of a CSS custom property (variable). * - * @param variableName The name of the variable (without prefix '--'). - * @param value The value to be set. + * @param propertyName The name of the custom property (without prefix '--'). + * @param propertyValue The value to be set. */ - setCssVariable(variableName: string, value: string): void { + setCssCustomProperty(propertyName: string, propertyValue: string): void { this.renderer.setStyle( this.document.documentElement, - `--${variableName.toLowerCase()}`, - value.toString(), + `--${propertyName.toLowerCase()}`, + propertyValue.toString(), RendererStyleFlags2.DashCase ); } diff --git a/src/environments/environment.model.ts b/src/environments/environment.model.ts index 6808d9fafe..42cb0732ef 100644 --- a/src/environments/environment.model.ts +++ b/src/environments/environment.model.ts @@ -29,6 +29,7 @@ export interface Environment { | 'productNotifications' | 'storeLocator' | 'contactUs' + | 'extraConfiguration' /* B2B features */ | 'businessCustomerRegistration' | 'costCenters'