Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { Extension } from '@console/dynamic-plugin-sdk/src/types';
import { isTranslatableString, getTranslationKey, translateExtension } from '../extension-i18n';
import {
isTranslatableString,
getTranslationKey,
getNamespacesFromExtensions,
translateExtension,
} from '../extension-i18n';

const nonTranslatableStrings: string[] = ['', null, undefined, '%', 'a%', '%a', '%%', 'foo'];

Expand Down Expand Up @@ -27,6 +32,44 @@ describe('getTranslationKey', () => {
});
});

describe('getNamespacesFromExtensions', () => {
it('extracts unique namespaces from translatable strings across extensions', () => {
const extensions: Extension[] = [
{
type: 'Nav/Item',
properties: { name: '%plugin__acm~Home%', section: '%plugin__acm~Overview%' },
},
{
type: 'Nav/Item',
properties: { name: '%plugin__mce~Clusters%' },
},
];
expect(getNamespacesFromExtensions(extensions).sort()).toEqual(['plugin__acm', 'plugin__mce']);
});

it('returns an empty array when no translatable strings have namespaces', () => {
const extensions: Extension[] = [
{ type: 'Foo/Bar', properties: { name: '%keyWithoutNs%' } },
{ type: 'Foo/Bar', properties: { name: 'plain string' } },
];
expect(getNamespacesFromExtensions(extensions)).toEqual([]);
});

it('returns an empty array for extensions with no translatable strings', () => {
const extensions: Extension[] = [{ type: 'Foo/Bar', properties: { count: 42, flag: true } }];
expect(getNamespacesFromExtensions(extensions)).toEqual([]);
});

it('deduplicates namespaces across multiple extensions', () => {
const extensions: Extension[] = [
{ type: 'Nav/Item', properties: { name: '%plugin__acm~Home%' } },
{ type: 'Nav/Item', properties: { name: '%plugin__acm~Overview%' } },
{ type: 'Nav/Item', properties: { name: '%plugin__acm~Search%' } },
];
expect(getNamespacesFromExtensions(extensions)).toEqual(['plugin__acm']);
});
});

describe('translateExtension', () => {
it("recursively replaces all translatable string values via the 't' function", () => {
const testExtension: Extension = {
Expand Down
24 changes: 24 additions & 0 deletions frontend/packages/console-plugin-sdk/src/utils/extension-i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { ConsoleTFunction } from '@console/dynamic-plugin-sdk/src/extension
import type { Extension } from '@console/dynamic-plugin-sdk/src/types';
import { deepForOwn } from '@console/dynamic-plugin-sdk/src/utils/object';

const NS_SEPARATOR = '~';

export const isTranslatableString = (value): value is string => {
return (
typeof value === 'string' && value.length > 2 && value.startsWith('%') && value.endsWith('%')
Expand All @@ -13,6 +15,28 @@ export const isTranslatableString = (value): value is string => {
export const getTranslationKey = (value: string) =>
isTranslatableString(value) ? value.substr(1, value.length - 2) : undefined;

/**
* Collects the unique i18next namespaces referenced by translatable strings across all
* extensions. Useful for passing to `useTranslation(ns)` so Suspense waits for them.
*/
export const getNamespacesFromExtensions = <TExtension extends Extension>(
extensions: TExtension[],
): string[] => {
const namespaces = new Set<string>();
extensions.forEach((extension) => {
deepForOwn(extension, isTranslatableString, (value) => {
const key = getTranslationKey(value);
if (key) {
const separatorIndex = key.indexOf(NS_SEPARATOR);
if (separatorIndex > 0) {
namespaces.add(key.substring(0, separatorIndex));
}
}
});
});
return Array.from(namespaces);
};

/**
* Recursively updates the extension's properties, replacing all `%key%` placeholders within
* string values using the provided `t` function.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Extension, LoadedExtension } from '@openshift/dynamic-plugin-sdk';
import { translateExtension } from './extension-i18n';
import { useTranslation } from 'react-i18next';
import {
getNamespacesFromExtensions,
getTranslationKey,
isTranslatableString,
translateExtension,
} from './extension-i18n';
import useTranslationExt from './useTranslationExt';

const NS_SEPARATOR = '~';

/**
* In each extension's `properties` object, replace `%key%` placeholders within string values
* with actual translations.
Expand All @@ -17,7 +25,51 @@ import useTranslationExt from './useTranslationExt';
export const useTranslatedExtensions = <TExtension extends Extension>(
extensions: LoadedExtension<TExtension>[],
) => {
const namespaces = useMemo(() => getNamespacesFromExtensions(extensions), [extensions]);
const { i18n } = useTranslation();
const [namespacesReady, setNamespacesReady] = useState(false);
const prevNamespacesRef = useRef<string>('');

useEffect(() => {
const nsKey = namespaces.join(',');
if (nsKey === prevNamespacesRef.current) return;
prevNamespacesRef.current = nsKey;

if (namespaces.length === 0) {
setNamespacesReady(true);
return;
}

setNamespacesReady(false);
i18n
?.loadNamespaces(namespaces)
?.then(() => setNamespacesReady(true))
?.catch(() => setNamespacesReady(true));
}, [i18n, namespaces]);

const { t } = useTranslationExt();

return useMemo(() => extensions.map((e) => translateExtension(e, t)), [extensions, t]);
// Wraps t() to avoid calling i18next for keys whose namespace hasn't loaded yet.
// For unloaded namespaces, returns the key portion (e.g. "Home" from "plugin__acm~Home")
// which matches i18next's own fallback behavior — without triggering missingKeyHandler.
const safeT = useCallback(
(value: string) => {
if (isTranslatableString(value)) {
const key = getTranslationKey(value);
const sepIdx = key.indexOf(NS_SEPARATOR);
if (sepIdx > 0) {
const ns = key.substring(0, sepIdx);
if (!i18n?.hasResourceBundle(i18n.language, ns)) {
return key.substring(sepIdx + 1);
}
}
}
return t(value);
},
// namespacesReady triggers safeT re-creation so useMemo re-translates after loading
// eslint-disable-next-line react-hooks/exhaustive-deps
[t, i18n, namespacesReady],
);

return useMemo(() => extensions.map((e) => translateExtension(e, safeT)), [extensions, safeT]);
};