Merge pull request #21541 from backstage/rugvip/i18nest

core-plugin-api: update createTranslationRef to support nested messages and use camelCase keys
This commit is contained in:
Patrik Oldsberg
2023-11-25 17:06:53 +01:00
committed by GitHub
15 changed files with 188 additions and 72 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-plugin-api': patch
---
The `createTranslationRef` function from the `/alpha` subpath can now also accept a nested object structure of default translation messages, which will be flatted using `.` separators.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-user-settings': patch
'@backstage/plugin-catalog': patch
'@backstage/plugin-adr': patch
---
Updated alpha translation message keys to use nested format and camel case.
+11 -6
View File
@@ -10,7 +10,7 @@ The Backstage core function provides internationalization for plugins
## For a plugin developer
When you are creating your plugin, you have the possibility to use `createTranslationRef` to define all messages for your plugin. For example
When you are creating your plugin, you have the possibility to use `createTranslationRef` to define all messages for your plugin. For example:
```ts
import { createTranslationRef } from '@backstage/core-plugin-api/alpha';
@@ -19,8 +19,13 @@ import { createTranslationRef } from '@backstage/core-plugin-api/alpha';
export const myPluginTranslationRef = createTranslationRef({
id: 'plugin.my-plugin',
messages: {
index_page_title: 'All your components',
create_component_button_label: 'Create new component',
indexPage: {
title: 'All your components',
createButtonTitle: 'Create new component',
},
entityPage: {
notFound: 'Entity not found',
},
},
});
```
@@ -33,9 +38,9 @@ import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
const { t } = useTranslationRef(myPluginTranslationRef);
return (
<PageHeader title={t('index_page_title')}>
<PageHeader title={t('indexPage.title')}>
<Button onClick={handleCreateComponent}>
{t('create_component_button_label')}
{t('indexPage.createButtonTitle')}
</Button>
</PageHeader>
);
@@ -53,7 +58,7 @@ In an app you can both override the default messages, as well as register transl
+ createTranslationMessages({
+ ref: myPluginTranslationRef,
+ messages: {
+ create_component_button_label: 'Create new entity',
+ 'indexPage.createButtonTitle': 'Create new entity',
+ },
+ }),
+ createTranslationResource({
+7 -11
View File
@@ -39,19 +39,17 @@ export function createTranslationMessages<
// @alpha (undocumented)
export function createTranslationRef<
TId extends string,
const TMessages extends {
[key in string]: string;
},
const TNestedMessages extends AnyNestedMessages,
TTranslations extends {
[language in string]: () => Promise<{
default: {
[key in keyof TMessages]: string | null;
[key in keyof FlattenedMessages<TNestedMessages>]: string | null;
};
}>;
},
>(
config: TranslationRefOptions<TId, TMessages, TTranslations>,
): TranslationRef<TId, TMessages>;
config: TranslationRefOptions<TId, TNestedMessages, TTranslations>,
): TranslationRef<TId, FlattenedMessages<TNestedMessages>>;
// @alpha (undocumented)
export function createTranslationResource<
@@ -169,13 +167,11 @@ export interface TranslationRef<
// @alpha (undocumented)
export interface TranslationRefOptions<
TId extends string,
TMessages extends {
[key in string]: string;
},
TNestedMessages extends AnyNestedMessages,
TTranslations extends {
[language in string]: () => Promise<{
default: {
[key in keyof TMessages]: string | null;
[key in keyof FlattenedMessages<TNestedMessages>]: string | null;
};
}>;
},
@@ -183,7 +179,7 @@ export interface TranslationRefOptions<
// (undocumented)
id: TId;
// (undocumented)
messages: TMessages;
messages: TNestedMessages;
// (undocumented)
translations?: TTranslations;
}
@@ -34,6 +34,40 @@ describe('TranslationRefImpl', () => {
expect(internalRef.getDefaultMessages()).toEqual({ key: 'value' });
});
it('should create a TranslationRef instance with nested messages', () => {
const ref = createTranslationRef({
id: 'test',
messages: {
key: 'value',
'nested.conflict1': 'outer conflict1',
nested: {
key: 'nested value',
key2: 'nested value2',
conflict1: 'inner conflict1',
conflict2: 'inner conflict2',
inner: {
key: 'inner value',
},
},
'nested.conflict2': 'outer conflict2',
},
});
const internalRef = toInternalTranslationRef(ref);
expect(internalRef.$$type).toBe('@backstage/TranslationRef');
expect(internalRef.version).toBe('v1');
expect(internalRef.id).toBe('test');
expect(internalRef.getDefaultMessages()).toEqual({
key: 'value',
'nested.key': 'nested value',
'nested.key2': 'nested value2',
'nested.conflict1': 'inner conflict1',
'nested.inner.key': 'inner value',
'nested.conflict2': 'outer conflict2',
});
});
it('should be created with lazy translations', async () => {
const ref = createTranslationRef({
id: 'test',
@@ -34,6 +34,43 @@ export interface TranslationRef<
/** @internal */
type AnyMessages = { [key in string]: string };
/** @ignore */
type AnyNestedMessages = { [key in string]: AnyNestedMessages | string };
/**
* Flattens a nested message declaration into a flat object with dot-separated keys.
*
* @ignore
*/
type FlattenedMessages<TMessages extends AnyNestedMessages> =
// Flatten out object keys into a union structure of objects, e.g. { a: 'a', b: 'b' } -> { a: 'a' } | { b: 'b' }
// Any nested object will be flattened into the individual unions, e.g. { a: 'a', b: { x: 'x', y: 'y' } } -> { a: 'a' } | { 'b.x': 'x', 'b.y': 'y' }
// We create this structure by first nesting the desired union types into the original object, and
// then extract them by indexing with `keyof TMessages` to form the union.
// Throughout this the objects are wrapped up in a function parameter, which allows us to have the
// final step of flipping this unions around to an intersection by inferring the function parameter.
{
[TKey in keyof TMessages]: (
_: TMessages[TKey] extends infer TValue // "local variable" for the value
? TValue extends AnyNestedMessages
? FlattenedMessages<TValue> extends infer TNested // Recurse into nested messages, "local variable" for the result
? {
[TNestedKey in keyof TNested as `${TKey & string}.${TNestedKey &
string}`]: TNested[TNestedKey];
}
: never
: { [_ in TKey]: TValue } // Primitive object values are passed through with the same key
: never,
) => void;
// The `[keyof TMessages]` extracts the object values union from our flattened structure, still wrapped up in function parameters.
// The `extends (_: infer TIntersection) => void` flips the union to an intersection, at which point we have the correct type.
}[keyof TMessages] extends (_: infer TIntersection) => void
? // This object mapping just expands similar to the Expand<> utility type, providing nicer type hints
{
readonly [TExpandKey in keyof TIntersection]: TIntersection[TExpandKey];
}
: never;
/** @internal */
export interface InternalTranslationRef<
TId extends string = string,
@@ -49,31 +86,53 @@ export interface InternalTranslationRef<
/** @alpha */
export interface TranslationRefOptions<
TId extends string,
TMessages extends { [key in string]: string },
TNestedMessages extends AnyNestedMessages,
TTranslations extends {
[language in string]: () => Promise<{
default: { [key in keyof TMessages]: string | null };
default: {
[key in keyof FlattenedMessages<TNestedMessages>]: string | null;
};
}>;
},
> {
id: TId;
messages: TMessages;
messages: TNestedMessages;
translations?: TTranslations;
}
function flattenMessages(nested: AnyNestedMessages): AnyMessages {
const entries = new Array<[string, string]>();
function visit(obj: AnyNestedMessages, prefix: string): void {
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'string') {
entries.push([prefix + key, value]);
} else {
visit(value, `${prefix}${key}.`);
}
}
}
visit(nested, '');
return Object.fromEntries(entries);
}
/** @internal */
class TranslationRefImpl<
TId extends string,
TMessages extends { [key in string]: string },
> implements InternalTranslationRef<TId, TMessages>
TNestedMessages extends AnyNestedMessages,
> implements InternalTranslationRef<TId, FlattenedMessages<TNestedMessages>>
{
#id: TId;
#messages: TMessages;
#messages: FlattenedMessages<TNestedMessages>;
#resources: TranslationResource | undefined;
constructor(options: TranslationRefOptions<TId, TMessages, any>) {
constructor(options: TranslationRefOptions<TId, TNestedMessages, any>) {
this.#id = options.id;
this.#messages = options.messages;
this.#messages = flattenMessages(
options.messages,
) as FlattenedMessages<TNestedMessages>;
}
$$type = '@backstage/TranslationRef' as const;
@@ -108,15 +167,17 @@ class TranslationRefImpl<
/** @alpha */
export function createTranslationRef<
TId extends string,
const TMessages extends { [key in string]: string },
const TNestedMessages extends AnyNestedMessages,
TTranslations extends {
[language in string]: () => Promise<{
default: { [key in keyof TMessages]: string | null };
default: {
[key in keyof FlattenedMessages<TNestedMessages>]: string | null;
};
}>;
},
>(
config: TranslationRefOptions<TId, TMessages, TTranslations>,
): TranslationRef<TId, TMessages> {
config: TranslationRefOptions<TId, TNestedMessages, TTranslations>,
): TranslationRef<TId, FlattenedMessages<TNestedMessages>> {
const ref = new TranslationRefImpl(config);
if (config.translations) {
ref.setDefaultResource(
@@ -132,7 +193,7 @@ export function createTranslationRef<
/** @internal */
export function toInternalTranslationRef<
TId extends string,
TMessages extends { [key in string]: string },
TMessages extends AnyMessages,
>(ref: TranslationRef<TId, TMessages>): InternalTranslationRef<TId, TMessages> {
const r = ref as InternalTranslationRef<TId, TMessages>;
if (r.$$type !== '@backstage/TranslationRef') {
+3 -3
View File
@@ -17,9 +17,9 @@ export const AdrSearchResultListItemExtension: Extension<{
export const adrTranslationRef: TranslationRef<
'adr',
{
readonly content_header_title: 'Architecture Decision Records';
readonly failed_to_fetch: 'Failed to fetch ADRs';
readonly no_adrs: 'No ADRs found';
readonly contentHeaderTitle: 'Architecture Decision Records';
readonly failedToFetch: 'Failed to fetch ADRs';
readonly notFound: 'No ADRs found';
}
>;
@@ -219,7 +219,7 @@ export const EntityAdrContent = (props: {
return (
<Content>
<ContentHeader title={t('content_header_title')}>
<ContentHeader title={t('contentHeaderTitle')}>
{appSupportConfigured && <SupportButton />}
</ContentHeader>
@@ -230,7 +230,7 @@ export const EntityAdrContent = (props: {
{loading && <Progress />}
{entityHasAdrs && !loading && error && (
<WarningPanel title={t('failed_to_fetch')} message={error?.message} />
<WarningPanel title={t('failedToFetch')} message={error?.message} />
)}
{entityHasAdrs &&
@@ -257,7 +257,7 @@ export const EntityAdrContent = (props: {
</Grid>
</Grid>
) : (
<Typography>{t('no_adrs')}</Typography>
<Typography>{t('notFound')}</Typography>
))}
</Content>
);
+3 -3
View File
@@ -19,8 +19,8 @@ import { createTranslationRef } from '@backstage/core-plugin-api/alpha';
export const adrTranslationRef = createTranslationRef({
id: 'adr',
messages: {
content_header_title: 'Architecture Decision Records',
failed_to_fetch: 'Failed to fetch ADRs',
no_adrs: 'No ADRs found',
contentHeaderTitle: 'Architecture Decision Records',
failedToFetch: 'Failed to fetch ADRs',
notFound: 'No ADRs found',
},
});
@@ -62,11 +62,11 @@ export function BaseCatalogPage(props: BaseCatalogPageProps) {
const { t } = useTranslationRef(catalogTranslationRef);
return (
<PageWithHeader title={t('catalog_page_title', { orgName })} themeId="home">
<PageWithHeader title={t('indexPage.title', { orgName })} themeId="home">
<Content>
<ContentHeader title="">
<CreateButton
title={t('catalog_page_create_button_title')}
title={t('indexPage.createButtonTitle')}
to={createComponentLink && createComponentLink()}
/>
<SupportButton>All your software catalog entities</SupportButton>
+4 -2
View File
@@ -20,7 +20,9 @@ import { createTranslationRef } from '@backstage/core-plugin-api/alpha';
export const catalogTranslationRef = createTranslationRef({
id: 'catalog',
messages: {
catalog_page_title: `{{orgName}} Catalog`,
catalog_page_create_button_title: 'Create',
indexPage: {
title: `{{orgName}} Catalog`,
createButtonTitle: 'Create',
},
},
});
+10 -10
View File
@@ -20,16 +20,16 @@ export default _default;
export const userSettingsTranslationRef: TranslationRef<
'user-settings',
{
readonly language: 'Language';
readonly change_the_language: 'Change the language';
readonly theme: 'Theme';
readonly theme_light: 'Light';
readonly theme_dark: 'Dark';
readonly theme_auto: 'Auto';
readonly change_the_theme_mode: 'Change the theme mode';
readonly select_theme: 'Select {{theme}}';
readonly select_theme_auto: 'Select Auto Theme';
readonly select_lng: 'Select language {{language}}';
readonly 'languageToggle.select': 'Select language {{language}}';
readonly 'languageToggle.title': 'Language';
readonly 'languageToggle.description': 'Change the language';
readonly 'themeToggle.select': 'Select theme {{theme}}';
readonly 'themeToggle.title': 'Theme';
readonly 'themeToggle.description': 'Change the theme mode';
readonly 'themeToggle.names.auto': 'Auto';
readonly 'themeToggle.names.dark': 'Dark';
readonly 'themeToggle.names.light': 'Light';
readonly 'themeToggle.selectAuto': 'Select Auto Theme';
}
>;
@@ -118,8 +118,8 @@ export const UserSettingsLanguageToggle = () => {
>
<ListItemText
className={classes.listItemText}
primary={t('language')}
secondary={t('change_the_language')}
primary={t('languageToggle.title')}
secondary={t('languageToggle.description')}
/>
<ListItemSecondaryAction className={classes.listItemSecondaryAction}>
<ToggleButtonGroup
@@ -132,7 +132,7 @@ export const UserSettingsLanguageToggle = () => {
return (
<TooltipToggleButton
key={language}
title={t('select_lng', { language })}
title={t('languageToggle.select', { language })}
value={language}
>
<>{language}</>
@@ -130,8 +130,8 @@ export const UserSettingsThemeToggle = () => {
>
<ListItemText
className={classes.listItemText}
primary={t('theme')}
secondary={t('change_the_theme_mode')}
primary={t('themeToggle.title')}
secondary={t('themeToggle.description')}
/>
<ListItemSecondaryAction className={classes.listItemSecondaryAction}>
<ToggleButtonGroup
@@ -146,12 +146,12 @@ export const UserSettingsThemeToggle = () => {
const themeTitle =
theme.title ||
(themeId === 'light' || themeId === 'dark'
? t(`theme_${themeId}`)
? t(`themeToggle.names.${themeId}`)
: themeId);
return (
<TooltipToggleButton
key={themeId}
title={t('select_theme', { theme: themeTitle })}
title={t('themeToggle.select', { theme: themeTitle })}
value={themeId}
>
<>
@@ -165,9 +165,9 @@ export const UserSettingsThemeToggle = () => {
</TooltipToggleButton>
);
})}
<Tooltip placement="top" arrow title={t('select_theme_auto')}>
<Tooltip placement="top" arrow title={t('themeToggle.selectAuto')}>
<ToggleButton value="auto" selected={activeThemeId === undefined}>
{t('theme_auto')}&nbsp;
{t('themeToggle.names.auto')}&nbsp;
<AutoIcon
color={activeThemeId === undefined ? 'primary' : undefined}
/>
+16 -10
View File
@@ -20,15 +20,21 @@ import { createTranslationRef } from '@backstage/core-plugin-api/alpha';
export const userSettingsTranslationRef = createTranslationRef({
id: 'user-settings',
messages: {
language: 'Language',
change_the_language: 'Change the language',
theme: 'Theme',
theme_light: 'Light',
theme_dark: 'Dark',
theme_auto: 'Auto',
change_the_theme_mode: 'Change the theme mode',
select_theme: 'Select {{theme}}',
select_theme_auto: 'Select Auto Theme',
select_lng: 'Select language {{language}}',
languageToggle: {
title: 'Language',
description: 'Change the language',
select: 'Select language {{language}}',
},
themeToggle: {
title: 'Theme',
description: 'Change the theme mode',
select: 'Select theme {{theme}}',
selectAuto: 'Select Auto Theme',
names: {
light: 'Light',
dark: 'Dark',
auto: 'Auto',
},
},
},
});