Merge pull request #321 from gristlabs/domt-function

Adding domT method for component interpolation
This commit is contained in:
jarek 2022-10-20 16:16:24 +02:00 committed by GitHub
commit cd69350072
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 214 additions and 14 deletions

View File

@ -1,5 +1,7 @@
import {getGristConfig} from 'app/common/urlUtils'; import {getGristConfig} from 'app/common/urlUtils';
import {DomContents} from 'grainjs';
import i18next from 'i18next'; import i18next from 'i18next';
import {G} from 'grainjs/dist/cjs/lib/browserGlobals';
export async function setupLocale() { export async function setupLocale() {
const now = Date.now(); const now = Date.now();
@ -68,14 +70,52 @@ export async function setupLocale() {
} }
/** /**
* Resolves the translation of the given key, using the given options. * Resolves the translation of the given key using the given options.
*/ */
export function t(key: string, args?: any): string { export function tString(key: string, args?: any, instance = i18next): string {
if (!i18next.exists(key, args)) { if (!instance.exists(key, args || undefined)) {
const error = new Error(`Missing translation for key: ${key} and language: ${i18next.language}`); const error = new Error(`Missing translation for key: ${key} and language: ${i18next.language}`);
reportError(error); reportError(error);
} }
return i18next.t(key, args); 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> = T extends Record<string, string | number | boolean>|undefined|null ? string : DomContents;
/**
* Resolves the translation of the given key and substitutes. Supports dom elements interpolation.
*/
export function t<T extends Record<string, any>>(key: string, args?: T|null, instance = i18next): InferResult<T> {
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 = !args ? [] : Object.entries(args).filter(([_, value]) => isLikeDomContents(value));
if (!args || !domElements.length) {
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.
const copy = {...args};
domElements.forEach(([prop]) => delete copy[prop]);
// Passing `missingInterpolationHandler` will allow as to resolve all missing keys
// and replace them with a marker.
const result: string = instance.t(key, {...copy, missingInterpolationHandler});
// Now replace all markers with dom elements passed as arguments.
const parts = result.split(/(\[\[\[[^\]]+?\]\]\])/);
for (let i = 1; i < parts.length; i += 2) { // Every second element is our dom element.
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;
}
return parts.filter(p => p !== '') as any; // Remove empty parts.
}
} }
/** /**
@ -84,3 +124,27 @@ export function t(key: string, args?: any): string {
export function hasTranslation(key: string) { export function hasTranslation(key: string) {
return i18next.exists(key); return i18next.exists(key);
} }
function missingInterpolationHandler(key: string, value: any) {
return `[[[${value[1]}]]]`;
}
/**
* Very naive detection if an element has DomContents type.
*/
function isLikeDomContents(value: any): boolean {
// As null and undefined are valid DomContents values, we don't treat them as such.
if (value === null || value === undefined) { return false; }
return value instanceof G.Node || // Node
(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<T extends Record<string, any>>(key: string, args?: T|null, instance = i18next) {
return t(`${scope}.${key}`, args, instance);
}
}

View File

@ -1,9 +1,9 @@
import {theme, vars} from 'app/client/ui2018/cssVars'; import {theme, vars} from 'app/client/ui2018/cssVars';
import {makeT} from 'app/client/lib/localization';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {dom, DomElementArg, Observable, styled} from "grainjs"; 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 = makeT(`AddNewButton`);
export function addNewButton(isOpen: Observable<boolean> | boolean = true, ...args: DomElementArg[]) { export function addNewButton(isOpen: Observable<boolean> | boolean = true, ...args: DomElementArg[]) {
return cssAddNewButton( return cssAddNewButton(

View File

@ -29,12 +29,12 @@ import {Document, Workspace} from 'app/common/UserAPI';
import {computed, Computed, dom, DomArg, DomContents, IDisposableOwner, import {computed, Computed, dom, DomArg, DomContents, IDisposableOwner,
makeTestId, observable, Observable} from 'grainjs'; makeTestId, observable, Observable} from 'grainjs';
import {buildTemplateDocs} from 'app/client/ui/TemplateDocs'; 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 {localStorageBoolObs} from 'app/client/lib/localStorageObs';
import {bigBasicButton} from 'app/client/ui2018/buttons'; import {bigBasicButton} from 'app/client/ui2018/buttons';
import sortBy = require('lodash/sortBy'); import sortBy = require('lodash/sortBy');
const translate = (x: string, args?: any): string => t(`DocMenu.${x}`, args); const translate = makeT(`DocMenu`);
const testId = makeTestId('test-dm-'); const testId = makeTestId('test-dm-');

View File

@ -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 {getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState';
import {HomeModel} from 'app/client/models/HomeModel'; import {HomeModel} from 'app/client/models/HomeModel';
import {productPill} from 'app/client/ui/AppHeader'; 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 * as roles from 'app/common/roles';
import {Computed, dom, DomContents, styled} from 'grainjs'; import {Computed, dom, DomContents, styled} from 'grainjs';
const translate = (x: string, args?: any): string => t(`HomeIntro.${x}`, args); const translate = makeT('HomeIntro');
export function buildHomeIntro(homeModel: HomeModel): DomContents { export function buildHomeIntro(homeModel: HomeModel): DomContents {
const isViewer = homeModel.app.currentOrg?.access === roles.VIEWER; 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')), css.docListHeader(`Welcome to Grist, ${user.name}!`, testId('welcome-title')),
cssIntroLine('Get started by creating your first Grist document.'), cssIntroLine('Get started by creating your first Grist document.'),
(shouldHideUiElement('helpCenter') ? null : (shouldHideUiElement('helpCenter') ? null :
cssIntroLine('Visit our ', helpCenterLink(), ' to learn more.', cssIntroLine(translate('VisitHelpCenter', { link: helpCenterLink() }),
testId('welcome-text')) testId('welcome-text'))
), ),
makeCreateButtons(homeModel), makeCreateButtons(homeModel),
@ -115,8 +115,8 @@ function makeAnonIntro(homeModel: HomeModel) {
return [ return [
css.docListHeader(translate('Welcome'), testId('welcome-title')), css.docListHeader(translate('Welcome'), testId('welcome-title')),
cssIntroLine('Get started by exploring templates, or creating your first Grist document.'), cssIntroLine('Get started by exploring templates, or creating your first Grist document.'),
cssIntroLine(signUp, ' to save your work.', cssIntroLine(signUp, ' to save your work. ',
(shouldHideUiElement('helpCenter') ? null : [' Visit our ', helpCenterLink(), ' to learn more.']), (shouldHideUiElement('helpCenter') ? null : translate('VisitHelpCenter', { link: helpCenterLink() })),
testId('welcome-text')), testId('welcome-text')),
makeCreateButtons(homeModel), makeCreateButtons(homeModel),
]; ];

View File

@ -101,6 +101,17 @@ function makeAnonIntro(homeModel: HomeModel) {
css.docListHeader(t('HomeIntro.Welcome'), testId('welcome-title')), 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 Some things are not supported at this moment and will need to be addressed in future development
tasks: tasks:

View File

@ -12,6 +12,7 @@
}, },
"HomeIntro": { "HomeIntro": {
"Welcome": "Welcome to Grist!", "Welcome": "Welcome to Grist!",
"SignUp": "Sign up" "SignUp": "Sign up",
"VisitHelpCenter": "Visit our {{link}} to learn more."
} }
} }

View File

@ -0,0 +1,124 @@
import {makeT, t} from 'app/client/lib/localization';
import {assert} from 'chai';
import i18next, {i18n} from 'i18next';
import {Disposable, dom, DomContents, observable} from "grainjs";
import {popGlobals, pushGlobals, G} from 'grainjs/dist/cjs/lib/browserGlobals';
import {JSDOM} from 'jsdom';
describe('localization', function() {
let instance: i18n;
before(() => {
instance = i18next.createInstance();
instance.init({
lng: 'en',
resources: {
en: {
translation: {
'Text': 'TranslatedText',
'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("<!doctype html><html><body></body></html>");
pushGlobals(jsdomDoc.window);
});
afterEach(function() {
popGlobals();
});
it('supports basic operation for strings', function() {
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)),
end: dom.create(Component)
}, 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 "." 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);
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)),
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);
});
});