mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
Merge branch 'main' into column-description
This commit is contained in:
@@ -13,10 +13,12 @@ import {transientInput} from 'app/client/ui/transientInput';
|
||||
import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs';
|
||||
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
|
||||
import {cssLink} from 'app/client/ui2018/links';
|
||||
import {select} from 'app/client/ui2018/menus';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import {FullUser} from 'app/common/UserAPI';
|
||||
import {detectCurrentLang, makeT} from 'app/client/lib/localization';
|
||||
import {translateLocale} from 'app/client/ui/LanguageMenu';
|
||||
import {Computed, Disposable, dom, domComputed, makeTestId, Observable, styled} from 'grainjs';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
|
||||
const testId = makeTestId('test-account-page-');
|
||||
const t = makeT('AccountPage');
|
||||
@@ -56,6 +58,22 @@ export class AccountPage extends Disposable {
|
||||
|
||||
private _buildContentMain() {
|
||||
const {enableCustomCss} = getGristConfig();
|
||||
const supportedLngs = getGristConfig().supportedLngs ?? ['en'];
|
||||
const languageOptions = supportedLngs
|
||||
.map((lng) => ({value: lng, label: translateLocale(lng)!}))
|
||||
.sort((a, b) => a.value.localeCompare(b.value));
|
||||
|
||||
const userLocale = Computed.create(this, use => {
|
||||
const selected = detectCurrentLang();
|
||||
if (!supportedLngs.includes(selected)) { return 'en'; }
|
||||
return selected;
|
||||
});
|
||||
userLocale.onWrite(async value => {
|
||||
await this._appModel.api.updateUserLocale(value || null);
|
||||
// Reload the page to apply the new locale.
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
return domComputed(this._userObs, (user) => user && (
|
||||
css.container(css.accountPage(
|
||||
css.header(t("Account settings")),
|
||||
@@ -73,7 +91,7 @@ export class AccountPage extends Disposable {
|
||||
save: (val) => this._isNameValid.get() && this._updateUserName(val),
|
||||
close: () => { this._isEditingName.set(false); this._nameEdit.set(''); },
|
||||
},
|
||||
{ size: '5' }, // Lower size so that input can shrink below ~152px.
|
||||
{size: '5'}, // Lower size so that input can shrink below ~152px.
|
||||
dom.on('input', (_ev, el) => this._nameEdit.set(el.value)),
|
||||
css.flexGrow.cls(''),
|
||||
),
|
||||
@@ -92,7 +110,7 @@ export class AccountPage extends Disposable {
|
||||
testId('username'),
|
||||
),
|
||||
// show warning for invalid name but not for the empty string
|
||||
dom.maybe(use => use(this._nameEdit) && !use(this._isNameValid), cssWarnings),
|
||||
dom.maybe(use => use(this._nameEdit) && !use(this._isNameValid), this._buildNameWarningsDom.bind(this)),
|
||||
css.header(t("Password & Security")),
|
||||
css.dataRow(
|
||||
css.inlineSubHeader(t("Login Method")),
|
||||
@@ -123,6 +141,15 @@ export class AccountPage extends Disposable {
|
||||
enableCustomCss ? null : [
|
||||
css.header(t("Theme")),
|
||||
dom.create(ThemeConfig, this._appModel),
|
||||
css.subHeader(t("Language")),
|
||||
css.dataRow({ style: 'width: 300px'},
|
||||
select(userLocale, languageOptions, {
|
||||
renderOptionArgs: () => {
|
||||
return dom.cls(cssFirstUpper.className);
|
||||
}
|
||||
}),
|
||||
testId('language'),
|
||||
)
|
||||
],
|
||||
css.header(t("API")),
|
||||
css.dataRow(css.inlineSubHeader(t("API Key")), css.content(
|
||||
@@ -131,7 +158,7 @@ export class AccountPage extends Disposable {
|
||||
onCreate: () => this._createApiKey(),
|
||||
onDelete: () => this._deleteApiKey(),
|
||||
anonymous: false,
|
||||
inputArgs: [{ size: '5' }], // Lower size so that input can shrink below ~152px.
|
||||
inputArgs: [{size: '5'}], // Lower size so that input can shrink below ~152px.
|
||||
})
|
||||
)),
|
||||
),
|
||||
@@ -141,7 +168,7 @@ export class AccountPage extends Disposable {
|
||||
|
||||
private _buildHeaderMain() {
|
||||
return dom.frag(
|
||||
cssBreadcrumbs({ style: 'margin-left: 16px;' },
|
||||
cssBreadcrumbs({style: 'margin-left: 16px;'},
|
||||
cssLink(
|
||||
urlState().setLinkUrl({}),
|
||||
'Home',
|
||||
@@ -194,6 +221,16 @@ export class AccountPage extends Disposable {
|
||||
private _showChangePasswordDialog() {
|
||||
return buildChangePasswordDialog();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds dom to show marning messages to the user.
|
||||
*/
|
||||
private _buildNameWarningsDom() {
|
||||
return cssWarnings(
|
||||
t("Names only allow letters, numbers and certain special characters"),
|
||||
testId('username-warning'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,16 +248,14 @@ export function checkName(name: string): boolean {
|
||||
return VALID_NAME_REGEXP.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds dom to show marning messages to the user.
|
||||
*/
|
||||
function buildNameWarningsDom() {
|
||||
return css.warning(
|
||||
t("Names only allow letters, numbers and certain special characters"),
|
||||
testId('username-warning'),
|
||||
);
|
||||
}
|
||||
|
||||
const cssWarnings = styled(buildNameWarningsDom, `
|
||||
|
||||
const cssWarnings = styled(css.warning, `
|
||||
margin: -8px 0 0 110px;
|
||||
`);
|
||||
|
||||
const cssFirstUpper = styled('div', `
|
||||
& > div::first-letter {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
`);
|
||||
|
||||
@@ -98,6 +98,7 @@ export const dataRow = styled('div', `
|
||||
margin: 8px 0px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
`);
|
||||
|
||||
export const betaTag = styled('span', `
|
||||
|
||||
@@ -86,7 +86,7 @@ export class AccountWidget extends Disposable {
|
||||
cssEmail(user.email, testId('usermenu-email'))
|
||||
)
|
||||
),
|
||||
menuItemLink(urlState().setLinkUrl({account: 'account'}), t("Profile Settings")),
|
||||
menuItemLink(urlState().setLinkUrl({account: 'account'}), t("Profile Settings"), testId('dm-account-settings')),
|
||||
|
||||
documentSettingsItem,
|
||||
|
||||
|
||||
107
app/client/ui/LanguageMenu.ts
Normal file
107
app/client/ui/LanguageMenu.ts
Normal 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;
|
||||
}
|
||||
`);
|
||||
@@ -11,6 +11,7 @@ import {manageTeamUsersApp} from 'app/client/ui/OpenUserManager';
|
||||
import {buildShareMenuButton} from 'app/client/ui/ShareMenu';
|
||||
import {hoverTooltip} from 'app/client/ui/tooltips';
|
||||
import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
|
||||
import {buildLanguageMenu} from 'app/client/ui/LanguageMenu';
|
||||
import {docBreadcrumbs} from 'app/client/ui2018/breadcrumbs';
|
||||
import {basicButton} from 'app/client/ui2018/buttons';
|
||||
import {cssHideForNarrowScreen, testId, theme} from 'app/client/ui2018/cssVars';
|
||||
@@ -38,6 +39,7 @@ export function createTopBarHome(appModel: AppModel) {
|
||||
null
|
||||
),
|
||||
|
||||
buildLanguageMenu(appModel),
|
||||
buildNotifyMenuButton(appModel.notifier, appModel),
|
||||
dom('div', dom.create(AccountWidget, appModel)),
|
||||
];
|
||||
|
||||
@@ -622,7 +622,8 @@ function getFullUser(member: IEditableMember): FullUser {
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
picture: member.picture
|
||||
picture: member.picture,
|
||||
locale: member.locale
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user