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/D3766removing-missing-key-error
@ -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;
|
||||||
|
}
|
||||||
|
`);
|
After Width: | Height: | Size: 2.9 KiB |
After Width: | Height: | Size: 432 B |
After Width: | Height: | Size: 6.1 KiB |
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.
|
After Width: | Height: | Size: 628 B |
After Width: | Height: | Size: 2.3 KiB |
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();
|
||||||
|
}
|