mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	Merge pull request #321 from gristlabs/domt-function
Adding domT method for component interpolation
This commit is contained in:
		
						commit
						cd69350072
					
				@ -1,5 +1,7 @@
 | 
			
		||||
import {getGristConfig} from 'app/common/urlUtils';
 | 
			
		||||
import {DomContents} from 'grainjs';
 | 
			
		||||
import i18next from 'i18next';
 | 
			
		||||
import {G} from 'grainjs/dist/cjs/lib/browserGlobals';
 | 
			
		||||
 | 
			
		||||
export async function setupLocale() {
 | 
			
		||||
  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 {
 | 
			
		||||
  if (!i18next.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);
 | 
			
		||||
  }
 | 
			
		||||
  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) {
 | 
			
		||||
  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);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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): string => t(`AddNewButton.${x}`, args);
 | 
			
		||||
const translate = makeT(`AddNewButton`);
 | 
			
		||||
 | 
			
		||||
export function addNewButton(isOpen: Observable<boolean> | boolean = true, ...args: DomElementArg[]) {
 | 
			
		||||
  return cssAddNewButton(
 | 
			
		||||
 | 
			
		||||
@ -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): string => t(`DocMenu.${x}`, args);
 | 
			
		||||
const translate = makeT(`DocMenu`);
 | 
			
		||||
 | 
			
		||||
const testId = makeTestId('test-dm-');
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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): string => 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('Visit our ', helpCenterLink(), ' to learn more.',
 | 
			
		||||
      cssIntroLine(translate('VisitHelpCenter', { link: helpCenterLink() }),
 | 
			
		||||
        testId('welcome-text'))
 | 
			
		||||
    ),
 | 
			
		||||
    makeCreateButtons(homeModel),
 | 
			
		||||
@ -115,8 +115,8 @@ function makeAnonIntro(homeModel: HomeModel) {
 | 
			
		||||
  return [
 | 
			
		||||
    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 : [' Visit our ', helpCenterLink(), ' to learn more.']),
 | 
			
		||||
    cssIntroLine(signUp, ' to save your work. ',
 | 
			
		||||
      (shouldHideUiElement('helpCenter') ? null : translate('VisitHelpCenter', { link: helpCenterLink() })),
 | 
			
		||||
      testId('welcome-text')),
 | 
			
		||||
    makeCreateButtons(homeModel),
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
@ -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:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -12,6 +12,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  "HomeIntro": {
 | 
			
		||||
    "Welcome": "Welcome to Grist!",
 | 
			
		||||
    "SignUp": "Sign up"
 | 
			
		||||
    "SignUp": "Sign up",
 | 
			
		||||
    "VisitHelpCenter": "Visit our {{link}} to learn more."
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										124
									
								
								test/client/lib/localization.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								test/client/lib/localization.ts
									
									
									
									
									
										Normal 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);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user