diff --git a/app/client/lib/localization.ts b/app/client/lib/localization.ts index 91b7f51f..a628a361 100644 --- a/app/client/lib/localization.ts +++ b/app/client/lib/localization.ts @@ -80,11 +80,15 @@ export function tString(key: string, args?: any, instance = i18next): string { return instance.t(key, args); } +// We will try to infer result from the arguments passed to `t` function. +// For plain objects we expect string as a result. If any property doesn't look as a plain value +// we assume that it might be a dom node and the result is DomContents. +type InferResult = T extends Record|undefined|null ? string : DomContents; /** * Resolves the translation of the given key and substitutes. Supports dom elements interpolation. */ - export function t(key: string, args?: any, instance = i18next): DomContents { + export function t>(key: string, args?: T|null, instance = i18next): InferResult { if (!instance.exists(key, args || undefined)) { const error = new Error(`Missing translation for key: ${key} and language: ${i18next.language}`); reportError(error); @@ -92,7 +96,7 @@ export function tString(key: string, args?: any, instance = i18next): string { // If there are any DomElements in args, handle it with missingInterpolationHandler. const domElements = !args ? [] : Object.entries(args).filter(([_, value]) => isLikeDomContents(value)); if (!args || !domElements.length) { - return instance.t(key, args || undefined) as string; + return instance.t(key, args || undefined) as any; } else { // Make a copy of the arguments, and remove any dom elements from it. It will instruct // i18next library to use `missingInterpolationHandler` handler. @@ -110,7 +114,7 @@ export function tString(key: string, args?: any, instance = i18next): string { const domElement = args[propName] ?? `{{${propName}}}`; // If the prop is not there, simulate default behavior. parts[i] = domElement; } - return parts; + return parts.filter(p => p !== '') as any; // Remove empty parts. } } @@ -135,3 +139,12 @@ function isLikeDomContents(value: any): boolean { (Array.isArray(value) && isLikeDomContents(value[0])) || // DomComputed typeof value === 'function'; // DomMethod } + +/** + * Helper function to create scoped t function. + */ +export function makeT(scope: string) { + return function>(key: string, args?: T|null, instance = i18next) { + return t(`${scope}.${key}`, args, instance); + } +} diff --git a/app/client/ui/AddNewButton.ts b/app/client/ui/AddNewButton.ts index 588f32e0..9c45a5f6 100644 --- a/app/client/ui/AddNewButton.ts +++ b/app/client/ui/AddNewButton.ts @@ -1,9 +1,9 @@ import {theme, vars} from 'app/client/ui2018/cssVars'; +import {makeT} from 'app/client/lib/localization'; 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) => t(`AddNewButton.${x}`, args); +const translate = makeT(`AddNewButton`); 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 86d1982e..e7b10aa7 100644 --- a/app/client/ui/DocMenu.ts +++ b/app/client/ui/DocMenu.ts @@ -29,12 +29,12 @@ import {Document, Workspace} from 'app/common/UserAPI'; import {computed, Computed, dom, DomArg, DomContents, IDisposableOwner, makeTestId, observable, Observable} from 'grainjs'; import {buildTemplateDocs} from 'app/client/ui/TemplateDocs'; -import {t} from 'app/client/lib/localization'; +import {makeT} from 'app/client/lib/localization'; 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) => t(`DocMenu.${x}`, args); +const translate = makeT(`DocMenu`); const testId = makeTestId('test-dm-'); diff --git a/app/client/ui/HomeIntro.ts b/app/client/ui/HomeIntro.ts index d341c104..3819da12 100644 --- a/app/client/ui/HomeIntro.ts +++ b/app/client/ui/HomeIntro.ts @@ -1,4 +1,4 @@ -import {t} from 'app/client/lib/localization'; +import {makeT} 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) => t(`HomeIntro.${x}`, args); +const translate = makeT('HomeIntro'); 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(t('HomeIntro.VisitHelpCenter', { link: helpCenterLink() }), + cssIntroLine(translate('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 : t('HomeIntro.VisitHelpCenter', { link: helpCenterLink() })), + (shouldHideUiElement('helpCenter') ? null : translate('VisitHelpCenter', { link: helpCenterLink() })), testId('welcome-text')), makeCreateButtons(homeModel), ]; diff --git a/test/client/lib/localization.ts b/test/client/lib/localization.ts index c0892088..0e64f08f 100644 --- a/test/client/lib/localization.ts +++ b/test/client/lib/localization.ts @@ -1,21 +1,11 @@ -import {t} from 'app/client/lib/localization'; +import {makeT, t} from 'app/client/lib/localization'; import {assert} from 'chai'; import i18next, {i18n} from 'i18next'; -import {dom, observable} from "grainjs"; +import {Disposable, dom, DomContents, observable} from "grainjs"; import {popGlobals, pushGlobals, G} from 'grainjs/dist/cjs/lib/browserGlobals'; import {JSDOM} from 'jsdom'; describe('localization', function() { - beforeEach(function() { - // These grainjs browserGlobals are needed for using dom() in tests. - const jsdomDoc = new JSDOM(""); - pushGlobals(jsdomDoc.window); - }); - - afterEach(function() { - popGlobals(); - }); - let instance: i18n; before(() => { instance = i18next.createInstance(); @@ -25,25 +15,44 @@ describe('localization', function() { en: { translation: { 'Text': 'TranslatedText', - 'Argument': 'Translated {{arg1}} {{arg2}}.', - 'Argument_variant': 'Variant {{arg1}} {{arg2}}.', + 'Argument': 'Translated {{arg1}} {{arg2}}{{end}}', + 'Argument_variant': 'Variant {{arg1}} {{arg2}}{{end}}', + 'Parent': { + 'Child': 'Translated child {{arg}}', + } } } } }); }); + beforeEach(function() { + // These grainjs browserGlobals are needed for using dom() in tests. + const jsdomDoc = new JSDOM(""); + pushGlobals(jsdomDoc.window); + }); + + afterEach(function() { + popGlobals(); + }); + 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('Argument', {arg1: '1', arg2: '2', end: '.'}, instance), 'Translated 1 2.'); + assert.equal(t('Argument', {arg1: '1', arg2: '2', end: '.', context: 'variant'}, instance), 'Variant 1 2.'); assert.equal(t('Text', null, instance), 'TranslatedText'); }); it('supports dom content interpolation', function() { + class Component extends Disposable { + public buildDom() { + return dom('span', '.'); + } + } const obs = observable("Second"); const result = t('Argument', { arg1: dom('span', 'First'), - arg2: dom.domComputed(obs, (value) => dom('span', value)) + arg2: dom.domComputed(obs, (value) => dom('span', value)), + end: dom.create(Component) }, instance) as any; assert.isTrue(Array.isArray(result)); assert.equal(result.length, 5); @@ -59,8 +68,11 @@ describe('localization', function() { 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], '.'); + // As last we have "." as grainjs component. + assert.isTrue(Array.isArray(result[4])); + assert.isTrue(result[4][0] instanceof G.Node); + assert.isTrue(result[4][1] instanceof G.Node); + assert.isTrue(typeof result[4][2] === 'function'); // Make sure that computed works. const span = dom('span', result); @@ -72,10 +84,41 @@ describe('localization', function() { const variantSpan = dom('span', t('Argument', { arg1: dom('span', 'First'), arg2: dom.domComputed(obs, (value) => dom('span', value)), + end: dom.create(Component), context: 'variant' }, instance)); assert.equal(variantSpan.textContent, "Variant First Third."); obs.set("Fourth"); assert.equal(variantSpan.textContent, "Variant First Fourth."); }); + + it('supports scoping through makeT', function() { + const scoped = makeT('Parent'); + assert.equal(scoped('Child', { arg : 'Arg'}, instance), 'Translated child Arg'); + }); + + it('infers result from parameters', function() { + class Component extends Disposable { + public buildDom() { + return dom('span', '.'); + } + } + // Here we only test that this "compiles" without errors and types are correct. + let typeString: string = ''; void typeString; + typeString = t('Argument', {arg1: 'argument 1', arg2: 'argument 2'}, instance); + typeString = t('Argument', {arg1: 1, arg2: true}, instance); + typeString = t('Argument', undefined, instance); + const scoped = makeT('Parent'); + typeString = scoped('Child', {arg: 'argument 1'}, instance); + typeString = scoped('Child', {arg: 1}, instance); + typeString = scoped('Child', undefined, instance); + + let domContent: DomContents = null; void domContent; + + domContent = t('Argument', {arg1: 'argument 1', arg2: dom('span')}, instance); + domContent = t('Argument', {arg1: 1, arg2: dom.domComputed(observable('test'))}, instance); + domContent = t('Argument', undefined, instance); + domContent = scoped('Child', {arg: dom.create(Component)}, instance); + domContent = scoped('Child', {arg: dom.maybe(observable(true), () => dom('span'))}, instance); + }); });