(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
removing-missing-key-error
Jarosław Sadziński 1 year ago
parent abea735470
commit 90d3ee037a

@ -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<any>[] = [];
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.
*/

@ -63,6 +63,7 @@ export interface IEditableMember {
name: string;
email: string;
picture?: string|null;
locale?: string|null;
access: Observable<roles.Role|null>;
parentAccess: roles.BasicRole|null;
inheritedAccess: Computed<roles.BasicRole|null>;

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

@ -98,6 +98,7 @@ export const dataRow = styled('div', `
margin: 8px 0px;
display: flex;
align-items: baseline;
gap: 2px;
`);
export const betaTag = styled('span', `

@ -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,

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

@ -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)),
];

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

@ -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",

@ -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<void>,
explanation?: Element|string,
explanation?: DomElementArg,
{hideCancel, extraButtons}: {hideCancel?: boolean, extraButtons?: DomContents} = {},
): void {
return saveModal((ctl, owner): ISaveModalOptions => ({

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

@ -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

@ -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.

@ -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<void>;
getUserProfile(): Promise<FullUser>;
updateUserName(name: string): Promise<void>;
updateUserLocale(locale: string|null): Promise<void>;
updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise<void>;
updateIsConsultant(userId: number, isConsultant: boolean): Promise<void>;
getWorker(key: string): Promise<string>;
@ -632,6 +635,13 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
});
}
public async updateUserLocale(locale: string|null): Promise<void> {
await this.request(`${this._url}/api/profile/user/locale`, {
method: 'POST',
body: JSON.stringify({locale})
});
}
public async updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise<void> {
await this.request(`${this._url}/api/profile/allowGoogleLogin`, {
method: 'POST',

@ -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");

@ -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) => {

@ -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)])

@ -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,

@ -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) {

@ -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);
}
/**

@ -65,6 +65,7 @@ export function makeGristConfig(homeUrl: string|null, extra: Partial<GristLoadCo
namespaces: readLoadedNamespaces(req?.i18n),
featureComments: process.env.COMMENTS === "true",
supportEmail: SUPPORT_EMAIL,
userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale,
...extra,
};
}
@ -111,9 +112,12 @@ export function makeSendAppPage(opts: {
const customHeadHtmlSnippet = server?.create.getExtraHeadHtml?.() ?? "";
const warning = testLogin ? "<div class=\"dev_warning\">Authentication is not enforced</div>" : "";
// Preload all languages that will be used or are requested by client.
const preloads = req.languages.map((lng) =>
readLoadedNamespaces(req.i18n).map((ns) =>
`<link rel="preload" href="locales/${lng}.${ns}.json" as="fetch" type="application/json" crossorigin>`
const preloads = req.languages
.filter(lng => (readLoadedLngs(req.i18n)).includes(lng))
.map(lng => lng.replace('-', '_'))
.map((lng) =>
readLoadedNamespaces(req.i18n).map((ns) =>
`<link rel="preload" href="locales/${lng}.${ns}.json" as="fetch" type="application/json" crossorigin>`
).join("\n")
).join('\n');
const content = fileContent

@ -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<string> = new Set();
const supportedLngs: Set<string> = new Set(readdirSync(localeDir).map((fileName) => {
const supportedLngs: Set<string> = 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[] {

@ -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",

@ -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=');

@ -0,0 +1,39 @@
<svg width="16" height="12" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="a" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
<path fill="#fff" d="M0 0h16v12H0z"/>
</mask>
<g mask="url(#a)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0v12h16V0H0z" fill="#093"/>
<mask id="b" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0v12h16V0H0z" fill="#fff"/>
</mask>
<g mask="url(#b)">
<g filter="url(#BR_-_Brazil__filter0_d)" fill-rule="evenodd" clip-rule="evenodd">
<path d="M7.963 1.852l6.101 4.252-6.184 3.982L1.904 6.02l6.06-4.169z" fill="#FFD221"/>
<path d="M7.963 1.852l6.101 4.252-6.184 3.982L1.904 6.02l6.06-4.169z" fill="url(#BR_-_Brazil__paint0_linear)"/>
</g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 8.6a2.5 2.5 0 100-5 2.5 2.5 0 000 5z" fill="#2E42A5"/>
<mask id="c" maskUnits="userSpaceOnUse" x="5" y="3" width="6" height="6">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 8.6a2.5 2.5 0 100-5 2.5 2.5 0 000 5z" fill="#fff"/>
</mask>
<g mask="url(#c)" fill="#F7FCFF">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.19 7.285l-.112.059.022-.125-.09-.088.124-.018L7.19 7l.056.113.125.018-.09.088.02.125-.111-.059zm1 0l-.112.059.022-.125-.09-.088.124-.018L8.19 7l.056.113.125.018-.09.088.02.125-.111-.059zm0 .6l-.112.059.022-.125-.09-.088.124-.018.056-.113.056.113.125.018-.09.088.02.125-.111-.059zm-.5-2.1l-.112.059.022-.125-.09-.088.124-.018.056-.113.056.113.125.018-.09.088.02.125-.111-.059zm0 1l-.112.059.022-.125-.09-.088.124-.018.056-.113.056.113.125.018-.09.088.02.125-.111-.059zm-.7-.5l-.112.059.022-.125-.09-.088.124-.018L6.99 6l.056.113.125.018-.09.088.02.125-.11-.059zm-.7.4l-.112.059.022-.125-.09-.088.124-.018.056-.113.056.113.125.018-.09.088.02.125-.111-.059zm2.3-1.7l-.112.059.022-.125-.09-.088.124-.018.056-.113.056.113.125.018-.09.088.02.125-.111-.059z"/>
<path d="M4.962 5.499l.076-.998c2.399.181 4.292.97 5.656 2.373l-.717.697C8.795 6.355 7.131 5.662 4.962 5.5z"/>
</g>
</g>
</g>
<defs>
<linearGradient id="BR_-_Brazil__paint0_linear" x1="16" y1="12" x2="16" y2="0" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFC600"/>
<stop offset="1" stop-color="#FFDE42"/>
</linearGradient>
<filter id="BR_-_Brazil__filter0_d" x="1.904" y="1.852" width="12.16" height="8.234" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset/>
<feColorMatrix values="0 0 0 0 0.0313726 0 0 0 0 0.368627 0 0 0 0 0 0 0 0 0.28 0"/>
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

@ -0,0 +1,10 @@
<svg width="16" height="12" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="a" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
<rect width="16" height="12" rx="-1" fill="#fff"/>
</mask>
<g mask="url(#a)" fill-rule="evenodd" clip-rule="evenodd">
<path d="M0 8h16v4H0V8z" fill="#FFD018"/>
<path d="M0 4h16v4H0V4z" fill="#E31D1C"/>
<path d="M0 0h16v4H0V0z" fill="#272727"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 432 B

@ -0,0 +1,46 @@
<svg width="16" height="12" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="a" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
<path fill="#fff" d="M0 0h16v12H0z"/>
</mask>
<g mask="url(#a)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0v12h16V0H0z" fill="#FFB400"/>
<mask id="b" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0v12h16V0H0z" fill="#fff"/>
</mask>
<g mask="url(#b)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0v3h16V0H0zm0 9v3h16V9H0z" fill="#C51918"/>
<path fill="#F1F9FF" d="M2.504 5.136h.56v2.912h-.56z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.288 4.632H2.28v.28h.168v.224h.672v-.224h.168v-.28zM3.12 8.216h.168v.28H2.28v-.28h.168v-.224h.672v.224z" fill="#C88A02"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.784 4.688c.122 0 .134-.046.206-.114.056-.054.186-.12.186-.194 0-.17-.175-.308-.392-.308-.216 0-.392.138-.392.308 0 .083.09.138.157.194.072.058.124.114.235.114z" fill="#AD1619"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.28 8.496h1.008v.448s-.126-.056-.252-.056-.252.056-.252.056-.126-.056-.252-.056-.252.056-.252.056v-.448z" fill="#005BBF"/>
<path fill="#F1F9FF" d="M7.992 5.136h.56v2.912h-.56z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.776 4.632H7.768v.28h.168v.224h.672v-.224h.168v-.28zm-.168 3.584h.168v.28H7.768v-.28h.168v-.224h.672v.224z" fill="#C88A02"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.272 4.688c.122 0 .134-.046.206-.114.056-.054.186-.12.186-.194 0-.17-.175-.308-.392-.308-.216 0-.392.138-.392.308 0 .083.09.138.157.194.072.058.124.114.235.114z" fill="#AD1619"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.768 8.496h1.008v.448s-.126-.056-.252-.056-.252.056-.252.056-.126-.056-.252-.056-.252.056-.252.056v-.448z" fill="#005BBF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.494 7.84c.101-.122.157-.234.157-.352a.316.316 0 00-.06-.192l.006-.003s.11-.048.15-.067c.072-.034.135-.07.197-.116.04-.028.092-.06.173-.103l.096-.051.096-.053a.62.62 0 00.183-.144.268.268 0 00-.061-.399.728.728 0 00-.301-.096l-.197-.03a4.931 4.931 0 01-.177-.03c.345-.057.836-.036 1.052.076l.206-.398c-.44-.228-1.445-.204-1.82.054-.275.19-.238.476.048.6.12.05.276.085.564.131a1.431 1.431 0 00-.126.081.799.799 0 01-.127.075 6.71 6.71 0 01-.125.055l-.017.008c-.233.106-.346.252-.312.517l.018.143.033.01.344.284zm-.288-.37v.002-.002zm6.147.018c0 .118.056.23.157.352l.344-.284.033-.01.018-.143c.034-.265-.079-.411-.312-.517l-.016-.008a6.704 6.704 0 01-.125-.055.8.8 0 01-.128-.075 1.431 1.431 0 00-.126-.08c.289-.047.445-.081.564-.133.286-.123.323-.41.048-.6-.375-.257-1.379-.28-1.82-.053l.206.398c.216-.112.708-.133 1.052-.075l-.177.029-.196.03a.728.728 0 00-.301.096.268.268 0 00-.062.4.605.605 0 00.183.143l.096.053.096.05c.081.044.134.076.173.104.062.045.126.082.198.116.039.02.15.068.15.067l.006.003a.316.316 0 00-.061.192z" fill="#AD1619"/>
<path d="M2.962 6.2l.165.034v.247c-.176.14-.623.377-.623.377V6.2h.458zm5.195 0l-.165.034v.247c.176.14.623.377.623.377V6.2h-.458z" fill="#F1F9FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.388 3.603v-.082a1.53 1.53 0 00-.905-.31 1.806 1.806 0 00-.918-.156v-.001c-.465-.046-.934.157-.934.157-.473 0-.905.31-.905.31v.082l.565.567s.159.546 1.272.418v.001s.737-.02.79-.037l.063-.02c.144-.042.31-.09.407-.362l.565-.567zm-1.825-.519z" fill="#AD1619"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.76 3.7l.038-.042.513.483c.097-.026.626-.16 1.216-.168h.045a5.281 5.281 0 011.232.172s-.006.053-.021.116l.056-.11.006-.006.517-.487.038.041-.514.483c-.075.138-.112.23-.112.267 0 .058-.15.092-.444.128-.23.027-.5.046-.722.048h-.056a7.222 7.222 0 01-.722-.048c-.294-.036-.444-.07-.444-.128a.118.118 0 00-.004-.027.44.44 0 01-.064-.154 2.84 2.84 0 00-.044-.086L3.76 3.7zm2.867.75c.039.019.07 0 .095-.035a.18.18 0 00-.004.027.541.541 0 01-.098.032 3.068 3.068 0 01-.296.047c-.238.029-.52.047-.744.049a7.095 7.095 0 01-.744-.049 3.067 3.067 0 01-.296-.047.633.633 0 01-.073-.02l.006-.003c.122-.058.93-.111 1.077-.12.145.009.954.062 1.077.12zm.154-.01l-.001.001z" fill="#C88A02"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.556 2.674a.168.168 0 100-.336.168.168 0 000 .336z" fill="#005BBF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.493 2.038h.117v.074h.076v.117H5.61v.233h.076v.117h-.27v-.117h.077v-.233h-.077v-.117h.077v-.074z" fill="#C88A02"/>
<path fill="#C88A02" d="M5.472 2.672h.224V3.4h-.224z"/>
<path d="M3.854 3.648l-.308-.011c.145-.839.86-1.25 2.002-1.25 1.144 0 1.856.413 1.99 1.255l-.415.043c-.066-.41-.752-.78-1.569-.78-.818 0-1.629.33-1.7.743z" fill="#C88A02"/>
<path opacity=".3" fill-rule="evenodd" clip-rule="evenodd" d="M3.736 4.632h3.64v3.27S7.106 9 5.556 9s-1.82-1.127-1.82-1.127V4.632z" fill="#E1E5E8"/>
<mask id="c" maskUnits="userSpaceOnUse" x="3" y="4" width="5" height="5">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.736 4.632h3.64v3.27S7.106 9 5.556 9s-1.82-1.127-1.82-1.127V4.632z" fill="#fff"/>
</mask>
<g mask="url(#c)">
<path fill="#FFC034" d="M3.736 6.648h1.848v2.184H3.736z"/>
<path fill="#AD1619" d="M3.736 4.576h1.848v2.128H3.736z"/>
<path fill="#AD1619" d="M5.528 6.592h1.848V8.72H5.528z"/>
<path fill="#F1F9FF" d="M5.528 4.632h1.96v2.072h-1.96z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.741 8.721s-1.186.094-1.186-.671c0 0-.011.671-1.25.671v.604h2.436v-.604z" fill="#F1F9FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.556 7.096c.232 0 .42-.2.42-.448 0-.247-.188-.448-.42-.448-.232 0-.42.2-.42.448 0 .247.188.448.42.448z" fill="#005BBF" stroke="#AD1619" stroke-width=".583"/>
</g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.5 6a.5.5 0 100-1 .5.5 0 000 1z" fill="#C88A02"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.5 6a.5.5 0 100-1 .5.5 0 000 1z" fill="#C37C9C"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.5 8a.5.5 0 100-1 .5.5 0 000 1z" fill="#FFC034"/>
<path d="M4.5 8a.5.5 0 100-1 .5.5 0 000 1z" fill="#AD1619"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

@ -0,0 +1,10 @@
<svg width="16" height="12" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="a" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
<path fill="#fff" d="M0 0h16v12H0z"/>
</mask>
<g mask="url(#a)" fill-rule="evenodd" clip-rule="evenodd">
<path d="M11 0h5v12h-5V0z" fill="#F50100"/>
<path d="M0 0h6v12H0V0z" fill="#2E42A5"/>
<path d="M5 0h6v12H5V0z" fill="#F7FCFF"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 421 B

@ -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.

@ -0,0 +1,14 @@
<svg width="16" height="12" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="a" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
<path fill="#fff" d="M0 0h16v12H0z"/>
</mask>
<g mask="url(#a)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0v12h16V0H0z" fill="#E31D1C"/>
<mask id="b" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0v12h16V0H0z" fill="#fff"/>
</mask>
<g mask="url(#b)">
<path d="M5-.5h-.5v5h-5v3h5v5h3v-5h9v-3h-9v-5H5z" fill="#2E42A5" stroke="#F7FCFF"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 628 B

@ -0,0 +1,11 @@
<svg width="16" height="12" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="a" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="12">
<path fill="#fff" d="M0 0h16v12H0z"/>
</mask>
<g mask="url(#a)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0h16v12H0V0z" fill="#E31D1C"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 1v1h16V1H0zm0 2v1h16V3H0zm0 3V5h16v1H0zm0 1v1h16V7H0zm0 3V9h16v1H0zm0 2v-1h16v1H0z" fill="#F7FCFF"/>
<path fill="#2E42A5" d="M0 0h9v7H0z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.04 2.174l.53-.37.411.297h-.233l.471.416-.159.584h-.249l-.242-.536-.206.536H.748l.471.416-.179.657.53-.37.411.297h-.233l.471.416-.159.584h-.249l-.242-.536-.206.536H.748l.471.416-.179.657.53-.37.513.37-.16-.657.412-.416h-.19l.425-.296.411.296h-.233l.471.416-.179.657.53-.37.513.37-.16-.657.412-.416h-.19l.425-.296.411.296h-.233l.471.416-.179.657.53-.37.513.37-.16-.657.412-.416h-.19l.425-.296.411.296h-.233l.471.416-.179.657.53-.37.513.37-.16-.657.412-.416h-.524l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.19l.425-.296.513.369-.16-.657.412-.416h-.524l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.19l.425-.296.513.369-.16-.657.412-.416h-.524L7.569.565l-.206.536h-.615l.471.416-.159.584h-.249l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.524L5.569.565l-.206.536h-.615l.471.416-.159.584h-.249l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.524L3.569.565l-.206.536h-.615l.471.416-.159.584h-.249l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.524L1.569.565l-.206.536H.748l.471.416-.179.657zM7.06 4.1l.159-.584-.47-.416h.232l-.411-.296-.425.296h.19l-.412.416.142.584h.298l.206-.536.242.536h.249zm-1.079 0l-.411-.296-.425.296h.19l-.412.416.142.584h.298l.206-.536.242.536h.249l.159-.584-.47-.416h.232zm-1.762.416L4.06 5.1h-.249l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.19l.425-.296.411.296h-.233l.471.416zm.144-.416h-.298l-.142-.584.412-.416h-.19l.425-.296.411.296h-.233l.471.416-.159.584h-.249l-.242-.536-.206.536zm-1.303 0l.159-.584-.47-.416h.232l-.411-.296-.425.296h.19l-.412.416.142.584h.298l.206-.536.242.536h.249zm3.159-1.584L6.06 3.1h-.249l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.19l.425-.296.411.296h-.233l.471.416zM3.981 2.1l-.411-.296-.425.296h.19l-.412.416.142.584h.298l.206-.536.242.536h.249l.159-.584-.47-.416h.232z" fill="#F7FCFF"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-flag"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"></path><line x1="4" y1="22" x2="4" y2="15"></line></svg>

After

Width:  |  Height:  |  Size: 334 B

@ -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<string | null> {
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();
}

@ -41,7 +41,7 @@ describe("Localization", function() {
const namespaces: Set<string> = 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);

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

@ -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"

Loading…
Cancel
Save