(core) updates from grist-core

This commit is contained in:
Paul Fitzpatrick 2022-10-24 10:53:18 -04:00
commit 0c82b746d0
12 changed files with 296 additions and 70 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();
@ -15,7 +17,7 @@ export async function setupLocale() {
} }
} }
const ns = getGristConfig().namespaces ?? ['core']; const ns = getGristConfig().namespaces ?? ['client'];
// Initialize localization plugin // Initialize localization plugin
try { try {
// We don't await this promise, as it is resolved synchronously due to initImmediate: false. // We don't await this promise, as it is resolved synchronously due to initImmediate: false.
@ -28,13 +30,13 @@ export async function setupLocale() {
initImmediate: false, initImmediate: false,
// Read language from navigator object. // Read language from navigator object.
lng, lng,
// By default we use core namespace. // By default we use client namespace.
defaultNS: 'core', defaultNS: 'client',
// Read namespaces that are supported by the server. // Read namespaces that are supported by the server.
// TODO: this can be converted to a dynamic list of namespaces, for async components. // TODO: this can be converted to a dynamic list of namespaces, for async components.
// for now just import all what server offers. // for now just import all what server offers.
// We can fallback to core namespace for any addons. // We can fallback to client namespace for any addons.
fallbackNS: 'core', fallbackNS: 'client',
ns, ns,
supportedLngs supportedLngs
}).catch((err: any) => { }).catch((err: any) => {
@ -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)) { 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,14 +1,16 @@
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 = 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(
cssAddNewButton.cls('-open', isOpen), cssAddNewButton.cls('-open', isOpen),
// Setting spacing as flex items allows them to shrink faster when there isn't enough space. // Setting spacing as flex items allows them to shrink faster when there isn't enough space.
cssLeftMargin(), cssLeftMargin(),
cssAddText(t('AddNew')), cssAddText(translate('AddNew')),
dom('div', {style: 'flex: 1 1 16px'}), dom('div', {style: 'flex: 1 1 16px'}),
cssPlusButton(cssPlusIcon('Plus')), cssPlusButton(cssPlusIcon('Plus')),
dom('div', {style: 'flex: 0 1 16px'}), dom('div', {style: 'flex: 0 1 16px'}),

View File

@ -29,11 +29,13 @@ 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 = makeT(`DocMenu`);
const testId = makeTestId('test-dm-'); const testId = makeTestId('test-dm-');
/** /**
@ -105,10 +107,10 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
null : null :
css.docListHeader( css.docListHeader(
( (
page === 'all' ? t('AllDocuments') : page === 'all' ? translate('AllDocuments') :
page === 'templates' ? page === 'templates' ?
dom.domComputed(use => use(home.featuredTemplates).length > 0, (hasFeaturedTemplates) => dom.domComputed(use => use(home.featuredTemplates).length > 0, (hasFeaturedTemplates) =>
hasFeaturedTemplates ? t('MoreExamplesAndTemplates') : t('ExamplesAndTemplates') hasFeaturedTemplates ? translate('MoreExamplesAndTemplates') : translate('ExamplesAndTemplates')
) : ) :
page === 'trash' ? 'Trash' : page === 'trash' ? 'Trash' :
workspace && [css.docHeaderIcon('Folder'), workspaceName(home.app, workspace)] workspace && [css.docHeaderIcon('Folder'), workspaceName(home.app, workspace)]
@ -268,7 +270,7 @@ function buildOtherSites(home: HomeModel) {
return css.otherSitesBlock( return css.otherSitesBlock(
dom.autoDispose(hideOtherSitesObs), dom.autoDispose(hideOtherSitesObs),
css.otherSitesHeader( css.otherSitesHeader(
t('OtherSites'), translate('OtherSites'),
dom.domComputed(hideOtherSitesObs, (collapsed) => dom.domComputed(hideOtherSitesObs, (collapsed) =>
collapsed ? css.otherSitesHeaderIcon('Expand') : css.otherSitesHeaderIcon('Collapse') collapsed ? css.otherSitesHeaderIcon('Expand') : css.otherSitesHeaderIcon('Collapse')
), ),
@ -280,7 +282,7 @@ function buildOtherSites(home: HomeModel) {
const siteName = home.app.currentOrgName; const siteName = home.app.currentOrgName;
return [ return [
dom('div', dom('div',
t('OtherSitesWelcome', { siteName, context: personal ? 'personal' : '' }), translate('OtherSitesWelcome', { siteName, context: personal ? 'personal' : '' }),
testId('other-sites-message') testId('other-sites-message')
), ),
css.otherSitesButtons( css.otherSitesButtons(

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,6 +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 = 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;
@ -102,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),
@ -110,12 +111,12 @@ function makePersonalIntro(homeModel: HomeModel, user: FullUser) {
} }
function makeAnonIntro(homeModel: HomeModel) { function makeAnonIntro(homeModel: HomeModel) {
const signUp = cssLink({href: getLoginOrSignupUrl()}, t('SignUp')); const signUp = cssLink({href: getLoginOrSignupUrl()}, translate('SignUp'));
return [ return [
css.docListHeader(t('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

@ -12,6 +12,8 @@ import jsesc from 'jsesc';
import * as handlebars from 'handlebars'; import * as handlebars from 'handlebars';
import * as path from 'path'; import * as path from 'path';
const translate = (req: express.Request, key: string, args?: any) => req.t(`sendAppPage.${key}`, args);
export interface ISendAppPageOptions { export interface ISendAppPageOptions {
path: string; // Ignored if .content is present (set to "" for clarity). path: string; // Ignored if .content is present (set to "" for clarity).
content?: string; content?: string;
@ -155,7 +157,7 @@ function configuredPageTitleSuffix() {
*/ */
function getPageTitle(req: express.Request, config: GristLoadConfig): string { function getPageTitle(req: express.Request, config: GristLoadConfig): string {
const maybeDoc = getDocFromConfig(config); const maybeDoc = getDocFromConfig(config);
if (!maybeDoc) { return req.t('Loading') + "..."; } if (!maybeDoc) { return translate(req, 'sendAppPage.Loading') + "..."; }
return handlebars.Utils.escapeExpression(maybeDoc.name); return handlebars.Utils.escapeExpression(maybeDoc.name);
} }

View File

@ -26,8 +26,8 @@ export function setupLocale(appRoot: string): i18n {
supportedNamespaces.add(namespace); supportedNamespaces.add(namespace);
return lang; return lang;
}).filter((lang) => lang)); }).filter((lang) => lang));
if (!supportedLngs.has('en') || !supportedNamespaces.has('core')) { if (!supportedLngs.has('en') || !supportedNamespaces.has('server')) {
throw new Error("Missing core English language file"); throw new Error("Missing server English language file");
} }
// Initialize localization filesystem plugin that will read the locale files from the localeDir. // Initialize localization filesystem plugin that will read the locale files from the localeDir.
instance.use(i18fsBackend); instance.use(i18fsBackend);
@ -40,7 +40,7 @@ export function setupLocale(appRoot: string): i18n {
initImmediate: false, initImmediate: false,
preload: [...supportedLngs], preload: [...supportedLngs],
supportedLngs: [...supportedLngs], supportedLngs: [...supportedLngs],
defaultNS: 'core', defaultNS: 'server',
ns: [...supportedNamespaces], ns: [...supportedNamespaces],
fallbackLng: 'en', fallbackLng: 'en',
backend: { backend: {
@ -71,5 +71,5 @@ export function readLoadedNamespaces(instance?: i18n): readonly string[] {
if (Array.isArray(instance?.options.ns)) { if (Array.isArray(instance?.options.ns)) {
return instance.options.ns; return instance.options.ns;
} }
return instance?.options.ns ? [instance.options.ns as string] : ['core']; return instance?.options.ns ? [instance.options.ns as string] : ['server'];
} }

View File

@ -20,37 +20,46 @@ default language _en_ (https://www.i18next.com/principles/translation-resolution
All language variants (e.g., _fr-FR_, _pl-PL_, _en-UK_) are supported if Grist can find a main All language variants (e.g., _fr-FR_, _pl-PL_, _en-UK_) are supported if Grist can find a main
language resource file. For example, to support a _fr-FR_ language code, Grist expects to have at language resource file. For example, to support a _fr-FR_ language code, Grist expects to have at
least _fr.core.json_ file. The main language file will be used as a default fallback for all French least _fr.client.json_ file. The main language file will be used as a default fallback for all French
language codes like _fr-FR_ or _fr-CA_, in case there is no resource file for a specif variant (like language codes like _fr-FR_ or _fr-CA_, in case there is no resource file for a specif variant (like
`fr-CA.core.json`) or some keys are missing from the variant file. `fr-CA.client.json`) or some keys are missing from the variant file.
Here is an example of a language resource file `en.core.json` currently used by Grist: Here is an example of a language resource file `en.client.json` currently used by Grist:
```json ```json
{ {
"Welcome": "Welcome to Grist!", "AddNewButton": {
"Loading": "Loading", "AddNew": "Add New"
"AddNew": "Add New", },
"OtherSites": "Other Sites", "DocMenu": {
"OtherSitesWelcome": "Your are on {{siteName}}. You also have access to the following sites:", "OtherSites": "Other Sites",
"OtherSitesWelcome_personal": "Your are on your personal site. You also have access to the following sites:", "OtherSitesWelcome": "You are on the {{siteName}} site. You also have access to the following sites:",
"AllDocuments": "All Documents", "OtherSitesWelcome_personal": "You are on your personal site. You also have access to the following sites:",
"ExamplesAndTemplates": "Examples and Templates", "AllDocuments": "All Documents",
"MoreExamplesAndTemplates": "More Examples and Templates" "ExamplesAndTemplates": "Examples and Templates",
"MoreExamplesAndTemplates": "More Examples and Templates"
},
"HomeIntro": {
"Welcome": "Welcome to Grist!",
"SignUp": "Sign up"
}
} }
``` ```
It maps a key to a translated message. It also has an example of interpolation and context features It maps a key to a translated message. It also has an example of interpolation and context features
in the `OtherSitesWelcome` resource key. More information about how to use those features can be in the `DocMenu.OtherSitesWelcome` resource key. More information about how to use those features can be
found at https://www.i18next.com/translation-function/interpolation and found at https://www.i18next.com/translation-function/interpolation and
https://www.i18next.com/translation-function/context. https://www.i18next.com/translation-function/context.
Both client and server code (node.js) use the same resource files. A resource file name format Client and server code (node.js) use separate resource files. A resource file name format
follows a pattern: [language code].[product].json (i.e. `pl-Pl.core.json`, `en-US.core.json`, follows a pattern: [language code].[product].json (e.g. `pl-Pl.client.json`, `en-US.client.json`,
`en.core.json`). Grist can be packaged as several different products, and each product can have its `en.client.json`). Grist can be packaged as several different products, and each product can have its
own translation files that are added to the core. Products are supported by leveraging `i18next` own translation files that are added to the core. Products are supported by leveraging `i18next`
feature called `namespaces` https://www.i18next.com/principles/namespaces. feature called `namespaces` https://www.i18next.com/principles/namespaces.
For now we use only two products called `client` and `server`.
Each of them is then organized by filename, in order to avoid conflicts.
## Translation instruction ## Translation instruction
### Client ### Client
@ -58,7 +67,7 @@ feature called `namespaces` https://www.i18next.com/principles/namespaces.
The entry point for all translations is a function exported from 'app/client/lib/localization'. The entry point for all translations is a function exported from 'app/client/lib/localization'.
```ts ```ts
import { t } from 'app/client/lib/localization'; import {t} from 'app/client/lib/localization';
``` ```
It is a wrapper around `i18next` exported method with the same interface It is a wrapper around `i18next` exported method with the same interface
@ -71,7 +80,7 @@ _app/client/ui.DocMenu.ts_
```ts ```ts
css.otherSitesHeader( css.otherSitesHeader(
t('OtherSites'), t('DocMenu.OtherSites'),
..... .....
), ),
dom.maybe((use) => !use(hideOtherSitesObs), () => { dom.maybe((use) => !use(hideOtherSitesObs), () => {
@ -79,7 +88,7 @@ _app/client/ui.DocMenu.ts_
const siteName = home.app.currentOrgName; const siteName = home.app.currentOrgName;
return [ return [
dom('div', dom('div',
t('OtherSitesWelcome', { siteName, context: personal ? 'personal' : '' }), t('DocMenu.OtherSitesWelcome', { siteName, context: personal ? 'personal' : '' }),
testId('other-sites-message') testId('other-sites-message')
``` ```
@ -87,9 +96,20 @@ _app/client/ui/HomeIntro.ts_
```ts ```ts
function makeAnonIntro(homeModel: HomeModel) { function makeAnonIntro(homeModel: HomeModel) {
const signUp = cssLink({href: getLoginOrSignupUrl()}, t('SignUp')); const signUp = cssLink({href: getLoginOrSignupUrl()}, t('HomeIntro.SignUp'));
return [ return [
css.docListHeader(t('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
@ -124,7 +144,7 @@ _app/server/lib/sendAppPage.ts_
function getPageTitle(req: express.Request, config: GristLoadConfig): string { function getPageTitle(req: express.Request, config: GristLoadConfig): string {
const maybeDoc = getDocFromConfig(config); const maybeDoc = getDocFromConfig(config);
if (!maybeDoc) { if (!maybeDoc) {
return req.t('Loading') + '...'; return req.t('sendAppPage.Loading') + '...';
} }
return handlebars.Utils.escapeExpression(maybeDoc.name); return handlebars.Utils.escapeExpression(maybeDoc.name);
@ -133,8 +153,8 @@ function getPageTitle(req: express.Request, config: GristLoadConfig): string {
### Next steps ### Next steps
- Annotate all client code and create all resource files in `en.core.json` file. Almost all static - Annotate all client code and create all resource files in `en.client.json` and `en.server.json` files.
text is ready for translation. Almost all static text is ready for translation.
- Store language settings with the user profile and allow a user to change it on the Account Page. - Store language settings with the user profile and allow a user to change it on the Account Page.
Consider also adding a cookie-based solution that custom widgets can use, or extend the Consider also adding a cookie-based solution that custom widgets can use, or extend the
**WidgetFrame** component so that it can pass current user language to the hosted widget page. **WidgetFrame** component so that it can pass current user language to the hosted widget page.

View File

@ -0,0 +1,18 @@
{
"AddNewButton": {
"AddNew": "Add New"
},
"DocMenu": {
"OtherSites": "Other Sites",
"OtherSitesWelcome": "You are on the {{siteName}} site. You also have access to the following sites:",
"OtherSitesWelcome_personal": "You are on your personal site. You also have access to the following sites:",
"AllDocuments": "All Documents",
"ExamplesAndTemplates": "Examples and Templates",
"MoreExamplesAndTemplates": "More Examples and Templates"
},
"HomeIntro": {
"Welcome": "Welcome to Grist!",
"SignUp": "Sign up",
"VisitHelpCenter": "Visit our {{link}} to learn more."
}
}

View File

@ -1,12 +0,0 @@
{
"Welcome": "Welcome to Grist!",
"SignUp": "Sign up",
"Loading": "Loading",
"AddNew": "Add New",
"OtherSites": "Other Sites",
"OtherSitesWelcome": "You are on the {{siteName}} site. You also have access to the following sites:",
"OtherSitesWelcome_personal": "You are on your personal site. You also have access to the following sites:",
"AllDocuments": "All Documents",
"ExamplesAndTemplates": "Examples and Templates",
"MoreExamplesAndTemplates": "More Examples and Templates"
}

View File

@ -0,0 +1,5 @@
{
"sendAppPage": {
"Loading": "Loading"
}
}

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);
});
});

View File

@ -23,8 +23,8 @@ describe("Localization", function() {
// Grist config should contain the list of supported languages; // Grist config should contain the list of supported languages;
const gristConfig: any = await driver.executeScript("return window.gristConfig"); const gristConfig: any = await driver.executeScript("return window.gristConfig");
// core and en is required. // client and en is required.
assert.isTrue(gristConfig.namespaces.includes("core")); assert.isTrue(gristConfig.namespaces.includes("client"));
assert.isTrue(gristConfig.supportedLngs.includes("en")); assert.isTrue(gristConfig.supportedLngs.includes("en"));
}); });
@ -81,13 +81,13 @@ describe("Localization", function() {
function present(response: string, ...langs: string[]) { function present(response: string, ...langs: string[]) {
for (const lang of langs) { for (const lang of langs) {
assert.include(response, `href="locales/${lang}.core.json"`); assert.include(response, `href="locales/${lang}.client.json"`);
} }
} }
function notPresent(response: string, ...langs: string[]) { function notPresent(response: string, ...langs: string[]) {
for (const lang of langs) { for (const lang of langs) {
assert.notInclude(response, `href="locales/${lang}.core.json"`); assert.notInclude(response, `href="locales/${lang}.client.json"`);
} }
} }
@ -109,7 +109,7 @@ describe("Localization", function() {
}); });
it("loads correct languages from file system", async function() { it("loads correct languages from file system", async function() {
modifyByCode(tempLocale, "en", {Welcome: 'TestMessage'}); modifyByCode(tempLocale, "en", {HomeIntro: {Welcome: 'TestMessage'}});
await driver.navigate().refresh(); await driver.navigate().refresh();
assert.equal(await driver.findWait('.test-welcome-title', 3000).getText(), 'TestMessage'); assert.equal(await driver.findWait('.test-welcome-title', 3000).getText(), 'TestMessage');
const gristConfig: any = await driver.executeScript("return window.gristConfig"); const gristConfig: any = await driver.executeScript("return window.gristConfig");
@ -163,8 +163,8 @@ describe("Localization", function() {
} }
function modifyByCode(localeDir: string, code: string, obj: any) { function modifyByCode(localeDir: string, code: string, obj: any) {
// Read current core localization file. // Read current client localization file.
const filePath = path.join(localeDir, `${code}.core.json`); const filePath = path.join(localeDir, `${code}.client.json`);
const resources = JSON.parse(fs.readFileSync(filePath).toString()); const resources = JSON.parse(fs.readFileSync(filePath).toString());
const newResource = Object.assign(resources, obj); const newResource = Object.assign(resources, obj);
fs.writeFileSync(filePath, JSON.stringify(newResource)); fs.writeFileSync(filePath, JSON.stringify(newResource));