core-plugin-api: add AppLanguageApi implementation
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
+154
@@ -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';
|
||||
Reference in New Issue
Block a user