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 ( + + {}} open={true}> + + + {}} /> + + ); +} + +// 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 & {};