From 3f6e6b59d0db644c51fd1cff6ff1d1fcfe56f7e0 Mon Sep 17 00:00:00 2001 From: Jan Cizmar Date: Wed, 29 Sep 2021 16:27:10 +0200 Subject: [PATCH] feat: Enable default value providing TG-335 --- e2e/cypress/integration/react/base.spec.ts | 8 + packages/core/src/Tolgee.test.ts | 94 +++++++- packages/core/src/Tolgee.ts | 76 +++++- .../core/src/services/TextService.test.ts | 119 ++++++++-- packages/core/src/services/TextService.ts | 147 ++++++++++-- .../src/services/TranslationService.test.ts | 32 +++ .../core/src/services/TranslationService.ts | 27 ++- packages/core/src/types.ts | 22 +- .../src/lib/stranslate.pipe.spec.ts | 6 +- .../ngx-tolgee/src/lib/stranslate.pipe.ts | 9 +- .../ngx-tolgee/src/lib/t.component.spec.ts | 67 ++++-- .../ngx-tolgee/src/lib/t.component.ts | 38 ++- .../ngx-tolgee/src/lib/translate.pipe.spec.ts | 59 +++-- .../ngx-tolgee/src/lib/translate.pipe.ts | 39 ++- .../src/lib/translate.service.spec.ts | 30 ++- .../ngx-tolgee/src/lib/translate.service.ts | 39 ++- packages/react/src/T.spec.tsx | 64 ++++- packages/react/src/T.tsx | 20 +- packages/react/src/useTranslate.spec.tsx | 223 ++++++++++++------ packages/react/src/useTranslate.ts | 67 ++++-- testapps/next/package-lock.json | 2 +- testapps/react/src/Page.tsx | 12 + 22 files changed, 954 insertions(+), 246 deletions(-) diff --git a/e2e/cypress/integration/react/base.spec.ts b/e2e/cypress/integration/react/base.spec.ts index a93ea4c293..c350ddd419 100644 --- a/e2e/cypress/integration/react/base.spec.ts +++ b/e2e/cypress/integration/react/base.spec.ts @@ -11,4 +11,12 @@ context('Base test', () => { cy.get('select').select('de'); cy.contains('Hallo Welt!'); }); + + it('shows default value when provided ', () => { + cy.contains('This is default').should('be.visible'); + }); + + it('shows key as default when default not provided ', () => { + cy.contains('unknown key').should('be.visible'); + }); }); diff --git a/packages/core/src/Tolgee.test.ts b/packages/core/src/Tolgee.test.ts index e2855da9a3..6cb0a494fa 100644 --- a/packages/core/src/Tolgee.test.ts +++ b/packages/core/src/Tolgee.test.ts @@ -154,7 +154,7 @@ describe('Tolgee', () => { propertiesMock.mock.instances[0].config.mode = 'development'; const translated = await tolgee.translate(dummyKey, dummyParams); - expect(mockedWrap).toBeCalledWith(dummyKey, dummyParams); + expect(mockedWrap).toBeCalledWith(dummyKey, dummyParams, undefined); expect(mockedTranslate).not.toBeCalled(); expect(translated).toEqual(wrappedDummyText); }); @@ -165,7 +165,13 @@ describe('Tolgee', () => { expect(translated).toEqual(translatedDummyText); expect(mockedWrap).not.toBeCalled(); - expect(mockedTranslate).toBeCalledWith(dummyKey, dummyParams); + expect(mockedTranslate).toBeCalledWith( + dummyKey, + dummyParams, + undefined, + undefined, + undefined + ); }); test('will not wrap when development is on, but noWrap is true', async () => { @@ -181,6 +187,41 @@ describe('Tolgee', () => { await tolgee.translate(dummyKey, dummyParams); expect(mockedLoadTranslations).toBeCalled(); }); + + test('passes default value to wrap fn', async () => { + propertiesMock.mock.instances[0].config.mode = 'development'; + await tolgee.translate(dummyKey, dummyParams, false, 'Default'); + expect(mockedWrap).toBeCalledWith('dummyText', {}, 'Default'); + }); + + test('passes default value to translate fn', async () => { + propertiesMock.mock.instances[0].config.mode = 'development'; + await tolgee.translate(dummyKey, dummyParams, true, 'Default'); + expect(mockedTranslate).toBeCalledWith( + 'dummyText', + {}, + undefined, + undefined, + 'Default' + ); + }); + + test('props object works correctly', async () => { + propertiesMock.mock.instances[0].config.mode = 'development'; + await tolgee.translate({ + key: dummyKey, + params: dummyParams, + noWrap: true, + defaultValue: 'Default', + }); + expect(mockedTranslate).toBeCalledWith( + dummyKey, + dummyParams, + undefined, + undefined, + 'Default' + ); + }); }); describe('sync instant', () => { @@ -189,7 +230,7 @@ describe('Tolgee', () => { const dummyParams = {}; const translated = tolgee.instant(dummyKey, dummyParams); - expect(mockedWrap).toBeCalledWith(dummyKey, dummyParams); + expect(mockedWrap).toBeCalledWith(dummyKey, dummyParams, undefined); expect(mockedInstant).not.toBeCalled(); expect(translated).toEqual(wrappedDummyText); }); @@ -204,6 +245,7 @@ describe('Tolgee', () => { dummyKey, dummyParams, undefined, + undefined, undefined ); }); @@ -214,7 +256,8 @@ describe('Tolgee', () => { dummyKey, dummyParams, undefined, - true + true, + undefined ); }); @@ -232,9 +275,50 @@ describe('Tolgee', () => { dummyKey, dummyParams, undefined, - true + true, + undefined ); }); + + test('passes default value to wrap fn', async () => { + propertiesMock.mock.instances[0].config.mode = 'development'; + const dummyParams = {}; + tolgee.instant(dummyKey, dummyParams, false, false, 'Default'); + + expect(mockedWrap).toBeCalledWith(dummyKey, dummyParams, 'Default'); + }); + + test('props object works correctly', async () => { + propertiesMock.mock.instances[0].config.mode = 'development'; + await tolgee.instant({ + key: dummyKey, + params: dummyParams, + orEmpty: false, + noWrap: true, + defaultValue: 'Default', + }); + expect(mockedInstant).toBeCalledWith( + dummyKey, + dummyParams, + undefined, + false, + 'Default' + ); + }); + }); + + test('passes default value to instant fn', async () => { + propertiesMock.mock.instances[0].config.mode = 'development'; + const dummyParams = {}; + tolgee.instant(dummyKey, dummyParams, true, false, 'Default'); + + expect(mockedInstant).toBeCalledWith( + dummyKey, + dummyParams, + undefined, + false, + 'Default' + ); }); }); diff --git a/packages/core/src/Tolgee.ts b/packages/core/src/Tolgee.ts index 31baa4475f..58bdedbf2c 100644 --- a/packages/core/src/Tolgee.ts +++ b/packages/core/src/Tolgee.ts @@ -1,5 +1,5 @@ import { TolgeeConfig } from './TolgeeConfig'; -import { TranslationParams } from './types'; +import { InstantProps, TranslateProps, TranslationParams } from './types'; import { NodeHelper } from './helpers/NodeHelper'; import { EventEmitterImpl } from './services/EventEmitter'; import { DependencyStore } from './services/DependencyStore'; @@ -105,35 +105,85 @@ export class Tolgee { ); } - translate = async ( + async translate(props: TranslateProps): Promise; + async translate( key: string, + params: TranslationParams, + noWrap?: boolean, + defaultValue?: string + ): Promise; + + async translate( + keyOrProps: string | TranslateProps, params: TranslationParams = {}, - noWrap = false - ): Promise => { + noWrap = false, + defaultValue: string | undefined = undefined + ): Promise { + const key = typeof keyOrProps === 'string' ? keyOrProps : keyOrProps.key; + if (typeof keyOrProps === 'object') { + const props = keyOrProps as TranslateProps; + // if values are not provided in props object, get them from function + // params defaults + params = props.params !== undefined ? props.params : params; + noWrap = props.noWrap !== undefined ? props.noWrap : noWrap; + defaultValue = + props.defaultValue !== undefined ? props.defaultValue : defaultValue; + } + if (this.properties.config.mode === 'development' && !noWrap) { await this.loadScopes(); await this.translationService.loadTranslations(); - return this.dependencyStore.textService.wrap(key, params); + return this.dependencyStore.textService.wrap(key, params, defaultValue); } - return this.dependencyStore.textService.translate(key, params); - }; + return this.dependencyStore.textService.translate( + key, + params, + undefined, + undefined, + defaultValue + ); + } - instant = ( + instant( key: string, + params?: TranslationParams, + noWrap?: boolean, + orEmpty?: boolean, + defaultValue?: string + ): string; + + instant(props: InstantProps): string; + + instant( + keyOrProps: string | InstantProps, params: TranslationParams = {}, noWrap = false, - orEmpty?: boolean - ): string => { + orEmpty?: boolean, + defaultValue?: string + ): string { + const key = typeof keyOrProps === 'string' ? keyOrProps : keyOrProps.key; + if (typeof keyOrProps === 'object') { + const props = keyOrProps as InstantProps; + // if values are not provided in props object, get them from function + // params defaults + params = props.params !== undefined ? props.params : params; + noWrap = props.noWrap !== undefined ? props.noWrap : noWrap; + defaultValue = + props.defaultValue !== undefined ? props.defaultValue : defaultValue; + orEmpty = props.orEmpty !== undefined ? props.orEmpty : orEmpty; + } + if (this.properties.config.mode === 'development' && !noWrap) { - return this.dependencyStore.textService.wrap(key, params); + return this.dependencyStore.textService.wrap(key, params, defaultValue); } return this.dependencyStore.textService.instant( key, params, undefined, - orEmpty + orEmpty, + defaultValue ); - }; + } public stop = () => { this.dependencyStore.observer.stopObserving(); diff --git a/packages/core/src/services/TextService.test.ts b/packages/core/src/services/TextService.test.ts index b2c9eb9a39..af61e52832 100644 --- a/packages/core/src/services/TextService.test.ts +++ b/packages/core/src/services/TextService.test.ts @@ -9,15 +9,26 @@ import { TranslationService } from './TranslationService'; import { DependencyStore } from './DependencyStore'; describe('TextService', () => { - const mockedTranslationReturn = 'Dummy translated text {param1} {param2}'; + let mockedTranslationReturn = ''; const params = { param1: 'Dummy param 1', param2: 'Dummy param 2' }; - const expectedTranslated = mockedTranslationReturn - .replace('{param1}', params.param1) - .replace('{param2}', params.param2); + let expectedTranslated = ''; let textService: TextService; + const getTranslationMock = jest.fn(async () => { + return mockedTranslationReturn; + }); + + const getFromCacheOrCallbackMock = jest.fn(() => { + return mockedTranslationReturn; + }); + beforeEach(async () => { textService = new DependencyStore().textService; + mockedTranslationReturn = 'Dummy translated text {param1} {param2}'; + expectedTranslated = mockedTranslationReturn + .replace('{param1}', params.param1) + .replace('{param2}', params.param2); + getMockedInstance(Properties).config = { inputPrefix: '{{', inputSuffix: '}}', @@ -26,15 +37,10 @@ describe('TextService', () => { '*': ['aria-label'], }, }; - getMockedInstance(TranslationService).getTranslation = jest.fn(async () => { - return mockedTranslationReturn; - }); + getMockedInstance(TranslationService).getTranslation = getTranslationMock; - getMockedInstance(TranslationService).getFromCacheOrFallback = jest.fn( - () => { - return mockedTranslationReturn; - } - ); + getMockedInstance(TranslationService).getFromCacheOrFallback = + getFromCacheOrCallbackMock; }); afterEach(async () => { @@ -45,14 +51,35 @@ describe('TextService', () => { test('it will translate asynchronously correctly', async () => { const translated = await textService.translate( mockedTranslationReturn, - params + params, + `en`, + true, + 'Default' ); expect(translated).toEqual(expectedTranslated); + expect(getTranslationMock).toBeCalledWith( + 'Dummy translated text {param1} {param2}', + 'en', + true, + 'Default' + ); }); test('it will translate synchronously correctly', () => { - const translated = textService.instant(mockedTranslationReturn, params); + const translated = textService.instant( + mockedTranslationReturn, + params, + 'en', + true, + 'Default' + ); expect(translated).toEqual(expectedTranslated); + expect(getFromCacheOrCallbackMock).toBeCalledWith( + 'Dummy translated text {param1} {param2}', + 'en', + true, + 'Default' + ); }); }); @@ -76,6 +103,27 @@ describe('TextService', () => { ); }); + test("it doesn't affect not related backslashes", async () => { + const text = + '\\This is \\text: {{text:param1:aaaa,param2:aaaa}} \\{{text}}, see? \\'; + const replaced = await textService.replace(text); + expect(replaced.text).toEqual( + '\\This is \\text: Dummy translated text aaaa aaaa {{text}}, see? \\' + ); + }); + + test('correctly parses default value', async () => { + const text = + '\\This is \\text: {{text,This is my default value.\\:look!:param1:aaaa,param2:aaaa}} \\{{text}}, see? \\'; + await textService.replace(text); + expect(getTranslationMock).toHaveBeenCalledWith( + 'text', + undefined, + false, + 'This is my default value.:look!' + ); + }); + test('replace function does not translate when params have escaped , or :', async () => { const text = 'This is text: {{text:param1:param 1 with\\,and \\:.,param2:hello \\:}}. Text continues'; @@ -94,6 +142,25 @@ describe('TextService', () => { ); }); + test('replace function works with new lines in key', async () => { + mockedTranslationReturn = 'yep'; + const text = 'This is text: {{text\nwith\nnew\nlines}}. Text continues'; + await textService.replace(text); + expect(getTranslationMock).toHaveBeenCalledWith( + 'text\nwith\nnew\nlines', + undefined, + false, + undefined + ); + }); + + test('works with escaped strings in params', async () => { + const text = 'Text: {{text\nwith\nnew\nlines:hello:w\\,or\\:ld}}.'; + mockedTranslationReturn = 'translated {hello}'; + const result = await textService.replace(text); + expect(result.keys[0].params['hello']).toEqual('w,or:ld'); + }); + describe('Different key occurrences', () => { beforeEach(() => { getMockedInstance(TranslationService).getTranslation = jest.fn( @@ -151,7 +218,7 @@ describe('TextService', () => { test('will translate when the text begins with escaped escape character, what is escaped', async () => { const text = '\\\\\\{{text}}, text continues {{other text}}'; const replaced = await textService.replace(text); - expect(replaced.text).toEqual('\\{{text}}, text continues translated'); + expect(replaced.text).toEqual('\\\\{{text}}, text continues translated'); }); }); @@ -227,6 +294,28 @@ describe('TextService', () => { ); }); + test('correctly wraps default value', async () => { + const wrapped = textService.wrap( + 'key', + { param1: 1, param2: 'Yes,yes,yes:yes' }, + 'Look: What a beautiful default\nvalue,' + + ' translating will be such an experience.' + ); + expect(wrapped).toEqual( + '{{key,Look\\: What a beautiful default\n' + + 'value\\, translating will be such an experience.' + + ':param1:1,param2:Yes\\,yes\\,yes\\:yes}}' + ); + + await textService.replace(wrapped); + expect(getTranslationMock).toBeCalledWith( + 'key', + undefined, + false, + 'Look: What a beautiful default\nvalue, translating will be such an experience.' + ); + }); + test('will correctly replace bigint parameter', async () => { getMockedInstance(TranslationService).getTranslation = jest.fn( async () => { diff --git a/packages/core/src/services/TextService.ts b/packages/core/src/services/TextService.ts index bcf1604298..db19cc57c2 100644 --- a/packages/core/src/services/TextService.ts +++ b/packages/core/src/services/TextService.ts @@ -26,16 +26,83 @@ export class TextService { return `(\\\\?)(${escapedPrefix}(.*?)${escapedSuffix})`; } - private static parseUnwrapped(unWrappedString: string): KeyAndParams { - const strings = unWrappedString.match(/(?:[^\\,:\n]|\\.)+/g); + private static parseUnwrapped(unwrappedString: string): KeyAndParams { + let readingKey = true; + let readingDefault = false; + let escaped = false; + let actual = ''; + let readingParamName = false; + let paramName = ''; + let readingParamValue = false; + const result = { - key: TextHelper.removeEscapes(strings.shift()), + key: '', params: {}, - }; + defaultValue: undefined as string | undefined, + } as KeyAndParams; + + for (const char of unwrappedString) { + if (char === '\\' && !escaped) { + escaped = true; + continue; + } + if (escaped) { + escaped = false; + actual += char; + continue; + } + if (readingKey && char === ',') { + readingKey = false; + readingDefault = true; + result.key = actual; + actual = ''; + continue; + } + + if (readingKey && char === ':') { + readingKey = false; + readingParamName = true; + result.key = actual; + actual = ''; + continue; + } + + if (readingDefault && char === ':') { + readingDefault = false; + readingParamName = true; + result.defaultValue = actual; + actual = ''; + continue; + } + + if (readingParamName && char === ':') { + readingParamName = false; + paramName = actual; + readingParamValue = true; + actual = ''; + continue; + } + + if (readingParamValue && char === ',') { + readingParamValue = false; + readingParamName = true; + result.params[paramName] = actual; + actual = ''; + continue; + } + actual += char; + } + + if (readingKey) { + result.key = actual; + } + + if (readingDefault) { + result.defaultValue = actual; + } - while (strings.length) { - const [name, value] = strings.splice(0, 2); - result.params[name] = value; + if (readingParamValue) { + result.params[paramName] = actual; } return result; } @@ -44,10 +111,16 @@ export class TextService { key: string, params: TranslationParams, lang = this.properties.currentLanguage, - orEmpty? + orEmpty?: boolean, + defaultValue?: string ) { return this.format( - await this.translationService.getTranslation(key, lang, orEmpty), + await this.translationService.getTranslation( + key, + lang, + orEmpty, + defaultValue + ), params ); } @@ -56,10 +129,16 @@ export class TextService { key: string, params: TranslationParams, lang = this.properties.currentLanguage, - orEmpty? + orEmpty?, + defaultValue?: string ) { return this.format( - this.translationService.getFromCacheOrFallback(key, lang, orEmpty), + this.translationService.getFromCacheOrFallback( + key, + lang, + orEmpty, + defaultValue + ), params ); } @@ -75,7 +154,8 @@ export class TextService { let start = 0; let result = ''; while ((match = matchRegexp.exec(text)) !== null) { - const [fullMatch, pre, wrapped, unwrapped] = match as [ + let pre = match[1] as string; + const [fullMatch, _, wrapped, unwrapped] = match as [ string, string, string, @@ -84,28 +164,37 @@ export class TextService { const { index, input } = match; result += input.substr(start, index - start); start = index + fullMatch.length; - if (pre === '\\' && !TextHelper.isCharEscaped(index, text)) { - result += wrapped; - continue; + if (pre === '\\') { + if (!TextHelper.isCharEscaped(index, text)) { + result += wrapped; + continue; + } + pre = ''; } const translated = await this.getTranslatedWithMetadata(unwrapped); - keysAndParams.push({ key: translated.key, params: translated.params }); + keysAndParams.push({ + key: translated.key, + params: translated.params, + defaultValue: translated.defaultValue, + }); matched = true; result += pre + translated.translated; } result += text.substring(start); - const withoutEscapes = TextHelper.removeEscapes(result); - if (matched) { - return { text: withoutEscapes, keys: keysAndParams }; + return { text: result, keys: keysAndParams }; } return undefined; } - public wrap(key: string, params: TranslationParams = {}): string { + public wrap( + key: string, + params: TranslationParams = {}, + defaultValue: string | undefined = undefined + ): string { let paramString = Object.entries(params) .map( ([name, value]) => @@ -113,17 +202,27 @@ export class TextService { ) .join(','); paramString = paramString.length ? `:${paramString}` : ''; + + const defaultString = + defaultValue !== undefined ? `,${this.escapeParam(defaultValue)}` : ''; + return `${this.properties.config.inputPrefix}${this.escapeParam( key - )}${paramString}${this.properties.config.inputSuffix}`; + )}${defaultString}${paramString}${this.properties.config.inputSuffix}`; } private async getTranslatedWithMetadata( text: string ): Promise { - const { key, params } = TextService.parseUnwrapped(text); - const translated = await this.translate(key, params, undefined, false); - return { translated, key: key, params }; + const { key, params, defaultValue } = TextService.parseUnwrapped(text); + const translated = await this.translate( + key, + params, + undefined, + false, + defaultValue + ); + return { translated, key, params, defaultValue }; } private readonly format = ( diff --git a/packages/core/src/services/TranslationService.test.ts b/packages/core/src/services/TranslationService.test.ts index ac532133ef..6446ff8dd7 100644 --- a/packages/core/src/services/TranslationService.test.ts +++ b/packages/core/src/services/TranslationService.test.ts @@ -274,12 +274,44 @@ describe('TranslationService', () => { ).toEqual('Just en.'); }); + test('getFromCacheOrCallback will return default when provided', async () => { + expect( + await translationService.getFromCacheOrFallback( + 'this_key_is_not_in_cache', + 'de', + false, + 'Default' + ) + ).toEqual('Default'); + }); + + test('getFromCacheOrCallback will return empty when onEmpty is true', async () => { + expect( + await translationService.getFromCacheOrFallback( + 'this_key_is_not_in_cache', + 'de', + true + ) + ).toEqual(''); + }); + test('will return last chunk of key path when no translation found', async () => { expect( await translationService.getTranslation('test\\.key.this\\.is\\.it', 'en') ).toEqual('this.is.it'); }); + test('returns default when provided', async () => { + expect( + await translationService.getTranslation( + 'youaaaahihahihh', + 'en', + undefined, + 'This is default' + ) + ).toEqual('This is default'); + }); + test('will return proper text without any dot', async () => { expect( await translationService.getTranslation('text without any dot', 'en') diff --git a/packages/core/src/services/TranslationService.ts b/packages/core/src/services/TranslationService.ts index 359af56d87..1f2b6006f8 100644 --- a/packages/core/src/services/TranslationService.ts +++ b/packages/core/src/services/TranslationService.ts @@ -25,12 +25,17 @@ export class TranslationService { private static translationByValue( message: string, key: string, - orEmpty: boolean + orEmpty: boolean, + defaultValue?: string ) { if (message) { return message; } + if (defaultValue) { + return defaultValue; + } + if (orEmpty) { return ''; } @@ -71,7 +76,8 @@ export class TranslationService { async getTranslation( key: string, lang: string = this.properties.currentLanguage, - orEmpty = false + orEmpty = false, + defaultValue?: string ): Promise { let message = this.getFromCache(key, lang); @@ -93,7 +99,12 @@ export class TranslationService { } } - return TranslationService.translationByValue(message, key, orEmpty); + return TranslationService.translationByValue( + message, + key, + orEmpty, + defaultValue + ); } async setTranslations(translationData: TranslationData) { @@ -149,12 +160,18 @@ export class TranslationService { getFromCacheOrFallback( key: string, lang: string = this.properties.currentLanguage, - orEmpty = false + orEmpty = false, + defaultValue?: string ): string { const message = this.getFromCache(key, lang) || this.getFromCache(key, this.properties.config.fallbackLanguage); - return TranslationService.translationByValue(message, key, orEmpty); + return TranslationService.translationByValue( + message, + key, + orEmpty, + defaultValue + ); } getTranslationsOfKey = async ( diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 351547a277..baa3cc80fa 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -29,11 +29,31 @@ export interface TextInputElementData { export type Translations = { [key: string]: string | Translations }; export type TranslationParams = { [key: string]: string | number | bigint }; -export type KeyAndParams = { key: string; params: TranslationParams }; +export type TranslateProps = { + key: string; + defaultValue?: string; + params?: TranslationParams; + noWrap?: boolean; +}; + +export type InstantProps = { + key: string; + defaultValue?: string; + params?: TranslationParams; + noWrap?: boolean; + orEmpty?: boolean; +}; + +export type KeyAndParams = { + key: string; + params: TranslationParams; + defaultValue?: string; +}; export type TranslatedWithMetadata = { translated: string; key: string; params: TranslationParams; + defaultValue: string | undefined; }; export type NodeWithMeta = Node & { diff --git a/packages/ngx/projects/ngx-tolgee/src/lib/stranslate.pipe.spec.ts b/packages/ngx/projects/ngx-tolgee/src/lib/stranslate.pipe.spec.ts index 18ec906016..f8f1d52457 100644 --- a/packages/ngx/projects/ngx-tolgee/src/lib/stranslate.pipe.spec.ts +++ b/packages/ngx/projects/ngx-tolgee/src/lib/stranslate.pipe.spec.ts @@ -56,6 +56,10 @@ describe('Safe Translate pipe', function () { it('calls the getSafe function with proper params', async () => { expect(getSafeMock).toHaveBeenCalledTimes(1); - expect(getSafeMock).toHaveBeenCalledWith('test', { key: 'value' }); + expect(getSafeMock).toHaveBeenCalledWith( + 'test', + { key: 'value' }, + undefined + ); }); }); diff --git a/packages/ngx/projects/ngx-tolgee/src/lib/stranslate.pipe.ts b/packages/ngx/projects/ngx-tolgee/src/lib/stranslate.pipe.ts index 57fbdd39f6..0fd4dce469 100755 --- a/packages/ngx/projects/ngx-tolgee/src/lib/stranslate.pipe.ts +++ b/packages/ngx/projects/ngx-tolgee/src/lib/stranslate.pipe.ts @@ -12,7 +12,12 @@ export class STranslatePipe extends TranslatePipe { super(translateService); } - protected get resultProvider(): (input, params) => Observable { - return (input, params) => this.translateService.getSafe(input, params); + protected get resultProvider(): ( + input, + params, + defaultValue + ) => Observable { + return (input, params, defaultValue) => + this.translateService.getSafe(input, params, defaultValue); } } diff --git a/packages/ngx/projects/ngx-tolgee/src/lib/t.component.spec.ts b/packages/ngx/projects/ngx-tolgee/src/lib/t.component.spec.ts index bc1556ac03..d22a72a722 100644 --- a/packages/ngx/projects/ngx-tolgee/src/lib/t.component.spec.ts +++ b/packages/ngx/projects/ngx-tolgee/src/lib/t.component.spec.ts @@ -24,9 +24,12 @@ describe('T component', function () { let translationChangeSubscribeMock = jest.fn(() => ({ unsubscribe: translationChangeUnsubscribeMock, })); - - beforeEach(async () => { - jest.clearAllMocks(); + let getSafeMock = jest.fn(() => ({ + subscribe: jest.fn((resolve) => { + resolve('translated'); + }), + })); + const getFixture = async (html: string) => { translateSer = createMock(TranslateService); (translateSer as any).onLangChange = { subscribe: langChangeSubscribeMock, @@ -34,13 +37,9 @@ describe('T component', function () { (translateSer as any).onTranslationChange = { subscribe: translationChangeSubscribeMock, }; - (translateSer as any).getSafe = jest.fn(() => ({ - subscribe: jest.fn((resolve) => { - resolve('translated'); - }), - })); + (translateSer as any).getSafe = getSafeMock; - fixture = await render('
', { + fixture = await render(html, { declarations: [TComponent], componentProperties: { key: 'hello', @@ -56,27 +55,45 @@ describe('T component', function () { await waitFor(() => { element = screen.getByText('translated'); }); - }); + return fixture; + }; - it('adds data attribute', async () => { - expect(element.getAttribute('data-tolgee-key-only')).toEqual('hello'); - }); + describe('without default', () => { + beforeEach(async () => { + jest.clearAllMocks(); + fixture = await getFixture('
'); + }); - it('subscribes for translation change', async () => { - expect(translationChangeSubscribeMock).toHaveBeenCalledTimes(1); - }); + it('adds data attribute', async () => { + expect(element.getAttribute('data-tolgee-key-only')).toEqual('hello'); + }); - it('subscribes for lang change', async () => { - expect(langChangeSubscribeMock).toHaveBeenCalledTimes(1); - }); + it('subscribes for translation change', async () => { + expect(translationChangeSubscribeMock).toHaveBeenCalledTimes(1); + }); + + it('subscribes for lang change', async () => { + expect(langChangeSubscribeMock).toHaveBeenCalledTimes(1); + }); + + it('unsubscribes from translation change', async () => { + fixture.fixture.destroy(); + expect(translationChangeUnsubscribeMock).toHaveBeenCalledTimes(1); + }); - it('unsubscribes from translation change', async () => { - fixture.fixture.destroy(); - expect(translationChangeUnsubscribeMock).toHaveBeenCalledTimes(1); + it('unsubscribes from lang change', async () => { + fixture.fixture.destroy(); + expect(langChangeUnsubscribeMock).toHaveBeenCalledTimes(1); + }); }); - it('unsubscribes from lang change', async () => { - fixture.fixture.destroy(); - expect(langChangeUnsubscribeMock).toHaveBeenCalledTimes(1); + describe('with default', () => { + beforeEach(async () => { + jest.clearAllMocks(); + }); + it('passes default value correctly', async () => { + await getFixture('
'); + expect(getSafeMock).toHaveBeenCalledWith('hello', undefined, 'Yaaaaai!'); + }); }); }); diff --git a/packages/ngx/projects/ngx-tolgee/src/lib/t.component.ts b/packages/ngx/projects/ngx-tolgee/src/lib/t.component.ts index 34c8da90f2..becaaf693d 100644 --- a/packages/ngx/projects/ngx-tolgee/src/lib/t.component.ts +++ b/packages/ngx/projects/ngx-tolgee/src/lib/t.component.ts @@ -1,4 +1,11 @@ -import { Component, ElementRef, Input, OnDestroy, OnInit } from '@angular/core'; +import { + Component, + ContentChild, + ElementRef, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; import { Observable, Subscription } from 'rxjs'; import { TranslateService } from './translate.service'; import { TOLGEE_WRAPPED_ONLY_DATA_ATTRIBUTE } from '@tolgee/core'; @@ -9,8 +16,9 @@ import { TOLGEE_WRAPPED_ONLY_DATA_ATTRIBUTE } from '@tolgee/core'; }) export class TComponent implements OnInit, OnDestroy { value: string; - @Input() params: Record; + @Input() params?: Record; @Input() key: string; + @Input() default?: string; onLangChangeSubscription: Subscription; onTranslationChangeSubscription: Subscription; @@ -19,30 +27,34 @@ export class TComponent implements OnInit, OnDestroy { private translateService: TranslateService ) {} - protected get resultProvider(): (input, params) => Observable { - return (input, params) => this.translateService.getSafe(input, params); + protected get resultProvider(): ( + key, + params, + defaultValue: string + ) => Observable { + return (key, params, defaultValue) => + this.translateService.getSafe(key, params, defaultValue); } ngOnInit(): void { - this.ref.nativeElement.setAttribute( - TOLGEE_WRAPPED_ONLY_DATA_ATTRIBUTE, - this.key - ); + const element = this.ref.nativeElement as HTMLElement; + + element.setAttribute(TOLGEE_WRAPPED_ONLY_DATA_ATTRIBUTE, this.key); //update value when language changed this.onLangChangeSubscription = this.translateService.onLangChange.subscribe(() => { - this.translate(this.key, this.params); + this.translate(this.key, this.params, this.default); }); //update value when translation changed this.onTranslationChangeSubscription = this.translateService.onTranslationChange.subscribe((data) => { if (data.key == this.key) { - this.translate(this.key, this.params); + this.translate(this.key, this.params, this.default); } }); - this.translate(this.key, this.params); + this.translate(this.key, this.params, this.default); } ngOnDestroy(): void { @@ -50,8 +62,8 @@ export class TComponent implements OnInit, OnDestroy { this.onTranslationChangeSubscription?.unsubscribe(); } - private translate(input, params) { - this.resultProvider(input, params).subscribe((r) => { + private translate(key, params, defaultValue: string) { + this.resultProvider(key, params, defaultValue).subscribe((r) => { this.ref.nativeElement.innerHTML = r; }); } diff --git a/packages/ngx/projects/ngx-tolgee/src/lib/translate.pipe.spec.ts b/packages/ngx/projects/ngx-tolgee/src/lib/translate.pipe.spec.ts index 8d6e580273..0d0c14cd4e 100644 --- a/packages/ngx/projects/ngx-tolgee/src/lib/translate.pipe.spec.ts +++ b/packages/ngx/projects/ngx-tolgee/src/lib/translate.pipe.spec.ts @@ -1,7 +1,6 @@ jest.dontMock('./translate.pipe'); import { getMock, - getSafeMock, langChangeSubscribeMock, langChangeUnsubscribeMock, translateSer, @@ -19,10 +18,8 @@ describe('Translate pipe', function () { let fixture: RenderResult; let element; - beforeEach(async () => { - jest.clearAllMocks(); - - fixture = await render("
{{ 'test' | translate:params }}
", { + const getFixture = async (template: string) => { + const fixture = await render(template, { declarations: [TranslatePipe], componentProperties: { params: { key: 'value' }, @@ -37,23 +34,49 @@ describe('Translate pipe', function () { await waitFor(() => { element = screen.getByText('translated'); }); - }); + return fixture; + }; - test('translates', () => { - screen.getByText('translated'); - }); + describe('without default', () => { + beforeEach(async () => { + jest.clearAllMocks(); + fixture = await getFixture("
{{ 'test' | translate:params }}
"); + }); - it('subscribes for lang change', async () => { - expect(langChangeSubscribeMock).toHaveBeenCalledTimes(1); - }); + test('translates', () => { + screen.getByText('translated'); + }); + + it('subscribes for lang change', async () => { + expect(langChangeSubscribeMock).toHaveBeenCalledTimes(1); + }); + + it('unsubscribes from lang change', async () => { + fixture.fixture.destroy(); + expect(langChangeUnsubscribeMock).toHaveBeenCalledTimes(1); + }); - it('unsubscribes from lang change', async () => { - fixture.fixture.destroy(); - expect(langChangeUnsubscribeMock).toHaveBeenCalledTimes(1); + it('calls the get function with proper params', async () => { + expect(getMock).toHaveBeenCalledTimes(1); + expect(getMock).toHaveBeenCalledWith('test', { key: 'value' }, undefined); + }); }); - it('calls the get function with proper params', async () => { - expect(getMock).toHaveBeenCalledTimes(1); - expect(getMock).toHaveBeenCalledWith('test', { key: 'value' }); + describe('with default', () => { + beforeEach(async () => { + jest.clearAllMocks(); + fixture = await getFixture( + "
{{ 'test' | translate:'What a beautiful default':params }}
" + ); + }); + + it('calls the get function with proper params', async () => { + expect(getMock).toHaveBeenCalledTimes(1); + expect(getMock).toHaveBeenCalledWith( + 'test', + { key: 'value' }, + 'What a beautiful default' + ); + }); }); }); diff --git a/packages/ngx/projects/ngx-tolgee/src/lib/translate.pipe.ts b/packages/ngx/projects/ngx-tolgee/src/lib/translate.pipe.ts index 81ba8cecb3..64bc7a97ec 100755 --- a/packages/ngx/projects/ngx-tolgee/src/lib/translate.pipe.ts +++ b/packages/ngx/projects/ngx-tolgee/src/lib/translate.pipe.ts @@ -13,19 +13,44 @@ export class TranslatePipe implements PipeTransform, OnDestroy { constructor(protected translateService: TranslateService) {} - protected get resultProvider(): (input, params) => Observable { - return (input, params) => this.translateService.get(input, params); + protected get resultProvider(): ( + key, + params, + defaultValue: string + ) => Observable { + return (input, params, defaultValue) => + this.translateService.get(input, params, defaultValue); } ngOnDestroy(): void { this.langChangeSubscription.unsubscribe(); } - transform(input: any, params = {}): any { + transform(input: any, params: Record): string; + transform( + input: any, + defaultValue: string, + params: Record + ): string; + + transform( + input: any, + paramsOrDefaultValue?: Record | string, + params?: Record + ): string { if (!input || input.length === 0) { return input; } + const defaultValue = + typeof paramsOrDefaultValue !== 'object' + ? paramsOrDefaultValue + : undefined; + + if (typeof paramsOrDefaultValue === 'object') { + params = paramsOrDefaultValue; + } + const newHash = this.getHash( input, params, @@ -39,11 +64,11 @@ export class TranslatePipe implements PipeTransform, OnDestroy { this.langChangeSubscription?.unsubscribe(); this.langChangeSubscription = this.translateService.onLangChange.subscribe( () => { - this.onLangChange(input, params); + this.onLangChange(input, params, defaultValue); } ); - this.onLangChange(input, params); + this.onLangChange(input, params, defaultValue); this.lastHash = newHash; @@ -54,8 +79,8 @@ export class TranslatePipe implements PipeTransform, OnDestroy { return JSON.stringify({ input, params, language }); } - private onLangChange(input, params) { - this.resultProvider(input, params).subscribe((r) => { + private onLangChange(input, params, defaultValue) { + this.resultProvider(input, params, defaultValue).subscribe((r) => { this.value = r; }); } diff --git a/packages/ngx/projects/ngx-tolgee/src/lib/translate.service.spec.ts b/packages/ngx/projects/ngx-tolgee/src/lib/translate.service.spec.ts index 7b58df0036..c21d6bf732 100644 --- a/packages/ngx/projects/ngx-tolgee/src/lib/translate.service.spec.ts +++ b/packages/ngx/projects/ngx-tolgee/src/lib/translate.service.spec.ts @@ -114,28 +114,30 @@ describe('Translate service', function () { }); it('getSafe works ', (done) => { - const observable = service.getSafe('test', { key: 'value' }); + const observable = service.getSafe('test', { key: 'value' }, 'Default'); runResolve(); observable.subscribe((r) => { expect(r).toEqual('translated'); expect(translateMock).toHaveBeenCalledWith( 'test', { key: 'value' }, - true + true, + 'Default' ); done(); }); }); it('get works ', (done) => { - const observable = service.get('test', { key: 'value' }); + const observable = service.get('test', { key: 'value' }, 'Default'); runResolve(); observable.subscribe((r) => { expect(r).toEqual('translated'); expect(translateMock).toHaveBeenCalledWith( 'test', { key: 'value' }, - false + false, + 'Default' ); done(); }); @@ -144,16 +146,28 @@ describe('Translate service', function () { it('instant works ', () => { service.start({}); runResolve(); - const r = service.instant('test', { key: 'value' }); + const r = service.instant('test', { key: 'value' }, 'Default'); expect(r).toEqual('translated instant'); - expect(instantMock).toHaveBeenCalledWith('test', { key: 'value' }); + expect(instantMock).toHaveBeenCalledWith( + 'test', + { key: 'value' }, + undefined, + undefined, + 'Default' + ); }); it('instantSage works ', () => { service.start({}); runResolve(); - const r = service.instantSafe('test', { key: 'value' }); + const r = service.instantSafe('test', { key: 'value' }, 'Default'); expect(r).toEqual('translated instant'); - expect(instantMock).toHaveBeenCalledWith('test', { key: 'value' }, true); + expect(instantMock).toHaveBeenCalledWith( + 'test', + { key: 'value' }, + true, + undefined, + 'Default' + ); }); }); diff --git a/packages/ngx/projects/ngx-tolgee/src/lib/translate.service.ts b/packages/ngx/projects/ngx-tolgee/src/lib/translate.service.ts index d7a8009fda..a9209a80bb 100755 --- a/packages/ngx/projects/ngx-tolgee/src/lib/translate.service.ts +++ b/packages/ngx/projects/ngx-tolgee/src/lib/translate.service.ts @@ -48,20 +48,38 @@ export class TranslateService implements OnDestroy { this.tolgee.lang = lang; } - public get(input: string, params = {}): Observable { - return from(this.translate(input, params)); + public get( + input: string, + params = {}, + defaultValue?: string + ): Observable { + return from(this.translate(input, params, false, defaultValue)); } - public getSafe(input: string, params = {}): Observable { - return from(this.translate(input, params, true)); + public getSafe( + input: string, + params = {}, + defaultValue?: string + ): Observable { + return from(this.translate(input, params, true, defaultValue)); } - public instant(input: string, params = {}): string { - return this.tolgee.instant(input, params); + public instant(input: string, params = {}, defaultValue?: string): string { + return this.tolgee.instant( + input, + params, + undefined, + undefined, + defaultValue + ); } - public instantSafe(input: string, params = {}): string { - return this.tolgee.instant(input, params, true); + public instantSafe( + input: string, + params = {}, + defaultValue?: string + ): string { + return this.tolgee.instant(input, params, true, undefined, defaultValue); } public getCurrentLang(): string { @@ -76,10 +94,11 @@ export class TranslateService implements OnDestroy { private async translate( input: string, params = {}, - noWrap = false + noWrap = false, + defaultValue: string ): Promise { //wait for start before translating await this.start(this.config); - return await this.tolgee.translate(input, params, noWrap); + return await this.tolgee.translate(input, params, noWrap, defaultValue); } } diff --git a/packages/react/src/T.spec.tsx b/packages/react/src/T.spec.tsx index f20f99559f..3ca393c361 100644 --- a/packages/react/src/T.spec.tsx +++ b/packages/react/src/T.spec.tsx @@ -74,7 +74,12 @@ describe('T component', function () { }); test('translate fn is called with proper params', async () => { - expect(translateMock).toBeCalledWith('hello', undefined, false); + expect(translateMock).toBeCalledWith({ + defaultValue: undefined, + key: 'hello', + noWrap: false, + params: undefined, + }); }); test('it is not wrapped by span with data attribute', async () => { @@ -133,7 +138,12 @@ describe('T component', function () { }); test('translate fn is called with proper params', async () => { - expect(translateMock).toBeCalledWith('hello', undefined, true); + expect(translateMock).toBeCalledWith({ + defaultValue: undefined, + key: 'hello', + noWrap: true, + params: undefined, + }); }); test('it is not wrapped by span with data attribute', async () => { @@ -152,7 +162,12 @@ describe('T component', function () { }); test('translate fn is called with proper params', async () => { - expect(translateMock).toBeCalledWith('hello', undefined, true); + expect(translateMock).toBeCalledWith({ + defaultValue: undefined, + key: 'hello', + noWrap: true, + params: undefined, + }); }); test('it is wrapped by span with data attribute', async () => { @@ -171,15 +186,54 @@ describe('T component', function () { }); test('instant fn is called with proper params', async () => { - expect(instantMock).toBeCalledWith('hello', undefined, true, true); + expect(instantMock).toBeCalledWith( + 'hello', + undefined, + true, + true, + undefined + ); }); test('translate fn is called with proper params', async () => { - expect(translateMock).toBeCalledWith('hello', undefined, true); + expect(translateMock).toBeCalledWith({ + defaultValue: undefined, + key: 'hello', + noWrap: true, + params: undefined, + }); }); test('it is not wrapped by span with data attribute', async () => { expect(translated.getAttribute('data-tolgee-key-only')).toEqual(null); }); }); + + describe('works fine with keyName prop and default value as children', () => { + beforeEach(async () => { + render(hello); + await waitFor(() => { + screen.getByText('translated'); + }); + }); + + test('instant fn is called with proper params', async () => { + expect(instantMock).toBeCalledWith( + 'what a key', + undefined, + true, + true, + 'hello' + ); + }); + + test('translate fn is called with proper params', async () => { + expect(translateMock).toBeCalledWith({ + defaultValue: 'hello', + key: 'what a key', + noWrap: true, + params: undefined, + }); + }); + }); }); diff --git a/packages/react/src/T.tsx b/packages/react/src/T.tsx index 2b6d0135d3..623bbcd1e4 100644 --- a/packages/react/src/T.tsx +++ b/packages/react/src/T.tsx @@ -12,6 +12,7 @@ type TProps = { */ noWrap?: boolean; strategy?: 'ELEMENT_WRAP' | 'TEXT_WRAP' | 'NO_WRAP'; + keyName?: string; }; export const T: FunctionComponent = (props: TProps) => { @@ -20,6 +21,9 @@ export const T: FunctionComponent = (props: TProps) => { const context = useTolgeeContext(); + const key = props.keyName || props.children; + const defaultValue = props.keyName ? props.children : undefined; + const translateFnNoWrap = typeof window !== 'undefined' ? strategy === 'ELEMENT_WRAP' || strategy === 'NO_WRAP' @@ -27,16 +31,22 @@ export const T: FunctionComponent = (props: TProps) => { const [translated, setTranslated] = useState( context.tolgee.instant( - props.children, + key, props.parameters, translateFnNoWrap, - true + true, + defaultValue ) ); const translate = () => context.tolgee - .translate(props.children, props.parameters, translateFnNoWrap) + .translate({ + key, + params: props.parameters, + noWrap: translateFnNoWrap, + defaultValue, + }) .then((t) => { setTranslated(t); }); @@ -57,7 +67,7 @@ export const T: FunctionComponent = (props: TProps) => { if (strategy === 'ELEMENT_WRAP' || strategy === 'NO_WRAP') { const translationChangeSubscription = context.tolgee.onTranslationChange.subscribe((data) => { - if (data.key === props.children) { + if (data.key === key) { translate(); } }); @@ -71,7 +81,7 @@ export const T: FunctionComponent = (props: TProps) => { if (strategy === 'ELEMENT_WRAP') { return ( ); diff --git a/packages/react/src/useTranslate.spec.tsx b/packages/react/src/useTranslate.spec.tsx index 6d280eefe1..8e5b3ff468 100644 --- a/packages/react/src/useTranslate.spec.tsx +++ b/packages/react/src/useTranslate.spec.tsx @@ -43,91 +43,170 @@ jest.mock('./useTolgeeContext', () => ({ })); describe('useTranslate hook', function () { - const TestComponent = () => { - const t = useTranslate(); - - return ( - <> - {t('hello')} - {t('hello2', { name: 'test' })} - {t('hello3', undefined, true)} - - ); - }; + describe('basics', () => { + const TestComponent = () => { + const t = useTranslate(); + + return ( + <> + {t('hello')} + {t('hello2', { name: 'test' })} + {t('hello3', undefined, true, 'Default')} + + ); + }; - let elements; + let elements; - beforeEach(async () => { - jest.clearAllMocks(); - translatedValue = 'translated'; - render(); - await waitFor(() => { - elements = screen.getAllByText('translated'); + beforeEach(async () => { + jest.clearAllMocks(); + translatedValue = 'translated'; + render(); + await waitFor(() => { + elements = screen.getAllByText('translated'); + }); }); - }); - test('proper result is rendered', () => { - expect(elements).toHaveLength(3); - }); + test('proper result is rendered', () => { + expect(elements).toHaveLength(3); + }); - test('calls instant function', async () => { - expect(instantMock).toBeCalledTimes(3); - }); + test('calls instant function', async () => { + expect(instantMock).toBeCalledTimes(3); + }); - test('calls instant function with proper params', async () => { - expect(instantMock).toHaveBeenCalledWith( - 'hello', - undefined, - undefined, - true - ); - expect(instantMock).toHaveBeenCalledWith( - 'hello2', - { name: 'test' }, - undefined, - true - ); - expect(instantMock).toHaveBeenCalledWith('hello3', undefined, true, true); - }); + test('calls instant function with proper params', async () => { + expect(instantMock).toHaveBeenCalledWith({ + defaultValue: undefined, + key: 'hello', + noWrap: undefined, + orEmpty: true, + params: undefined, + }); + expect(instantMock).toHaveBeenCalledWith({ + defaultValue: undefined, + key: 'hello2', + noWrap: undefined, + orEmpty: true, + params: { name: 'test' }, + }); + expect(instantMock).toHaveBeenCalledWith({ + defaultValue: 'Default', + key: 'hello3', + noWrap: true, + orEmpty: true, + params: undefined, + }); + }); - test('calls translate function with proper params', async () => { - expect(translateMock).toHaveBeenCalledWith('hello', undefined, undefined); - expect(translateMock).toHaveBeenCalledWith( - 'hello2', - { name: 'test' }, - undefined - ); - expect(translateMock).toHaveBeenCalledWith('hello3', undefined, true); - }); + test('calls translate function with proper params', async () => { + expect(translateMock).toHaveBeenCalledWith( + 'hello', + undefined, + undefined, + undefined + ); + expect(translateMock).toHaveBeenCalledWith( + 'hello2', + { name: 'test' }, + undefined, + undefined + ); + expect(translateMock).toHaveBeenCalledWith( + 'hello3', + undefined, + true, + 'Default' + ); + }); - test('listens to language change', async () => { - jest.clearAllMocks(); - translatedValue = 'translated in new lang'; - act(() => { - langChangeCallback(); + test('listens to language change', async () => { + jest.clearAllMocks(); + translatedValue = 'translated in new lang'; + act(() => { + langChangeCallback(); + }); + expect(translateMock).toBeCalledTimes(3); + await waitFor(() => { + elements = screen.getAllByText('translated in new lang'); + }); + expect(elements).toHaveLength(3); + expect(translateMock).toHaveBeenCalledWith( + 'hello3', + undefined, + true, + 'Default' + ); }); - expect(translateMock).toBeCalledTimes(3); - await waitFor(() => { - elements = screen.getAllByText('translated in new lang'); + + test('listens to translation change', async () => { + jest.clearAllMocks(); + translatedValue = 'translated changed'; + act(() => { + translationChangeCallback({ key: 'hello2' }); + }); + expect(translateMock).toBeCalledTimes(1); + await waitFor(() => { + elements = screen.getAllByText('translated changed'); + }); + expect(elements).toHaveLength(1); + expect(translateMock).toHaveBeenCalledWith( + 'hello2', + { name: 'test' }, + undefined, + undefined + ); }); - expect(elements).toHaveLength(3); }); - test('listens to translation change', async () => { - jest.clearAllMocks(); - translatedValue = 'translated changed'; - act(() => { - translationChangeCallback({ key: 'hello2' }); + describe('object params', () => { + const TestComponent = () => { + const t = useTranslate(); + + return ( + <> + + {t({ + key: 'hello', + parameters: { name: 'test' }, + defaultValue: 'Default', + noWrap: false, + })} + + + ); + }; + + beforeEach(async () => { + jest.clearAllMocks(); + translatedValue = 'translated'; + render(); + await waitFor(() => { + screen.getByText('translated'); + }); }); - expect(translateMock).toBeCalledTimes(1); - await waitFor(() => { - elements = screen.getAllByText('translated changed'); + + test('calls instant function', async () => { + expect(instantMock).toBeCalledTimes(1); + }); + + test('calls instant function with proper params', async () => { + expect(instantMock).toHaveBeenCalledWith({ + defaultValue: 'Default', + key: 'hello', + noWrap: false, + orEmpty: true, + params: { name: 'test' }, + }); + }); + + test('calls translate function with proper params', async () => { + expect(translateMock).toHaveBeenCalledWith( + 'hello', + { name: 'test' }, + false, + 'Default' + ); }); - expect(elements).toHaveLength(1); - expect(translateMock).toHaveBeenCalledWith( - 'hello2', - { name: 'test' }, - undefined - ); }); }); diff --git a/packages/react/src/useTranslate.ts b/packages/react/src/useTranslate.ts index 88cb9d2c7b..9fb58ba9f4 100644 --- a/packages/react/src/useTranslate.ts +++ b/packages/react/src/useTranslate.ts @@ -2,7 +2,24 @@ import { useTolgeeContext } from './useTolgeeContext'; import { TranslationParameters } from './types'; import { useEffect, useState } from 'react'; -export const useTranslate = () => { +type UseTranslateResultFnProps = { + key: string; + parameters?: TranslationParameters; + noWrap?: boolean; + defaultValue?: string; +}; + +type ReturnFnType = { + (props: UseTranslateResultFnProps): string; + ( + key: string, + parameters?: TranslationParameters, + noWrap?: boolean, + defaultValue?: string + ): string; +}; + +export const useTranslate: () => ReturnFnType = () => { const context = useTolgeeContext(); const [translated, setTranslated] = useState( {} as Record> @@ -14,32 +31,38 @@ export const useTranslate = () => { ): { parameters: TranslationParameters | undefined; noWrap: boolean | undefined; + defaultValue: string | undefined; } => { return JSON.parse(jsonKey); }; - - const getJsonHash = (parameters: TranslationParameters, noWrap: boolean) => { - return JSON.stringify({ parameters, noWrap }); + const getJsonHash = ( + parameters: TranslationParameters, + noWrap: boolean, + defaultValue: string | undefined + ) => { + return JSON.stringify({ parameters, noWrap, defaultValue }); }; const translationFromState = ( key: string, parameters: TranslationParameters, - noWrap: boolean + noWrap: boolean, + defaultValue: string | undefined ) => { - const jsonHash = getJsonHash(parameters, noWrap); + const jsonHash = getJsonHash(parameters, noWrap, defaultValue); if (translated[key] === undefined) { translated[key] = {}; } if (translated[key][jsonHash] === undefined) { - translated[key][jsonHash] = context.tolgee.instant( + translated[key][jsonHash] = context.tolgee.instant({ key, - parameters, + params: parameters, noWrap, - true - ); + orEmpty: true, + defaultValue, + }); setTranslated({ ...translated }); setWasInstant(true); } @@ -58,10 +81,10 @@ export const useTranslate = () => { const translationPromises = Object.entries(translated).flatMap( ([key, data]) => { return Object.keys(data).map((jsonHash) => { - const { parameters, noWrap } = parseJsonHash(jsonHash); + const { parameters, noWrap, defaultValue } = parseJsonHash(jsonHash); return new Promise((resolve) => { context.tolgee - .translate(key, parameters, noWrap) + .translate(key, parameters, noWrap, defaultValue) .then((translated) => resolve({ key: key, jsonHash, translated }) ); @@ -107,7 +130,8 @@ export const useTranslate = () => { const newTranslated = await context.tolgee.translate( key, params.parameters, - params.noWrap + params.noWrap, + params.defaultValue ); setTranslated((oldTranslated) => ({ ...oldTranslated, @@ -123,8 +147,19 @@ export const useTranslate = () => { }, []); return ( - source: string, + keyOrProps: string | UseTranslateResultFnProps, parameters?: TranslationParameters, - noWrap?: boolean - ) => translationFromState(source, parameters, noWrap); + noWrap?: boolean, + defaultValue?: string + ) => { + // allow user to pass object of params and make the code cleaner + const key = typeof keyOrProps === 'string' ? keyOrProps : keyOrProps.key; + if (typeof keyOrProps === 'object') { + parameters = keyOrProps.parameters; + noWrap = keyOrProps.noWrap; + defaultValue = keyOrProps.defaultValue; + } + + return translationFromState(key, parameters, noWrap, defaultValue); + }; }; diff --git a/testapps/next/package-lock.json b/testapps/next/package-lock.json index 43a7ca4b5d..dda169cc44 100644 --- a/testapps/next/package-lock.json +++ b/testapps/next/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "tolgee-next-example", - "version": "0.1.0", + "version": "1.4.0", "dependencies": { "next": "11.1.2", "react": "17.0.2", diff --git a/testapps/react/src/Page.tsx b/testapps/react/src/Page.tsx index 574126b178..c9f3fb1286 100644 --- a/testapps/react/src/Page.tsx +++ b/testapps/react/src/Page.tsx @@ -69,6 +69,18 @@ export const Page: FunctionComponent = () => {

ELEMENT_WRAP strategy

sampleApp.hello_world! + +
+

Default value

+ + This is default! + +
+ +
+

Key is default value

+ unknown key +
); };