core-plugin-api: add AppLanguageApi implementation

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-09-14 17:13:20 +02:00
parent 1e84508592
commit 49c352603b
3 changed files with 296 additions and 0 deletions
@@ -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<string>();
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'",
);
});
});
@@ -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;
}
}
@@ -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';