(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
This commit is contained in:
Jarosław Sadziński 2023-01-24 14:13:18 +01:00
parent abea735470
commit 90d3ee037a
36 changed files with 696 additions and 74 deletions

View File

@ -6,18 +6,7 @@ import {G} from 'grainjs/dist/cjs/lib/browserGlobals';
export async function setupLocale() { export async function setupLocale() {
const now = Date.now(); const now = Date.now();
const supportedLngs = getGristConfig().supportedLngs ?? ['en']; const supportedLngs = getGristConfig().supportedLngs ?? ['en'];
let lng = window.navigator.language || 'en'; const lng = detectCurrentLang();
// 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 ns = getGristConfig().namespaces ?? ['client']; const ns = getGristConfig().namespaces ?? ['client'];
// Initialize localization plugin // Initialize localization plugin
try { try {
@ -25,8 +14,6 @@ export async function setupLocale() {
i18next.init({ i18next.init({
// By default we use english language. // By default we use english language.
fallbackLng: 'en', fallbackLng: 'en',
// Fallback from en-US, en-GB, etc to en.
nonExplicitSupportedLngs: true,
// We will load resources ourselves. // We will load resources ourselves.
initImmediate: false, initImmediate: false,
// Read language from navigator object. // Read language from navigator object.
@ -38,8 +25,7 @@ export async function setupLocale() {
// for now just import all what server offers. // for now just import all what server offers.
// We can fallback to client namespace for any addons. // We can fallback to client namespace for any addons.
fallbackNS: 'client', fallbackNS: 'client',
ns, ns
supportedLngs
}).catch((err: any) => { }).catch((err: any) => {
// This should not happen, the promise should be resolved synchronously, without // This should not happen, the promise should be resolved synchronously, without
// any errors reported. // any errors reported.
@ -51,14 +37,14 @@ export async function setupLocale() {
const loadPath = `${document.baseURI}locales/{{lng}}.{{ns}}.json`; const loadPath = `${document.baseURI}locales/{{lng}}.{{ns}}.json`;
const pathsToLoad: Promise<any>[] = []; const pathsToLoad: Promise<any>[] = [];
async function load(lang: string, n: string) { 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); const response = await fetch(resourceUrl);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load ${resourceUrl}`); throw new Error(`Failed to load ${resourceUrl}`);
} }
i18next.addResourceBundle(lang, n, await response.json()); 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) { for (const n of ns) {
pathsToLoad.push(load(lang, n)); 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. * Resolves the translation of the given key using the given options.
*/ */

View File

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

View File

@ -13,10 +13,12 @@ import {transientInput} from 'app/client/ui/transientInput';
import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs'; import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs';
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox'; import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
import {cssLink} from 'app/client/ui2018/links'; import {cssLink} from 'app/client/ui2018/links';
import {select} from 'app/client/ui2018/menus';
import {getGristConfig} from 'app/common/urlUtils'; import {getGristConfig} from 'app/common/urlUtils';
import {FullUser} from 'app/common/UserAPI'; 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 {Computed, Disposable, dom, domComputed, makeTestId, Observable, styled} from 'grainjs';
import {makeT} from 'app/client/lib/localization';
const testId = makeTestId('test-account-page-'); const testId = makeTestId('test-account-page-');
const t = makeT('AccountPage'); const t = makeT('AccountPage');
@ -56,6 +58,22 @@ export class AccountPage extends Disposable {
private _buildContentMain() { private _buildContentMain() {
const {enableCustomCss} = getGristConfig(); 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 && ( return domComputed(this._userObs, (user) => user && (
css.container(css.accountPage( css.container(css.accountPage(
css.header(t("Account settings")), css.header(t("Account settings")),
@ -73,7 +91,7 @@ export class AccountPage extends Disposable {
save: (val) => this._isNameValid.get() && this._updateUserName(val), save: (val) => this._isNameValid.get() && this._updateUserName(val),
close: () => { this._isEditingName.set(false); this._nameEdit.set(''); }, 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)), dom.on('input', (_ev, el) => this._nameEdit.set(el.value)),
css.flexGrow.cls(''), css.flexGrow.cls(''),
), ),
@ -92,7 +110,7 @@ export class AccountPage extends Disposable {
testId('username'), testId('username'),
), ),
// show warning for invalid name but not for the empty string // 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.header(t("Password & Security")),
css.dataRow( css.dataRow(
css.inlineSubHeader(t("Login Method")), css.inlineSubHeader(t("Login Method")),
@ -123,6 +141,15 @@ export class AccountPage extends Disposable {
enableCustomCss ? null : [ enableCustomCss ? null : [
css.header(t("Theme")), css.header(t("Theme")),
dom.create(ThemeConfig, this._appModel), 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.header(t("API")),
css.dataRow(css.inlineSubHeader(t("API Key")), css.content( css.dataRow(css.inlineSubHeader(t("API Key")), css.content(
@ -131,7 +158,7 @@ export class AccountPage extends Disposable {
onCreate: () => this._createApiKey(), onCreate: () => this._createApiKey(),
onDelete: () => this._deleteApiKey(), onDelete: () => this._deleteApiKey(),
anonymous: false, 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() { private _buildHeaderMain() {
return dom.frag( return dom.frag(
cssBreadcrumbs({ style: 'margin-left: 16px;' }, cssBreadcrumbs({style: 'margin-left: 16px;'},
cssLink( cssLink(
urlState().setLinkUrl({}), urlState().setLinkUrl({}),
'Home', 'Home',
@ -194,6 +221,16 @@ export class AccountPage extends Disposable {
private _showChangePasswordDialog() { private _showChangePasswordDialog() {
return buildChangePasswordDialog(); 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); 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; margin: -8px 0 0 110px;
`); `);
const cssFirstUpper = styled('div', `
& > div::first-letter {
text-transform: capitalize;
}
`);

View File

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

View File

@ -86,7 +86,7 @@ export class AccountWidget extends Disposable {
cssEmail(user.email, testId('usermenu-email')) 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, documentSettingsItem,

View File

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

View File

@ -11,6 +11,7 @@ import {manageTeamUsersApp} from 'app/client/ui/OpenUserManager';
import {buildShareMenuButton} from 'app/client/ui/ShareMenu'; import {buildShareMenuButton} from 'app/client/ui/ShareMenu';
import {hoverTooltip} from 'app/client/ui/tooltips'; import {hoverTooltip} from 'app/client/ui/tooltips';
import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss'; import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
import {buildLanguageMenu} from 'app/client/ui/LanguageMenu';
import {docBreadcrumbs} from 'app/client/ui2018/breadcrumbs'; import {docBreadcrumbs} from 'app/client/ui2018/breadcrumbs';
import {basicButton} from 'app/client/ui2018/buttons'; import {basicButton} from 'app/client/ui2018/buttons';
import {cssHideForNarrowScreen, testId, theme} from 'app/client/ui2018/cssVars'; import {cssHideForNarrowScreen, testId, theme} from 'app/client/ui2018/cssVars';
@ -38,6 +39,7 @@ export function createTopBarHome(appModel: AppModel) {
null null
), ),
buildLanguageMenu(appModel),
buildNotifyMenuButton(appModel.notifier, appModel), buildNotifyMenuButton(appModel.notifier, appModel),
dom('div', dom.create(AccountWidget, appModel)), dom('div', dom.create(AccountWidget, appModel)),
]; ];

View File

@ -622,7 +622,8 @@ function getFullUser(member: IEditableMember): FullUser {
id: member.id, id: member.id,
name: member.name, name: member.name,
email: member.email, email: member.email,
picture: member.picture picture: member.picture,
locale: member.locale
}; };
} }

View File

@ -61,6 +61,7 @@ export type IconName = "ChartArea" |
"Filter" | "Filter" |
"FilterSimple" | "FilterSimple" |
"Fireworks" | "Fireworks" |
"Flag" |
"Folder" | "Folder" |
"FontBold" | "FontBold" |
"FontItalic" | "FontItalic" |
@ -195,6 +196,7 @@ export const IconList: IconName[] = ["ChartArea",
"Filter", "Filter",
"FilterSimple", "FilterSimple",
"Fireworks", "Fireworks",
"Flag",
"Folder", "Folder",
"FontBold", "FontBold",
"FontItalic", "FontItalic",

View File

@ -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. * See saveModal() for error handling notes that here apply to the onConfirm callback.
*/ */
export function confirmModal( export function confirmModal(
title: string, title: DomElementArg,
btnText: string, btnText: DomElementArg,
onConfirm: () => Promise<void>, onConfirm: () => Promise<void>,
explanation?: Element|string, explanation?: DomElementArg,
{hideCancel, extraButtons}: {hideCancel?: boolean, extraButtons?: DomContents} = {}, {hideCancel, extraButtons}: {hideCancel?: boolean, extraButtons?: DomContents} = {},
): void { ): void {
return saveModal((ctl, owner): ISaveModalOptions => ({ return saveModal((ctl, owner): ISaveModalOptions => ({

View File

@ -68,3 +68,17 @@ try {
} }
currencies = [...currencies].sort((a, b) => nativeCompare(a.code, b.code)); 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;
}

View File

@ -6,6 +6,7 @@ export interface UserProfile {
anonymous?: boolean; // when present, asserts whether user is anonymous (not authorized). anonymous?: boolean; // when present, asserts whether user is anonymous (not authorized).
connectId?: string|null, // used by GristConnect to identify user in external provider. connectId?: string|null, // used by GristConnect to identify user in external provider.
loginMethod?: 'Google'|'Email + Password'|'External'; loginMethod?: 'Google'|'Email + Password'|'External';
locale?: string|null;
} }
// User profile including user id and user ref. All information in it should // User profile including user id and user ref. All information in it should

View File

@ -31,6 +31,8 @@ export interface UserPrefs extends Prefs {
behavioralPrompts?: BehavioralPromptPrefs; behavioralPrompts?: BehavioralPromptPrefs;
// Welcome popups a user has dismissed. // Welcome popups a user has dismissed.
dismissedWelcomePopups?: DismissedReminder[]; dismissedWelcomePopups?: DismissedReminder[];
// Localization support.
locale?: string;
} }
// A collection of preferences related to a combination of user and org. // A collection of preferences related to a combination of user and org.

View File

@ -141,6 +141,8 @@ export interface UserOptions {
// Whether user is a consultant. Consultant users can be added to sites // Whether user is a consultant. Consultant users can be added to sites
// without being counted for billing. Defaults to false if unset. // without being counted for billing. Defaults to false if unset.
isConsultant?: boolean; isConsultant?: boolean;
// Locale selected by the user. Defaults to 'en' if unset.
locale?: string;
} }
export interface PermissionDelta { export interface PermissionDelta {
@ -331,6 +333,7 @@ export interface UserAPI {
moveDoc(docId: string, workspaceId: number): Promise<void>; moveDoc(docId: string, workspaceId: number): Promise<void>;
getUserProfile(): Promise<FullUser>; getUserProfile(): Promise<FullUser>;
updateUserName(name: string): Promise<void>; updateUserName(name: string): Promise<void>;
updateUserLocale(locale: string|null): Promise<void>;
updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise<void>; updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise<void>;
updateIsConsultant(userId: number, isConsultant: boolean): Promise<void>; updateIsConsultant(userId: number, isConsultant: boolean): Promise<void>;
getWorker(key: string): Promise<string>; 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> { public async updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise<void> {
await this.request(`${this._url}/api/profile/allowGoogleLogin`, { await this.request(`${this._url}/api/profile/allowGoogleLogin`, {
method: 'POST', method: 'POST',

View File

@ -585,6 +585,9 @@ export interface GristLoadConfig {
// Email address of the support user. // Email address of the support user.
supportEmail?: string; supportEmail?: string;
// Current user locale, read from the user options;
userLocale?: string;
} }
export const HideableUiElements = StringUnion("helpCenter", "billing", "templates", "multiSite", "multiAccounts"); export const HideableUiElements = StringUnion("helpCenter", "billing", "templates", "multiSite", "multiAccounts");

View File

@ -1,6 +1,8 @@
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import * as express from 'express'; import * as express from 'express';
import {EntityManager} from 'typeorm'; import {EntityManager} from 'typeorm';
import * as cookie from 'cookie';
import {Request} from 'express';
import {ApiError} from 'app/common/ApiError'; import {ApiError} from 'app/common/ApiError';
import {FullUser} from 'app/common/LoginSessionAPI'; import {FullUser} from 'app/common/LoginSessionAPI';
@ -13,10 +15,10 @@ import log from 'app/server/lib/log';
import {addPermit, clearSessionCacheIfNeeded, getDocScope, getScope, integerParam, import {addPermit, clearSessionCacheIfNeeded, getDocScope, getScope, integerParam,
isParameterOn, sendOkReply, sendReply, stringParam} from 'app/server/lib/requestUtils'; isParameterOn, sendOkReply, sendReply, stringParam} from 'app/server/lib/requestUtils';
import {IWidgetRepository} from 'app/server/lib/WidgetRepository'; import {IWidgetRepository} from 'app/server/lib/WidgetRepository';
import {Request} from 'express';
import {User} from './entity/User'; import {User} from './entity/User';
import {HomeDBManager, QueryResult, Scope} from './lib/HomeDBManager'; import {HomeDBManager, QueryResult, Scope} from './lib/HomeDBManager';
import {getCookieDomain} from 'app/server/lib/gristSessions';
// Special public organization that contains examples and templates. // Special public organization that contains examples and templates.
export const TEMPLATES_ORG_DOMAIN = process.env.GRIST_ID_PREFIX ? export const TEMPLATES_ORG_DOMAIN = process.env.GRIST_ID_PREFIX ?
@ -371,6 +373,23 @@ export class ApiServer {
res.sendStatus(200); 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 // POST /api/profile/allowGoogleLogin
// Update user's preference for allowing Google login. // Update user's preference for allowing Google login.
this._app.post('/api/profile/allowGoogleLogin', expressWrap(async (req, res) => { this._app.post('/api/profile/allowGoogleLogin', expressWrap(async (req, res) => {

View File

@ -490,6 +490,7 @@ export class HomeDBManager extends EventEmitter {
name: user.name, name: user.name,
picture: user.picture, picture: user.picture,
ref: user.ref, ref: user.ref,
locale: user.options?.locale
}; };
if (this.getAnonymousUserId() === user.id) { if (this.getAnonymousUserId() === user.id) {
result.anonymous = true; result.anonymous = true;
@ -2663,7 +2664,8 @@ export class HomeDBManager extends EventEmitter {
email: login.displayEmail, email: login.displayEmail,
name: login.user.name, name: login.user.name,
picture: login.user.picture, 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)]) return profiles.map(profile => completedProfiles[normalizeEmail(profile.email)])

View File

@ -373,6 +373,14 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer
mreq.users = [dbManager.makeFullUser(anon)]; 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 = { const meta = {
customHostSession, customHostSession,
method: mreq.method, method: mreq.method,

View File

@ -122,7 +122,7 @@ export function getDocSessionUser(docSession: OptDocSession): FullUser|null {
const user = getUser(docSession.req); const user = getUser(docSession.req);
const email = user.loginEmail; const email = user.loginEmail;
if (email) { 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) { if (docSession.client) {

View File

@ -59,7 +59,7 @@ import {startTestingHooks} from 'app/server/lib/TestingHooks';
import {getTestLoginSystem} from 'app/server/lib/TestLogin'; import {getTestLoginSystem} from 'app/server/lib/TestLogin';
import {addUploadRoute} from 'app/server/lib/uploads'; import {addUploadRoute} from 'app/server/lib/uploads';
import {buildWidgetRepository, IWidgetRepository} from 'app/server/lib/WidgetRepository'; 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 axios from 'axios';
import * as bodyParser from 'body-parser'; import * as bodyParser from 'body-parser';
import express from 'express'; import express from 'express';
@ -1278,10 +1278,7 @@ export class FlexServer implements GristServer {
} }
public getGristConfig(): GristLoadConfig { public getGristConfig(): GristLoadConfig {
return makeGristConfig(this.getDefaultHomeUrl(), { return makeGristConfig(this.getDefaultHomeUrl(), {}, this._defaultBaseDomain);
supportedLngs: readLoadedLngs(this.i18Instance),
namespaces: readLoadedNamespaces(this.i18Instance),
}, this._defaultBaseDomain);
} }
/** /**

View File

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

View File

@ -1,6 +1,5 @@
import {lstatSync, readdirSync} from 'fs'; import {lstatSync, readdirSync, readFileSync} from 'fs';
import {createInstance, i18n} from 'i18next'; import {createInstance, i18n} from 'i18next';
import i18fsBackend from 'i18next-fs-backend';
import {LanguageDetector} from 'i18next-http-middleware'; import {LanguageDetector} from 'i18next-http-middleware';
import path from 'path'; 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 // By default locales are located in the appRoot folder, unless the environment variable
// GRIST_LOCALES_DIR is set. // GRIST_LOCALES_DIR is set.
const localeDir = process.env.GRIST_LOCALES_DIR || path.join(appRoot, 'static', 'locales'); const localeDir = process.env.GRIST_LOCALES_DIR || path.join(appRoot, 'static', 'locales');
const preload: [string, string, string][] = [];
const supportedNamespaces: Set<string> = new Set(); 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 fullPath = path.join(localeDir, fileName);
const isDirectory = lstatSync(fullPath).isDirectory(); const isDirectory = lstatSync(fullPath).isDirectory();
if (isDirectory) { if (isDirectory) {
return ""; continue;
} }
const baseName = path.basename(fileName, '.json'); const baseName = path.basename(fileName, '.json');
const lang = baseName.split('.')[0]; const lang = baseName.split('.')[0]?.replace(/_/g, '-');
const namespace = baseName.split('.')[1]; const namespace = baseName.split('.')[1];
if (!lang || !namespace) { if (!lang || !namespace) {
throw new Error("Unrecognized resource file " + fileName); throw new Error("Unrecognized resource file " + fileName);
} }
supportedNamespaces.add(namespace); supportedNamespaces.add(namespace);
return lang; preload.push([lang, namespace, fullPath]);
}).filter((lang) => lang)); supportedLngs.add(lang);
}
if (!supportedLngs.has('en') || !supportedNamespaces.has('server')) { if (!supportedLngs.has('en') || !supportedNamespaces.has('server')) {
throw new Error("Missing server English language file"); 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. // Initialize localization language detector plugin that will read the language from the request.
instance.use(LanguageDetector); instance.use(LanguageDetector);
let errorDuringLoad: Error | undefined; let errorDuringLoad: Error | undefined;
instance.init({ instance.init({
// Load all files synchronously.
initImmediate: false,
preload: [...supportedLngs],
supportedLngs: [...supportedLngs],
defaultNS: 'server', defaultNS: 'server',
ns: [...supportedNamespaces], ns: [...supportedNamespaces],
fallbackLng: 'en', fallbackLng: 'en',
backend: { detection: {
loadPath: `${localeDir}/{{lng}}.{{ns}}.json` lookupCookie: 'grist_user_locale'
}, }
}, (err: any) => { }, (err: any) => {
if (err) { if (err) {
errorDuringLoad = err; errorDuringLoad = err;
@ -56,14 +54,19 @@ export function setupLocale(appRoot: string): i18n {
console.error("i18next failed unexpectedly", err); console.error("i18next failed unexpectedly", err);
}); });
if (errorDuringLoad) { if (errorDuringLoad) {
console.error('i18next failed to load', errorDuringLoad);
throw 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; return instance;
} }
export function readLoadedLngs(instance?: i18n): readonly string[] { export function readLoadedLngs(instance?: i18n): readonly string[] {
if (!instance) { return []; } if (!instance) { return []; }
return instance?.options.preload || ['en']; return Object.keys(instance?.services.resourceStore.data);
} }
export function readLoadedNamespaces(instance?: i18n): readonly string[] { export function readLoadedNamespaces(instance?: i18n): readonly string[] {

View File

@ -144,7 +144,6 @@
"https-proxy-agent": "5.0.1", "https-proxy-agent": "5.0.1",
"i18n-iso-countries": "6.1.0", "i18n-iso-countries": "6.1.0",
"i18next": "21.9.1", "i18next": "21.9.1",
"i18next-fs-backend": "1.1.5",
"i18next-http-middleware": "3.2.1", "i18next-http-middleware": "3.2.1",
"image-size": "0.6.3", "image-size": "0.6.3",
"jquery": "3.5.0", "jquery": "3.5.0",

View File

@ -62,6 +62,7 @@
--icon-Filter: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNSw0IEwxLjUsNCBDMS4yMjQsNCAxLDMuNzc2IDEsMy41IEMxLDMuMjI0IDEuMjI0LDMgMS41LDMgTDIuNSwzIEMyLjc3NiwzIDMsMy4yMjQgMywzLjUgQzMsMy43NzYgMi43NzYsNCAyLjUsNCBaIE00LjUsNyBMMS41LDcgQzEuMjI0LDcgMSw2Ljc3NiAxLDYuNSBDMSw2LjIyNCAxLjIyNCw2IDEuNSw2IEw0LjUsNiBDNC43NzYsNiA1LDYuMjI0IDUsNi41IEM1LDYuNzc2IDQuNzc2LDcgNC41LDcgWiBNNi41LDEwIEwxLjUsMTAgQzEuMjI0LDEwIDEsOS43NzYgMSw5LjUgQzEsOS4yMjQgMS4yMjQsOSAxLjUsOSBMNi41LDkgQzYuNzc2LDkgNyw5LjIyNCA3LDkuNSBDNyw5Ljc3NiA2Ljc3NiwxMCA2LjUsMTAgWiBNOC41LDEzIEwxLjUsMTMgQzEuMjI0LDEzIDEsMTIuNzc2IDEsMTIuNSBDMSwxMi4yMjQgMS4yMjQsMTIgMS41LDEyIEw4LjUsMTIgQzguNzc2LDEyIDksMTIuMjI0IDksMTIuNSBDOSwxMi43NzYgOC43NzYsMTMgOC41LDEzIFogTTE0Ljk3Mjg4OCwyLjI4NDkwODg1IEMxNS4wMjY1MzgyLDIuNDU3MzYxNzggMTQuOTk2ODg2NCwyLjY1NTk2NzI1IDE0Ljg5Nzg5MzIsMi43ODc4MDY4MyBMMTEuNjgxMzA2OCw3LjA3NjY2OTY1IEMxMS42MTE3MzQ3LDcuMTY5NTg4MDkgMTEuNTM4MDA5LDcuNDA2ODA3MDIgMTEuNTM4MDA5LDcuNTM4MTg1MDggTDExLjUzODAwOSwxMy41Mzc4ODU2IEMxMS41MzgwMDksMTMuNzI0NjQ1NSAxMS40NTM3ODQsMTMuODkyOTQ0OCAxMS4zMjQzMzE0LDEzLjk2NDE3MiBDMTEuMjgxNTI2NiwxMy45ODgwMTcgMTEuMjM2NTI5NywxMy45OTk0MDEgMTEuMTkxOTk0MywxMy45OTk0MDEgQzExLjEwMTg4NTEsMTMuOTk5NDAxIDExLjAxMzI3NTgsMTMuOTUyNDgwMyAxMC45NDcxNjUsMTMuODY0MTc3IEw5LjU2MjY0NDg2LDEyLjAxODExNTMgQzkuNDk3Njg3NzksMTEuOTMxNjU4MSA5LjQ2MTIyODc2LDExLjgxNDI3OTMgOS40NjEyMjg3NiwxMS42OTE4MjM5IEw5LjQ2MTIyODc2LDcuNTM4MTg1MDggQzkuNDYxMjI4NzYsNy40MDY4MDcwMiA5LjM4NzYxODQ0LDcuMTY5NTg4MDkgOS4zMTc5MzA5Miw3LjA3Njk3NzMzIEw2LjEwMTM0NDUyLDIuNzg3OTYwNjcgQzYuMDAyMzUxMzMsMi42NTU5NjcyNSA1Ljk3MjgxNDksMi40NTc1MTU2MiA2LjAyNjM0OTY4LDIuMjg0OTA4ODUgQzYuMDc5ODg0NDUsMi4xMTIzMDIwOSA2LjIwNjEwNjU0LDIgNi4zNDYwNTg0NSwyIEwxNC42NTMxNzkzLDIgQzE0Ljc5MzI0NjYsMiAxNC45MTk0Njg2LDIuMTEyMzAyMDkgMTQuOTcyODg4LDIuMjg0OTA4ODUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); --icon-Filter: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNSw0IEwxLjUsNCBDMS4yMjQsNCAxLDMuNzc2IDEsMy41IEMxLDMuMjI0IDEuMjI0LDMgMS41LDMgTDIuNSwzIEMyLjc3NiwzIDMsMy4yMjQgMywzLjUgQzMsMy43NzYgMi43NzYsNCAyLjUsNCBaIE00LjUsNyBMMS41LDcgQzEuMjI0LDcgMSw2Ljc3NiAxLDYuNSBDMSw2LjIyNCAxLjIyNCw2IDEuNSw2IEw0LjUsNiBDNC43NzYsNiA1LDYuMjI0IDUsNi41IEM1LDYuNzc2IDQuNzc2LDcgNC41LDcgWiBNNi41LDEwIEwxLjUsMTAgQzEuMjI0LDEwIDEsOS43NzYgMSw5LjUgQzEsOS4yMjQgMS4yMjQsOSAxLjUsOSBMNi41LDkgQzYuNzc2LDkgNyw5LjIyNCA3LDkuNSBDNyw5Ljc3NiA2Ljc3NiwxMCA2LjUsMTAgWiBNOC41LDEzIEwxLjUsMTMgQzEuMjI0LDEzIDEsMTIuNzc2IDEsMTIuNSBDMSwxMi4yMjQgMS4yMjQsMTIgMS41LDEyIEw4LjUsMTIgQzguNzc2LDEyIDksMTIuMjI0IDksMTIuNSBDOSwxMi43NzYgOC43NzYsMTMgOC41LDEzIFogTTE0Ljk3Mjg4OCwyLjI4NDkwODg1IEMxNS4wMjY1MzgyLDIuNDU3MzYxNzggMTQuOTk2ODg2NCwyLjY1NTk2NzI1IDE0Ljg5Nzg5MzIsMi43ODc4MDY4MyBMMTEuNjgxMzA2OCw3LjA3NjY2OTY1IEMxMS42MTE3MzQ3LDcuMTY5NTg4MDkgMTEuNTM4MDA5LDcuNDA2ODA3MDIgMTEuNTM4MDA5LDcuNTM4MTg1MDggTDExLjUzODAwOSwxMy41Mzc4ODU2IEMxMS41MzgwMDksMTMuNzI0NjQ1NSAxMS40NTM3ODQsMTMuODkyOTQ0OCAxMS4zMjQzMzE0LDEzLjk2NDE3MiBDMTEuMjgxNTI2NiwxMy45ODgwMTcgMTEuMjM2NTI5NywxMy45OTk0MDEgMTEuMTkxOTk0MywxMy45OTk0MDEgQzExLjEwMTg4NTEsMTMuOTk5NDAxIDExLjAxMzI3NTgsMTMuOTUyNDgwMyAxMC45NDcxNjUsMTMuODY0MTc3IEw5LjU2MjY0NDg2LDEyLjAxODExNTMgQzkuNDk3Njg3NzksMTEuOTMxNjU4MSA5LjQ2MTIyODc2LDExLjgxNDI3OTMgOS40NjEyMjg3NiwxMS42OTE4MjM5IEw5LjQ2MTIyODc2LDcuNTM4MTg1MDggQzkuNDYxMjI4NzYsNy40MDY4MDcwMiA5LjM4NzYxODQ0LDcuMTY5NTg4MDkgOS4zMTc5MzA5Miw3LjA3Njk3NzMzIEw2LjEwMTM0NDUyLDIuNzg3OTYwNjcgQzYuMDAyMzUxMzMsMi42NTU5NjcyNSA1Ljk3MjgxNDksMi40NTc1MTU2MiA2LjAyNjM0OTY4LDIuMjg0OTA4ODUgQzYuMDc5ODg0NDUsMi4xMTIzMDIwOSA2LjIwNjEwNjU0LDIgNi4zNDYwNTg0NSwyIEwxNC42NTMxNzkzLDIgQzE0Ljc5MzI0NjYsMiAxNC45MTk0Njg2LDIuMTEyMzAyMDkgMTQuOTcyODg4LDIuMjg0OTA4ODUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
--icon-FilterSimple: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTkuOTUwODMzMzMsMC4yMiBDOS44NzgyODY2NywwLjA4NDQ5NzcxMDggOS43MzcwMzQsLTYuMTY5Njc5NTFlLTA1IDkuNTgzMzMzMzMsLTQuNjI1OTMyOGUtMTcgTDAuNDE2NjY2NjY3LC00LjYyNTkzMjhlLTE3IEMwLjI2MzEwMzQ1NCw3LjkzMjczNDM1ZS0wNSAwLjEyMjAzMTQsMC4wODQ2MTg0ODc1IDAuMDQ5NTQ5NDAxMiwwLjIxOTk5OTUyOSBDLTAuMDIyOTMyNTk3OCwwLjM1NTM4MDU3MSAtMC4wMTUwNzQwNTE3LDAuNTE5NjU2MDYyIDAuMDcsMC42NDc1IEwzLjMzMzMzMzMzLDUuNTQyNSBMMy4zMzMzMzMzMyw5LjU4MzMzMzMzIEMzLjMzMzMzMzMzLDkuODEzNDUyIDMuNTE5ODgxMzYsMTAgMy43NSwxMCBMNi4yNSwxMCBDNi40ODAxMTg2NCwxMCA2LjY2NjY2NjY3LDkuODEzNDUyIDYuNjY2NjY2NjcsOS41ODMzMzMzMyBMNi42NjY2NjY2Nyw1LjU0MjUgTDkuOTMsMC42NDc1IEMxMC4wMTUxOTU2LDAuNTE5NzI3OTkxIDEwLjAyMzIwMTMsMC4zNTU0NTA1NDcgOS45NTA4MzMzMywwLjIyIFoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDMgMykiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-FilterSimple: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTkuOTUwODMzMzMsMC4yMiBDOS44NzgyODY2NywwLjA4NDQ5NzcxMDggOS43MzcwMzQsLTYuMTY5Njc5NTFlLTA1IDkuNTgzMzMzMzMsLTQuNjI1OTMyOGUtMTcgTDAuNDE2NjY2NjY3LC00LjYyNTkzMjhlLTE3IEMwLjI2MzEwMzQ1NCw3LjkzMjczNDM1ZS0wNSAwLjEyMjAzMTQsMC4wODQ2MTg0ODc1IDAuMDQ5NTQ5NDAxMiwwLjIxOTk5OTUyOSBDLTAuMDIyOTMyNTk3OCwwLjM1NTM4MDU3MSAtMC4wMTUwNzQwNTE3LDAuNTE5NjU2MDYyIDAuMDcsMC42NDc1IEwzLjMzMzMzMzMzLDUuNTQyNSBMMy4zMzMzMzMzMyw5LjU4MzMzMzMzIEMzLjMzMzMzMzMzLDkuODEzNDUyIDMuNTE5ODgxMzYsMTAgMy43NSwxMCBMNi4yNSwxMCBDNi40ODAxMTg2NCwxMCA2LjY2NjY2NjY3LDkuODEzNDUyIDYuNjY2NjY2NjcsOS41ODMzMzMzMyBMNi42NjY2NjY2Nyw1LjU0MjUgTDkuOTMsMC42NDc1IEMxMC4wMTUxOTU2LDAuNTE5NzI3OTkxIDEwLjAyMzIwMTMsMC4zNTU0NTA1NDcgOS45NTA4MzMzMywwLjIyIFoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDMgMykiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-Fireworks: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTguNjY2NDcgMTUuMzMzM0g3LjMzMzE0QzcuMzMzMTQgOS44NDc5NyA0LjQ0NzggOC42MzA2MyA0LjQxODQ3IDguNjE5M0wzLjc5OTggOC4zNzEzIDQuMjk1MTQgNy4xMzMzIDQuOTE0NDcgNy4zODA2M0M1LjAyNTE0IDcuNDI1OTcgNi45NTE4IDguMjM1MyA3Ljk5OTggMTEuMTE0IDkuMDQ3OCA4LjIzNTk3IDEwLjk3NDUgNy40MjY2MyAxMS4wODUxIDcuMzgwNjNMMTEuNzA0NSA3LjEzMzMgMTIuMTk5OCA4LjM3MTMgMTEuNTg0NSA4LjYxNzNDMTEuNDM5OCA4LjY3OTk3IDguNjY2NDcgOS45NDE5NyA4LjY2NjQ3IDE1LjMzMzN6TTEwLjQ1MzEgMS43OTA1NEw4Ljc1NzcyIDEuNTQ0NTQgNy45OTk3Mi4wMDg1NDQ5MiA3LjI0MTcyIDEuNTQ0NTQgNS41NDYzOSAxLjc5MDU0IDYuNzczMDUgMi45ODY1NCA2LjQ4MzcyIDQuNjc1MjEgNy45OTk3MiAzLjg3Nzg4IDkuNTE1NzIgNC42NzUyMSA5LjIyNjM5IDIuOTg2NTQgMTAuNDUzMSAxLjc5MDU0ek01LjI5OTE3IDEyLjM1OEwzLjY5ODUgMTIuMTI1MyAyLjk4MzE3IDEwLjY3NTMgMi4yNjcxNyAxMi4xMjUzLjY2NjUwNCAxMi4zNTggMS44MjQ1IDEzLjQ4NjYgMS41NTExNyAxNS4wODA2IDIuOTgzMTcgMTQuMzI4NiA0LjQxNDUgMTUuMDgwNiA0LjE0MTE3IDEzLjQ4NjYgNS4yOTkxNyAxMi4zNTh6TTE1LjMzMzMgMTIuMzU4TDEzLjczMjcgMTIuMTI1MyAxMy4wMTY3IDEwLjY3NTMgMTIuMzAxMyAxMi4xMjUzIDEwLjcwMDcgMTIuMzU4IDExLjg1ODcgMTMuNDg2NiAxMS41ODUzIDE1LjA4MDYgMTMuMDE2NyAxNC4zMjg2IDE0LjQ0ODcgMTUuMDgwNiAxNC4xNzUzIDEzLjQ4NjYgMTUuMzMzMyAxMi4zNTh6IiBmaWxsPSIjZmZmIi8+PHBhdGggZD0iTTguNjY2MzQgNy41MzMxM1Y1LjM0MThINy4zMzMwMVY3LjUyMDQ2QzcuNTc2MjEgNy43NjgxMSA3LjgwMjUgOC4wMzE4MSA4LjAxMDM0IDguMzA5OCA4LjIxMjIgOC4wMzcxNyA4LjQzMTMyIDcuNzc3NzUgOC42NjYzNCA3LjUzMzEzek0xLjY2NjUgNy4zMzMyNUMyLjIxODc5IDcuMzMzMjUgMi42NjY1IDYuODg1NTQgMi42NjY1IDYuMzMzMjUgMi42NjY1IDUuNzgwOTcgMi4yMTg3OSA1LjMzMzI1IDEuNjY2NSA1LjMzMzI1IDEuMTE0MjIgNS4zMzMyNS42NjY1MDQgNS43ODA5Ny42NjY1MDQgNi4zMzMyNS42NjY1MDQgNi44ODU1NCAxLjExNDIyIDcuMzMzMjUgMS42NjY1IDcuMzMzMjV6TTE0LjMzMyA3LjMzMzI1QzE0Ljg4NTMgNy4zMzMyNSAxNS4zMzMgNi44ODU1NCAxNS4zMzMgNi4zMzMyNSAxNS4zMzMgNS43ODA5NyAxNC44ODUzIDUuMzMzMjUgMTQuMzMzIDUuMzMzMjUgMTMuNzgwNyA1LjMzMzI1IDEzLjMzMyA1Ljc4MDk3IDEzLjMzMyA2LjMzMzI1IDEzLjMzMyA2Ljg4NTU0IDEzLjc4MDcgNy4zMzMyNSAxNC4zMzMgNy4zMzMyNXoiIGZpbGw9IiNmZmYiLz48L3N2Zz4='); --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-Folder: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMsNSBMMTMsNSBDMTMuNTUyMjg0Nyw1IDE0LDUuNDQ3NzE1MjUgMTQsNiBMMTQsMTMgQzE0LDEzLjU1MjI4NDcgMTMuNTUyMjg0NywxNCAxMywxNCBMMywxNCBDMi40NDc3MTUyNSwxNCAyLDEzLjU1MjI4NDcgMiwxMyBMMiw2IEMyLDUuNDQ3NzE1MjUgMi40NDc3MTUyNSw1IDMsNSBaIE00LDIgTDEyLDIgTDEyLDQgTDQsNCBMNCwyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-FontBold: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTTMuMTk1MDk4OCAxLjgzNTQxOTloNS42OTAzODE4Yy43NTQ1OTUyIDAgMS40NzgyNzI0LjI5OTc1NjcgMi4wMTE4NTM0LjgzMzMzNzUuNTMzNTgxLjUzMzU4MDguODMzMzM3IDEuMjU3MjU4Mi44MzMzMzcgMi4wMTE4NTM1IDAgLjc1NDU5NTItLjI5OTc1NiAxLjQ3ODI3MjctLjgzMzMzNyAyLjAxMTg1MzVDMTAuMzYzNzUzIDcuMjI2MDQ1MSA5LjY0MDA3NTggNy41MjU4MDE4IDguODg1NDgwNiA3LjUyNTgwMThINS4wOTE4OTI3TTUuMDkxODkyNyA3LjUyNTgwMThoNS4yMTYxODMzYy44ODAzNTIgMCAxLjcyNDY1My4zNDk3MTgyIDIuMzQ3MTY5Ljk3MjIyMDcuNjIyNTAyLjYyMjUxNTIuOTcyMjIgMS40NjY4MTYxLjk3MjIyIDIuMzQ3MTY4NXYwYzAgLjg4MDM1My0uMzQ5NzE4IDEuNzI0NjU0LS45NzIyMiAyLjM0NzIxOS0uNjIyNTE2LjYyMjQwMi0xLjQ2NjgxNy45NzIxNy0yLjM0NzE2OS45NzIxN0gzLjE5NTA5ODhNNS4wOTE4OTI3IDEuODM1NDE5OVYxNC4xNjQ1OCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDMwMDAwIiBzdHJva2Utd2lkdGg9IjEuMjY1IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4='); --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='); --icon-FontItalic: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDEyVjEySDB6Ii8+PC9jbGlwUGF0aD48L2RlZnM+PGcgdHJhbnNmb3JtPSJtYXRyaXgoMS4xMzU2IDAgMCAxLjEzNTYgLjk4MyAxLjM5KSIgY2xpcC1wYXRoPSJ1cmwoI2EpIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGZpbGw9Im5vbmUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0ibSA0Ljg3NSwwLjM3NSBoIDQuNSIgc3Ryb2tlPSIjMDAwIi8+PHBhdGggZD0ibSAyLjYyNSwxMS42MjUgaCA0LjUiIHN0cm9rZT0iIzA2MDAwMCIvPjxwYXRoIGQ9Im0gNy4xMjUsMC4zNzUgLTIuMjUsMTEuMjUiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLW9wYWNpdHk9Ii45OSIvPjwvZz48L3N2Zz4=');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -41,7 +41,7 @@ describe("Localization", function() {
const namespaces: Set<string> = new Set(); const namespaces: Set<string> = new Set();
for (const file of fs.readdirSync(localeDirectory)) { for (const file of fs.readdirSync(localeDirectory)) {
if (file.endsWith(".json")) { if (file.endsWith(".json")) {
const lang = file.split('.')[0]; const lang = file.split('.')[0]?.replace(/_/g, '-');
const ns = file.split('.')[1]; const ns = file.split('.')[1];
langs.add(lang); langs.add(lang);
namespaces.add(ns); namespaces.add(ns);

View File

@ -35,6 +35,8 @@ namespace gristUtils {
// Allow overriding the global 'driver' to use in gristUtil. // Allow overriding the global 'driver' to use in gristUtil.
let driver: WebDriver; let driver: WebDriver;
export function currentDriver() { return driver; }
// Substitute a custom driver to use with gristUtils functions. Omit argument to restore to default. // Substitute a custom driver to use with gristUtils functions. Omit argument to restore to default.
export function setDriver(customDriver: WebDriver = driverOrig) { driver = customDriver; } export function setDriver(customDriver: WebDriver = driverOrig) { driver = customDriver; }
@ -2470,7 +2472,7 @@ export async function openAccountMenu() {
export async function openProfileSettingsPage() { export async function openProfileSettingsPage() {
await openAccountMenu(); 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); await driver.findWait('.test-account-page-login-method', 5000);
} }

View File

@ -4509,11 +4509,6 @@ i18n-iso-countries@6.1.0:
dependencies: dependencies:
diacritics "1.3.0" 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: i18next-http-middleware@3.2.1:
version "3.2.1" version "3.2.1"
resolved "https://registry.npmjs.org/i18next-http-middleware/-/i18next-http-middleware-3.2.1.tgz" resolved "https://registry.npmjs.org/i18next-http-middleware/-/i18next-http-middleware-3.2.1.tgz"