core-app-api: initial translation api refactor for new message/resource strucutre

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-09-12 12:30:29 +02:00
parent 2294b438dc
commit d3c7b71929
5 changed files with 105 additions and 173 deletions
@@ -14,7 +14,11 @@
* limitations under the License.
*/
import { createTranslationRef } from '@backstage/core-plugin-api/alpha';
import {
createTranslationMessages,
createTranslationRef,
createTranslationResource,
} from '@backstage/core-plugin-api/alpha';
import { AppTranslationApiImpl } from './AppTranslationImpl';
import i18next from 'i18next';
@@ -70,112 +74,68 @@ describe('AppTranslationApiImpl', () => {
});
it('should init messages correctly', () => {
const useResourcesMock = jest.spyOn(
const addMessagesMock = jest.spyOn(
AppTranslationApiImpl.prototype,
'addResources',
'addMessages',
);
const useLazyResourcesMock = jest.spyOn(
const addLazyResourcesMock = jest.spyOn(
AppTranslationApiImpl.prototype,
'addLazyResources',
);
const ref = createTranslationRef({
id: 'ref-id',
messages: {
key1: '',
key: '',
},
});
const options = {
supportedLanguages: ['en'],
messages: [
{
ref,
messages: {
en: { key1: 'value1' },
},
lazyMessages: {
en: () => Promise.resolve({ key2: 'value2' }),
} as any,
},
],
};
const instance = AppTranslationApiImpl.create({
supportedLanguages: ['en'],
const overrides = createTranslationMessages({
ref,
messages: { key: 'value1' },
});
const resource = createTranslationResource({
ref,
translations: {
en: () =>
Promise.resolve({
default: {
key: 'value2',
},
}),
},
});
instance.initMessages(options);
expect(useResourcesMock).toHaveBeenCalledWith(
options.messages[0].ref,
options.messages[0].messages,
);
expect(useLazyResourcesMock).toHaveBeenCalledWith(
options.messages[0].ref,
options.messages[0].lazyMessages,
);
AppTranslationApiImpl.create({
supportedLanguages: ['en'],
resources: [overrides, resource],
});
expect(addMessagesMock).toHaveBeenCalledWith(overrides);
expect(addLazyResourcesMock).toHaveBeenCalledWith(resource);
});
it('should useResources correctly', () => {
const i18nMock = i18next.createInstance() as any;
const useReturnMock = i18nMock.use();
jest.spyOn(i18nMock, 'use').mockReturnValue(useReturnMock);
jest.spyOn(i18next, 'createInstance').mockReturnValue(i18nMock);
const ref = createTranslationRef({
id: 'ref-id',
messages: {
key1: 'value1',
},
resources: {
en: {
key1: 'value2',
},
},
});
const instance = AppTranslationApiImpl.create({
supportedLanguages: ['en'],
});
instance.addResources(ref);
expect(useReturnMock.addResourceBundle).toHaveBeenCalledWith(
'en',
'ref-id',
{ key1: 'value2' },
true,
false,
const addLazyResourcesMock = jest.spyOn(
AppTranslationApiImpl.prototype,
'addLazyResources',
);
});
it('should useLazyResources correctly', () => {
const i18nMock = i18next.createInstance() as any;
const useReturnMock = i18nMock.use();
jest.spyOn(i18nMock, 'use').mockReturnValue(useReturnMock);
jest.spyOn(i18next, 'createInstance').mockReturnValue(i18nMock);
const ref = createTranslationRef({
id: 'ref-id',
messages: {
key1: 'value1',
},
lazyResources: {
en: () => Promise.resolve({ messages: { key1: 'value2' } }),
translations: {
en: () => Promise.resolve({ default: { key1: 'value2' } }),
},
});
const instance = AppTranslationApiImpl.create({
supportedLanguages: ['en'],
});
instance.addLazyResources(ref);
setTimeout(() => {
expect(useReturnMock.addResourceBundle).toHaveBeenCalledWith(
'en',
'ref-id',
{ key1: 'value2' },
true,
false,
);
});
instance.addResource(ref);
expect(addLazyResourcesMock).toHaveBeenCalledTimes(1);
expect(addLazyResourcesMock.mock.calls[0][0].id).toBe('ref-id');
});
});
@@ -16,31 +16,39 @@
import {
AppTranslationApi,
TranslationMessages,
TranslationRef,
TranslationResource,
} from '@backstage/core-plugin-api/alpha';
import i18next, { type i18n } from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import { TranslationMessages } from '../../../alpha';
// Internal import to avoid code duplication, this will lead to duplication in build output
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalTranslationResource } from '../../../../../core-plugin-api/src/translation/TranslationResource';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalTranslationRef } from '../../../../../core-plugin-api/src/translation/TranslationRef';
const DEFAULT_LANGUAGE = 'en';
/** @alpha */
export type ExperimentalI18n = {
supportedLanguages: string[];
fallbackLanguage?: string | string[];
messages?: Array<{
ref: TranslationRef;
messages?: Record<string, TranslationMessages<TranslationRef>>;
lazyMessages: Record<
string,
() => Promise<{ messages: TranslationMessages<TranslationRef> }>
>;
}>;
supportedLanguages?: string[];
resources?: Array<TranslationMessages | TranslationResource>;
};
function removeNulls(
messages: Record<string, string | null>,
): Record<string, string> {
return Object.fromEntries(
Object.entries(messages).filter(
(e): e is [string, string] => e[1] !== null,
),
);
}
/** @alpha */
export class AppTranslationApiImpl implements AppTranslationApi {
static create(options?: ExperimentalI18n) {
@@ -49,8 +57,8 @@ export class AppTranslationApiImpl implements AppTranslationApi {
i18n.use(LanguageDetector);
i18n.init({
fallbackLng: options?.fallbackLanguage || 'en',
supportedLngs: options?.supportedLanguages || ['en'],
fallbackLng: options?.fallbackLanguage || DEFAULT_LANGUAGE,
supportedLngs: options?.supportedLanguages || [DEFAULT_LANGUAGE],
interpolation: {
escapeValue: false,
},
@@ -62,70 +70,52 @@ export class AppTranslationApiImpl implements AppTranslationApi {
return new AppTranslationApiImpl(i18n, options);
}
private readonly cache = new WeakSet<TranslationRef>();
private readonly lazyCache = new WeakMap<TranslationRef, Set<string>>();
private readonly cache = new Set<string>();
private readonly lazyCache = new Map<string, Set<string>>();
getI18n() {
return this.i18n;
}
initMessages(options?: ExperimentalI18n) {
if (options?.messages?.length) {
options.messages.forEach(appMessage => {
if (appMessage.messages) {
this.addResources(appMessage.ref, appMessage.messages);
}
if (appMessage.lazyMessages) {
this.addLazyResources(appMessage.ref, appMessage.lazyMessages);
}
});
for (const resource of options?.resources || []) {
if (resource.$$type === '@backstage/TranslationResource') {
this.addLazyResources(resource);
} else if (resource.$$type === '@backstage/TranslationMessages') {
// Overrides for default messages, created with createTranslationMessages and installed via app
this.addMessages(resource);
}
}
}
addResourcesByRef<Messages extends Record<string, string>>(
translationRef: TranslationRef<Messages>,
): void {
this.addResources(translationRef);
this.addLazyResources(translationRef);
addResource(translationRef: TranslationRef): void {
const internalRef = toInternalTranslationRef(translationRef);
const defaultResource = internalRef.getDefaultResource();
if (defaultResource) {
this.addLazyResources(defaultResource);
}
}
addResources<Messages extends Record<string, string>>(
translationRef: TranslationRef<Messages>,
initResources?: Record<
string,
TranslationMessages<TranslationRef<Messages>>
>,
) {
const internalRef = toInternalTranslationRef(translationRef);
const resources = initResources || internalRef.getResources();
if (!resources || this.cache.has(internalRef)) {
addMessages(messages: TranslationMessages) {
if (this.cache.has(messages.id)) {
return;
}
this.cache.add(internalRef);
Object.entries(resources).forEach(([language, messages]) => {
this.i18n.addResourceBundle(
language,
internalRef.id,
messages,
true,
false,
);
});
this.cache.add(messages.id);
this.i18n.addResourceBundle(
DEFAULT_LANGUAGE,
messages.id,
removeNulls(messages.messages),
true,
false,
);
}
addLazyResources<Messages extends Record<string, string>>(
translationRef: TranslationRef<Messages>,
initResources?: Record<
string,
() => Promise<{ messages: TranslationMessages<TranslationRef> }>
>,
) {
let cache = this.lazyCache.get(translationRef);
addLazyResources(resource: TranslationResource) {
let cache = this.lazyCache.get(resource.id);
if (!cache) {
cache = new Set();
this.lazyCache.set(translationRef, cache);
this.lazyCache.set(resource.id, cache);
}
const {
@@ -140,9 +130,8 @@ export class AppTranslationApiImpl implements AppTranslationApi {
return;
}
const internalRef = toInternalTranslationRef(translationRef);
const namespace = internalRef.id;
const lazyResources = initResources || internalRef.getLazyResources();
const internalResource = toInternalTranslationResource(resource);
const namespace = internalResource.id;
Promise.allSettled((options.supportedLngs || []).map(addLanguage)).then(
results => {
@@ -165,7 +154,9 @@ export class AppTranslationApiImpl implements AppTranslationApi {
loadBackend = reloadResources([language], [namespace]);
}
const loadLazyResources = lazyResources?.[language];
const loadLazyResources = internalResource.resources.find(
entry => entry.language === language,
)?.loader;
if (!loadLazyResources) {
await loadBackend;
+9 -25
View File
@@ -27,7 +27,11 @@ import {
FeatureFlag,
} from '@backstage/core-plugin-api';
import { AppConfig } from '@backstage/config';
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
import {
TranslationMessages,
TranslationRef,
TranslationResource,
} from '@backstage/core-plugin-api/alpha';
/**
* Props for the `BootErrorPage` component of {@link AppComponents}.
@@ -178,16 +182,6 @@ export type AppRouteBinder = <
>,
) => void;
/**
* TODO: To be remove when TranslationMessages in packages/core-app-api/src/app/TranslationResource.ts
* come to be public
*
* @ignore
* */
type TranslationMessages<T> = T extends TranslationRef<infer R>
? Partial<R>
: never;
/**
* The options accepted by {@link createSpecializedApp}.
*
@@ -290,21 +284,11 @@ export type AppOptions = {
*/
bindRoutes?(context: { bind: AppRouteBinder }): void;
/**
* TODO: Change to ExperimentalI18n type when packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts
* become to public
*/
__experimentalI18n?: {
supportedLanguages: string[];
// TODO: Change to ExperimentalI18n type when packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts
__experimentalTranslations?: {
fallbackLanguage?: string | string[];
messages?: Array<{
ref: TranslationRef;
messages?: Record<string, TranslationMessages<TranslationRef>>;
lazyMessages: Record<
string,
() => Promise<{ messages: TranslationMessages<TranslationRef> }>
>;
}>;
supportedLanguages?: string[];
resources?: Array<TranslationMessages | TranslationResource>;
};
};
@@ -22,9 +22,7 @@ import { ApiRef, createApiRef } from '@backstage/core-plugin-api';
export type AppTranslationApi = {
getI18n(): i18n;
addResourcesByRef<TMessages extends Record<string, string>>(
translationRef: TranslationRef<TMessages>,
): void;
addResource(resource: TranslationRef): void;
};
/**
@@ -29,13 +29,12 @@ export interface TranslationOptions {
export const useTranslationRef = <
TMessages extends { [key in string]: string },
>(
translationRef: TranslationRef<TMessages>,
translationRef: TranslationRef<string, TMessages>,
) => {
const appTranslationApi = useApi(appTranslationApiRef);
appTranslationApi.addResourcesByRef(translationRef);
const translationApi = useApi(appTranslationApiRef);
const internalRef = toInternalTranslationRef(translationRef);
translationApi.addResource(translationRef);
const { t } = useTranslation(internalRef.id, {
useSuspense: process.env.NODE_ENV !== 'test',