From 90d3ee037aeadef0cfe6756958a1408d142980f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Tue, 24 Jan 2023 14:13:18 +0100 Subject: [PATCH] (core) User language switcher Summary: New language selector on the Account page for logged-in users. New icon for switching language for an anonymous user. For anonymous users, language is stored in a cookie grist_user_locale. Language is stored in user settings for authenticated users and takes precedence over what is stored in the cookie. Test Plan: New tests Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D3766 --- app/client/lib/localization.ts | 41 +++-- app/client/models/UserManagerModel.ts | 1 + app/client/ui/AccountPage.ts | 65 +++++-- app/client/ui/AccountPageCss.ts | 1 + app/client/ui/AccountWidget.ts | 2 +- app/client/ui/LanguageMenu.ts | 107 +++++++++++ app/client/ui/TopBar.ts | 2 + app/client/ui/UserManager.ts | 3 +- app/client/ui2018/IconList.ts | 2 + app/client/ui2018/modals.ts | 6 +- app/common/Locales.ts | 14 ++ app/common/LoginSessionAPI.ts | 1 + app/common/Prefs.ts | 2 + app/common/UserAPI.ts | 10 + app/common/gristUrls.ts | 3 + app/gen-server/ApiServer.ts | 21 ++- app/gen-server/lib/HomeDBManager.ts | 4 +- app/server/lib/Authorizer.ts | 8 + app/server/lib/DocSession.ts | 2 +- app/server/lib/FlexServer.ts | 7 +- app/server/lib/sendAppPage.ts | 10 +- app/server/localization.ts | 37 ++-- package.json | 1 - static/icons/icons.css | 1 + static/icons/locales/BR.svg | 39 ++++ static/icons/locales/DE.svg | 10 + static/icons/locales/ES.svg | 46 +++++ static/icons/locales/FR.svg | 10 + static/icons/locales/LICENSE | 21 +++ static/icons/locales/NO.svg | 14 ++ static/icons/locales/US.svg | 11 ++ static/ui-icons/UI/Flag.svg | 1 + test/nbrowser/LanguageSettings.ts | 256 ++++++++++++++++++++++++++ test/nbrowser/Localization.ts | 2 +- test/nbrowser/gristUtils.ts | 4 +- yarn.lock | 5 - 36 files changed, 696 insertions(+), 74 deletions(-) create mode 100644 app/client/ui/LanguageMenu.ts create mode 100644 static/icons/locales/BR.svg create mode 100644 static/icons/locales/DE.svg create mode 100644 static/icons/locales/ES.svg create mode 100644 static/icons/locales/FR.svg create mode 100644 static/icons/locales/LICENSE create mode 100644 static/icons/locales/NO.svg create mode 100644 static/icons/locales/US.svg create mode 100644 static/ui-icons/UI/Flag.svg create mode 100644 test/nbrowser/LanguageSettings.ts diff --git a/app/client/lib/localization.ts b/app/client/lib/localization.ts index 5bb2b967..0167e190 100644 --- a/app/client/lib/localization.ts +++ b/app/client/lib/localization.ts @@ -6,18 +6,7 @@ import {G} from 'grainjs/dist/cjs/lib/browserGlobals'; export async function setupLocale() { const now = Date.now(); const supportedLngs = getGristConfig().supportedLngs ?? ['en']; - let lng = window.navigator.language || 'en'; - // If user agent language is not in the list of supported languages, use the default one. - lng = lng.replace(/-/g, '_'); - if (!supportedLngs.includes(lng)) { - // Test if server supports general language. - if (lng.includes("_") && supportedLngs.includes(lng.split("_")[0])) { - lng = lng.split("_")[0]!; - } else { - lng = 'en'; - } - } - + const lng = detectCurrentLang(); const ns = getGristConfig().namespaces ?? ['client']; // Initialize localization plugin try { @@ -25,8 +14,6 @@ export async function setupLocale() { i18next.init({ // By default we use english language. fallbackLng: 'en', - // Fallback from en-US, en-GB, etc to en. - nonExplicitSupportedLngs: true, // We will load resources ourselves. initImmediate: false, // Read language from navigator object. @@ -38,8 +25,7 @@ export async function setupLocale() { // for now just import all what server offers. // We can fallback to client namespace for any addons. fallbackNS: 'client', - ns, - supportedLngs + ns }).catch((err: any) => { // This should not happen, the promise should be resolved synchronously, without // any errors reported. @@ -51,14 +37,14 @@ export async function setupLocale() { const loadPath = `${document.baseURI}locales/{{lng}}.{{ns}}.json`; const pathsToLoad: Promise[] = []; async function load(lang: string, n: string) { - const resourceUrl = loadPath.replace('{{lng}}', lang).replace('{{ns}}', n); + const resourceUrl = loadPath.replace('{{lng}}', lang.replace("-", "_")).replace('{{ns}}', n); const response = await fetch(resourceUrl); if (!response.ok) { throw new Error(`Failed to load ${resourceUrl}`); } i18next.addResourceBundle(lang, n, await response.json()); } - for (const lang of languages) { + for (const lang of languages.filter((l) => supportedLngs.includes(l))) { for (const n of ns) { pathsToLoad.push(load(lang, n)); } @@ -70,6 +56,25 @@ export async function setupLocale() { } } +export function detectCurrentLang() { + const { userLocale, supportedLngs } = getGristConfig(); + const detected = userLocale + || document.cookie.match(/grist_user_locale=([^;]+)/)?.[1] + || window.navigator.language + || 'en'; + const supportedList = supportedLngs ?? ['en']; + // If we have this language in the list (or more general version) mark it as selected. + // Compare languages in lower case, as navigator.language can return en-US, en-us (for older Safari). + const selected = supportedList.find(supported => supported.toLowerCase() === detected.toLowerCase()) ?? + supportedList.find(supported => supported === detected.split(/[-_]/)[0]) ?? 'en'; + return selected; +} + +export function setAnonymousLocale(lng: string) { + document.cookie = lng ? `grist_user_locale=${lng}; path=/; max-age=31536000` + : 'grist_user_locale=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC'; +} + /** * Resolves the translation of the given key using the given options. */ diff --git a/app/client/models/UserManagerModel.ts b/app/client/models/UserManagerModel.ts index 2e6d4096..e9de74d2 100644 --- a/app/client/models/UserManagerModel.ts +++ b/app/client/models/UserManagerModel.ts @@ -63,6 +63,7 @@ export interface IEditableMember { name: string; email: string; picture?: string|null; + locale?: string|null; access: Observable; parentAccess: roles.BasicRole|null; inheritedAccess: Computed; diff --git a/app/client/ui/AccountPage.ts b/app/client/ui/AccountPage.ts index b2fa4fb2..81a46746 100644 --- a/app/client/ui/AccountPage.ts +++ b/app/client/ui/AccountPage.ts @@ -13,10 +13,12 @@ import {transientInput} from 'app/client/ui/transientInput'; import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs'; import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox'; import {cssLink} from 'app/client/ui2018/links'; +import {select} from 'app/client/ui2018/menus'; import {getGristConfig} from 'app/common/urlUtils'; import {FullUser} from 'app/common/UserAPI'; +import {detectCurrentLang, makeT} from 'app/client/lib/localization'; +import {translateLocale} from 'app/client/ui/LanguageMenu'; import {Computed, Disposable, dom, domComputed, makeTestId, Observable, styled} from 'grainjs'; -import {makeT} from 'app/client/lib/localization'; const testId = makeTestId('test-account-page-'); const t = makeT('AccountPage'); @@ -56,6 +58,22 @@ export class AccountPage extends Disposable { private _buildContentMain() { const {enableCustomCss} = getGristConfig(); + const supportedLngs = getGristConfig().supportedLngs ?? ['en']; + const languageOptions = supportedLngs + .map((lng) => ({value: lng, label: translateLocale(lng)!})) + .sort((a, b) => a.value.localeCompare(b.value)); + + const userLocale = Computed.create(this, use => { + const selected = detectCurrentLang(); + if (!supportedLngs.includes(selected)) { return 'en'; } + return selected; + }); + userLocale.onWrite(async value => { + await this._appModel.api.updateUserLocale(value || null); + // Reload the page to apply the new locale. + window.location.reload(); + }); + return domComputed(this._userObs, (user) => user && ( css.container(css.accountPage( css.header(t("Account settings")), @@ -73,7 +91,7 @@ export class AccountPage extends Disposable { save: (val) => this._isNameValid.get() && this._updateUserName(val), close: () => { this._isEditingName.set(false); this._nameEdit.set(''); }, }, - { size: '5' }, // Lower size so that input can shrink below ~152px. + {size: '5'}, // Lower size so that input can shrink below ~152px. dom.on('input', (_ev, el) => this._nameEdit.set(el.value)), css.flexGrow.cls(''), ), @@ -92,7 +110,7 @@ export class AccountPage extends Disposable { testId('username'), ), // show warning for invalid name but not for the empty string - dom.maybe(use => use(this._nameEdit) && !use(this._isNameValid), cssWarnings), + dom.maybe(use => use(this._nameEdit) && !use(this._isNameValid), this._buildNameWarningsDom.bind(this)), css.header(t("Password & Security")), css.dataRow( css.inlineSubHeader(t("Login Method")), @@ -123,6 +141,15 @@ export class AccountPage extends Disposable { enableCustomCss ? null : [ css.header(t("Theme")), dom.create(ThemeConfig, this._appModel), + css.subHeader(t("Language ")), + css.dataRow({ style: 'width: 300px'}, + select(userLocale, languageOptions, { + renderOptionArgs: () => { + return dom.cls(cssFirstUpper.className); + } + }), + testId('language'), + ) ], css.header(t("API")), css.dataRow(css.inlineSubHeader(t("API Key")), css.content( @@ -131,7 +158,7 @@ export class AccountPage extends Disposable { onCreate: () => this._createApiKey(), onDelete: () => this._deleteApiKey(), anonymous: false, - inputArgs: [{ size: '5' }], // Lower size so that input can shrink below ~152px. + inputArgs: [{size: '5'}], // Lower size so that input can shrink below ~152px. }) )), ), @@ -141,7 +168,7 @@ export class AccountPage extends Disposable { private _buildHeaderMain() { return dom.frag( - cssBreadcrumbs({ style: 'margin-left: 16px;' }, + cssBreadcrumbs({style: 'margin-left: 16px;'}, cssLink( urlState().setLinkUrl({}), 'Home', @@ -194,6 +221,16 @@ export class AccountPage extends Disposable { private _showChangePasswordDialog() { return buildChangePasswordDialog(); } + + /** + * Builds dom to show marning messages to the user. + */ + private _buildNameWarningsDom() { + return cssWarnings( + t("Names only allow letters, numbers and certain special characters"), + testId('username-warning'), + ); + } } /** @@ -211,16 +248,14 @@ export function checkName(name: string): boolean { return VALID_NAME_REGEXP.test(name); } -/** - * Builds dom to show marning messages to the user. - */ -function buildNameWarningsDom() { - return css.warning( - t("Names only allow letters, numbers and certain special characters"), - testId('username-warning'), - ); -} -const cssWarnings = styled(buildNameWarningsDom, ` + +const cssWarnings = styled(css.warning, ` margin: -8px 0 0 110px; `); + +const cssFirstUpper = styled('div', ` + & > div::first-letter { + text-transform: capitalize; + } +`); diff --git a/app/client/ui/AccountPageCss.ts b/app/client/ui/AccountPageCss.ts index 034b62a4..f5147d27 100644 --- a/app/client/ui/AccountPageCss.ts +++ b/app/client/ui/AccountPageCss.ts @@ -98,6 +98,7 @@ export const dataRow = styled('div', ` margin: 8px 0px; display: flex; align-items: baseline; + gap: 2px; `); export const betaTag = styled('span', ` diff --git a/app/client/ui/AccountWidget.ts b/app/client/ui/AccountWidget.ts index 5769c2b4..869cd26c 100644 --- a/app/client/ui/AccountWidget.ts +++ b/app/client/ui/AccountWidget.ts @@ -86,7 +86,7 @@ export class AccountWidget extends Disposable { cssEmail(user.email, testId('usermenu-email')) ) ), - menuItemLink(urlState().setLinkUrl({account: 'account'}), t("Profile Settings")), + menuItemLink(urlState().setLinkUrl({account: 'account'}), t("Profile Settings"), testId('dm-account-settings')), documentSettingsItem, diff --git a/app/client/ui/LanguageMenu.ts b/app/client/ui/LanguageMenu.ts new file mode 100644 index 00000000..a25c1371 --- /dev/null +++ b/app/client/ui/LanguageMenu.ts @@ -0,0 +1,107 @@ +import {detectCurrentLang, makeT, setAnonymousLocale} from 'app/client/lib/localization'; +import {AppModel} from 'app/client/models/AppModel'; +import {hoverTooltip} from 'app/client/ui/tooltips'; +import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss'; +import {icon} from 'app/client/ui2018/icons'; +import {menu, menuItem} from 'app/client/ui2018/menus'; +import {getCountryCode} from 'app/common/Locales'; +import {getGristConfig} from 'app/common/urlUtils'; +import {dom, makeTestId, styled} from 'grainjs'; + +const testId = makeTestId('test-language-'); +const t = makeT('LanguageMenu'); + +export function buildLanguageMenu(appModel: AppModel) { + // Get the list of languages from the config, or default to English. + const languages = getGristConfig().supportedLngs ?? ['en']; + // Get the current language (from user's preference, cookie or browser) + const userLanguage = detectCurrentLang(); + + if (appModel.currentValidUser) { + // For logged in users, we don't need to show the menu (they have a preference in their profile). + // But for tests we will show a hidden indicator. + return dom('input', {type: 'hidden'}, (testId(`current-` + userLanguage))); + } + + // When we switch language, we need to reload the page to get the new translations. + // This button is only for anonymous users, so we don't need to save the preference or wait for anything. + const changeLanguage = (lng: string) => { + setAnonymousLocale(lng); + window.location.reload(); + }; + // Try to convert locale setting to the emoji flag, fallback to plain flag icon. + const emojiFlag = buildEmoji(userLanguage); + return cssHoverCircle( + // Margin is common for all hover buttons on TopBar. + {style: `margin: 5px;`}, + // Flag or emoji flag if we have it. + emojiFlag ?? cssTopBarBtn('Flag'), + // Expose for test the current language use. + testId(`current-` + userLanguage), + menu( + // Convert the list of languages we support to menu items. + () => languages.map((lng) => menuItem(() => changeLanguage(lng), [ + // Try to convert the locale to nice name, fallback to locale itself. + cssFirstUpper(translateLocale(lng) ?? lng), + // If this is current language, mark it with a tick (by default we mark en). + userLanguage === lng ? cssWrapper(icon('Tick'), testId('selected')) : null, + testId(`lang-` + lng), + ])), + { + placement: 'bottom-end', + } + ), + hoverTooltip(t('Language'), {key: 'topBarBtnTooltip'}), + testId('button'), + ); +} + +// Unfortunately, Windows doesn't support emoji flags, so we need to use SVG icons. +function buildEmoji(locale: string) { + const countryCode = getCountryCode(locale); + if (!countryCode) { return null; } + return [ + cssSvgIcon({ + style: `background-image: url("icons/locales/${countryCode}.svg")` + }), + dom.cls(cssSvgIconWrapper.className) + ]; +} + +export function translateLocale(locale: string) { + try { + locale = locale.replace("_", "-"); + // This API might not be available in all browsers. + const languageNames = new Intl.DisplayNames([locale], {type: 'language'}); + return languageNames.of(locale) || null; + } catch (err) { + return null; + } +} + +const cssWrapper = styled('div', ` + margin-left: auto; + display: inline-block; +`); + +const cssSvgIconWrapper = styled('div', ` + display: grid; + place-content: center; + cursor: pointer; + user-select: none; +`); + +const cssSvgIcon = styled('div', ` + width: 16px; + height: 16px; + background-repeat: no-repeat; + background-position: center; + background-color: transparent; + background-size: contain; +`); + +const cssFirstUpper = styled('span', ` + &::first-letter { + text-transform: capitalize; + } +`); diff --git a/app/client/ui/TopBar.ts b/app/client/ui/TopBar.ts index ddc63193..f3019714 100644 --- a/app/client/ui/TopBar.ts +++ b/app/client/ui/TopBar.ts @@ -11,6 +11,7 @@ import {manageTeamUsersApp} from 'app/client/ui/OpenUserManager'; import {buildShareMenuButton} from 'app/client/ui/ShareMenu'; import {hoverTooltip} from 'app/client/ui/tooltips'; import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss'; +import {buildLanguageMenu} from 'app/client/ui/LanguageMenu'; import {docBreadcrumbs} from 'app/client/ui2018/breadcrumbs'; import {basicButton} from 'app/client/ui2018/buttons'; import {cssHideForNarrowScreen, testId, theme} from 'app/client/ui2018/cssVars'; @@ -38,6 +39,7 @@ export function createTopBarHome(appModel: AppModel) { null ), + buildLanguageMenu(appModel), buildNotifyMenuButton(appModel.notifier, appModel), dom('div', dom.create(AccountWidget, appModel)), ]; diff --git a/app/client/ui/UserManager.ts b/app/client/ui/UserManager.ts index 82c75ca2..5b12fb1f 100644 --- a/app/client/ui/UserManager.ts +++ b/app/client/ui/UserManager.ts @@ -622,7 +622,8 @@ function getFullUser(member: IEditableMember): FullUser { id: member.id, name: member.name, email: member.email, - picture: member.picture + picture: member.picture, + locale: member.locale }; } diff --git a/app/client/ui2018/IconList.ts b/app/client/ui2018/IconList.ts index 5b218dab..626742ce 100644 --- a/app/client/ui2018/IconList.ts +++ b/app/client/ui2018/IconList.ts @@ -61,6 +61,7 @@ export type IconName = "ChartArea" | "Filter" | "FilterSimple" | "Fireworks" | + "Flag" | "Folder" | "FontBold" | "FontItalic" | @@ -195,6 +196,7 @@ export const IconList: IconName[] = ["ChartArea", "Filter", "FilterSimple", "Fireworks", + "Flag", "Folder", "FontBold", "FontItalic", diff --git a/app/client/ui2018/modals.ts b/app/client/ui2018/modals.ts index a6b365d1..69564815 100644 --- a/app/client/ui2018/modals.ts +++ b/app/client/ui2018/modals.ts @@ -330,10 +330,10 @@ export function saveModal(createFunc: (ctl: IModalControl, owner: MultiHolder) = * See saveModal() for error handling notes that here apply to the onConfirm callback. */ export function confirmModal( - title: string, - btnText: string, + title: DomElementArg, + btnText: DomElementArg, onConfirm: () => Promise, - explanation?: Element|string, + explanation?: DomElementArg, {hideCancel, extraButtons}: {hideCancel?: boolean, extraButtons?: DomContents} = {}, ): void { return saveModal((ctl, owner): ISaveModalOptions => ({ diff --git a/app/common/Locales.ts b/app/common/Locales.ts index 162b0f9e..b9a5d52a 100644 --- a/app/common/Locales.ts +++ b/app/common/Locales.ts @@ -68,3 +68,17 @@ try { } currencies = [...currencies].sort((a, b) => nativeCompare(a.code, b.code)); + + +export function getCountryCode(locale: string) { + // We have some defaults defined. + if (locale === 'en') { return 'US'; } + let countryCode = locale.split(/[-_]/)[1]; + if (countryCode) { return countryCode.toUpperCase(); } + countryCode = locale.toUpperCase(); + // Test if we can use language as a country code. + if (localeCodes.map(code => code.split(/[-_]/)[1]).includes(countryCode)) { + return countryCode; + } + return null; +} diff --git a/app/common/LoginSessionAPI.ts b/app/common/LoginSessionAPI.ts index d4d6a78a..23fb50b4 100644 --- a/app/common/LoginSessionAPI.ts +++ b/app/common/LoginSessionAPI.ts @@ -6,6 +6,7 @@ export interface UserProfile { anonymous?: boolean; // when present, asserts whether user is anonymous (not authorized). connectId?: string|null, // used by GristConnect to identify user in external provider. loginMethod?: 'Google'|'Email + Password'|'External'; + locale?: string|null; } // User profile including user id and user ref. All information in it should diff --git a/app/common/Prefs.ts b/app/common/Prefs.ts index ec091771..882d741b 100644 --- a/app/common/Prefs.ts +++ b/app/common/Prefs.ts @@ -31,6 +31,8 @@ export interface UserPrefs extends Prefs { behavioralPrompts?: BehavioralPromptPrefs; // Welcome popups a user has dismissed. dismissedWelcomePopups?: DismissedReminder[]; + // Localization support. + locale?: string; } // A collection of preferences related to a combination of user and org. diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index 85de14a0..ad9f9a79 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -141,6 +141,8 @@ export interface UserOptions { // Whether user is a consultant. Consultant users can be added to sites // without being counted for billing. Defaults to false if unset. isConsultant?: boolean; + // Locale selected by the user. Defaults to 'en' if unset. + locale?: string; } export interface PermissionDelta { @@ -331,6 +333,7 @@ export interface UserAPI { moveDoc(docId: string, workspaceId: number): Promise; getUserProfile(): Promise; updateUserName(name: string): Promise; + updateUserLocale(locale: string|null): Promise; updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise; updateIsConsultant(userId: number, isConsultant: boolean): Promise; getWorker(key: string): Promise; @@ -632,6 +635,13 @@ export class UserAPIImpl extends BaseAPI implements UserAPI { }); } + public async updateUserLocale(locale: string|null): Promise { + await this.request(`${this._url}/api/profile/user/locale`, { + method: 'POST', + body: JSON.stringify({locale}) + }); + } + public async updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise { await this.request(`${this._url}/api/profile/allowGoogleLogin`, { method: 'POST', diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 7a0c0f5a..c5585a01 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -585,6 +585,9 @@ export interface GristLoadConfig { // Email address of the support user. supportEmail?: string; + + // Current user locale, read from the user options; + userLocale?: string; } export const HideableUiElements = StringUnion("helpCenter", "billing", "templates", "multiSite", "multiAccounts"); diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index 715bcbd0..2aeaf2e2 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -1,6 +1,8 @@ import * as crypto from 'crypto'; import * as express from 'express'; import {EntityManager} from 'typeorm'; +import * as cookie from 'cookie'; +import {Request} from 'express'; import {ApiError} from 'app/common/ApiError'; import {FullUser} from 'app/common/LoginSessionAPI'; @@ -13,10 +15,10 @@ import log from 'app/server/lib/log'; import {addPermit, clearSessionCacheIfNeeded, getDocScope, getScope, integerParam, isParameterOn, sendOkReply, sendReply, stringParam} from 'app/server/lib/requestUtils'; import {IWidgetRepository} from 'app/server/lib/WidgetRepository'; -import {Request} from 'express'; import {User} from './entity/User'; import {HomeDBManager, QueryResult, Scope} from './lib/HomeDBManager'; +import {getCookieDomain} from 'app/server/lib/gristSessions'; // Special public organization that contains examples and templates. export const TEMPLATES_ORG_DOMAIN = process.env.GRIST_ID_PREFIX ? @@ -371,6 +373,23 @@ export class ApiServer { res.sendStatus(200); })); + // POST /api/profile/user/locale + // Body params: string + // Update users profile. + this._app.post('/api/profile/user/locale', expressWrap(async (req, res) => { + const userId = getAuthorizedUserId(req); + await this._dbManager.updateUserOptions(userId, {locale: req.body.locale || null}); + res.append('Set-Cookie', cookie.serialize('grist_user_locale', req.body.locale || '', { + httpOnly: false, // make available to client-side scripts + domain: getCookieDomain(req), + path: '/', + secure: true, + maxAge: req.body.locale ? 31536000 : 0, + sameSite: 'None', // there is no security concern to expose this information. + })); + res.sendStatus(200); + })); + // POST /api/profile/allowGoogleLogin // Update user's preference for allowing Google login. this._app.post('/api/profile/allowGoogleLogin', expressWrap(async (req, res) => { diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index 2ea076ea..164096df 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -490,6 +490,7 @@ export class HomeDBManager extends EventEmitter { name: user.name, picture: user.picture, ref: user.ref, + locale: user.options?.locale }; if (this.getAnonymousUserId() === user.id) { result.anonymous = true; @@ -2663,7 +2664,8 @@ export class HomeDBManager extends EventEmitter { email: login.displayEmail, name: login.user.name, picture: login.user.picture, - anonymous: login.user.id === this.getAnonymousUserId() + anonymous: login.user.id === this.getAnonymousUserId(), + locale: login.user.options?.locale }; } return profiles.map(profile => completedProfiles[normalizeEmail(profile.email)]) diff --git a/app/server/lib/Authorizer.ts b/app/server/lib/Authorizer.ts index cd3157c0..dde7159f 100644 --- a/app/server/lib/Authorizer.ts +++ b/app/server/lib/Authorizer.ts @@ -373,6 +373,14 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer mreq.users = [dbManager.makeFullUser(anon)]; } + if (mreq.userId) { + if (mreq.user?.options?.locale) { + mreq.language = mreq.user.options.locale; + // This is a synchronous call (as it was configured with initImmediate: false). + mreq.i18n.changeLanguage(mreq.language).catch(() => {}); + } + } + const meta = { customHostSession, method: mreq.method, diff --git a/app/server/lib/DocSession.ts b/app/server/lib/DocSession.ts index b9961ecb..3563f0cf 100644 --- a/app/server/lib/DocSession.ts +++ b/app/server/lib/DocSession.ts @@ -122,7 +122,7 @@ export function getDocSessionUser(docSession: OptDocSession): FullUser|null { const user = getUser(docSession.req); const email = user.loginEmail; if (email) { - return {id: user.id, name: user.name, email, ref: user.ref}; + return {id: user.id, name: user.name, email, ref: user.ref, locale: user.options?.locale}; } } if (docSession.client) { diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 34a08bee..111d3132 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -59,7 +59,7 @@ import {startTestingHooks} from 'app/server/lib/TestingHooks'; import {getTestLoginSystem} from 'app/server/lib/TestLogin'; import {addUploadRoute} from 'app/server/lib/uploads'; import {buildWidgetRepository, IWidgetRepository} from 'app/server/lib/WidgetRepository'; -import {readLoadedLngs, readLoadedNamespaces, setupLocale} from 'app/server/localization'; +import {setupLocale} from 'app/server/localization'; import axios from 'axios'; import * as bodyParser from 'body-parser'; import express from 'express'; @@ -1278,10 +1278,7 @@ export class FlexServer implements GristServer { } public getGristConfig(): GristLoadConfig { - return makeGristConfig(this.getDefaultHomeUrl(), { - supportedLngs: readLoadedLngs(this.i18Instance), - namespaces: readLoadedNamespaces(this.i18Instance), - }, this._defaultBaseDomain); + return makeGristConfig(this.getDefaultHomeUrl(), {}, this._defaultBaseDomain); } /** diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts index 28a300b2..accdae3f 100644 --- a/app/server/lib/sendAppPage.ts +++ b/app/server/lib/sendAppPage.ts @@ -65,6 +65,7 @@ export function makeGristConfig(homeUrl: string|null, extra: PartialAuthentication is not enforced" : ""; // Preload all languages that will be used or are requested by client. - const preloads = req.languages.map((lng) => - readLoadedNamespaces(req.i18n).map((ns) => - `` + const preloads = req.languages + .filter(lng => (readLoadedLngs(req.i18n)).includes(lng)) + .map(lng => lng.replace('-', '_')) + .map((lng) => + readLoadedNamespaces(req.i18n).map((ns) => + `` ).join("\n") ).join('\n'); const content = fileContent diff --git a/app/server/localization.ts b/app/server/localization.ts index ba5b1b2a..c97252ab 100644 --- a/app/server/localization.ts +++ b/app/server/localization.ts @@ -1,6 +1,5 @@ -import {lstatSync, readdirSync} from 'fs'; +import {lstatSync, readdirSync, readFileSync} from 'fs'; import {createInstance, i18n} from 'i18next'; -import i18fsBackend from 'i18next-fs-backend'; import {LanguageDetector} from 'i18next-http-middleware'; import path from 'path'; @@ -10,42 +9,41 @@ export function setupLocale(appRoot: string): i18n { // By default locales are located in the appRoot folder, unless the environment variable // GRIST_LOCALES_DIR is set. const localeDir = process.env.GRIST_LOCALES_DIR || path.join(appRoot, 'static', 'locales'); + const preload: [string, string, string][] = []; const supportedNamespaces: Set = new Set(); - const supportedLngs: Set = new Set(readdirSync(localeDir).map((fileName) => { + const supportedLngs: Set = new Set(); + + for(const fileName of readdirSync(localeDir)) { const fullPath = path.join(localeDir, fileName); const isDirectory = lstatSync(fullPath).isDirectory(); if (isDirectory) { - return ""; + continue; } const baseName = path.basename(fileName, '.json'); - const lang = baseName.split('.')[0]; + const lang = baseName.split('.')[0]?.replace(/_/g, '-'); const namespace = baseName.split('.')[1]; if (!lang || !namespace) { throw new Error("Unrecognized resource file " + fileName); } supportedNamespaces.add(namespace); - return lang; - }).filter((lang) => lang)); + preload.push([lang, namespace, fullPath]); + supportedLngs.add(lang); + } + if (!supportedLngs.has('en') || !supportedNamespaces.has('server')) { throw new Error("Missing server English language file"); } - // Initialize localization filesystem plugin that will read the locale files from the localeDir. - instance.use(i18fsBackend); // Initialize localization language detector plugin that will read the language from the request. instance.use(LanguageDetector); let errorDuringLoad: Error | undefined; instance.init({ - // Load all files synchronously. - initImmediate: false, - preload: [...supportedLngs], - supportedLngs: [...supportedLngs], defaultNS: 'server', ns: [...supportedNamespaces], fallbackLng: 'en', - backend: { - loadPath: `${localeDir}/{{lng}}.{{ns}}.json` - }, + detection: { + lookupCookie: 'grist_user_locale' + } }, (err: any) => { if (err) { errorDuringLoad = err; @@ -56,14 +54,19 @@ export function setupLocale(appRoot: string): i18n { console.error("i18next failed unexpectedly", err); }); if (errorDuringLoad) { + console.error('i18next failed to load', errorDuringLoad); throw errorDuringLoad; } + // Load all files synchronously. + for(const [lng, ns, fullPath] of preload) { + instance.addResourceBundle(lng, ns, JSON.parse(readFileSync(fullPath, 'utf8'))); + } return instance; } export function readLoadedLngs(instance?: i18n): readonly string[] { if (!instance) { return []; } - return instance?.options.preload || ['en']; + return Object.keys(instance?.services.resourceStore.data); } export function readLoadedNamespaces(instance?: i18n): readonly string[] { diff --git a/package.json b/package.json index f94a79a2..c88fefbf 100644 --- a/package.json +++ b/package.json @@ -144,7 +144,6 @@ "https-proxy-agent": "5.0.1", "i18n-iso-countries": "6.1.0", "i18next": "21.9.1", - "i18next-fs-backend": "1.1.5", "i18next-http-middleware": "3.2.1", "image-size": "0.6.3", "jquery": "3.5.0", diff --git a/static/icons/icons.css b/static/icons/icons.css index 3fa8561b..9abc7963 100644 --- a/static/icons/icons.css +++ b/static/icons/icons.css @@ -62,6 +62,7 @@ --icon-Filter: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNSw0IEwxLjUsNCBDMS4yMjQsNCAxLDMuNzc2IDEsMy41IEMxLDMuMjI0IDEuMjI0LDMgMS41LDMgTDIuNSwzIEMyLjc3NiwzIDMsMy4yMjQgMywzLjUgQzMsMy43NzYgMi43NzYsNCAyLjUsNCBaIE00LjUsNyBMMS41LDcgQzEuMjI0LDcgMSw2Ljc3NiAxLDYuNSBDMSw2LjIyNCAxLjIyNCw2IDEuNSw2IEw0LjUsNiBDNC43NzYsNiA1LDYuMjI0IDUsNi41IEM1LDYuNzc2IDQuNzc2LDcgNC41LDcgWiBNNi41LDEwIEwxLjUsMTAgQzEuMjI0LDEwIDEsOS43NzYgMSw5LjUgQzEsOS4yMjQgMS4yMjQsOSAxLjUsOSBMNi41LDkgQzYuNzc2LDkgNyw5LjIyNCA3LDkuNSBDNyw5Ljc3NiA2Ljc3NiwxMCA2LjUsMTAgWiBNOC41LDEzIEwxLjUsMTMgQzEuMjI0LDEzIDEsMTIuNzc2IDEsMTIuNSBDMSwxMi4yMjQgMS4yMjQsMTIgMS41LDEyIEw4LjUsMTIgQzguNzc2LDEyIDksMTIuMjI0IDksMTIuNSBDOSwxMi43NzYgOC43NzYsMTMgOC41LDEzIFogTTE0Ljk3Mjg4OCwyLjI4NDkwODg1IEMxNS4wMjY1MzgyLDIuNDU3MzYxNzggMTQuOTk2ODg2NCwyLjY1NTk2NzI1IDE0Ljg5Nzg5MzIsMi43ODc4MDY4MyBMMTEuNjgxMzA2OCw3LjA3NjY2OTY1IEMxMS42MTE3MzQ3LDcuMTY5NTg4MDkgMTEuNTM4MDA5LDcuNDA2ODA3MDIgMTEuNTM4MDA5LDcuNTM4MTg1MDggTDExLjUzODAwOSwxMy41Mzc4ODU2IEMxMS41MzgwMDksMTMuNzI0NjQ1NSAxMS40NTM3ODQsMTMuODkyOTQ0OCAxMS4zMjQzMzE0LDEzLjk2NDE3MiBDMTEuMjgxNTI2NiwxMy45ODgwMTcgMTEuMjM2NTI5NywxMy45OTk0MDEgMTEuMTkxOTk0MywxMy45OTk0MDEgQzExLjEwMTg4NTEsMTMuOTk5NDAxIDExLjAxMzI3NTgsMTMuOTUyNDgwMyAxMC45NDcxNjUsMTMuODY0MTc3IEw5LjU2MjY0NDg2LDEyLjAxODExNTMgQzkuNDk3Njg3NzksMTEuOTMxNjU4MSA5LjQ2MTIyODc2LDExLjgxNDI3OTMgOS40NjEyMjg3NiwxMS42OTE4MjM5IEw5LjQ2MTIyODc2LDcuNTM4MTg1MDggQzkuNDYxMjI4NzYsNy40MDY4MDcwMiA5LjM4NzYxODQ0LDcuMTY5NTg4MDkgOS4zMTc5MzA5Miw3LjA3Njk3NzMzIEw2LjEwMTM0NDUyLDIuNzg3OTYwNjcgQzYuMDAyMzUxMzMsMi42NTU5NjcyNSA1Ljk3MjgxNDksMi40NTc1MTU2MiA2LjAyNjM0OTY4LDIuMjg0OTA4ODUgQzYuMDc5ODg0NDUsMi4xMTIzMDIwOSA2LjIwNjEwNjU0LDIgNi4zNDYwNTg0NSwyIEwxNC42NTMxNzkzLDIgQzE0Ljc5MzI0NjYsMiAxNC45MTk0Njg2LDIuMTEyMzAyMDkgMTQuOTcyODg4LDIuMjg0OTA4ODUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); --icon-FilterSimple: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTkuOTUwODMzMzMsMC4yMiBDOS44NzgyODY2NywwLjA4NDQ5NzcxMDggOS43MzcwMzQsLTYuMTY5Njc5NTFlLTA1IDkuNTgzMzMzMzMsLTQuNjI1OTMyOGUtMTcgTDAuNDE2NjY2NjY3LC00LjYyNTkzMjhlLTE3IEMwLjI2MzEwMzQ1NCw3LjkzMjczNDM1ZS0wNSAwLjEyMjAzMTQsMC4wODQ2MTg0ODc1IDAuMDQ5NTQ5NDAxMiwwLjIxOTk5OTUyOSBDLTAuMDIyOTMyNTk3OCwwLjM1NTM4MDU3MSAtMC4wMTUwNzQwNTE3LDAuNTE5NjU2MDYyIDAuMDcsMC42NDc1IEwzLjMzMzMzMzMzLDUuNTQyNSBMMy4zMzMzMzMzMyw5LjU4MzMzMzMzIEMzLjMzMzMzMzMzLDkuODEzNDUyIDMuNTE5ODgxMzYsMTAgMy43NSwxMCBMNi4yNSwxMCBDNi40ODAxMTg2NCwxMCA2LjY2NjY2NjY3LDkuODEzNDUyIDYuNjY2NjY2NjcsOS41ODMzMzMzMyBMNi42NjY2NjY2Nyw1LjU0MjUgTDkuOTMsMC42NDc1IEMxMC4wMTUxOTU2LDAuNTE5NzI3OTkxIDEwLjAyMzIwMTMsMC4zNTU0NTA1NDcgOS45NTA4MzMzMywwLjIyIFoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDMgMykiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-Fireworks: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTguNjY2NDcgMTUuMzMzM0g3LjMzMzE0QzcuMzMzMTQgOS44NDc5NyA0LjQ0NzggOC42MzA2MyA0LjQxODQ3IDguNjE5M0wzLjc5OTggOC4zNzEzIDQuMjk1MTQgNy4xMzMzIDQuOTE0NDcgNy4zODA2M0M1LjAyNTE0IDcuNDI1OTcgNi45NTE4IDguMjM1MyA3Ljk5OTggMTEuMTE0IDkuMDQ3OCA4LjIzNTk3IDEwLjk3NDUgNy40MjY2MyAxMS4wODUxIDcuMzgwNjNMMTEuNzA0NSA3LjEzMzMgMTIuMTk5OCA4LjM3MTMgMTEuNTg0NSA4LjYxNzNDMTEuNDM5OCA4LjY3OTk3IDguNjY2NDcgOS45NDE5NyA4LjY2NjQ3IDE1LjMzMzN6TTEwLjQ1MzEgMS43OTA1NEw4Ljc1NzcyIDEuNTQ0NTQgNy45OTk3Mi4wMDg1NDQ5MiA3LjI0MTcyIDEuNTQ0NTQgNS41NDYzOSAxLjc5MDU0IDYuNzczMDUgMi45ODY1NCA2LjQ4MzcyIDQuNjc1MjEgNy45OTk3MiAzLjg3Nzg4IDkuNTE1NzIgNC42NzUyMSA5LjIyNjM5IDIuOTg2NTQgMTAuNDUzMSAxLjc5MDU0ek01LjI5OTE3IDEyLjM1OEwzLjY5ODUgMTIuMTI1MyAyLjk4MzE3IDEwLjY3NTMgMi4yNjcxNyAxMi4xMjUzLjY2NjUwNCAxMi4zNTggMS44MjQ1IDEzLjQ4NjYgMS41NTExNyAxNS4wODA2IDIuOTgzMTcgMTQuMzI4NiA0LjQxNDUgMTUuMDgwNiA0LjE0MTE3IDEzLjQ4NjYgNS4yOTkxNyAxMi4zNTh6TTE1LjMzMzMgMTIuMzU4TDEzLjczMjcgMTIuMTI1MyAxMy4wMTY3IDEwLjY3NTMgMTIuMzAxMyAxMi4xMjUzIDEwLjcwMDcgMTIuMzU4IDExLjg1ODcgMTMuNDg2NiAxMS41ODUzIDE1LjA4MDYgMTMuMDE2NyAxNC4zMjg2IDE0LjQ0ODcgMTUuMDgwNiAxNC4xNzUzIDEzLjQ4NjYgMTUuMzMzMyAxMi4zNTh6IiBmaWxsPSIjZmZmIi8+PHBhdGggZD0iTTguNjY2MzQgNy41MzMxM1Y1LjM0MThINy4zMzMwMVY3LjUyMDQ2QzcuNTc2MjEgNy43NjgxMSA3LjgwMjUgOC4wMzE4MSA4LjAxMDM0IDguMzA5OCA4LjIxMjIgOC4wMzcxNyA4LjQzMTMyIDcuNzc3NzUgOC42NjYzNCA3LjUzMzEzek0xLjY2NjUgNy4zMzMyNUMyLjIxODc5IDcuMzMzMjUgMi42NjY1IDYuODg1NTQgMi42NjY1IDYuMzMzMjUgMi42NjY1IDUuNzgwOTcgMi4yMTg3OSA1LjMzMzI1IDEuNjY2NSA1LjMzMzI1IDEuMTE0MjIgNS4zMzMyNS42NjY1MDQgNS43ODA5Ny42NjY1MDQgNi4zMzMyNS42NjY1MDQgNi44ODU1NCAxLjExNDIyIDcuMzMzMjUgMS42NjY1IDcuMzMzMjV6TTE0LjMzMyA3LjMzMzI1QzE0Ljg4NTMgNy4zMzMyNSAxNS4zMzMgNi44ODU1NCAxNS4zMzMgNi4zMzMyNSAxNS4zMzMgNS43ODA5NyAxNC44ODUzIDUuMzMzMjUgMTQuMzMzIDUuMzMzMjUgMTMuNzgwNyA1LjMzMzI1IDEzLjMzMyA1Ljc4MDk3IDEzLjMzMyA2LjMzMzI1IDEzLjMzMyA2Ljg4NTU0IDEzLjc4MDcgNy4zMzMyNSAxNC4zMzMgNy4zMzMyNXoiIGZpbGw9IiNmZmYiLz48L3N2Zz4='); + --icon-Flag: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0iZmVhdGhlciBmZWF0aGVyLWZsYWciPjxwYXRoIGQ9Ik00IDE1czEtMSA0LTEgNSAyIDggMiA0LTEgNC0xVjNzLTEgMS00IDEtNS0yLTgtMi00IDEtNCAxek00IDIyTDQgMTUiLz48L3N2Zz4='); --icon-Folder: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMsNSBMMTMsNSBDMTMuNTUyMjg0Nyw1IDE0LDUuNDQ3NzE1MjUgMTQsNiBMMTQsMTMgQzE0LDEzLjU1MjI4NDcgMTMuNTUyMjg0NywxNCAxMywxNCBMMywxNCBDMi40NDc3MTUyNSwxNCAyLDEzLjU1MjI4NDcgMiwxMyBMMiw2IEMyLDUuNDQ3NzE1MjUgMi40NDc3MTUyNSw1IDMsNSBaIE00LDIgTDEyLDIgTDEyLDQgTDQsNCBMNCwyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-FontBold: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTTMuMTk1MDk4OCAxLjgzNTQxOTloNS42OTAzODE4Yy43NTQ1OTUyIDAgMS40NzgyNzI0LjI5OTc1NjcgMi4wMTE4NTM0LjgzMzMzNzUuNTMzNTgxLjUzMzU4MDguODMzMzM3IDEuMjU3MjU4Mi44MzMzMzcgMi4wMTE4NTM1IDAgLjc1NDU5NTItLjI5OTc1NiAxLjQ3ODI3MjctLjgzMzMzNyAyLjAxMTg1MzVDMTAuMzYzNzUzIDcuMjI2MDQ1MSA5LjY0MDA3NTggNy41MjU4MDE4IDguODg1NDgwNiA3LjUyNTgwMThINS4wOTE4OTI3TTUuMDkxODkyNyA3LjUyNTgwMThoNS4yMTYxODMzYy44ODAzNTIgMCAxLjcyNDY1My4zNDk3MTgyIDIuMzQ3MTY5Ljk3MjIyMDcuNjIyNTAyLjYyMjUxNTIuOTcyMjIgMS40NjY4MTYxLjk3MjIyIDIuMzQ3MTY4NXYwYzAgLjg4MDM1My0uMzQ5NzE4IDEuNzI0NjU0LS45NzIyMiAyLjM0NzIxOS0uNjIyNTE2LjYyMjQwMi0xLjQ2NjgxNy45NzIxNy0yLjM0NzE2OS45NzIxN0gzLjE5NTA5ODhNNS4wOTE4OTI3IDEuODM1NDE5OVYxNC4xNjQ1OCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDMwMDAwIiBzdHJva2Utd2lkdGg9IjEuMjY1IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4='); --icon-FontItalic: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDEyVjEySDB6Ii8+PC9jbGlwUGF0aD48L2RlZnM+PGcgdHJhbnNmb3JtPSJtYXRyaXgoMS4xMzU2IDAgMCAxLjEzNTYgLjk4MyAxLjM5KSIgY2xpcC1wYXRoPSJ1cmwoI2EpIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGZpbGw9Im5vbmUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0ibSA0Ljg3NSwwLjM3NSBoIDQuNSIgc3Ryb2tlPSIjMDAwIi8+PHBhdGggZD0ibSAyLjYyNSwxMS42MjUgaCA0LjUiIHN0cm9rZT0iIzA2MDAwMCIvPjxwYXRoIGQ9Im0gNy4xMjUsMC4zNzUgLTIuMjUsMTEuMjUiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLW9wYWNpdHk9Ii45OSIvPjwvZz48L3N2Zz4='); diff --git a/static/icons/locales/BR.svg b/static/icons/locales/BR.svg new file mode 100644 index 00000000..bd1b6a30 --- /dev/null +++ b/static/icons/locales/BR.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/icons/locales/DE.svg b/static/icons/locales/DE.svg new file mode 100644 index 00000000..331fa81a --- /dev/null +++ b/static/icons/locales/DE.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/static/icons/locales/ES.svg b/static/icons/locales/ES.svg new file mode 100644 index 00000000..e991fb88 --- /dev/null +++ b/static/icons/locales/ES.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/icons/locales/FR.svg b/static/icons/locales/FR.svg new file mode 100644 index 00000000..4e5dcf3c --- /dev/null +++ b/static/icons/locales/FR.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/static/icons/locales/LICENSE b/static/icons/locales/LICENSE new file mode 100644 index 00000000..aaec82fb --- /dev/null +++ b/static/icons/locales/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Yummygum (https://github.com/Yummygum/flagpack-core) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/static/icons/locales/NO.svg b/static/icons/locales/NO.svg new file mode 100644 index 00000000..17823967 --- /dev/null +++ b/static/icons/locales/NO.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/static/icons/locales/US.svg b/static/icons/locales/US.svg new file mode 100644 index 00000000..81671770 --- /dev/null +++ b/static/icons/locales/US.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/static/ui-icons/UI/Flag.svg b/static/ui-icons/UI/Flag.svg new file mode 100644 index 00000000..037737cb --- /dev/null +++ b/static/ui-icons/UI/Flag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/nbrowser/LanguageSettings.ts b/test/nbrowser/LanguageSettings.ts new file mode 100644 index 00000000..d0ca5ba3 --- /dev/null +++ b/test/nbrowser/LanguageSettings.ts @@ -0,0 +1,256 @@ +import {assert, createDriver, driver, WebDriver} from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import {server, setupTestSuite} from 'test/nbrowser/testUtils'; + +describe("LanguageSettings", function() { + this.timeout('50s'); + const cleanup = setupTestSuite(); + + before(async function() { + if (server.isExternalServer()) { + this.skip(); + } + }); + + // List of languages that chrome supports https://developer.chrome.com/docs/webstore/i18n/#localeTable + const locales = [ // [language to set in the browser, country code detected, language name detected] + ['fr', 'FR', 'Français'], + ['te', 'US', 'English'], // Telugu is not supported yet, so Grist should fallback to English (US). + ['en', 'US', 'English'], // This is a default language for Grist. + ['pt-BR', 'BR', 'Português (Brasil)'] + ]; + + for (const [locale, countryCode, language] of locales) { + describe(`correctly detects browser language ${locale}`, () => { + // Change the language to the one we want to test. + withLang(locale); + before(async function() { + const session = await gu.session().personalSite.anon.login(); + await session.loadRelPath("/"); + await gu.waitForDocMenuToLoad(); + }); + it("shows correct language from browser settings", async () => { + // Find the button to switch the language. + const button = await langButton(); + assert.isTrue(await button.isDisplayed()); + // Make sure correct flag is shown. + const flag = await button.find("div").getCssValue("background-image"); + assert.isTrue(flag.endsWith(countryCode + '.svg")'), `Flag is ${flag} search for ${countryCode}`); + // Make sure we see the all languages in the menu. + await button.click(); + const menu = await gu.currentDriver().findWait(".grist-floating-menu", 100); + const allLangues = (await menu.findAll("li", e => e.getText())).map(l => l.toLowerCase()); + for (const [, , language] of locales) { + assert.include(allLangues, language.toLowerCase()); + } + // Make sure that this language is selected. + assert.equal(await selectedLang(), language.toLowerCase()); + // Smoke test that we see the correct language. + const welcomeText = await gu.currentDriver().find(".test-welcome-title").getText(); + if (locale === 'en') { + assert.equal(welcomeText, "Welcome to Grist!"); + } else if (locale === 'fr') { + assert.equal(welcomeText, "Bienvenue sur Grist !"); + } + }); + }); + } + + describe("for Anonymous", function() { + before(async function() { + const session = await gu.session().personalSite.anon.login(); + await session.loadRelPath("/"); + await gu.waitForDocMenuToLoad(); + }); + it("allows anonymous user to switch a language", async () => { + await langButton().click(); + // By default we have English (US) selected. + assert.equal(await selectedLang(), "english"); + // Change to French. + await gu.currentDriver().find(".test-language-lang-fr").click(); + // We will be reloaded, so wait until we see the new language. + await waitForLangButton("fr"); + // Now we have a cookie with the language selected, so reloading the page should keep it. + await gu.currentDriver().navigate().refresh(); + await gu.waitForDocMenuToLoad(); + await waitForLangButton("fr"); + assert.equal(await languageInCookie(), "fr"); + // Switch to German. + await langButton().click(); + await gu.currentDriver().find(".test-language-lang-de").click(); + await waitForLangButton("de"); + // Make sure we see new cookie. + assert.equal(await languageInCookie(), "de"); + // Remove the cookie and reload. + await clearCookie(); + await gu.currentDriver().navigate().refresh(); + await gu.waitForDocMenuToLoad(); + // Make sure we see the default language. + await waitForLangButton("en"); + // Test if changing the cookie is reflected in the UI. This cookie is available for javascript. + await setCookie("fr"); + await gu.currentDriver().navigate().refresh(); + await gu.waitForDocMenuToLoad(); + await waitForLangButton("fr"); + assert.equal(await languageInCookie(), "fr"); + // Go back to English. + await clearCookie(); + await gu.currentDriver().navigate().refresh(); + await gu.waitForDocMenuToLoad(); + }); + it("when user is logged in the language is still taken from the cookie", async () => { + await langButton().click(); + // By default we have English (US) selected () + assert.equal(await selectedLang(), "english"); + + // Now login to the account. + const user = await gu.session().personalSite.user('user1').login(); + await user.loadRelPath("/"); + await gu.waitForDocMenuToLoad(); + // Language should still be english. + await waitForHiddenButton("en"); + // And we should not have a cookie. + assert.isNull(await languageInCookie()); + + // Go back to anonymous. + const anonym = await gu.session().personalSite.anon.login(); + await anonym.loadRelPath("/"); + await gu.waitForDocMenuToLoad(); + + // Change language to french. + await langButton().click(); + await driver.find(".test-language-lang-fr").click(); + await waitForLangButton("fr"); + + // Login as user. + await user.login(); + await anonym.loadRelPath("/"); + await gu.waitForDocMenuToLoad(); + + // Language should still be french. + await waitForHiddenButton("fr"); + // But now we should have a cookie (cookie is reused). + assert.equal(await languageInCookie(), 'fr'); + await clearCookie(); + }); + }); + + describe("for logged in user with nb-NO", function() { + withLang("de"); + let session: gu.Session; + before(async function() { + session = await gu.session().login(); + await session.loadRelPath("/"); + await gu.waitForDocMenuToLoad(); + }); + after(async function() { + await clearCookie(); + const api = session.createHomeApi(); + await api.updateUserLocale(null); + }); + it("profile page detects correct language", async () => { + const driver = gu.currentDriver(); + // Make sure we don't have a cookie yet. + assert.isNull(await languageInCookie()); + // Or a saved setting. + let gristConfig: any = await driver.executeScript("return window.gristConfig"); + assert.isNull(gristConfig.userLocale); + await gu.openProfileSettingsPage(); + // Make sure we see the correct language. + assert.equal(await languageMenu().getText(), "Deutsch"); + // Make sure we see hidden indicator. + await waitForHiddenButton("de"); + // Change language to nb-.NO + await languageMenu().click(); + await driver.findContentWait('.test-select-menu li', 'Norsk bokmål (Norge)', 100).click(); + // This is api call and we will be reloaded, so wait for the hidden indicator. + await waitForHiddenButton("nb-NO"); + // Now we should have a cookie. + assert.equal(await languageInCookie(), "nb-NO"); + // And the gristConfig should have this language. + gristConfig = await driver.executeScript("return window.gristConfig"); + assert.equal(gristConfig.userLocale, "nb-NO"); + // If we remove the cookie, we should still use the gristConfig. + await clearCookie(); + await driver.navigate().refresh(); + await waitForHiddenButton("nb-NO"); + // If we set a different cookie, we should still use the saved setting. + await setCookie("de"); + await driver.navigate().refresh(); + await waitForHiddenButton("nb-NO"); + // Make sure this works on the document, by adding a new doc and smoke testing the Add New button. + await session.tempNewDoc(cleanup, "Test"); + assert.equal(await driver.findWait(".test-dp-add-new", 3000).getText(), "Legg til ny"); + }); + }); +}); + + +function languageMenu() { + return gu.currentDriver().find('.test-account-page-language .test-select-open'); +} + +async function clearCookie() { + await gu.currentDriver().executeScript( + "document.cookie = 'grist_user_locale=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';"); +} + +async function setCookie(locale: string) { + await gu.currentDriver().executeScript( + `document.cookie = 'grist_user_locale=${locale}; expires=Thu, 01 Jan 2970 00:00:00 UTC; path=/;';`); +} + +async function waitForLangButton(locale: string) { + await gu.waitToPass(async () => + assert.isTrue(await gu.currentDriver().findWait(`.test-language-current-${locale}`, 1000).isDisplayed()), 4000); +} + +async function waitForHiddenButton(locale: string) { + await gu.waitToPass(async () => + assert.isTrue(await gu.currentDriver().findWait(`input.test-language-current-${locale}`, 1000).isPresent()), 4000); +} + +async function languageInCookie(): Promise { + const cookie2: string = await gu.currentDriver().executeScript("return document.cookie"); + return cookie2.match(/grist_user_locale=([^;]+)/)?.[1] ?? null; +} + +function withLang(locale: string) { + let customDriver: WebDriver; + let oldLanguage: string | undefined; + before(async function() { + // On Mac we can't change the language, so skip the test. + if (await gu.isMac()) { return this.skip(); } + oldLanguage = process.env.LANGUAGE; + // How to run chrome with a different language: + // https://developer.chrome.com/docs/extensions/reference/i18n/#how-to-set-browsers-locale + process.env.LANGUAGE = locale; + customDriver = await createDriver({ + extraArgs: [ + 'lang=' + locale, + ...(process.env.MOCHA_WEBDRIVER_HEADLESS ? [`headless=chrome`] : []) + ] + }); + server.setDriver(customDriver); + gu.setDriver(customDriver); + const session = await gu.session().personalSite.anon.login(); + await session.loadRelPath("/"); + await gu.waitForDocMenuToLoad(); + }); + after(async function() { + if (await gu.isMac()) { return this.skip(); } + gu.setDriver(); + server.setDriver(); + await customDriver.quit(); + process.env.LANGUAGE = oldLanguage; + }); +} + +function langButton() { + return gu.currentDriver().findWait(".test-language-button", 500); +} + +async function selectedLang() { + const menu = gu.currentDriver().findWait(".grist-floating-menu", 100); + return (await menu.find(".test-language-selected").findClosest("li").getText()).toLowerCase(); +} diff --git a/test/nbrowser/Localization.ts b/test/nbrowser/Localization.ts index 5be89319..6b66be15 100644 --- a/test/nbrowser/Localization.ts +++ b/test/nbrowser/Localization.ts @@ -41,7 +41,7 @@ describe("Localization", function() { const namespaces: Set = new Set(); for (const file of fs.readdirSync(localeDirectory)) { if (file.endsWith(".json")) { - const lang = file.split('.')[0]; + const lang = file.split('.')[0]?.replace(/_/g, '-'); const ns = file.split('.')[1]; langs.add(lang); namespaces.add(ns); diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index aae76c80..29352120 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -35,6 +35,8 @@ namespace gristUtils { // Allow overriding the global 'driver' to use in gristUtil. let driver: WebDriver; +export function currentDriver() { return driver; } + // Substitute a custom driver to use with gristUtils functions. Omit argument to restore to default. export function setDriver(customDriver: WebDriver = driverOrig) { driver = customDriver; } @@ -2470,7 +2472,7 @@ export async function openAccountMenu() { export async function openProfileSettingsPage() { await openAccountMenu(); - await driver.findContent('.grist-floating-menu a', 'Profile Settings').click(); + await driver.find('.grist-floating-menu .test-dm-account-settings').click(); await driver.findWait('.test-account-page-login-method', 5000); } diff --git a/yarn.lock b/yarn.lock index 2a851210..a1c5cd22 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4509,11 +4509,6 @@ i18n-iso-countries@6.1.0: dependencies: diacritics "1.3.0" -i18next-fs-backend@1.1.5: - version "1.1.5" - resolved "https://registry.npmjs.org/i18next-fs-backend/-/i18next-fs-backend-1.1.5.tgz" - integrity sha512-raTel3EfshiUXxR0gvmIoqp75jhkj8+7R1LjB006VZKPTFBbXyx6TlUVhb8Z9+7ahgpFbcQg1QWVOdf/iNzI5A== - i18next-http-middleware@3.2.1: version "3.2.1" resolved "https://registry.npmjs.org/i18next-http-middleware/-/i18next-http-middleware-3.2.1.tgz"