Skip to content

Commit

Permalink
feat: save language selection as cookie (#1447)
Browse files Browse the repository at this point in the history
* use 'preferredLocale' cookie to set language
* add feature toggle 'saveLanguageSelection'

---------

Co-authored-by: Marcel Eisentraut <meisentraut@intershop.de>
  • Loading branch information
SGrueber and Eisie96 committed Sep 8, 2023
1 parent 6cabc7d commit c2e43bb
Show file tree
Hide file tree
Showing 14 changed files with 261 additions and 71 deletions.
1 change: 1 addition & 0 deletions docs/concepts/configuration.md
Expand Up @@ -159,6 +159,7 @@ Of course, the ICM server must supply appropriate REST resources to leverage fun
| productNotifications | Product notifications feature for price and in stock notifications |
| rating | Display product ratings |
| recently | Display recently viewed products (additional configuration via `dataRetention` configuration options) |
| saveLanguageSelection | Save the user's language selection and restore it after PWA load |
| storeLocator | Display physical stores and their addresses |
| **B2B Features** | |
| businessCustomerRegistration | Create business customers on registration |
Expand Down
13 changes: 7 additions & 6 deletions docs/guides/cookie-consent.md
Expand Up @@ -33,7 +33,7 @@ cookieConsentOptions: {
description: 'cookie.consent.option.tracking.description',
},
},
allowedCookies: ['cookieConsent', 'apiToken'],
allowedCookies: ['apiToken', 'cookieConsent', 'preferredLocale'],
},
```

Expand All @@ -42,7 +42,7 @@ The `options` array configures the presented options in the _Cookie Preferences_
- The option `id` is the value that will be stored in the user's `cookieConsent` settings.
- The `name` makes up the checkbox label name, usually given as localization key.
- The `description` contains the additional option description, usually given as localization key.
- With the `required` flag, an option can be marked as not deselectable.
- With the `required` flag, an option can be marked as not non-selectable.
In this way, the user can be informed that necessary cookies are always set without explicit consent of the user.

The following screenshot is the rendered representation of the default cookie consent options configuration:
Expand Down Expand Up @@ -100,10 +100,11 @@ This route can be linked to from anywhere within the application.

## PWA Required Cookies

| Name | Expiration | Provider | Description | Category |
| ------------- | ---------- | ------------- | ----------------------------------------------------------------- | ----------- |
| apiToken | 1 year | Intershop PWA | The API token used by the Intershop Commerce Management REST API. | First Party |
| cookieConsent | 1 year | Intershop PWA | Saves the user's cookie consent settings. | First Party |
| Name | Expiration | Provider | Description | Category |
| --------------- | ---------- | ------------- | ----------------------------------------------------------------- | ----------- |
| apiToken | 1 year | Intershop PWA | The API token used by the Intershop Commerce Management REST API. | First Party |
| cookieConsent | 1 year | Intershop PWA | Saves the user's cookie consent settings. | First Party |
| preferredLocale | 1 year | Intershop PWA | Saves the user's language selection. | First Party |

## Disabling the Integrated Cookie Consent Handling

Expand Down
3 changes: 3 additions & 0 deletions docs/guides/migrations.md
Expand Up @@ -20,6 +20,9 @@ Be aware that some browsers no longer accept cookies with `SameSite=None` withou
Before by default no `SameSite` was set so browsers treated it as `SameSite=Lax`, this needs to be set explicitly now if it is really intended.
For migrating check the calls of the `cookies.service` `put` method whether they need to be adapted.

The user's language selection is saved as a cookie (`preferredLocale`) now and restored after the PWA is loaded.
This functionality can be enabled/disabled with the feature toggle `saveLanguageSelection`.

## 4.0 to 4.1

The Intershop PWA now uses Node.js 18.16.0 LTS with the corresponding npm version 9.5.1 to resolve an issue with Azure Docker deployments (see #1416).
Expand Down
Expand Up @@ -19,7 +19,10 @@ const _ = {

describe('Language Changing User', () => {
describe('when logged in', () => {
before(() => LoginPage.navigateTo());
before(() => {
cy.clearCookie('preferredLocale');
LoginPage.navigateTo();
});

it('should log in', () => {
createUserViaREST(_.user);
Expand Down Expand Up @@ -47,6 +50,7 @@ describe('Language Changing User', () => {
describe('when accessing protected content without cookie', () => {
before(() => {
cy.clearCookie('apiToken');
cy.clearCookie('preferredLocale');
MyAccountPage.navigateTo();
});

Expand Down
46 changes: 25 additions & 21 deletions src/app/core/pipes/make-href.pipe.spec.ts
Expand Up @@ -17,38 +17,42 @@ describe('Make Href Pipe', () => {
providers: [{ provide: MultiSiteService, useFactory: () => instance(multiSiteService) }, MakeHrefPipe],
});
makeHrefPipe = TestBed.inject(MakeHrefPipe);
when(multiSiteService.getLangUpdatedUrl(anything(), anything(), anything())).thenCall(
(url: string, _: LocationStrategy) => of(url)

when(multiSiteService.getLangUpdatedUrl(anything(), anything())).thenCall((url: string, _: LocationStrategy) =>
of(url)
);
when(multiSiteService.appendUrlParams(anything(), anything(), anything())).thenCall(
(url: string, _, __: string) => url
);
});

it('should be created', () => {
expect(makeHrefPipe).toBeTruthy();
});
// workaround for https://github.com/DefinitelyTyped/DefinitelyTyped/issues/34617
// eslint-disable-next-line @typescript-eslint/no-explicit-any
it.each<any | jest.DoneCallback>([
[undefined, undefined, 'undefined'],
['/test', undefined, '/test'],
['/test', {}, '/test'],
['/test', { foo: 'bar' }, '/test;foo=bar'],
['/test', { foo: 'bar', marco: 'polo' }, '/test;foo=bar;marco=polo'],
['/test?query=q', undefined, '/test?query=q'],
['/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, done: jest.DoneCallback) => {
makeHrefPipe.transform({ path: () => url, getBaseHref: () => '/' } as LocationStrategy, params).subscribe(res => {
expect(res).toEqual(expected);
done();
});

it('should call appendUrlParams from the multiSiteService if no parameter exists', done => {
makeHrefPipe
.transform({ path: () => '/de/test', getBaseHref: () => '/de' } as LocationStrategy, undefined)
.subscribe(() => {
verify(multiSiteService.appendUrlParams(anything(), anything(), anything())).once();
done();
});
});

it('should call the multiSiteService if lang parameter exists', done => {
it('should call getLangUpdatedUrl from 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();
verify(multiSiteService.getLangUpdatedUrl(anything(), anything())).once();
done();
});
});

it('should call appendUrlParams from the multiSiteService if other parameter exists', done => {
makeHrefPipe
.transform({ path: () => '/de/test', getBaseHref: () => '/de' } as LocationStrategy, { foo: 'bar' })
.subscribe(() => {
verify(multiSiteService.appendUrlParams(anything(), anything(), anything())).once();
done();
});
});
Expand Down
18 changes: 4 additions & 14 deletions src/app/core/pipes/make-href.pipe.ts
Expand Up @@ -21,29 +21,19 @@ export class MakeHrefPipe implements PipeTransform {

if (urlParams) {
if (urlParams.lang) {
return this.multiSiteService.getLangUpdatedUrl(urlParams.lang, newUrl, location.getBaseHref()).pipe(
return this.multiSiteService.getLangUpdatedUrl(urlParams.lang, newUrl).pipe(
map(modifiedUrl => {
const modifiedUrlParams = modifiedUrl === newUrl ? urlParams : omit(urlParams, 'lang');
return appendUrlParams(modifiedUrl, modifiedUrlParams, split?.[1]);
return this.multiSiteService.appendUrlParams(modifiedUrl, modifiedUrlParams, split?.[1]);
})
);
} else {
return of(newUrl).pipe(map(url => appendUrlParams(url, urlParams, split?.[1])));
return of(newUrl).pipe(map(url => this.multiSiteService.appendUrlParams(url, urlParams, split?.[1])));
}
} else {
return of(appendUrlParams(newUrl, undefined, split?.[1]));
return of(this.multiSiteService.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}` : ''}`;
}
@@ -1,18 +1,22 @@
import { TestBed } from '@angular/core/testing';
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { Action } from '@ngrx/store';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { cold, hot } from 'jasmine-marbles';
import { Observable, of, throwError } from 'rxjs';
import { instance, mock, when } from 'ts-mockito';
import { Observable, noop, of, throwError } from 'rxjs';
import { anything, instance, mock, when } from 'ts-mockito';

import { FeatureToggleModule } from 'ish-core/feature-toggle.module';
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 { getAvailableLocales, getCurrentLocale } from 'ish-core/store/core/configuration/configuration.selectors';
import { CoreStoreModule } from 'ish-core/store/core/core-store.module';
import { serverConfigError } from 'ish-core/store/core/error';
import { CookiesService } from 'ish-core/utils/cookies/cookies.service';
import { makeHttpError } from 'ish-core/utils/dev/api-service-utils';
import { StoreWithSnapshots, provideStoreSnapshots } from 'ish-core/utils/dev/ngrx-testing';
import { routerTestNavigationAction } from 'ish-core/utils/dev/routing';
import { MultiSiteService } from 'ish-core/utils/multi-site/multi-site.service';

import { loadServerConfig, loadServerConfigFail, loadServerConfigSuccess } from './server-config.actions';
import { ServerConfigEffects } from './server-config.effects';
Expand All @@ -23,15 +27,20 @@ describe('Server Config Effects', () => {

beforeEach(() => {
const configurationServiceMock = mock(ConfigurationService);
const cookiesServiceMock = mock(CookiesService);
const multiSiteServiceMock = mock(MultiSiteService);

when(configurationServiceMock.getServerConfiguration()).thenReturn(of({}));
when(cookiesServiceMock.get(anything())).thenReturn('de_DE');
when(multiSiteServiceMock.getLangUpdatedUrl(anything(), anything())).thenReturn(of('/home;lang=de_DE'));
when(multiSiteServiceMock.appendUrlParams(anything(), anything(), anything())).thenReturn('/home;lang=de_DE');

TestBed.configureTestingModule({
imports: [
CoreStoreModule.forTesting(['serverConfig'], [ServerConfigEffects]),
FeatureToggleModule.forTesting('extraConfiguration'),
],
imports: [CoreStoreModule.forTesting(['serverConfig', 'configuration'], [ServerConfigEffects])],
providers: [
{ provide: ConfigurationService, useFactory: () => instance(configurationServiceMock) },
{ provide: CookiesService, useFactory: () => instance(cookiesServiceMock) },
{ provide: MultiSiteService, useFactory: () => instance(multiSiteServiceMock) },
provideStoreSnapshots(),
],
});
Expand All @@ -44,6 +53,10 @@ describe('Server Config Effects', () => {
});

it('should trigger the loading of config data on the first page', () => {
Object.defineProperty(window, 'location', {
value: { assign: jest.fn() },
writable: true,
});
store$.dispatch(routerTestNavigationAction({ routerState: { url: '/any' } }));

expect(store$.actionsArray()).toMatchInlineSnapshot(`
Expand All @@ -60,13 +73,22 @@ describe('Server Config Effects', () => {
let effects: ServerConfigEffects;
let store$: MockStore;
let configurationServiceMock: ConfigurationService;
let cookiesServiceMock: CookiesService;
let multiSiteServiceMock: MultiSiteService;
let featureToggleServiceMock: FeatureToggleService;

beforeEach(() => {
configurationServiceMock = mock(ConfigurationService);
cookiesServiceMock = mock(CookiesService);
multiSiteServiceMock = mock(MultiSiteService);
featureToggleServiceMock = mock(FeatureToggleService);

TestBed.configureTestingModule({
providers: [
{ provide: ConfigurationService, useFactory: () => instance(configurationServiceMock) },
{ provide: CookiesService, useFactory: () => instance(cookiesServiceMock) },
{ provide: FeatureToggleService, useFactory: () => instance(featureToggleServiceMock) },
{ provide: MultiSiteService, useFactory: () => instance(multiSiteServiceMock) },
provideMockActions(() => actions$),
provideMockStore(),
ServerConfigEffects,
Expand Down Expand Up @@ -104,7 +126,7 @@ describe('Server Config Effects', () => {
when(configurationServiceMock.getServerConfiguration()).thenReturn(of({}));
});

it('should map to action of type ApplyConfiguration', () => {
it('should map to action of type LoadServerConfigSuccess', () => {
const action = loadServerConfig();
const completion = loadServerConfigSuccess({ config: {} });

Expand Down Expand Up @@ -135,5 +157,63 @@ describe('Server Config Effects', () => {

expect(effects.mapToServerConfigError$).toBeObservable(expected$);
});

describe('restore language after loadServerConfigSuccess$', () => {
const action = loadServerConfigSuccess({ config: {} as ServerConfig });
beforeEach(() => {
when(multiSiteServiceMock.getLangUpdatedUrl(anything(), anything())).thenReturn(of('/home;lang=de_DE'));
when(multiSiteServiceMock.appendUrlParams(anything(), anything(), anything())).thenReturn('/home;lang=de_DE');
when(featureToggleServiceMock.enabled(anything())).thenReturn(true);
store$.overrideSelector(getAvailableLocales, ['en_US', 'de_DE', 'fr_FR']);
store$.overrideSelector(getCurrentLocale, 'en_US');

// mock location.assign() with jest.fn()
Object.defineProperty(window, 'location', {
value: { assign: jest.fn() },
writable: true,
});
});

it("should reload the current page if the user's locale cookie differs from the current locale", fakeAsync(() => {
when(cookiesServiceMock.get(anything())).thenReturn('de_DE');

actions$ = of(action);
effects.switchToPreferredLanguage$.subscribe({ next: noop, error: fail, complete: noop });

tick(500);
expect(window.location.assign).toHaveBeenCalled();
}));

it("should not reload the current page if the user's locale cookie is equal to the current locale", fakeAsync(() => {
when(cookiesServiceMock.get(anything())).thenReturn('en_US');

actions$ = of(action);
effects.switchToPreferredLanguage$.subscribe({ next: noop, error: fail, complete: noop });

tick(500);
expect(window.location.assign).not.toHaveBeenCalled();
}));

it('should not reload the current page if the feature toggle `saveLanguageSelection` is off', fakeAsync(() => {
when(cookiesServiceMock.get(anything())).thenReturn('de_DE');
when(featureToggleServiceMock.enabled(anything())).thenReturn(false);

actions$ = of(action);
effects.switchToPreferredLanguage$.subscribe({ next: noop, error: fail, complete: noop });

tick(500);
expect(window.location.assign).not.toHaveBeenCalled();
}));

it("should not reload the current page if the user's cookie locale is not available", fakeAsync(() => {
when(cookiesServiceMock.get(anything())).thenReturn('it_IT');

actions$ = of(action);
effects.switchToPreferredLanguage$.subscribe({ next: noop, error: fail, complete: noop });

tick(500);
expect(window.location.assign).not.toHaveBeenCalled();
}));
});
});
});

0 comments on commit c2e43bb

Please sign in to comment.