Skip to content

Commit

Permalink
feat: load extra configuration from Configuration CMS component for d…
Browse files Browse the repository at this point in the history
…ynamic 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 <s.grueber@intershop.de>
Co-authored-by: Stefan Hauke <s.hauke@intershop.de>
  • Loading branch information
3 people committed Jun 1, 2023
1 parent 849ebc1 commit f50671a
Show file tree
Hide file tree
Showing 11 changed files with 262 additions and 25 deletions.
12 changes: 11 additions & 1 deletion src/app/core/facades/app.facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -113,6 +113,16 @@ export class AppFacade {
return this.store.pipe(select(getServerConfigParameter<T>(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$<T>(path: string) {
return this.store.pipe(select(getExtraConfigParameter<T>(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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]}`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
93 changes: 91 additions & 2 deletions src/app/core/services/configuration/configuration.service.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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<ServerConfig> {
return this.apiService
.get<ContentPageletEntryPointData>(`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<HTMLLinkElement>('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<HTMLLinkElement>('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<HTMLStyleElement>('style', this.document.head);
this.domService.createTextNode(config.CSSStyling.toString(), style);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(),
Expand Down
81 changes: 75 additions & 6 deletions src/app/core/store/core/server-config/server-config.effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -41,11 +57,64 @@ 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),
mapToPayloadProperty('error'),
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<ConfigurationState> {
const featureConfig: Partial<ConfigurationState> = {};
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;
}
}
14 changes: 12 additions & 2 deletions src/app/core/store/core/server-config/server-config.reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
)
);
13 changes: 13 additions & 0 deletions src/app/core/store/core/server-config/server-config.selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,16 @@ export const getServerConfigParameter = <T>(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 = <T>(path: string) =>
createSelector(
getExtraConfig,
(extraConfig): T =>
path
.split('.')
.reduce((obj, key) => (obj?.[key] !== undefined ? obj[key] : undefined), extraConfig) as unknown as T
);
Loading

0 comments on commit f50671a

Please sign in to comment.