From 49c352603bbc9e0d9a112f618e879dae5410d92d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 Sep 2023 17:13:20 +0200 Subject: [PATCH] core-plugin-api: add AppLanguageApi implementation Signed-off-by: Patrik Oldsberg --- .../AppLanguageSelector.test.ts | 154 ++++++++++++++++++ .../AppLanguageApi/AppLanguageSelector.ts | 122 ++++++++++++++ .../implementations/AppLanguageApi/index.ts | 20 +++ 3 files changed, 296 insertions(+) create mode 100644 packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.test.ts create mode 100644 packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts create mode 100644 packages/core-app-api/src/apis/implementations/AppLanguageApi/index.ts diff --git a/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.test.ts b/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.test.ts new file mode 100644 index 0000000000..e24ccee0d2 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.test.ts @@ -0,0 +1,154 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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. + */ + +import { AppLanguageSelector } from './AppLanguageSelector'; + +const baseOptions = { + availableLanguages: ['en', 'de'], +}; + +describe('AppLanguageSelector', () => { + beforeEach(() => { + localStorage.removeItem('language'); + }); + + it('should select language', async () => { + const selector = AppLanguageSelector.createWithStorage(baseOptions); + + expect(selector.getAvailableLanguages()).toEqual({ + languages: ['en', 'de'], + }); + + const subFn = jest.fn(); + selector.language$().subscribe(subFn); + expect(selector.getLanguage()).toEqual({ language: 'en' }); + await 'wait a tick'; + expect(subFn).toHaveBeenLastCalledWith({ language: 'en' }); + + selector.setLanguage('de'); + expect(subFn).toHaveBeenLastCalledWith({ language: 'de' }); + expect(selector.getLanguage()).toEqual({ language: 'de' }); + + selector.setLanguage('en'); + expect(subFn).toHaveBeenLastCalledWith({ language: 'en' }); + expect(selector.getLanguage()).toEqual({ language: 'en' }); + }); + + it('should return a new array of languages', () => { + const languages = ['en', 'de']; + const selector = AppLanguageSelector.createWithStorage({ + availableLanguages: languages, + }); + + expect(selector.getAvailableLanguages().languages).toEqual(languages); + expect(selector.getAvailableLanguages().languages).not.toBe(languages); + expect(selector.getAvailableLanguages().languages).toEqual( + selector.getAvailableLanguages().languages, + ); + expect(selector.getAvailableLanguages().languages).not.toBe( + selector.getAvailableLanguages().languages, + ); + }); + + it('should skip duplicates', async () => { + const languages = ['en', 'de']; + const selector = AppLanguageSelector.createWithStorage({ + availableLanguages: languages, + }); + + const emitted = new Array(); + selector.language$().subscribe(({ language }) => { + emitted.push(language); + }); + selector.setLanguage('en'); + selector.setLanguage('en'); + selector.setLanguage('de'); + selector.setLanguage('de'); + selector.setLanguage('de'); + selector.setLanguage('en'); + selector.setLanguage('en'); + selector.setLanguage('en'); + await 'wait a tick'; + + expect(emitted).toEqual(['en', 'de', 'en']); + }); + + it('should be initialized from storage', () => { + expect( + AppLanguageSelector.createWithStorage(baseOptions).getLanguage(), + ).toEqual({ language: 'en' }); + + localStorage.setItem('language', 'de'); + + expect( + AppLanguageSelector.createWithStorage(baseOptions).getLanguage(), + ).toEqual({ language: 'de' }); + + localStorage.removeItem('language'); + + expect( + AppLanguageSelector.createWithStorage(baseOptions).getLanguage(), + ).toEqual({ language: 'en' }); + }); + + it('should sync with storage', async () => { + const addListenerSpy = jest.spyOn(window, 'addEventListener'); + const selector = AppLanguageSelector.createWithStorage(baseOptions); + + expect(addListenerSpy).toHaveBeenCalledTimes(1); + expect(addListenerSpy).toHaveBeenCalledWith( + 'storage', + expect.any(Function), + ); + + selector.setLanguage('de'); + await 'wait a tick'; + expect(localStorage.getItem('language')).toBe('de'); + + selector.setLanguage('en'); + await 'wait a tick'; + expect(localStorage.getItem('language')).toBe('en'); + + localStorage.setItem('language', 'de'); + expect(selector.getLanguage()).toEqual({ language: 'en' }); + + const listener = addListenerSpy.mock.calls[0][1] as EventListener; + listener({ key: 'language' } as StorageEvent); + + expect(selector.getLanguage()).toEqual({ language: 'de' }); + }); + + it('should reject invalid input', async () => { + expect(() => + AppLanguageSelector.createWithStorage({ + availableLanguages: ['en', 'de', 'en'], + }), + ).toThrow( + "Supported languages may not contain duplicates, got 'en', 'de', 'en'", + ); + + expect(() => + AppLanguageSelector.createWithStorage({ + availableLanguages: ['de'], + }), + ).toThrow("Supported languages must include 'en'"); + + const selector = AppLanguageSelector.createWithStorage(baseOptions); + expect(() => selector.setLanguage('sv')).toThrow( + "Failed to change language to 'sv', available languages are 'en', 'de'", + ); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts b/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts new file mode 100644 index 0000000000..17bffdb9ab --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts @@ -0,0 +1,122 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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. + */ + +// Internal import to avoid code duplication, this will lead to duplication in build output +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { AppLanguageApi } from '@backstage/core-plugin-api/alpha'; +import { Observable } from '@backstage/types'; +import { BehaviorSubject } from '../../../lib'; + +const STORAGE_KEY = 'language'; +const DEFAULT_LANGUAGE = 'en'; + +/** @alpha */ +export interface AppLanguageSelectorOptions { + defaultLanguage?: string; + availableLanguages?: string[]; +} + +/** + * Exposes the available languages in the app and allows for switching of the active language. + * + * @alpha + */ +export class AppLanguageSelector implements AppLanguageApi { + static createWithStorage(options: AppLanguageSelectorOptions) { + const languages = options.availableLanguages ?? [DEFAULT_LANGUAGE]; + if (languages.length !== new Set(languages).size) { + throw new Error( + `Supported languages may not contain duplicates, got '${languages.join( + "', '", + )}'`, + ); + } + if (!languages.includes(DEFAULT_LANGUAGE)) { + throw new Error(`Supported languages must include '${DEFAULT_LANGUAGE}'`); + } + + let initialLanguage = languages[0]; + + if (!window.localStorage) { + return new AppLanguageSelector(languages, initialLanguage); + } + + const storedLanguage = + window.localStorage.getItem(STORAGE_KEY) ?? undefined; + if (storedLanguage && languages.includes(storedLanguage)) { + initialLanguage = storedLanguage; + } + + const selector = new AppLanguageSelector(languages, initialLanguage); + + selector.language$().subscribe(({ language }) => { + if (language !== window.localStorage.getItem(STORAGE_KEY)) { + window.localStorage.setItem(STORAGE_KEY, language); + } + }); + + window.addEventListener('storage', event => { + if (event.key === STORAGE_KEY) { + const language = localStorage.getItem(STORAGE_KEY) ?? undefined; + if (language) { + selector.setLanguage(language); + } + } + }); + + return selector; + } + + #languages: string[]; + #language: string; + #subject: BehaviorSubject<{ language: string }>; + + private constructor(languages: string[], initialLanguage: string) { + this.#languages = languages; + this.#language = initialLanguage; + this.#subject = new BehaviorSubject<{ language: string }>({ + language: initialLanguage, + }); + } + + getAvailableLanguages(): { languages: string[] } { + return { languages: this.#languages.slice() }; + } + + setLanguage(language?: string | undefined): void { + const lng = language ?? DEFAULT_LANGUAGE; + if (lng === this.#language) { + return; + } + if (lng && !this.#languages.includes(lng)) { + throw new Error( + `Failed to change language to '${lng}', available languages are '${this.#languages.join( + "', '", + )}'`, + ); + } + this.#language = lng; + this.#subject.next({ language: lng }); + } + + getLanguage(): { language: string } { + return { language: this.#language }; + } + + language$(): Observable<{ language: string }> { + return this.#subject; + } +} diff --git a/packages/core-app-api/src/apis/implementations/AppLanguageApi/index.ts b/packages/core-app-api/src/apis/implementations/AppLanguageApi/index.ts new file mode 100644 index 0000000000..769bab2a31 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AppLanguageApi/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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. + */ + +export { + AppLanguageSelector, + type AppLanguageSelectorOptions, +} from './AppLanguageSelector';