diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx index efe9c42ae53..89d46921970 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx @@ -390,7 +390,7 @@ const validator = debounceAsync(async function myValidator(value) { render() ``` -### Localization (locale) and translation +### Localization You can set the locale for your form by using the `locale` property on the [Form.Handler](/uilib/extensions/forms/Form/Handler/) component. This will ensure that the correct language is used for all the fields in your form. @@ -406,17 +406,39 @@ function MyForm() { } ``` -Alternatively, you can use the global Eufemia [Provider](/uilib/usage/customisation/localization/) to set the locale for your application (forms). The locale will be inherited by all the components in your form. +#### Customize translations -#### Localization with translation +You can customize the internal translations in a flat structure: -In addition, you can customize the translations: +```tsx +{ + 'nb-NO': { 'PhoneNumber.label': 'Egendefinert' }, + 'en-GB': { 'PhoneNumber.label': 'Custom' }, +} +``` + +or an object based structure: ```tsx -const myTranslations = { +{ 'nb-NO': { PhoneNumber: { label: 'Egendefinert' } }, 'en-GB': { PhoneNumber: { label: 'Custom' } }, } +``` + +#### How to customize translations in a form + +You can customize the translations in a form by using the `translations` property on the [Form.Handler](/uilib/extensions/forms/Form/Handler/). + +Alternatively, you can use the global Eufemia [Provider](/uilib/usage/customisation/localization/) (example further down). + +```tsx +import { Form, Field } from '@dnb/eufemia/extensions/forms' + +const myTranslations = { + 'nb-NO': { 'PhoneNumber.label': 'Egendefinert' }, + 'en-GB': { 'PhoneNumber.label': 'Custom' }, +} render( @@ -425,10 +447,91 @@ render( ) ``` -Here is an [example](/uilib/extensions/forms/Form/Handler/demos/#locale-and-translations) of how to use the translations in a form. +You can consume them with the `useTranslation` hook: + +```tsx +import { Form, Field } from '@dnb/eufemia/extensions/forms' +import { useTranslation } from '@dnb/eufemia/shared' + +const myTranslations = { + 'nb-NO': { 'custom.string': 'Min egendefinerte streng' }, + 'en-GB': { 'custom.string': 'My custom string' }, +} + +const MyComponent = () => { + const t = useTranslation() + return t.custom.string +} + +render( + + + , +) +``` + +Or customize them with the `` component: + +```tsx +import { Form, Field } from '@dnb/eufemia/extensions/forms' +import { Translation, TranslationProps } from '@dnb/eufemia/shared' + +const myTranslations = { + 'nb-NO': { 'custom.string': 'Min egendefinerte streng' }, + 'en-GB': { 'custom.string': 'My custom string' }, +} + +// For TypeScript support +type Tr = TranslationProps +const Tr = (props: Tr) => + +render( + + + + + + + t.custom.string} /> + + , +) +``` + +Here is a [demo](/uilib/extensions/forms/Form/Handler/demos/#locale-and-translations) of how to use the translations in a form. When creating [your own field](/uilib/extensions/forms/create-component/#localization-and-translations), you can use the `Form.useTranslation` hook to localize your field. +#### Use the shared Provider to customize translations + +```tsx +import { Form, Field } from '@dnb/eufemia/extensions/forms' +import { Provider, Translation } from '@dnb/eufemia/shared' + +const myTranslations = { + 'nb-NO': { + 'PhoneNumber.label': 'Egendefinert', + 'custom.string': 'Min egendefinerte streng', + }, + 'en-GB': { + 'PhoneNumber.label': 'Custom', + 'custom.string': 'My custom string', + }, +} + +render( + + + + + + + + + , +) +``` + ### Layout When building your application forms, preferably use the following layout components. They seamlessly places all the fields and components of Eufemia Forms correctly into place. diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/FieldProps/FieldProps.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/FieldProps/FieldProps.tsx index 178a8f28341..20bd6a6f7e2 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/FieldProps/FieldProps.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/FieldProps/FieldProps.tsx @@ -8,7 +8,7 @@ import { } from '../../../../shared/component-helper' import FieldPropsContext from './FieldPropsContext' import SharedProvider from '../../../../shared/Provider' -import { ContextProps } from '../../../../shared/Context' +import SharedContext, { ContextProps } from '../../../../shared/Context' import type { FieldProps, Path, UseFieldProps } from '../../types' export type FieldPropsProps = FieldProps & { @@ -49,21 +49,16 @@ function FieldPropsProvider(props: FieldPropsProps) { ...restProps } = props + const sharedProviderParams: ContextProps = {} const nestedContext = useContext(FieldPropsContext) + const sharedContext = useContext(SharedContext) const dataContextRef = useRef() - dataContextRef.current = useContext(DataContext) - - const sharedProviderProps: ContextProps = {} + dataContextRef.current = useContext(DataContext) /** * Always use data context as the last source for localization */ const locale = dataContextRef.current?.props?.locale ?? restProps?.locale - const translations = extendDeep( - {}, - restProps?.translations, - dataContextRef.current?.props?.translations - ) as ContextProps const nestedFieldProps = useMemo(() => { return Object.assign( @@ -73,27 +68,29 @@ function FieldPropsProvider(props: FieldPropsProps) { }, [nestedContext?.inheritedProps, restProps]) if (typeof nestedFieldProps.disabled === 'boolean') { - sharedProviderProps.formElement = { + sharedProviderParams.formElement = { disabled: nestedFieldProps.disabled, } } if (formElement) { - sharedProviderProps.formElement = formElement + sharedProviderParams.formElement = formElement } if (FormStatus) { - sharedProviderProps.FormStatus = FormStatus + sharedProviderParams.FormStatus = FormStatus } if (locale) { - sharedProviderProps.locale = locale - } - const formTranslations = useMemo(() => { - if (translations) { - return transformTranslations(translations) - } - }, [translations]) - if (formTranslations) { - sharedProviderProps.translations = formTranslations + sharedProviderParams.locale = locale } + sharedProviderParams.translations = useMemo(() => { + const translations = extendDeep( + {}, + sharedContext.translations, + restProps?.translations, + dataContextRef.current?.props?.translations + ) as ContextProps + + return translations + }, [restProps?.translations, sharedContext.translations]) const extend = useCallback( (fieldProps: T) => { @@ -135,46 +132,10 @@ function FieldPropsProvider(props: FieldPropsProps) { inheritedContext: nestedFieldProps, }} > - {children} + {children} ) } -/** - * Transform translations into { 'nb-NO': { Forms: { ... } } } - */ -function transformTranslations( - translations: ContextProps['translations'] -) { - const result = {} - const trObj = translations as Record< - ContextProps['locale'], - Record> - > - - for (const locale in trObj) { - const newObj: Record< - 'Forms', - Record> - > = { - Forms: {}, - } - - for (const key in trObj[locale]) { - const newKeyObj: Record = {} - - for (const subKey in trObj[locale][key]) { - newKeyObj[subKey] = trObj[locale][key][subKey] - } - - newObj.Forms[key] = newKeyObj - } - - result[locale] = newObj - } - - return result -} - FieldPropsProvider._supportsSpacingProps = 'children' export default FieldPropsProvider diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx index 21eb03a971c..6cb2ca80a03 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx @@ -958,7 +958,7 @@ describe('Form.Handler', () => { ) }) - it('should support nested translations prop', () => { + it('should support translations prop', () => { const translations = { 'nb-NO': { PhoneNumber: { label: 'Egendefinert' } }, 'en-GB': { PhoneNumber: { label: 'Custom' } }, diff --git a/packages/dnb-eufemia/src/extensions/forms/constants/__tests__/FormsTranslations.test.tsx b/packages/dnb-eufemia/src/extensions/forms/constants/__tests__/FormsTranslations.test.tsx new file mode 100644 index 00000000000..bd4c84b2a92 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/constants/__tests__/FormsTranslations.test.tsx @@ -0,0 +1,564 @@ +import React from 'react' +import { render } from '@testing-library/react' +import { Field, Form } from '../..' +import { + Provider, + Translation, + useTranslation, + TranslationProps, +} from '../../../../../shared' +import nbNO from '../locales/nb-NO' +import enGB from '../locales/en-GB' + +const nb = nbNO['nb-NO'] +const gb = enGB['en-GB'] + +type Tr = TranslationProps + +describe('Form.Handler', () => { + describe('with object translations', () => { + it('should handle custom and internal strings', () => { + const translations = { + 'nb-NO': { + my: { string: 'Min streng 1' }, + PhoneNumber: { label: 'Egendefinert 1' }, + }, + 'en-GB': { + my: { string: 'My string 1' }, + PhoneNumber: { label: 'Custom 1' }, + }, + } + + const Tr = (p: Tr) => + + const MockComponent = (props) => { + return ( + + + + t.my.string} /> + + + ) + } + + const { rerender } = render() + + const [countryCode, phoneNumber, output1] = Array.from( + document.querySelectorAll('label, output') + ) + + expect(countryCode).toHaveTextContent( + gb.PhoneNumber.countryCodeLabel + ) + expect(phoneNumber).toHaveTextContent( + translations['en-GB'].PhoneNumber.label + ) + expect(output1).toHaveTextContent(translations['en-GB'].my.string) + + rerender() + + expect(countryCode).toHaveTextContent( + nb.PhoneNumber.countryCodeLabel + ) + expect(phoneNumber).toHaveTextContent( + translations['nb-NO'].PhoneNumber.label + ) + expect(output1).toHaveTextContent(translations['nb-NO'].my.string) + }) + }) + + describe('with flat translations', () => { + it('should handle custom and internal strings', () => { + const translations = { + 'nb-NO': { + 'my.string': 'Min streng 2', + 'PhoneNumber.label': 'Egendefinert 2', + }, + 'en-GB': { + 'my.string': 'My string 2', + 'PhoneNumber.label': 'Custom 2', + }, + } + + const Tr = (p: Tr) => + + const GlobalHook = () => { + const t = useTranslation() + return t.my.string + } + const FormsHook = () => { + const t = Form.useTranslation() + return t.PhoneNumber.label + } + + const MockComponent = (props) => { + return ( + + + + t.my.string} /> + + + + + + + + + ) + } + + const { rerender } = render() + + const [countryCode, phoneNumber, output1, output2, output3] = + Array.from(document.querySelectorAll('label, output')) + + expect(countryCode).toHaveTextContent( + gb.PhoneNumber.countryCodeLabel + ) + expect(phoneNumber).toHaveTextContent( + translations['en-GB']['PhoneNumber.label'] + ) + expect(output1).toHaveTextContent(translations['en-GB']['my.string']) + expect(output2).toHaveTextContent(translations['en-GB']['my.string']) + expect(output3).toHaveTextContent( + translations['en-GB']['PhoneNumber.label'] + ) + + rerender() + + expect(countryCode).toHaveTextContent( + nb.PhoneNumber.countryCodeLabel + ) + expect(phoneNumber).toHaveTextContent( + translations['nb-NO']['PhoneNumber.label'] + ) + expect(output1).toHaveTextContent(translations['nb-NO']['my.string']) + expect(output2).toHaveTextContent(translations['nb-NO']['my.string']) + expect(output3).toHaveTextContent( + translations['nb-NO']['PhoneNumber.label'] + ) + }) + }) +}) + +describe('Form.Section', () => { + describe('with object translations', () => { + it('should handle custom and internal strings', () => { + const translations = { + 'nb-NO': { + my: { string: 'Min streng 1' }, + PhoneNumber: { label: 'Egendefinert 1' }, + }, + 'en-GB': { + my: { string: 'My string 1' }, + PhoneNumber: { label: 'Custom 1' }, + }, + } + + const Tr = (p: Tr) => + + const MockComponent = (props) => { + return ( + + + + + t.my.string} /> + + + + ) + } + + const { rerender } = render() + + const [countryCode, phoneNumber, output1] = Array.from( + document.querySelectorAll('label, output') + ) + + expect(countryCode).toHaveTextContent( + gb.PhoneNumber.countryCodeLabel + ) + expect(phoneNumber).toHaveTextContent( + translations['en-GB'].PhoneNumber.label + ) + expect(output1).toHaveTextContent(translations['en-GB'].my.string) + + rerender() + + expect(countryCode).toHaveTextContent( + nb.PhoneNumber.countryCodeLabel + ) + expect(phoneNumber).toHaveTextContent( + translations['nb-NO'].PhoneNumber.label + ) + expect(output1).toHaveTextContent(translations['nb-NO'].my.string) + }) + + it('should let Form.Handler overwrite translations', () => { + const sectionTranslations = { + 'nb-NO': { + my: { string: 'Min streng 1' }, + PhoneNumber: { label: 'Egendefinert 1' }, + }, + 'en-GB': { + my: { string: 'My string 1' }, + PhoneNumber: { label: 'Custom 1' }, + }, + } + const handlerTranslations = { + 'nb-NO': {}, // Skip overwriting of sectionTranslations when switching to nb-NO + 'en-GB': { + my: { string: 'My string 2' }, + PhoneNumber: { label: 'Custom 2' }, + }, + } + + const Tr = (p: Tr) => ( + + ) + + const MockComponent = (props) => { + return ( + + + + + t.my.string} /> + + + + ) + } + + const { rerender } = render() + + const [countryCode, phoneNumber, output1] = Array.from( + document.querySelectorAll('label, output') + ) + + expect(countryCode).toHaveTextContent( + gb.PhoneNumber.countryCodeLabel + ) + expect(phoneNumber).toHaveTextContent( + handlerTranslations['en-GB'].PhoneNumber.label + ) + expect(output1).toHaveTextContent( + handlerTranslations['en-GB'].my.string + ) + + rerender() + + expect(countryCode).toHaveTextContent( + nb.PhoneNumber.countryCodeLabel + ) + expect(phoneNumber).toHaveTextContent( + sectionTranslations['nb-NO'].PhoneNumber.label + ) + expect(output1).toHaveTextContent( + sectionTranslations['nb-NO'].my.string + ) + }) + }) + + describe('with flat translations', () => { + it('should handle custom and internal strings', () => { + const translations = { + 'nb-NO': { + 'my.string': 'Min streng 2', + 'PhoneNumber.label': 'Egendefinert 2', + }, + 'en-GB': { + 'my.string': 'My string 2', + 'PhoneNumber.label': 'Custom 2', + }, + } + + const Tr = (p: Tr) => + + const GlobalHook = () => { + const t = useTranslation() + return t.my.string + } + const FormsHook = () => { + const t = Form.useTranslation() + return t.PhoneNumber.label + } + + const MockComponent = (props) => { + return ( + + + + + t.my.string} /> + + + + + + + + + + ) + } + + const { rerender } = render() + + const [countryCode, phoneNumber, output1, output2, output3] = + Array.from(document.querySelectorAll('label, output')) + + expect(countryCode).toHaveTextContent( + gb.PhoneNumber.countryCodeLabel + ) + expect(phoneNumber).toHaveTextContent( + translations['en-GB']['PhoneNumber.label'] + ) + expect(output1).toHaveTextContent(translations['en-GB']['my.string']) + expect(output2).toHaveTextContent(translations['en-GB']['my.string']) + expect(output3).toHaveTextContent( + translations['en-GB']['PhoneNumber.label'] + ) + + rerender() + + expect(countryCode).toHaveTextContent( + nb.PhoneNumber.countryCodeLabel + ) + expect(phoneNumber).toHaveTextContent( + translations['nb-NO']['PhoneNumber.label'] + ) + expect(output1).toHaveTextContent(translations['nb-NO']['my.string']) + expect(output2).toHaveTextContent(translations['nb-NO']['my.string']) + expect(output3).toHaveTextContent( + translations['nb-NO']['PhoneNumber.label'] + ) + }) + + it('should let Form.Handler overwrite translations', () => { + const sectionTranslations = { + 'nb-NO': { + 'my.string': 'Min streng 2', + 'PhoneNumber.label': 'Egendefinert 2', + }, + 'en-GB': { + 'my.string': 'My string 2', + 'PhoneNumber.label': 'Custom 2', + }, + } + const handlerTranslations = { + 'nb-NO': {}, // Skip overwriting of sectionTranslations when switching to nb-NO + 'en-GB': { + 'my.string': 'My string 2', + 'PhoneNumber.label': 'Custom 2', + }, + } + + const Tr = (p: Tr) => ( + + ) + + const GlobalHook = () => { + const t = useTranslation() + return t.my.string + } + const FormsHook = () => { + const t = Form.useTranslation() + return t.PhoneNumber.label + } + + const MockComponent = (props) => { + return ( + + + + + t.my.string} /> + + + + + + + + + + ) + } + + const { rerender } = render() + + const [countryCode, phoneNumber, output1, output2, output3] = + Array.from(document.querySelectorAll('label, output')) + + expect(countryCode).toHaveTextContent( + gb.PhoneNumber.countryCodeLabel + ) + expect(phoneNumber).toHaveTextContent( + handlerTranslations['en-GB']['PhoneNumber.label'] + ) + expect(output1).toHaveTextContent( + handlerTranslations['en-GB']['my.string'] + ) + expect(output2).toHaveTextContent( + handlerTranslations['en-GB']['my.string'] + ) + expect(output3).toHaveTextContent( + handlerTranslations['en-GB']['PhoneNumber.label'] + ) + + rerender() + + expect(countryCode).toHaveTextContent( + nb.PhoneNumber.countryCodeLabel + ) + expect(phoneNumber).toHaveTextContent( + sectionTranslations['nb-NO']['PhoneNumber.label'] + ) + expect(output1).toHaveTextContent( + sectionTranslations['nb-NO']['my.string'] + ) + expect(output2).toHaveTextContent( + sectionTranslations['nb-NO']['my.string'] + ) + expect(output3).toHaveTextContent( + sectionTranslations['nb-NO']['PhoneNumber.label'] + ) + }) + }) +}) + +describe('Shared Provider', () => { + describe('with object translations', () => { + it('should handle custom and internal strings', () => { + const translations = { + 'nb-NO': { + my: { string: 'Min streng 3' }, + PhoneNumber: { label: 'Egendefinert 3' }, + }, + 'en-GB': { + my: { string: 'My string 3' }, + PhoneNumber: { label: 'Custom 3' }, + }, + } + + const Tr = (p: Tr) => + + const MockComponent = (props) => { + return ( + + + + + t.my.string} /> + + + + ) + } + + const { rerender } = render() + + const [countryCode, phoneNumber, output1] = Array.from( + document.querySelectorAll('label, output') + ) + + expect(countryCode).toHaveTextContent( + gb.PhoneNumber.countryCodeLabel + ) + expect(phoneNumber).toHaveTextContent( + translations['en-GB'].PhoneNumber.label + ) + expect(output1).toHaveTextContent(translations['en-GB'].my.string) + + rerender() + + expect(countryCode).toHaveTextContent( + nb.PhoneNumber.countryCodeLabel + ) + expect(phoneNumber).toHaveTextContent( + translations['nb-NO'].PhoneNumber.label + ) + expect(output1).toHaveTextContent(translations['nb-NO'].my.string) + }) + }) + + describe('with flat translations', () => { + it('should handle custom and internal strings', () => { + const translations = { + 'nb-NO': { + 'my.string': 'Min streng 4', + 'PhoneNumber.label': 'Egendefinert 4', + }, + 'en-GB': { + 'my.string': 'My string 4', + 'PhoneNumber.label': 'Custom 4', + }, + } + + const Tr = (p: Tr) => + + const GlobalHook = () => { + const t = useTranslation() + return t.my.string + } + const FormsHook = () => { + const t = Form.useTranslation() + return t.PhoneNumber.label + } + + const MockComponent = (props) => { + return ( + + + + + t.my.string} /> + + + + + + + + + + ) + } + + const { rerender } = render() + + const [countryCode, phoneNumber, output1, output2, output3] = + Array.from(document.querySelectorAll('label, output')) + + expect(countryCode).toHaveTextContent( + gb.PhoneNumber.countryCodeLabel + ) + expect(phoneNumber).toHaveTextContent( + translations['en-GB']['PhoneNumber.label'] + ) + expect(output1).toHaveTextContent(translations['en-GB']['my.string']) + expect(output2).toHaveTextContent(translations['en-GB']['my.string']) + expect(output3).toHaveTextContent( + translations['en-GB']['PhoneNumber.label'] + ) + + rerender() + + expect(countryCode).toHaveTextContent( + nb.PhoneNumber.countryCodeLabel + ) + expect(phoneNumber).toHaveTextContent( + translations['nb-NO']['PhoneNumber.label'] + ) + expect(output1).toHaveTextContent(translations['nb-NO']['my.string']) + expect(output2).toHaveTextContent(translations['nb-NO']['my.string']) + expect(output3).toHaveTextContent( + translations['nb-NO']['PhoneNumber.label'] + ) + }) + }) +}) diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useTranslation.test.tsx b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useTranslation.test.tsx index 68d166c36ab..116c895ed89 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useTranslation.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useTranslation.test.tsx @@ -10,8 +10,10 @@ import Provider from '../../../../shared/Provider' import { LOCALE as defaultLocale } from '../../../../shared/defaults' // Translations -import nbNO from '../../constants/locales/nb-NO' -import enGB from '../../constants/locales/en-GB' +import forms_nbNO from '../../constants/locales/nb-NO' +import forms_enGB from '../../constants/locales/en-GB' +import global_nbNO from '../../../../shared/locales/nb-NO' +import global_enGB from '../../../../shared/locales/en-GB' describe('Form.useTranslation', () => { it('should default to nb-NO if no locale is specified in context', () => { @@ -20,9 +22,13 @@ describe('Form.useTranslation', () => { }) expect(result.current).toEqual( - Object.assign(nbNO[defaultLocale], { - formatMessage: expect.any(Function), - }) + Object.assign( + forms_nbNO[defaultLocale], + global_nbNO[defaultLocale], + { + formatMessage: expect.any(Function), + } + ) ) }) @@ -34,7 +40,9 @@ describe('Form.useTranslation', () => { }) expect(resultGB.current).toEqual( - Object.assign(enGB['en-GB'], { formatMessage: expect.any(Function) }) + Object.assign(forms_enGB['en-GB'], global_enGB['en-GB'], { + formatMessage: expect.any(Function), + }) ) const { result: resultNO } = renderHook(() => useTranslation(), { @@ -44,7 +52,9 @@ describe('Form.useTranslation', () => { }) expect(resultNO.current).toEqual( - Object.assign(nbNO['nb-NO'], { formatMessage: expect.any(Function) }) + Object.assign(forms_nbNO['nb-NO'], { + formatMessage: expect.any(Function), + }) ) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useTranslation.tsx b/packages/dnb-eufemia/src/extensions/forms/hooks/useTranslation.tsx index 31480f62b28..5637f3fc62a 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useTranslation.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useTranslation.tsx @@ -1,6 +1,7 @@ import { useMemo, useContext } from 'react' import SharedContext from '../../../shared/Context' -import { combineTranslations } from '../../../shared/useTranslation' +import { combineWithExternalTranslations } from '../../../shared/useTranslation' +import { extendDeep } from '../../../shared/component-helper' import { DeepPartial } from '../../../shared/types' import { LOCALE } from '../../../shared/defaults' import formsLocales from '../constants/locales' @@ -25,22 +26,20 @@ export default function useTranslation( | CustomLocales | Record ) { - const { locale, translation } = useContext(SharedContext) + const { locale, translation: globalTranslation } = + useContext(SharedContext) return useMemo(() => { - const tr = formsLocales[locale] || formsLocales[LOCALE] + const translation = extendDeep( + {}, + formsLocales[locale] || formsLocales[LOCALE], + globalTranslation + ) - const Forms = translation?.Forms - if (Forms) { - for (const key in Forms) { - tr[key] = Object.assign(tr?.[key] || {}, Forms[key]) - } - } - - return combineTranslations({ - translation: tr, + return combineWithExternalTranslations({ + translation, messages, locale, }) as T - }, [locale, messages, translation]) + }, [globalTranslation, locale, messages]) } diff --git a/packages/dnb-eufemia/src/shared/Context.tsx b/packages/dnb-eufemia/src/shared/Context.tsx index e324e673c31..48fb37aa60a 100644 --- a/packages/dnb-eufemia/src/shared/Context.tsx +++ b/packages/dnb-eufemia/src/shared/Context.tsx @@ -3,10 +3,11 @@ * */ -import React from 'react' +import { createContext } from 'react' import { LOCALE, CURRENCY, CURRENCY_DISPLAY } from './defaults' import defaultLocales from './locales' import { extendDeep } from './component-helper' +import pointer from 'json-pointer' // All TypeScript based Eufemia elements import type { ScrollViewProps } from '../fragments/scroll-view/ScrollView' @@ -200,9 +201,7 @@ export type TranslationLocale = keyof TranslationDefaultLocales export type TranslationKeys = keyof TranslationDefaultLocales[TranslationLocale] export type TranslationValues = - TranslationDefaultLocales[TranslationLocale] & { - Forms?: Record - } + TranslationDefaultLocales[TranslationLocale] export type TranslationCustomLocales = Record< never, string | Record @@ -221,24 +220,35 @@ export type TranslationFlat = Record< TranslationKeys | ComponentTranslation > +export type TranslationFlatToObject = T extends Record + ? { + // eslint-disable-next-line no-unused-vars + [K in keyof T as K extends `${infer First}.${infer Rest}` + ? First + : // eslint-disable-next-line no-unused-vars + K]: K extends `${infer First}.${infer Rest}` + ? TranslationFlatToObject> + : T[K] + } + : T + export function prepareContext( props: ContextProps = {} ): Props & ContextProps { - const translations: Translations = - props.translations || props.locales - ? extendDeep( - { ...defaultLocales }, - props.translations || props.locales - ) - : defaultLocales - - if (props.__context__) { - Object.assign(props, props.__context__) + if (props?.__context__) { + props = Object.assign({}, props, props.__context__) delete props.__context__ } - const key = handleLocaleFallbacks(props.locale || LOCALE, translations) - const translation = translations[key] || defaultLocales[LOCALE] || {} // here we could use Object.freeze + const translations: Translations = + props.translations || props.locales + ? extendDeep({}, defaultLocales, props.translations || props.locales) + : extendDeep({}, defaultLocales) + + const localeWithFallback = handleLocaleFallbacks( + props.locale || LOCALE, + props.translations || props.locales + ) /** * The code above adds support for strings, defined like: @@ -246,56 +256,57 @@ export function prepareContext( * "Modal.close_title": "Lukk", * } */ - if (translations[key]) { - translations[key] = destruct( - translations[key] as TranslationFlat, - translation + for (const locale in translations) { + translations[locale] = destructFlatTranslation( + translations[locale] as TranslationFlat ) } + const translation = + translations[localeWithFallback] || defaultLocales[LOCALE] || {} + const context = { - // We may use that in future - updateTranslation: (locale, translation) => { - context.translation = (context.translations || context.locales)[ - locale - ] = translation + ...props, + updateTranslation: (locale, newTranslations) => { + context.translation = newTranslations[locale] + context.translations = newTranslations + + if (context.locales) { + context.locales = context.translations + } }, - getTranslation: (props) => { - if (props) { - const lang = props.lang || props.locale + getTranslation: (localProps) => { + if (localProps) { + const locale = localProps.lang || localProps.locale if ( - lang && - (context.translations || context.locales)[lang] && - lang !== key + locale && + (context.translations || context.locales)[locale] && + locale !== localeWithFallback ) { - return (context.translations || context.locales)[lang] + const tr = context.translations || context.locales + return tr[locale] } } - return context.translation + return context.translation || defaultLocales[LOCALE] }, - translations, - locale: null, - breakpoints: null, - skeleton: null, - - ...props, - /** * Make sure we set this after props, since we update this one! */ + locales: translations, // @deprecated – can be removed in v11 + translations, translation, } as Props & ContextProps - return context + return { ...context } } function handleLocaleFallbacks( locale: InternalLocale | AnyLocale, - translations: Translations + translations: Translations = {} ) { if (!translations[locale]) { - if (locale === 'en' || locale.split('-')[0] === 'en') { + if (locale === 'en' || String(locale).split('-')[0] === 'en') { return 'en-GB' } } @@ -303,7 +314,7 @@ function handleLocaleFallbacks( } // If no provider is given, we use the default context from here -const Context = React.createContext( +const Context = createContext( prepareContext({ locale: LOCALE, currency: CURRENCY, @@ -313,28 +324,20 @@ const Context = React.createContext( export default Context -function destruct( - source: TranslationFlat, - validKeys: Record -): TranslationFlat { +export function destructFlatTranslation(source: TranslationFlat) { + let hasFlatTr = false + const destructed = {} + for (const k in source) { if (String(k).includes('.')) { - const list = k.split('.') - - if (validKeys[list[0]]) { - const val = source[k] - const last = list.length - 1 - - list.forEach((k, i) => { - source[k] = i === last ? val : source[k] - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - source = source[k] || {} - }) - } + pointer.set(destructed, '/' + k.replace(/\./g, '/'), source[k]) + hasFlatTr = true } } + if (hasFlatTr) { + return extendDeep({}, source, destructed) + } + return source } diff --git a/packages/dnb-eufemia/src/shared/Provider.tsx b/packages/dnb-eufemia/src/shared/Provider.tsx index 705dac438ab..fa2230af71a 100644 --- a/packages/dnb-eufemia/src/shared/Provider.tsx +++ b/packages/dnb-eufemia/src/shared/Provider.tsx @@ -3,9 +3,12 @@ * */ -import React from 'react' -import Context, { prepareContext } from './Context' -import type { ContextProps, InternalLocale } from './Context' +import React, { useCallback, useContext, useMemo, useState } from 'react' +import Context, { + prepareContext, + ContextProps, + InternalLocale, +} from './Context' import { prepareFormElementContext } from './helpers/filterValidProps' export type ProviderProps = { @@ -23,44 +26,70 @@ export type ProviderProps = { export default function Provider( localProps: ProviderProps & Props ) { - const { children, ...props } = localProps + const { children, props } = useMemo(() => { + const { children, ...props } = localProps + return { children, props } + }, [localProps]) - const context = React.useContext(Context) - const [localContext, setLocalContext] = React.useState(null) + const nestedContext = useContext(Context) + const [localContext, setLocalContext] = useState(null) - let value = mergeContext(context, { ...localContext, ...props }) - - if (context) { - value = prepareContext(value) - context.updateTranslation(value.locale, value.translation) - } - - value.update = updateAll - value.setLocale = setAllLocale - value.updateCurrent = updateCurrent - value.setCurrentLocale = setCurrentLocale - - return {children} - - function updateCurrent(props: ContextProps) { + const updateCurrent = useCallback((props: ContextProps) => { setLocalContext({ __context__: props }) - } + }, []) - function setCurrentLocale(locale: InternalLocale) { + const setCurrentLocale = useCallback((locale: InternalLocale) => { setLocalContext({ __context__: { locale } }) - } + }, []) + + const update = useCallback( + (props: ContextProps) => { + nestedContext.update?.(props) + setLocalContext({ __context__: props }) + }, + [nestedContext] + ) + + const setLocale = useCallback( + (locale: InternalLocale) => { + update({ locale }) + }, + [update] + ) + + const value = useMemo(() => { + const preparedContext = { + // Make copy to avoid extending the root context + ...prepareContext( + mergeContextWithProps(nestedContext, { + ...localContext, + ...props, + }) + ), + } - function setAllLocale(locale: InternalLocale) { - updateAll({ locale }) - } + preparedContext.update = update + preparedContext.setLocale = setLocale + preparedContext.updateCurrent = updateCurrent + preparedContext.setCurrentLocale = setCurrentLocale - function updateAll(props: ContextProps) { - if (typeof context.update === 'function') { - context.update(props) - } + nestedContext.updateTranslation( + preparedContext.locale, + preparedContext.translations + ) - setLocalContext({ __context__: props }) - } + return preparedContext + }, [ + nestedContext, + localContext, + props, + setLocale, + setCurrentLocale, + update, + updateCurrent, + ]) + + return {children} } type MergeContext = { @@ -70,24 +99,24 @@ type MergeContextProps = { value: ProviderProps } & MergeContext -function mergeContext( +function mergeContextWithProps( context: ContextT & ContextProps, - props: PropsT & MergeContextProps + providerProps: PropsT & MergeContextProps ) { // When value is given as so: - const { value, ...rest } = props + const { value, ...rest } = providerProps // Make sure we create a copy, because we add some custom methods to it - const merge = { ...value, ...rest } + const props = { ...value, ...rest } // Merge our new values with an existing context - const mergedContext = { ...context, ...merge } + const mergedContext = { ...context, ...props } // Because we don't want to deep merge, we merge formElement additionally - if (context?.formElement && merge.formElement) { + if (context?.formElement && props.formElement) { mergedContext.formElement = { ...context.formElement, - ...merge.formElement, + ...props.formElement, } mergedContext.formElement = prepareFormElementContext( mergedContext.formElement @@ -95,10 +124,10 @@ function mergeContext( } // Deprecated – can be removed in v11 - if (context?.FormRow && merge.FormRow) { + if (context?.FormRow && props.FormRow) { mergedContext.FormRow = { ...context.FormRow, - ...merge.FormRow, + ...props.FormRow, } mergedContext.FormRow = prepareFormElementContext( mergedContext.FormRow diff --git a/packages/dnb-eufemia/src/shared/Translation.tsx b/packages/dnb-eufemia/src/shared/Translation.tsx index 6331cee79c9..2fb52563eb2 100644 --- a/packages/dnb-eufemia/src/shared/Translation.tsx +++ b/packages/dnb-eufemia/src/shared/Translation.tsx @@ -1,14 +1,17 @@ -import React, { useContext } from 'react' +import { useContext } from 'react' import { TranslationArguments, TranslationId, TranslationIdAsFunction, formatMessage, } from './useTranslation' -import Context, { TranslationCustomLocales } from './Context' +import SharedContext, { + TranslationCustomLocales, + TranslationFlatToObject, +} from './Context' export type TranslationProps = { - id?: TranslationId | TranslationIdAsFunction + id?: TranslationId | TranslationIdAsFunction> children?: TranslationId } & TranslationArguments @@ -17,10 +20,12 @@ export default function Translation({ children, ...params }: TranslationProps) { - const { translation } = useContext(Context) - return formatMessage(id || children, params, translation) -} + const { translation } = useContext(SharedContext) + const result = formatMessage(id || children, params, translation) + + if (typeof result !== 'string') { + return String(id) + } -export function getTranslation(id: TranslationId, params) { - return + return result } diff --git a/packages/dnb-eufemia/src/shared/__tests__/Provider.test.tsx b/packages/dnb-eufemia/src/shared/__tests__/Provider.test.tsx index 027d428ce3e..96c08a2fb66 100644 --- a/packages/dnb-eufemia/src/shared/__tests__/Provider.test.tsx +++ b/packages/dnb-eufemia/src/shared/__tests__/Provider.test.tsx @@ -16,467 +16,469 @@ import Provider, { ProviderProps } from '../Provider' import { fireEvent, render } from '@testing-library/react' describe('Provider', () => { - const title_nb = 'Tekst' - const title_gb = 'Text' - - const nbNO: TranslationFlat = { - 'HelpButton.title': title_nb, - } - const enGB: TranslationFlat = { - 'HelpButton.title': title_gb, - } - - const defaultTranslations: Translations = { - 'nb-NO': nbNO, - 'en-GB': enGB, - } - - const LocalProvider = (props: ProviderProps) => { - return - } - - const ChangeLocale = () => { - const { setLocale, locale } = React.useContext(Context) - - expect(typeof setLocale).toBe('function') - - return ( - { - setLocale(value) - }} - > - - nb-NO - - - en-GB - - - en-US - - - ) - } - - const MagicProvider = ({ - children = null, - ...props - }: Partial) => { - return ( - - - {(context) => { - const title = context.translation.HelpButton.title - return ( - <> -

{title}

- - {children} - - ) + describe('translations', () => { + const title_nb = 'Tekst' + const title_gb = 'Text' + + const nbNO: TranslationFlat = { + 'HelpButton.title': title_nb, + } + const enGB: TranslationFlat = { + 'HelpButton.title': title_gb, + } + + const defaultTranslations: Translations = { + 'nb-NO': nbNO, + 'en-GB': enGB, + } + + const LocalProvider = (props: ProviderProps) => { + return + } + + const ChangeLocale = () => { + const { setLocale, locale } = React.useContext(Context) + + expect(typeof setLocale).toBe('function') + + return ( + { + setLocale(value) }} -
-
- ) - } - - it('locales should translate component strings', () => { - const { rerender } = render( - - content - - ) - - expect( - document - .querySelector('button.dnb-help-button') - .getAttribute('aria-label') - ).toBe(title_nb) - expect( - document - .querySelector('button.dnb-help-button') - .getAttribute('aria-roledescription') - ).toBe('Hjelp-knapp') - - rerender( - - content - - ) - - expect( - document - .querySelector('button.dnb-help-button') - .getAttribute('aria-label') - ).toBe(title_gb) - expect( - document - .querySelector('button.dnb-help-button') - .getAttribute('aria-roledescription') - ).toBe('Help button') - }) + > + + nb-NO + + + en-GB + + + en-US + + + ) + } + + const MagicProvider = ({ + children = null, + ...props + }: Partial) => { + return ( + + + {(context) => { + const title = context.translation.HelpButton.title + return ( + <> +

{title}

+ + {children} + + ) + }} +
+
+ ) + } - it('locales should react on prop change', () => { - const { rerender } = render() + it('should translate component strings', () => { + const { rerender } = render( + + content + + ) - expect(document.querySelector('p').textContent).toBe(title_nb) + expect( + document + .querySelector('button.dnb-help-button') + .getAttribute('aria-label') + ).toBe(title_nb) + expect( + document + .querySelector('button.dnb-help-button') + .getAttribute('aria-roledescription') + ).toBe('Hjelp-knapp') + + rerender( + + content + + ) - rerender() + expect( + document + .querySelector('button.dnb-help-button') + .getAttribute('aria-label') + ).toBe(title_gb) + expect( + document + .querySelector('button.dnb-help-button') + .getAttribute('aria-roledescription') + ).toBe('Help button') + }) - expect(document.querySelector('p').textContent).toBe(title_gb) - }) + it('should react on prop change', () => { + const { rerender } = render() - it('locales should react on locale change', () => { - render() + expect(document.querySelector('p').textContent).toBe(title_nb) - expect(document.querySelector('p').textContent).toBe(title_nb) + rerender() - fireEvent.click(document.querySelector('.en-GB button')) + expect(document.querySelector('p').textContent).toBe(title_gb) + }) - expect(document.querySelector('p').textContent).toBe(title_gb) + it('should react on locale change', () => { + render() - fireEvent.click(document.querySelector('.en-US button')) + expect(document.querySelector('p').textContent).toBe(title_nb) - expect(document.querySelector('p').textContent).toBe(title_gb) + fireEvent.click(document.querySelector('.en-GB button')) - fireEvent.click(document.querySelector('.nb-NO button')) + expect(document.querySelector('p').textContent).toBe(title_gb) - expect(document.querySelector('p').textContent).toBe(title_nb) - }) + fireEvent.click(document.querySelector('.en-US button')) - it('locales should support nested providers', () => { - render( - - - - ) - - const getRootElement = () => document.querySelectorAll('p')[0] - const getNestedElement = () => document.querySelectorAll('p')[1] - const switchRootTo = (locale: string) => { - fireEvent.click(document.querySelectorAll(`.${locale} button`)[0]) - } - const switchNestedTo = (locale: string) => { - fireEvent.click(document.querySelectorAll(`.${locale} button`)[1]) - } + expect(document.querySelector('p').textContent).toBe(title_gb) - expect(getRootElement().textContent).toBe(title_nb) - expect(getNestedElement().textContent).toBe(title_gb) + fireEvent.click(document.querySelector('.nb-NO button')) - switchNestedTo('nb-NO') + expect(document.querySelector('p').textContent).toBe(title_nb) + }) - expect(getNestedElement().textContent).toBe(title_nb) - expect( - document - .querySelectorAll('.nb-NO button')[1] - .getAttribute('aria-pressed') - ).toBe('true') + it('should support nested providers handling locales', () => { + render( + + + + ) - // should not have changed - expect(getRootElement().textContent).toBe(title_nb) + const getRootElement = () => document.querySelectorAll('p')[0] + const getNestedElement = () => document.querySelectorAll('p')[1] + const switchRootTo = (locale: string) => { + fireEvent.click(document.querySelectorAll(`.${locale} button`)[0]) + } + const switchNestedTo = (locale: string) => { + fireEvent.click(document.querySelectorAll(`.${locale} button`)[1]) + } - switchRootTo('en-GB') + expect(getRootElement().textContent).toBe(title_nb) + expect(getNestedElement().textContent).toBe(title_gb) - expect( - document - .querySelectorAll('.en-GB button')[0] - .getAttribute('aria-pressed') - ).toBe('true') - expect(getRootElement().textContent).toBe(title_gb) + switchNestedTo('nb-NO') - switchRootTo('en-US') + expect(getNestedElement().textContent).toBe(title_nb) + expect( + document + .querySelectorAll('.nb-NO button')[1] + .getAttribute('aria-pressed') + ).toBe('true') - expect( - document - .querySelectorAll('.en-US button')[0] - .getAttribute('aria-pressed') - ).toBe('true') - expect(getRootElement().textContent).toBe(title_gb) + // should not have changed + expect(getRootElement().textContent).toBe(title_nb) - // should not have changed - expect(getNestedElement().textContent).toBe(title_nb) - }) + switchRootTo('en-GB') - it('locale should be inherited in nested providers', () => { - const locale = 'nb-NO' - let receivedLocale = null + expect( + document + .querySelectorAll('.en-GB button')[0] + .getAttribute('aria-pressed') + ).toBe('true') + expect(getRootElement().textContent).toBe(title_gb) - const Consumer = () => { - receivedLocale = React.useContext(Context).locale - return null - } + switchRootTo('en-US') - render( - - - - - - ) + expect( + document + .querySelectorAll('.en-US button')[0] + .getAttribute('aria-pressed') + ).toBe('true') + expect(getRootElement().textContent).toBe(title_gb) - expect(receivedLocale).toBe(locale) - expect(document.querySelectorAll('p')[0].textContent).toBe(title_nb) - }) + // should not have changed + expect(getNestedElement().textContent).toBe(title_nb) + }) - it('locale should be changed in root context', () => { - const Consumer = ({ id }) => { - const context = React.useContext(Context) - const { locale, setLocale } = context + it('should inherit locale in nested providers', () => { + const locale = 'nb-NO' + let receivedLocale = null - const handleOnChange = () => { - setLocale(locale === 'nb-NO' ? 'en-GB' : 'nb-NO') + const Consumer = () => { + receivedLocale = React.useContext(Context).locale + return null } - return ( - <> -

{locale}

- - + render( + + + + + ) - } - const RootConsumer = () => { - return - } + expect(receivedLocale).toBe(locale) + expect(document.querySelectorAll('p')[0].textContent).toBe(title_nb) + }) + + it('should change locale in root context', () => { + const Consumer = ({ id }) => { + const context = React.useContext(Context) + const { locale, setLocale } = context + + const handleOnChange = () => { + setLocale(locale === 'nb-NO' ? 'en-GB' : 'nb-NO') + } + + return ( + <> +

{locale}

+ + + ) + } - const NestedConsumer = () => { - return - } + const RootConsumer = () => { + return + } + + const NestedConsumer = () => { + return + } - render( - - - - + render( + + + + + - - ) + ) - const getRootLocale = () => - document.getElementById('root-locale').textContent - const getNestedLocale = () => - document.getElementById('nested-locale').textContent + const getRootLocale = () => + document.getElementById('root-locale').textContent + const getNestedLocale = () => + document.getElementById('nested-locale').textContent - expect(getRootLocale()).toBe('nb-NO') - expect(getNestedLocale()).toBe('nb-NO') + expect(getRootLocale()).toBe('nb-NO') + expect(getNestedLocale()).toBe('nb-NO') - fireEvent.click(document.getElementById('root-button')) + fireEvent.click(document.getElementById('root-button')) - expect(getRootLocale()).toBe('en-GB') - expect(getNestedLocale()).toBe('en-GB') + expect(getRootLocale()).toBe('en-GB') + expect(getNestedLocale()).toBe('en-GB') - fireEvent.click(document.getElementById('nested-button')) + fireEvent.click(document.getElementById('nested-button')) - expect(getRootLocale()).toBe('nb-NO') - expect(getNestedLocale()).toBe('nb-NO') - }) + expect(getRootLocale()).toBe('nb-NO') + expect(getNestedLocale()).toBe('nb-NO') + }) - it('locale should be changed in local context', () => { - const Consumer = ({ id }) => { - const context = React.useContext(Context) - const { locale, setCurrentLocale } = context + it('should change locale in local context', () => { + const Consumer = ({ id }) => { + const context = React.useContext(Context) + const { locale, setCurrentLocale } = context - const handleOnChange = () => { - setCurrentLocale(locale === 'nb-NO' ? 'en-GB' : 'nb-NO') - } + const handleOnChange = () => { + setCurrentLocale(locale === 'nb-NO' ? 'en-GB' : 'nb-NO') + } - return ( - <> -

{locale}

- - - ) - } + return ( + <> +

{locale}

+ + + ) + } - const RootConsumer = () => { - return - } + const RootConsumer = () => { + return + } - const NestedConsumer = () => { - return - } + const NestedConsumer = () => { + return + } - render( - - - - + render( + + + + + - - ) + ) - const getRootLocale = () => - document.getElementById('root-locale').textContent - const getNestedLocale = () => - document.getElementById('nested-locale').textContent + const getRootLocale = () => + document.getElementById('root-locale').textContent + const getNestedLocale = () => + document.getElementById('nested-locale').textContent - expect(getRootLocale()).toBe('nb-NO') - expect(getNestedLocale()).toBe('nb-NO') + expect(getRootLocale()).toBe('nb-NO') + expect(getNestedLocale()).toBe('nb-NO') - fireEvent.click(document.getElementById('nested-button')) + fireEvent.click(document.getElementById('nested-button')) - expect(getRootLocale()).toBe('nb-NO') - expect(getNestedLocale()).toBe('en-GB') + expect(getRootLocale()).toBe('nb-NO') + expect(getNestedLocale()).toBe('en-GB') - fireEvent.click(document.getElementById('nested-button')) + fireEvent.click(document.getElementById('nested-button')) - expect(getRootLocale()).toBe('nb-NO') - expect(getNestedLocale()).toBe('nb-NO') + expect(getRootLocale()).toBe('nb-NO') + expect(getNestedLocale()).toBe('nb-NO') - fireEvent.click(document.getElementById('root-button')) + fireEvent.click(document.getElementById('root-button')) - expect(getRootLocale()).toBe('en-GB') - expect(getNestedLocale()).toBe('nb-NO') - }) + expect(getRootLocale()).toBe('en-GB') + expect(getNestedLocale()).toBe('nb-NO') + }) - it('will support "value" prop in nested contexts', () => { - type ConsumerContext = { - myProperty: string - } + it('will support "value" prop in nested contexts', () => { + type ConsumerContext = { + myProperty: string + } - const Consumer = ({ id }) => { - const context = React.useContext< - ContextProps & Partial - >(Context) + const Consumer = ({ id }) => { + const context = React.useContext< + ContextProps & Partial + >(Context) - return

{context.myProperty}

- } + return

{context.myProperty}

+ } - const RootConsumer = () => { - return - } + const RootConsumer = () => { + return + } - const NestedConsumer = () => { - return - } + const NestedConsumer = () => { + return + } - const { rerender } = render( - - - - + const { rerender } = render( + + + + + - - ) + ) - const getRootLocale = () => - document.getElementById('root-locale').textContent - const getNestedLocale = () => - document.getElementById('nested-locale').textContent + const getRootLocale = () => + document.getElementById('root-locale').textContent + const getNestedLocale = () => + document.getElementById('nested-locale').textContent - expect(getRootLocale()).toBe('bar') - expect(getNestedLocale()).toBe('bar') + expect(getRootLocale()).toBe('bar') + expect(getNestedLocale()).toBe('bar') - const value: ConsumerContext = { myProperty: 'changed' } + const value: ConsumerContext = { myProperty: 'changed' } - rerender( - - - - + rerender( + + + + + - - ) + ) - expect(getRootLocale()).toBe('changed') - expect(getNestedLocale()).toBe('changed') - }) + expect(getRootLocale()).toBe('changed') + expect(getNestedLocale()).toBe('changed') + }) - it('should support nested providers and update the root context', () => { - render( - - - - ) - - const getRootElement = () => document.querySelectorAll('p')[0] - const getNestedElement = () => document.querySelectorAll('p')[1] - const switchRootTo = (locale: string) => { - fireEvent.click(document.querySelectorAll(`.${locale} button`)[0]) - } - const switchNestedTo = (locale: string) => { - fireEvent.click(document.querySelectorAll(`.${locale} button`)[1]) - } + it('should support nested providers and update the root context', () => { + render( + + + + ) + + const getRootElement = () => document.querySelectorAll('p')[0] + const getNestedElement = () => document.querySelectorAll('p')[1] + const switchRootTo = (locale: string) => { + fireEvent.click(document.querySelectorAll(`.${locale} button`)[0]) + } + const switchNestedTo = (locale: string) => { + fireEvent.click(document.querySelectorAll(`.${locale} button`)[1]) + } - expect(getRootElement().textContent).toBe(title_gb) - expect( - document - .querySelectorAll('.en-GB button')[0] - .getAttribute('aria-pressed') - ).toBe('true') - expect(getNestedElement().textContent).toBe(title_nb) - expect( - document - .querySelectorAll('.nb-NO button')[1] - .getAttribute('aria-pressed') - ).toBe('true') - - // First, let's change the inner - switchNestedTo('nb-NO') - - expect(getRootElement().textContent).toBe(title_gb) - expect( - document - .querySelectorAll('.en-GB button')[0] - .getAttribute('aria-pressed') - ).toBe('true') - expect(getNestedElement().textContent).toBe(title_nb) - expect( - document - .querySelectorAll('.nb-NO button')[1] - .getAttribute('aria-pressed') - ).toBe('true') - - switchNestedTo('en-GB') - - expect(getRootElement().textContent).toBe(title_gb) - expect( - document - .querySelectorAll('.en-GB button')[0] - .getAttribute('aria-pressed') - ).toBe('true') - expect(getNestedElement().textContent).toBe(title_gb) - expect( - document - .querySelectorAll('.en-GB button')[1] - .getAttribute('aria-pressed') - ).toBe('true') - - switchNestedTo('nb-NO') - - expect(getRootElement().textContent).toBe(title_nb) - expect( - document - .querySelectorAll('.nb-NO button')[0] - .getAttribute('aria-pressed') - ).toBe('true') - expect(getNestedElement().textContent).toBe(title_nb) - expect( - document - .querySelectorAll('.nb-NO button')[1] - .getAttribute('aria-pressed') - ).toBe('true') - - // Now, let's change the outer - switchRootTo('en-GB') - - expect(getRootElement().textContent).toBe(title_gb) - expect( - document - .querySelectorAll('.en-GB button')[0] - .getAttribute('aria-pressed') - ).toBe('true') - expect(getNestedElement().textContent).toBe(title_nb) - expect( - document - .querySelectorAll('.nb-NO button')[1] - .getAttribute('aria-pressed') - ).toBe('true') + expect(getRootElement().textContent).toBe(title_gb) + expect( + document + .querySelectorAll('.en-GB button')[0] + .getAttribute('aria-pressed') + ).toBe('true') + expect(getNestedElement().textContent).toBe(title_nb) + expect( + document + .querySelectorAll('.nb-NO button')[1] + .getAttribute('aria-pressed') + ).toBe('true') + + // First, let's change the inner + switchNestedTo('nb-NO') + + expect(getRootElement().textContent).toBe(title_gb) + expect( + document + .querySelectorAll('.en-GB button')[0] + .getAttribute('aria-pressed') + ).toBe('true') + expect(getNestedElement().textContent).toBe(title_nb) + expect( + document + .querySelectorAll('.nb-NO button')[1] + .getAttribute('aria-pressed') + ).toBe('true') + + switchNestedTo('en-GB') + + expect(getRootElement().textContent).toBe(title_gb) + expect( + document + .querySelectorAll('.en-GB button')[0] + .getAttribute('aria-pressed') + ).toBe('true') + expect(getNestedElement().textContent).toBe(title_gb) + expect( + document + .querySelectorAll('.en-GB button')[1] + .getAttribute('aria-pressed') + ).toBe('true') + + switchNestedTo('nb-NO') + + expect(getRootElement().textContent).toBe(title_nb) + expect( + document + .querySelectorAll('.nb-NO button')[0] + .getAttribute('aria-pressed') + ).toBe('true') + expect(getNestedElement().textContent).toBe(title_nb) + expect( + document + .querySelectorAll('.nb-NO button')[1] + .getAttribute('aria-pressed') + ).toBe('true') + + // Now, let's change the outer + switchRootTo('en-GB') + + expect(getRootElement().textContent).toBe(title_gb) + expect( + document + .querySelectorAll('.en-GB button')[0] + .getAttribute('aria-pressed') + ).toBe('true') + expect(getNestedElement().textContent).toBe(title_nb) + expect( + document + .querySelectorAll('.nb-NO button')[1] + .getAttribute('aria-pressed') + ).toBe('true') + }) }) }) diff --git a/packages/dnb-eufemia/src/shared/__tests__/Translation.test.tsx b/packages/dnb-eufemia/src/shared/__tests__/Translation.test.tsx index 6cc8af265fa..daab302659c 100644 --- a/packages/dnb-eufemia/src/shared/__tests__/Translation.test.tsx +++ b/packages/dnb-eufemia/src/shared/__tests__/Translation.test.tsx @@ -1,9 +1,6 @@ import React from 'react' import { render } from '@testing-library/react' -import Translation, { - TranslationProps, - getTranslation, -} from '../Translation' +import Translation, { TranslationProps } from '../Translation' import Context from '../Context' import Provider from '../Provider' @@ -24,76 +21,61 @@ describe('flatten translations', () => { 'Modal.close_title': 'Close', 'other.string': given_enGB, } - - const defaultLocales = { + const translations = { 'nb-NO': nbNO, 'en-GB': enGB, } - it('"getTranslation" should return requested string inside render', () => { - render( - - - {getTranslation('other.string', { - foo: 'foo', - bar: 'bar', - max: 'max', - })} - - - ) - - expect(document.querySelector('span.getTranslation').textContent).toBe( - expected_nbNO - ) - }) - it('should return requested string inside render', () => { render( - - + + - + - + other.string - + ) - expect(document.querySelector('span.Translation').textContent).toBe( + expect(document.querySelector('.Translation').textContent).toBe( expected_nbNO ) expect( - document.querySelector('span.TranslationIdAsChildren').textContent + document.querySelector('.TranslationIdAsChildren').textContent ).toBe(expected_nbNO) }) -}) -describe('Context.getTranslation', () => { - nbNO['nb-NO'].HelpButton['other.string'] = given_nbNO - enGB['en-GB'].HelpButton['other.string'] = given_enGB + it('should return given id if nothing found', () => { + const id = 'invalid.id' + render( + + + + + + ) + expect(document.querySelector('.invalid').textContent).toBe(id) + }) +}) + +describe('context.getTranslation', () => { const MagicContext = (props) => { return ( {(context) => { - // We may use that in future - // if (props.translation) { - // context.setTranslation(props.translation) - // } const title = context.getTranslation(props).HelpButton.title const otherString = - context.getTranslation(props).HelpButton['other.string'] + context.getTranslation(props).HelpButton?.['other']?.string return ( <>

{context.locale}

{title}

- {otherString && ( -

{otherString}

- )} +

{otherString}

) }} @@ -122,14 +104,35 @@ describe('Context.getTranslation', () => { expect(document.querySelector('p.locale').textContent).toBe('nb-NO') }) - it('should react on new lang prop and prepare other.string', () => { - const { rerender } = render() + it('should react on new lang prop and return other.string', () => { + const nbNO = { + 'Modal.close_title': 'Steng', + 'HelpButton.other.string': given_nbNO, + } + const enGB = { + 'Modal.close_title': 'Close', + 'HelpButton.other.string': given_enGB, + } + const translations = { + 'nb-NO': nbNO, + 'en-GB': enGB, + } + + const { rerender } = render( + + + + ) expect(document.querySelector('p.other-string').textContent).toBe( given_nbNO ) - rerender() + rerender( + + + + ) expect(document.querySelector('p.other-string').textContent).toBe( given_enGB diff --git a/packages/dnb-eufemia/src/shared/__tests__/useTranslation.test.tsx b/packages/dnb-eufemia/src/shared/__tests__/useTranslation.test.tsx index 166f3e545e1..0681d552e9b 100644 --- a/packages/dnb-eufemia/src/shared/__tests__/useTranslation.test.tsx +++ b/packages/dnb-eufemia/src/shared/__tests__/useTranslation.test.tsx @@ -17,7 +17,7 @@ describe('useTranslation without an ID', () => { }) expect(result.current).toEqual( - Object.assign(nbNO[defaultLocale], { + Object.assign({}, nbNO[defaultLocale], { formatMessage: expect.any(Function), }) ) @@ -31,7 +31,9 @@ describe('useTranslation without an ID', () => { }) expect(resultGB.current).toEqual( - Object.assign(enGB['en-GB'], { formatMessage: expect.any(Function) }) + Object.assign({}, enGB['en-GB'], { + formatMessage: expect.any(Function), + }) ) const { result: resultNO } = renderHook(() => useTranslation(), { @@ -41,7 +43,9 @@ describe('useTranslation without an ID', () => { }) expect(resultNO.current).toEqual( - Object.assign(nbNO['nb-NO'], { formatMessage: expect.any(Function) }) + Object.assign({}, nbNO['nb-NO'], { + formatMessage: expect.any(Function), + }) ) }) @@ -214,7 +218,7 @@ describe('useTranslation with an ID', () => { 'en-GB': enGB_nested, } - const RenderGetTranslation = () => { + const RenderUseTranslation = () => { return useTranslation('other.string', { foo: 'foo', bar: 'bar', @@ -251,7 +255,7 @@ describe('useTranslation with an ID', () => { render( - + ) @@ -265,7 +269,7 @@ describe('useTranslation with an ID', () => { render( - + @@ -291,7 +295,7 @@ describe('useTranslation with an ID', () => { - + diff --git a/packages/dnb-eufemia/src/shared/index.tsx b/packages/dnb-eufemia/src/shared/index.tsx index cd062e87a4d..e1256023a93 100644 --- a/packages/dnb-eufemia/src/shared/index.tsx +++ b/packages/dnb-eufemia/src/shared/index.tsx @@ -13,5 +13,6 @@ export { default as useMediaQuery } from './useMediaQuery' export { default as useMedia } from './useMedia' export { default as useTranslation } from './useTranslation' export { default as Translation } from './Translation' +export * from './Translation' export type { ThemeNames } from './Theme' diff --git a/packages/dnb-eufemia/src/shared/stories/Translation.stories.tsx b/packages/dnb-eufemia/src/shared/stories/Translation.stories.tsx index fe6bd0242e6..4dab2926688 100644 --- a/packages/dnb-eufemia/src/shared/stories/Translation.stories.tsx +++ b/packages/dnb-eufemia/src/shared/stories/Translation.stories.tsx @@ -8,7 +8,7 @@ import { Wrapper, Box } from 'storybook-utils/helpers' import { Dialog, Dropdown } from '../../components' import { Provider, Context, useTranslation } from '../' -import Translation, { getTranslation } from '../Translation' +import Translation from '../Translation' import { P } from '../..' // import nbNO from '../locales/nb-NO' @@ -86,12 +86,6 @@ export const TranslationSandbox = () => ( 'en-GB': enGB, }} > - {getTranslation('other.string', { - foo: 'foo', - bar: 'riskScore', - max: 'max', - })} - ( return useMemo(() => { const id = typeof messages === 'string' ? messages : undefined if (id) { - return formatMessage(String(id), args, translation) + return formatMessage(id, args, translation) } - return combineTranslations({ + return combineWithExternalTranslations({ translation, messages, locale, @@ -38,22 +38,24 @@ export default function useTranslation( }, [locale, messages, args, translation]) } -export type CombineTranslationsArgs = { +export type combineWithExternalTranslationsArgs = { translation: Translation messages?: TranslationCustomLocales locale?: InternalLocale } -export type CombineTranslationsReturn = Translation & +export type combineWithExternalTranslationsReturn = Translation & TranslationCustomLocales & { formatMessage: typeof formatMessage } -export function combineTranslations({ +export function combineWithExternalTranslations({ translation, messages, locale, -}: CombineTranslationsArgs): CombineTranslationsReturn { - let combined = { ...translation } as CombineTranslationsReturn +}: combineWithExternalTranslationsArgs): combineWithExternalTranslationsReturn { + let combined = { + ...translation, + } as combineWithExternalTranslationsReturn if (messages) { if (Object.keys(defaultLocales).some((locale) => messages[locale])) { @@ -71,7 +73,7 @@ export function combineTranslations({ id: TranslationId, args: TranslationArguments ) => { - return formatMessage(id, args, translation) + return formatMessage(id, args, combined) } return combined