diff --git a/src/components/theme/types.ts b/src/components/theme/types.ts
index c3551f521b..e7fab50357 100644
--- a/src/components/theme/types.ts
+++ b/src/components/theme/types.ts
@@ -1,5 +1,6 @@
-// Use (string & {}) for better autocomplete https://stackoverflow.com/a/61048124
-export type RealTheme = 'light' | 'light-hc' | 'dark' | 'dark-hc' | (string & {});
+import type {AutocompleteSafeString} from '../../utils/autocomplete-safe-string';
+
+export type RealTheme = 'light' | 'light-hc' | 'dark' | 'dark-hc' | AutocompleteSafeString;
export type ThemeType = 'light' | 'dark';
export type Theme = 'system' | RealTheme;
export type Direction = 'ltr' | 'rtl';
diff --git a/src/components/utils/configure.ts b/src/components/utils/configure.ts
index 89f4f2cdd4..0e20384800 100644
--- a/src/components/utils/configure.ts
+++ b/src/components/utils/configure.ts
@@ -1,10 +1,12 @@
+import type {AutocompleteSafeString} from '../../utils/autocomplete-safe-string';
+
export enum Lang {
Ru = 'ru',
En = 'en',
}
interface Config {
- lang: `${Lang}`;
+ lang: `${Lang}` | AutocompleteSafeString;
}
type Subscriber = (config: Config) => void;
diff --git a/src/components/utils/registerCustomKeysets.test.tsx b/src/components/utils/registerCustomKeysets.test.tsx
new file mode 100644
index 0000000000..80695cbaf5
--- /dev/null
+++ b/src/components/utils/registerCustomKeysets.test.tsx
@@ -0,0 +1,169 @@
+import React from 'react';
+
+import type {KeysetData} from '@gravity-ui/i18n';
+
+import {render, screen} from '../../../test-utils/utils';
+import {i18n} from '../../i18n';
+import {Dialog} from '../Dialog';
+import {Pagination} from '../Pagination';
+
+import {Lang, configure} from './configure';
+import {registerCustomKeysets} from './registerCustomKeysets';
+
+test('should render components with custom keysets', () => {
+ registerCustomKeysets(
+ 'rs',
+ createTestKeyset({
+ Dialog: {
+ close: 'Затвори дијалог',
+ },
+ Pagination: {
+ button_previous: 'Претходно',
+ button_next: 'Следеће',
+ button_first: 'Прво',
+ label_select_size: 'Изаберите величину странице',
+ 'label_input-placeholder': 'Стр.',
+ 'label_page-of': 'из',
+ },
+ }),
+ );
+ registerCustomKeysets(
+ 'it',
+ createTestKeyset({
+ Dialog: {
+ close: 'Chiudere il dialogo',
+ },
+ Pagination: {
+ button_previous: 'Precedente',
+ button_next: 'Avanti',
+ button_first: 'Primo',
+ label_select_size: 'Seleziona la dimensione della pagina',
+ 'label_input-placeholder': 'Pagina n.',
+ 'label_page-of': 'di',
+ },
+ }),
+ );
+
+ configure({lang: 'rs'});
+ render();
+ expect(screen.getByRole('button', {name: 'Затвори дијалог'})).toBeInTheDocument();
+ expect(screen.getByRole('button', {name: 'Претходно'})).toBeInTheDocument();
+
+ configure({lang: 'it'});
+ render();
+ expect(screen.getByRole('button', {name: 'Chiudere il dialogo'})).toBeInTheDocument();
+ expect(screen.getByRole('button', {name: 'Precedente'})).toBeInTheDocument();
+});
+
+test('should render components with bundled keysets after a custom keysets registration', () => {
+ registerCustomKeysets(
+ 'rs',
+ createTestKeyset({
+ Dialog: {
+ close: 'Затвори дијалог',
+ },
+ Pagination: {
+ button_previous: 'Претходно',
+ button_next: 'Следеће',
+ button_first: 'Прво',
+ label_select_size: 'Изаберите величину странице',
+ 'label_input-placeholder': 'Стр.',
+ 'label_page-of': 'из',
+ },
+ }),
+ );
+
+ configure({lang: Lang.En});
+ render();
+ expect(screen.getByRole('button', {name: 'Close dialog'})).toBeInTheDocument();
+ expect(screen.getByRole('button', {name: 'Previous'})).toBeInTheDocument();
+});
+
+test('should override bundled keysets', () => {
+ registerCustomKeysets(
+ Lang.En,
+ createTestKeyset({
+ Dialog: {
+ close: '[Overriden] Close dialog',
+ },
+ Pagination: {
+ button_previous: '[Overriden] Previous',
+ button_next: '[Overriden] Next',
+ button_first: '[Overriden] First',
+ label_select_size: '[Overriden] Select page size',
+ 'label_input-placeholder': '[Overriden] Page #',
+ 'label_page-of': '[Overriden] of',
+ },
+ }),
+ );
+
+ configure({lang: Lang.En});
+ render();
+ expect(screen.getByRole('button', {name: '[Overriden] Close dialog'})).toBeInTheDocument();
+ expect(screen.getByRole('button', {name: '[Overriden] Previous'})).toBeInTheDocument();
+});
+
+test('should throw an error if a component is not provided', () => {
+ const keysetData = createTestKeyset({});
+ delete keysetData.Table;
+
+ expect(() => registerCustomKeysets('rs', keysetData)).toThrow();
+});
+
+test('should throw an error if a component key is not provided', () => {
+ const keysetData = createTestKeyset({});
+ Object.assign(keysetData, {
+ Table: {
+ label_empty: 'empty',
+ // The values are omitted on purpose
+ // 'label-actions': pluggedValue,
+ // 'label-row-select': pluggedValue,
+ },
+ });
+
+ expect(() => registerCustomKeysets('rs', keysetData)).toThrow();
+});
+
+test('should throw an error if excess components are provided', () => {
+ const keysetData = createTestKeyset({
+ NonexistentComponent1: {
+ label_cancel: 'cancel',
+ label_submit: 'submit',
+ },
+ NonexistentComponent2: {
+ label_load: 'load',
+ label_preload: 'preload',
+ },
+ });
+ expect(() => registerCustomKeysets('it', keysetData)).toThrow();
+});
+
+test('should throw an error if excess component keys are provided', () => {
+ const keysetData = createTestKeyset({
+ Alert: {
+ label_close: 'cancel',
+ nonexistent_key: 'nonexistent',
+ nonexistent_key2: 'nonexistent2',
+ },
+ });
+ expect(() => registerCustomKeysets('it', keysetData)).toThrow();
+});
+
+function TestComponents(): React.ReactElement {
+ return (
+
+
+ {}} />
+
+ );
+}
+
+// Custom keyset registration needs keysets for every component, or validation will fail.
+// We don't want to provide every keyset in every test, otherwise tests will be too verbose.
+// So we copy all the keysets from English, and then override the ones we'll test on.
+function createTestKeyset(dataToOverride: KeysetData): KeysetData {
+ const keysetPrototype = JSON.parse(JSON.stringify(i18n.data.en));
+ return Object.assign(keysetPrototype, dataToOverride);
+}
diff --git a/src/components/utils/registerCustomKeysets.ts b/src/components/utils/registerCustomKeysets.ts
new file mode 100644
index 0000000000..1c45046168
--- /dev/null
+++ b/src/components/utils/registerCustomKeysets.ts
@@ -0,0 +1,65 @@
+import type {KeysetData} from '@gravity-ui/i18n';
+
+import {i18n} from '../../i18n';
+
+export const registerCustomKeysets = (language: string, data: KeysetData) => {
+ validateCustomKeysets(language, data);
+ i18n.registerKeysets(language, data);
+};
+
+function validateCustomKeysets(language: string, givenData: KeysetData): void {
+ const trustedData = i18n.data.en;
+
+ const trustedComponents = Object.keys(trustedData);
+ const givenComponents = Object.keys(givenData);
+
+ trustedComponents.forEach((componentName) => {
+ const trustedKeys = Object.keys(trustedData[componentName]);
+ const givenKeys = Object.keys(givenData[componentName]);
+
+ // Check that all component keys in trusted data exist in given data
+ trustedKeys.forEach((keyName) => {
+ if (givenData[componentName] === undefined) {
+ throw createValidationError(
+ language,
+ `keyset for component '${componentName}' is required`,
+ );
+ }
+ if (givenData[componentName][keyName] === undefined) {
+ throw createValidationError(
+ language,
+ `key '${keyName}' for component '${componentName}' is required`,
+ );
+ }
+ });
+
+ // Check for extra component keys
+ if (trustedKeys.length !== givenKeys.length) {
+ const keyDifference = getArrayDifference(trustedKeys, givenKeys);
+ throw createValidationError(
+ language,
+ `excess component '${componentName}' keys for found ${JSON.stringify(keyDifference)}`,
+ );
+ }
+ });
+
+ // Check for extra components
+ if (trustedComponents.length !== givenComponents.length) {
+ const componentDifference = getArrayDifference(trustedComponents, givenComponents);
+ throw createValidationError(
+ language,
+ `excess components found ${JSON.stringify(componentDifference)}`,
+ );
+ }
+}
+
+function createValidationError(language: string, text: string): Error {
+ return new Error(`Custom keysets '${language}' validation error: ${text}`);
+}
+
+function getArrayDifference(arr1: string[], arr2: string[]): string[] {
+ const arr1Extra = arr1.filter((x) => !arr2.includes(x));
+ const arr2Extra = arr2.filter((x) => !arr1.includes(x));
+
+ return arr1Extra.concat(arr2Extra);
+}
diff --git a/src/utils/autocomplete-safe-string.ts b/src/utils/autocomplete-safe-string.ts
new file mode 100644
index 0000000000..63eb1fdde3
--- /dev/null
+++ b/src/utils/autocomplete-safe-string.ts
@@ -0,0 +1,2 @@
+// Use it for better autocomplete https://stackoverflow.com/a/61048124
+export type AutocompleteSafeString = string & {};