(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() {
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.
*/

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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.
*/
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 => ({