diff --git a/app/client/lib/localization.ts b/app/client/lib/localization.ts index 4c03954a..91b7f51f 100644 --- a/app/client/lib/localization.ts +++ b/app/client/lib/localization.ts @@ -1,7 +1,7 @@ import {getGristConfig} from 'app/common/urlUtils'; import {DomContents} from 'grainjs'; -import {G} from 'grainjs/dist/cjs/lib/browserGlobals'; import i18next from 'i18next'; +import {G} from 'grainjs/dist/cjs/lib/browserGlobals'; export async function setupLocale() { const now = Date.now(); @@ -72,8 +72,8 @@ export async function setupLocale() { /** * Resolves the translation of the given key using the given options. */ -export function t(key: string, args?: any, instance = i18next): string { - if (!instance.exists(key, args)) { +export function tString(key: string, args?: any, instance = i18next): string { + if (!instance.exists(key, args || undefined)) { const error = new Error(`Missing translation for key: ${key} and language: ${i18next.language}`); reportError(error); } @@ -84,15 +84,15 @@ export function t(key: string, args?: any, instance = i18next): string { /** * Resolves the translation of the given key and substitutes. Supports dom elements interpolation. */ - export function domT(key: string, args?: any, instance = i18next): DomContents { - if (!instance.exists(key)) { + export function t(key: string, args?: any, instance = i18next): DomContents { + if (!instance.exists(key, args || undefined)) { const error = new Error(`Missing translation for key: ${key} and language: ${i18next.language}`); reportError(error); } // If there are any DomElements in args, handle it with missingInterpolationHandler. - const domElements = Object.entries(args).filter(([_, value]) => isLikeDomContents(value)); + const domElements = !args ? [] : Object.entries(args).filter(([_, value]) => isLikeDomContents(value)); if (!args || !domElements.length) { - return t(key, args); + return instance.t(key, args || undefined) as string; } else { // Make a copy of the arguments, and remove any dom elements from it. It will instruct // i18next library to use `missingInterpolationHandler` handler. @@ -104,9 +104,9 @@ export function t(key: string, args?: any, instance = i18next): string { const result: string = instance.t(key, {...copy, missingInterpolationHandler}); // Now replace all markers with dom elements passed as arguments. - const parts = result.split(new RegExp(`(${DOM_MARKER}\\w+)`)); + const parts = result.split(/(\[\[\[[^\]]+?\]\]\])/); for (let i = 1; i < parts.length; i += 2) { // Every second element is our dom element. - const propName = parts[i].replace(DOM_MARKER, ""); + const propName = parts[i].substring(3, parts[i].length - 3); const domElement = args[propName] ?? `{{${propName}}}`; // If the prop is not there, simulate default behavior. parts[i] = domElement; } @@ -121,9 +121,8 @@ export function hasTranslation(key: string) { return i18next.exists(key); } -const DOM_MARKER = '__domKey_'; function missingInterpolationHandler(key: string, value: any) { - return `${DOM_MARKER}${value[1]}`; + return `[[[${value[1]}]]]`; } /** diff --git a/app/client/ui/AddNewButton.ts b/app/client/ui/AddNewButton.ts index 7e792c6a..588f32e0 100644 --- a/app/client/ui/AddNewButton.ts +++ b/app/client/ui/AddNewButton.ts @@ -3,7 +3,7 @@ import {icon} from 'app/client/ui2018/icons'; import {dom, DomElementArg, Observable, styled} from "grainjs"; import {t} from 'app/client/lib/localization'; -const translate = (x: string, args?: any): string => t(`AddNewButton.${x}`, args); +const translate = (x: string, args?: any) => t(`AddNewButton.${x}`, args); export function addNewButton(isOpen: Observable | boolean = true, ...args: DomElementArg[]) { return cssAddNewButton( diff --git a/app/client/ui/DocMenu.ts b/app/client/ui/DocMenu.ts index 28ec71ea..86d1982e 100644 --- a/app/client/ui/DocMenu.ts +++ b/app/client/ui/DocMenu.ts @@ -34,7 +34,7 @@ import {localStorageBoolObs} from 'app/client/lib/localStorageObs'; import {bigBasicButton} from 'app/client/ui2018/buttons'; import sortBy = require('lodash/sortBy'); -const translate = (x: string, args?: any): string => t(`DocMenu.${x}`, args); +const translate = (x: string, args?: any) => t(`DocMenu.${x}`, args); const testId = makeTestId('test-dm-'); diff --git a/app/client/ui/HomeIntro.ts b/app/client/ui/HomeIntro.ts index 2e27c2ed..d341c104 100644 --- a/app/client/ui/HomeIntro.ts +++ b/app/client/ui/HomeIntro.ts @@ -1,4 +1,4 @@ -import {domT, t} from 'app/client/lib/localization'; +import {t} from 'app/client/lib/localization'; import {getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState'; import {HomeModel} from 'app/client/models/HomeModel'; import {productPill} from 'app/client/ui/AppHeader'; @@ -14,7 +14,7 @@ import {FullUser} from 'app/common/LoginSessionAPI'; import * as roles from 'app/common/roles'; import {Computed, dom, DomContents, styled} from 'grainjs'; -const translate = (x: string, args?: any): string => t(`HomeIntro.${x}`, args); +const translate = (x: string, args?: any) => t(`HomeIntro.${x}`, args); export function buildHomeIntro(homeModel: HomeModel): DomContents { const isViewer = homeModel.app.currentOrg?.access === roles.VIEWER; @@ -103,7 +103,7 @@ function makePersonalIntro(homeModel: HomeModel, user: FullUser) { css.docListHeader(`Welcome to Grist, ${user.name}!`, testId('welcome-title')), cssIntroLine('Get started by creating your first Grist document.'), (shouldHideUiElement('helpCenter') ? null : - cssIntroLine(domT('HomeIntro.VisitHelpCenter', { link: helpCenterLink() }), + cssIntroLine(t('HomeIntro.VisitHelpCenter', { link: helpCenterLink() }), testId('welcome-text')) ), makeCreateButtons(homeModel), @@ -116,7 +116,7 @@ function makeAnonIntro(homeModel: HomeModel) { css.docListHeader(translate('Welcome'), testId('welcome-title')), cssIntroLine('Get started by exploring templates, or creating your first Grist document.'), cssIntroLine(signUp, ' to save your work. ', - (shouldHideUiElement('helpCenter') ? null : domT('HomeIntro.VisitHelpCenter', { link: helpCenterLink() })), + (shouldHideUiElement('helpCenter') ? null : t('HomeIntro.VisitHelpCenter', { link: helpCenterLink() })), testId('welcome-text')), makeCreateButtons(homeModel), ]; diff --git a/documentation/translations.md b/documentation/translations.md index 36bc2898..a82b91e0 100644 --- a/documentation/translations.md +++ b/documentation/translations.md @@ -101,6 +101,17 @@ function makeAnonIntro(homeModel: HomeModel) { css.docListHeader(t('HomeIntro.Welcome'), testId('welcome-title')), ``` + +Function `t` on the client side is also able to use `DomContents` values (so material produced by +the GrainJS library) for interpolation. For example: + +```ts +dom('span', t('Argument', { + arg1: dom('span', 'First'), + arg2: dom.domComputed(obs, (value) => dom('span', value)) +})); +``` + Some things are not supported at this moment and will need to be addressed in future development tasks: diff --git a/test/client/lib/localization.ts b/test/client/lib/localization.ts index ccd1fa7e..c0892088 100644 --- a/test/client/lib/localization.ts +++ b/test/client/lib/localization.ts @@ -1,7 +1,7 @@ -import {domT, t} from 'app/client/lib/localization'; +import {t} from 'app/client/lib/localization'; import {assert} from 'chai'; import i18next, {i18n} from 'i18next'; -import {dom} from "grainjs"; +import {dom, observable} from "grainjs"; import {popGlobals, pushGlobals, G} from 'grainjs/dist/cjs/lib/browserGlobals'; import {JSDOM} from 'jsdom'; @@ -26,31 +26,56 @@ describe('localization', function() { translation: { 'Text': 'TranslatedText', 'Argument': 'Translated {{arg1}} {{arg2}}.', + 'Argument_variant': 'Variant {{arg1}} {{arg2}}.', } } } }); }); - it('supports basic operation', function() { - assert.equal(t('Text', null, instance), 'TranslatedText'); + it('supports basic operation for strings', function() { assert.equal(t('Argument', {arg1: '1', arg2: '2'}, instance), 'Translated 1 2.'); + assert.equal(t('Argument', {arg1: '1', arg2: '2', context: 'variant'}, instance), 'Variant 1 2.'); + assert.equal(t('Text', null, instance), 'TranslatedText'); }); it('supports dom content interpolation', function() { - const result = domT('Argument', { + const obs = observable("Second"); + const result = t('Argument', { arg1: dom('span', 'First'), - arg2: dom.domComputed("test", (value) => dom('span', value)) + arg2: dom.domComputed(obs, (value) => dom('span', value)) }, instance) as any; assert.isTrue(Array.isArray(result)); assert.equal(result.length, 5); + // First we have a plain string. assert.equal(result[0], 'Translated '); + // Next we have a span element. assert.equal(result[1]?.tagName, 'SPAN'); assert.equal(result[1]?.textContent, 'First'); + // Empty space assert.equal(result[2], ' '); // Element 3 is the domComputed [Comment, Comment, function()] assert.isTrue(Array.isArray(result[3])); assert.isTrue(result[3][0] instanceof G.Node); + assert.isTrue(result[3][1] instanceof G.Node); + assert.isTrue(typeof result[3][2] === 'function'); + // As last we have "." assert.equal(result[4], '.'); + + // Make sure that computed works. + const span = dom('span', result); + assert.equal(span.textContent, "Translated First Second."); + obs.set("Third"); + assert.equal(span.textContent, "Translated First Third."); + + // Test that context variable works. + const variantSpan = dom('span', t('Argument', { + arg1: dom('span', 'First'), + arg2: dom.domComputed(obs, (value) => dom('span', value)), + context: 'variant' + }, instance)); + assert.equal(variantSpan.textContent, "Variant First Third."); + obs.set("Fourth"); + assert.equal(variantSpan.textContent, "Variant First Fourth."); }); });