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:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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({
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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')}
|
||||
{t('themeToggle.names.auto')}
|
||||
<AutoIcon
|
||||
color={activeThemeId === undefined ? 'primary' : undefined}
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user