Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: language switch with multisite support #722

Merged
merged 23 commits into from
Jul 1, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ services:
# - PROXY_ICM=true
- TRUST_ICM=true
# - PROMETHEUS=on
# - MULTI_SITE_LOCALE_MAP={"en_US":"/en","de_DE":"/de","fr_FR":"/fr"}
nginx:
build: nginx
depends_on:
Expand All @@ -43,7 +44,6 @@ services:
- baseHref: /de
channel: default
lang: de_DE
protected: false
- baseHref: /fr
channel: default
lang: fr_FR
Expand Down
29 changes: 28 additions & 1 deletion docs/guides/multi-site-configurations.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ kb_sync_latest_only

# Multi Site Configurations

- [Multi Site Configurations](#multi-site-configurations)
- [Syntax](#syntax)
- [Examples](#examples)
- [One domain, One Channel, Multiple Locales](#one-domain-one-channel-multiple-locales)
- [Multiple Domains, Multiple Channels, Multiple Locales](#multiple-domains-multiple-channels-multiple-locales)
- [Multiple Subdomains, Multiple channels, Multiple Locales](#multiple-subdomains-multiple-channels-multiple-locales)
- [Extended Example with Many Different Configurations](#extended-example-with-many-different-configurations)
- [Extended Example with two domains, one with basic auth (except /fr), the other without](#extended-example-with-two-domains-one-with-basic-auth-except-fr-the-other-without)
- [Integrate your multi-site configuration with the language switch](#integrate-your-multi-site-configuration-with-the-language-switch)
- [Further References](#further-references)

As explained in [Multi-Site Handling](../concepts/multi-site-handling.md), the PWA supports dynamic configurations of a single PWA deployment.
This guide explains the YAML syntax used to define a configuration and provides some common configuration examples, mainly focusing on different setups for handling locales and channels.
For more information about how the YAML configuration is processed, refer to [Multi-Site Handling](../concepts/multi-site-handling.md) and [Building and Running NGINX Docker Image | Multi-Site](../guides/nginx-startup.md#Multi-Site).
Expand Down Expand Up @@ -35,7 +46,7 @@ All other properties are optional:
- **features**: Comma-separated list of activated features
- **lang**: The default language as defined in the Angular CLI environment
- **theme**: The theme used for the channel (format: `<theme-name>(|<icon-color>)?`)
- **protected**: Selectively unprotect a given domain and/or baseHref. Only applies in combination with globally activated nginx basic authentication.
- **protected**: Selectively disable protection of a given domain and/or baseHref. Only applies in combination with globally activated nginx basic authentication.

Dynamically directing the PWA to different ICM installations can be done by using:

Expand Down Expand Up @@ -205,6 +216,22 @@ ca.+\.com:
lang: en_US
```

## Integrate your multi-site configuration with the language switch

To construct new multi-site URLs when switching between languages, the PWA uses the `multi-site.service.ts`.
The `getLangUpdatedUrl` is called with the desired locale string, current url and current baseHref.
From this it constructs a new URL, conforming to our multi-site setup (see [One domain, one channel, multiple locales](#one-domain-one-channel-multiple-locales)).

To control the transformation of urls, the `multiSiteLocaleMap` environment variable is used.
Depending on your needs, `multiSiteLocaleMap` can be set in either the `environment.ts` or as an environment variable (`MULTI_SITE_LOCALE_MAP`).
See [`docker-compose.yml`](../../docker-compose.yml) for a commented out example or [`environment.model.ts`](../../src/environments/environment.model.ts) for the default value.

In case you want to disable this functionality, simply override the default environment variable `multiSiteLocaleMap` with `undefined` or `MULTI_SITE_LOCALE_MAP` with `false`.

In case you want to extend this functionality to work with more locales, extend the default environment variable `multiSiteLocaleMap` with your additional locales.

In case you want to transfer this functionality to work with your specific multi-site setup, override the `multi-site.service.ts` and provide an implementation that conforms to your setup (as well as configuring the environment variable for your specific use case).

# Further References

- [Guide - Building and Running nginx Docker Image](../guides/nginx-startup.md)
Expand Down
37 changes: 19 additions & 18 deletions docs/guides/ssr-startup.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,24 +34,25 @@ If the format is _switch_, the property is switched on by supplying `on`, `1`, `
All parameters are **case sensitive**.
Make sure to use them as written in the table below.

| | parameter | format | comment |
| ------------------- | --------------------- | -------------------- | ------------------------------------------------------------ |
| **SSR Specific** | PORT | number | Port for running the application |
| | SSL | any | Enables SSL/TLS |
| **General** | ICM_BASE_URL | string | Sets the base URL for the ICM |
| | ICM_CHANNEL | string | Overrides the default channel |
| | ICM_APPLICATION | string | Overrides the default application |
| | FEATURES | comma-separated list | Overrides active features |
| | THEME | string | Overrides the default theme |
| **Debug** :warning: | TRUST_ICM | any | Use this if ICM is deployed with an insecure certificate |
| | LOGGING | switch | Enables extra log output |
| **Hybrid Approach** | SSR_HYBRID | any | Enables running PWA and ICM in [Hybrid Mode][concept-hybrid] |
| | PROXY_ICM | any \| URL | Proxy ICM via `/INTERSHOP` (enabled if SSR_HYBRID is active) |
| **Third party** | GTM_TOKEN | string | Token for Google Tag Manager |
| | SENTRY_DSN | string | Sentry DSN URL for using Sentry Error Monitor |
| | PROMETHEUS | switch | Exposes Prometheus metrics |
| | ICM_IDENTITY_PROVIDER | string | ID of Identity Provider for [SSO][concept-sso] |
| | IDENTITY_PROVIDERS | JSON | Configuration of Identity Providers for [SSO][concept-sso] |
| | parameter | format | comment |
| ------------------- | --------------------- | -------------------- | -------------------------------------------------------------------------------------------- |
| **SSR Specific** | PORT | number | Port for running the application |
| | SSL | any | Enables SSL/TLS |
| **General** | ICM_BASE_URL | string | Sets the base URL for the ICM |
| | ICM_CHANNEL | string | Overrides the default channel |
| | ICM_APPLICATION | string | Overrides the default application |
| | FEATURES | comma-separated list | Overrides active features |
| | THEME | string | Overrides the default theme |
| | MULTI_SITE_LOCALE_MAP | JSON \| false | Used to map locales to [url modification parameters](../guides/multi-site-configurations.md) |
| **Debug** :warning: | TRUST_ICM | any | Use this if ICM is deployed with an insecure certificate |
| | LOGGING | switch | Enables extra log output |
| **Hybrid Approach** | SSR_HYBRID | any | Enables running PWA and ICM in [Hybrid Mode][concept-hybrid] |
| | PROXY_ICM | any \| URL | Proxy ICM via `/INTERSHOP` (enabled if SSR_HYBRID is active) |
| **Third party** | GTM_TOKEN | string | Token for Google Tag Manager |
| | SENTRY_DSN | string | Sentry DSN URL for using Sentry Error Monitor |
| | PROMETHEUS | switch | Exposes Prometheus metrics |
| | ICM_IDENTITY_PROVIDER | string | ID of Identity Provider for [SSO][concept-sso] |
| | IDENTITY_PROVIDERS | JSON | Configuration of Identity Providers for [SSO][concept-sso] |

## Running with https

Expand Down
32 changes: 27 additions & 5 deletions src/app/core/pipes/make-href.pipe.spec.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,33 @@
import { LocationStrategy } from '@angular/common';
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { anything, instance, mock, verify, when } from 'ts-mockito';

import { MultiSiteService } from 'ish-core/utils/multi-site/multi-site.service';

import { MakeHrefPipe } from './make-href.pipe';

describe('Make Href Pipe', () => {
let makeHrefPipe: MakeHrefPipe;
let multiSiteService: MultiSiteService;

beforeEach(() => {
multiSiteService = mock(MultiSiteService);
TestBed.configureTestingModule({
providers: [MakeHrefPipe],
providers: [MakeHrefPipe, { provide: MultiSiteService, useFactory: () => instance(multiSiteService) }],
});
makeHrefPipe = TestBed.inject(MakeHrefPipe);
when(multiSiteService.getLangUpdatedUrl(anything(), anything(), anything())).thenCall(
(url: string, _: LocationStrategy) => of(url)
);
});

it('should be created', () => {
expect(makeHrefPipe).toBeTruthy();
});

it.each([
// workaround for https://github.com/DefinitelyTyped/DefinitelyTyped/issues/34617
// tslint:disable-next-line: no-any
it.each<any | jest.DoneCallback>([
[undefined, undefined, 'undefined'],
['/test', undefined, '/test'],
['/test', {}, '/test'],
Expand All @@ -27,7 +37,19 @@ describe('Make Href Pipe', () => {
['/test?query=q', {}, '/test?query=q'],
['/test?query=q', { foo: 'bar' }, '/test;foo=bar?query=q'],
['/test?query=q', { foo: 'bar', marco: 'polo' }, '/test;foo=bar;marco=polo?query=q'],
])(`should transform "%s" with %j to "%s"`, (url, params, expected) => {
expect(makeHrefPipe.transform({ path: () => url } as LocationStrategy, params)).toEqual(expected);
])(`should transform "%s" with %j to "%s"`, (url, params, expected, done: jest.DoneCallback) => {
makeHrefPipe.transform({ path: () => url, getBaseHref: () => '/' } as LocationStrategy, params).subscribe(res => {
expect(res).toEqual(expected);
done();
});
});

it('should call the multiSiteService if lang parameter exists', done => {
makeHrefPipe
.transform({ path: () => '/de/test', getBaseHref: () => '/de' } as LocationStrategy, { lang: 'en_US' })
.subscribe(() => {
verify(multiSiteService.getLangUpdatedUrl(anything(), anything(), anything())).once();
done();
});
});
});
57 changes: 38 additions & 19 deletions src/app/core/pipes/make-href.pipe.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,49 @@
import { LocationStrategy } from '@angular/common';
import { Pipe, PipeTransform } from '@angular/core';
import { Observable, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';

import { omit } from 'ish-core/utils/functions';
import { MultiSiteService } from 'ish-core/utils/multi-site/multi-site.service';

@Pipe({ name: 'makeHref', pure: false })
export class MakeHrefPipe implements PipeTransform {
transform(location: LocationStrategy, urlParams: { [key: string]: string }): string {
constructor(private multiSiteService: MultiSiteService) {}
transform(location: LocationStrategy, urlParams: Record<string, string>): Observable<string> {
if (!location || !location.path()) {
return 'undefined';
}

const split = location.path().split('?');

// url without query params
let newUrl = split[0];

// add supplied url params
if (urlParams) {
newUrl += Object.keys(urlParams)
.map(k => `;${k}=${urlParams[k]}`)
.join('');
return of('undefined');
}

// add query params at the end
if (split.length > 1) {
newUrl += `?${split[1]}`;
}
return of(location.path().split('?')).pipe(
switchMap(split => {
// url without query params
const newUrl = split[0];

return newUrl;
if (urlParams) {
if (urlParams.lang) {
return this.multiSiteService.getLangUpdatedUrl(urlParams.lang, newUrl, location.getBaseHref()).pipe(
map(modifiedUrl => {
const modifiedUrlParams = modifiedUrl === newUrl ? urlParams : omit(urlParams, 'lang');
return appendUrlParams(modifiedUrl, modifiedUrlParams, split?.[1]);
})
);
} else {
return of(newUrl).pipe(map(url => appendUrlParams(url, urlParams, split?.[1])));
}
} else {
return of(appendUrlParams(newUrl, undefined, split?.[1]));
}
})
);
}
}

function appendUrlParams(url: string, urlParams: Record<string, string>, queryParams: string | undefined): string {
return `${url}${
urlParams
? Object.keys(urlParams)
.map(k => `;${k}=${urlParams[k]}`)
.join('')
: ''
}${queryParams ? `?${queryParams}` : ''}`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe('Configuration Effects', () => {

testComplete$.pipe(take(2)).subscribe({ complete: done });

effects.setInitialRestEndpoint$.subscribe(
effects.transferEnvironmentProperties$.subscribe(
data => {
expect(data.type).toEqual(applyConfiguration.type);
testComplete$.next();
Expand Down
17 changes: 15 additions & 2 deletions src/app/core/store/core/configuration/configuration.effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export class ConfigurationEffects {
});
}

setInitialRestEndpoint$ = createEffect(() =>
transferEnvironmentProperties$ = createEffect(() =>
iif(
() => !this.transferState.hasKey(NGRX_STATE_SK),
this.actions$.pipe(
Expand All @@ -100,7 +100,18 @@ export class ConfigurationEffects {
.pipe(map(x => x || 'ICM')),
this.stateProperties
.getStateOrEnvOrDefault<string | object>('IDENTITY_PROVIDERS', 'identityProviders')
.pipe(map(config => (typeof config === 'string' ? JSON.parse(config) : config)))
.pipe(map(config => (typeof config === 'string' ? JSON.parse(config) : config))),
this.stateProperties
.getStateOrEnvOrDefault<Record<string, unknown> | string | false>(
'MULTI_SITE_LOCALE_MAP',
'multiSiteLocaleMap'
)
.pipe(
map(multiSiteLocaleMap => (multiSiteLocaleMap === false ? undefined : multiSiteLocaleMap)),
map(multiSiteLocaleMap =>
typeof multiSiteLocaleMap === 'string' ? JSON.parse(multiSiteLocaleMap) : multiSiteLocaleMap
)
)
),
map(
([
Expand All @@ -114,6 +125,7 @@ export class ConfigurationEffects {
theme,
identityProvider,
identityProviders,
multiSiteLocaleMap,
]) =>
applyConfiguration({
baseURL,
Expand All @@ -125,6 +137,7 @@ export class ConfigurationEffects {
theme,
identityProvider,
identityProviders,
multiSiteLocaleMap,
})
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface ConfigurationState {
locales?: Locale[];
lang?: string;
serverTranslations: { [lang: string]: Translations };
multiSiteLocaleMap: Record<string, unknown>;
// not synced via state transfer
_deviceType?: DeviceType;
}
Expand All @@ -43,6 +44,7 @@ const initialState: ConfigurationState = {
locales: environment.locales,
lang: undefined,
serverTranslations: {},
multiSiteLocaleMap: {},
_deviceType: environment.defaultDeviceType,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,8 @@ export const getServerTranslations = (lang: string) =>

export const getSpecificServerTranslation = (lang: string, key: string) =>
createSelector(getServerTranslations(lang), translations => translations?.[key]);

export const getMultiSiteLocaleMap = createSelector(
getConfigurationState,
(state: ConfigurationState) => state.multiSiteLocaleMap
);
45 changes: 45 additions & 0 deletions src/app/core/utils/multi-site/multi-site.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { TestBed } from '@angular/core/testing';
import { provideMockStore } from '@ngrx/store/testing';

import { getMultiSiteLocaleMap } from 'ish-core/store/core/configuration';

import { MultiSiteService } from './multi-site.service';

const multiSiteLocaleMap = {
en_US: '/en',
de_DE: '/de',
fr_FR: '/fr',
};

describe('Multi Site Service', () => {
let multiSiteService: MultiSiteService;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideMockStore({ selectors: [{ selector: getMultiSiteLocaleMap, value: multiSiteLocaleMap }] })],
});
multiSiteService = TestBed.inject(MultiSiteService);
});

it('should be created', () => {
expect(multiSiteService).toBeTruthy();
});
it('should return url unchanged if no language baseHref exists', done => {
multiSiteService.getLangUpdatedUrl('de_DE', '/testpath', '/').subscribe(url => {
expect(url).toMatchInlineSnapshot(`"/testpath"`);
done();
});
});
it('should return with new url if language baseHref exists and language is valid', done => {
multiSiteService.getLangUpdatedUrl('en_US', '/de/testpath', '/de').subscribe(url => {
expect(url).toMatchInlineSnapshot(`"/en/testpath"`);
done();
});
});
it('should return url unchanged if language baseHref exists but language is invalid', done => {
multiSiteService.getLangUpdatedUrl('xy_XY', '/de/testpath', '/de').subscribe(url => {
expect(url).toMatchInlineSnapshot(`"/de/testpath"`);
done();
});
});
});
Loading