mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
Make a good part of the app localizable and add French translations (#325)
Co-authored-by: Yohan Boniface <yohanboniface@free.fr>
This commit is contained in:
@@ -16,8 +16,10 @@ import {cssLink} from 'app/client/ui2018/links';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import {FullUser} from 'app/common/UserAPI';
|
||||
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');
|
||||
|
||||
/**
|
||||
* Creates the account page where a user can manage their profile settings.
|
||||
@@ -56,13 +58,13 @@ export class AccountPage extends Disposable {
|
||||
const {enableCustomCss} = getGristConfig();
|
||||
return domComputed(this._userObs, (user) => user && (
|
||||
css.container(css.accountPage(
|
||||
css.header('Account settings'),
|
||||
css.header(t('AccountSettings')),
|
||||
css.dataRow(
|
||||
css.inlineSubHeader('Email'),
|
||||
css.inlineSubHeader(t('Email')),
|
||||
css.email(user.email),
|
||||
),
|
||||
css.dataRow(
|
||||
css.inlineSubHeader('Name'),
|
||||
css.inlineSubHeader(t('Name')),
|
||||
domComputed(this._isEditingName, (isEditing) => (
|
||||
isEditing ? [
|
||||
transientInput(
|
||||
@@ -76,13 +78,13 @@ export class AccountPage extends Disposable {
|
||||
css.flexGrow.cls(''),
|
||||
),
|
||||
css.textBtn(
|
||||
css.icon('Settings'), 'Save',
|
||||
css.icon('Settings'), t('Save'),
|
||||
// No need to save on 'click'. The transient input already does it on close.
|
||||
),
|
||||
] : [
|
||||
css.name(user.name),
|
||||
css.textBtn(
|
||||
css.icon('Settings'), 'Edit',
|
||||
css.icon('Settings'), t('Edit'),
|
||||
dom.on('click', () => this._isEditingName.set(true)),
|
||||
),
|
||||
]
|
||||
@@ -91,11 +93,11 @@ export class AccountPage extends Disposable {
|
||||
),
|
||||
// show warning for invalid name but not for the empty string
|
||||
dom.maybe(use => use(this._nameEdit) && !use(this._isNameValid), cssWarnings),
|
||||
css.header('Password & Security'),
|
||||
css.header(t('PasswordSecurity')),
|
||||
css.dataRow(
|
||||
css.inlineSubHeader('Login Method'),
|
||||
css.inlineSubHeader(t("LoginMethod")),
|
||||
css.loginMethod(user.loginMethod),
|
||||
user.loginMethod === 'Email + Password' ? css.textBtn('Change Password',
|
||||
user.loginMethod === 'Email + Password' ? css.textBtn(t("ChangePassword"),
|
||||
dom.on('click', () => this._showChangePasswordDialog()),
|
||||
) : null,
|
||||
testId('login-method'),
|
||||
@@ -104,26 +106,24 @@ export class AccountPage extends Disposable {
|
||||
css.dataRow(
|
||||
labeledSquareCheckbox(
|
||||
this._allowGoogleLogin,
|
||||
'Allow signing in to this account with Google',
|
||||
t('AllowGoogleSigning'),
|
||||
testId('allow-google-login-checkbox'),
|
||||
),
|
||||
testId('allow-google-login'),
|
||||
),
|
||||
css.subHeader('Two-factor authentication'),
|
||||
css.subHeader(t('TwoFactorAuth')),
|
||||
css.description(
|
||||
"Two-factor authentication is an extra layer of security for your Grist account designed " +
|
||||
"to ensure that you're the only person who can access your account, even if someone " +
|
||||
"knows your password."
|
||||
t("TwoFactorAuthDescription")
|
||||
),
|
||||
dom.create(MFAConfig, user),
|
||||
),
|
||||
// Custom CSS is incompatible with custom themes.
|
||||
enableCustomCss ? null : [
|
||||
css.header('Theme'),
|
||||
css.header(t('Theme')),
|
||||
dom.create(ThemeConfig, this._appModel),
|
||||
],
|
||||
css.header('API'),
|
||||
css.dataRow(css.inlineSubHeader('API Key'), css.content(
|
||||
css.header(t('API')),
|
||||
css.dataRow(css.inlineSubHeader(t('APIKey')), css.content(
|
||||
dom.create(ApiKey, {
|
||||
apiKey: this._apiKey,
|
||||
onCreate: () => this._createApiKey(),
|
||||
@@ -214,7 +214,7 @@ export function checkName(name: string): boolean {
|
||||
*/
|
||||
function buildNameWarningsDom() {
|
||||
return css.warning(
|
||||
"Names only allow letters, numbers and certain special characters",
|
||||
t("WarningUsername"),
|
||||
testId('username-warning'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ import * as roles from 'app/common/roles';
|
||||
import {Disposable, dom, DomElementArg, styled} from 'grainjs';
|
||||
import {cssMenuItem} from 'popweasel';
|
||||
import {maybeAddSiteSwitcherSection} from 'app/client/ui/SiteSwitcher';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
|
||||
const t = makeT('AccountWidget');
|
||||
|
||||
/**
|
||||
* Render the user-icon that opens the account menu. When no user is logged in, render a Sign-in
|
||||
@@ -33,7 +36,7 @@ export class AccountWidget extends Disposable {
|
||||
cssUserIcon(createUserImage(user, 'medium', testId('user-icon')),
|
||||
menu(() => this._makeAccountMenu(user), {placement: 'bottom-end'}),
|
||||
) :
|
||||
cssSignInButton('Sign in', icon('Collapse'), testId('user-signin'),
|
||||
cssSignInButton(t('SignIn'), icon('Collapse'), testId('user-signin'),
|
||||
menu(() => this._makeAccountMenu(user), {placement: 'bottom-end'}),
|
||||
)
|
||||
)
|
||||
@@ -54,24 +57,24 @@ export class AccountWidget extends Disposable {
|
||||
// The 'Document Settings' item, when there is an open document.
|
||||
const documentSettingsItem = (gristDoc ?
|
||||
menuItem(async () => (await loadGristDoc()).showDocSettingsModal(gristDoc.docInfo, this._docPageModel!),
|
||||
'Document Settings',
|
||||
t('DocumentSettings'),
|
||||
testId('dm-doc-settings')) :
|
||||
null);
|
||||
|
||||
// The item to toggle mobile mode (presence of viewport meta tag).
|
||||
const mobileModeToggle = menuItem(viewport.toggleViewport,
|
||||
cssSmallDeviceOnly.cls(''), // Only show this toggle on small devices.
|
||||
'Toggle Mobile Mode',
|
||||
t('ToggleMobileMode'),
|
||||
cssCheckmark('Tick', dom.show(viewport.viewportEnabled)),
|
||||
testId('usermenu-toggle-mobile'),
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return [
|
||||
menuItemLink({href: getLoginOrSignupUrl()}, 'Sign in'),
|
||||
menuItemLink({href: getLoginOrSignupUrl()}, t('SignIn')),
|
||||
menuDivider(),
|
||||
documentSettingsItem,
|
||||
menuItemLink({href: commonUrls.plans}, 'Pricing'),
|
||||
menuItemLink({href: commonUrls.plans}, t('Pricing')),
|
||||
mobileModeToggle,
|
||||
];
|
||||
}
|
||||
@@ -85,14 +88,14 @@ export class AccountWidget extends Disposable {
|
||||
cssEmail(user.email, testId('usermenu-email'))
|
||||
)
|
||||
),
|
||||
menuItemLink(urlState().setLinkUrl({account: 'account'}), 'Profile Settings'),
|
||||
menuItemLink(urlState().setLinkUrl({account: 'account'}), t('ProfileSettings')),
|
||||
|
||||
documentSettingsItem,
|
||||
|
||||
// Show 'Organization Settings' when on a home page of a valid org.
|
||||
(!this._docPageModel && currentOrg && this._appModel.isTeamSite ?
|
||||
menuItem(() => manageTeamUsers(currentOrg, user, this._appModel.api),
|
||||
roles.canEditAccess(currentOrg.access) ? 'Manage Team' : 'Access Details',
|
||||
roles.canEditAccess(currentOrg.access) ? t('ManageTeam') : t('AccessDetails'),
|
||||
testId('dm-org-access')) :
|
||||
// Don't show on doc pages, or for personal orgs.
|
||||
null),
|
||||
@@ -108,7 +111,7 @@ export class AccountWidget extends Disposable {
|
||||
// org-listing UI below.
|
||||
this._appModel.topAppModel.isSingleOrg || shouldHideUiElement("multiAccounts") ? [] : [
|
||||
menuDivider(),
|
||||
menuSubHeader(dom.text((use) => use(users).length > 1 ? 'Switch Accounts' : 'Accounts')),
|
||||
menuSubHeader(dom.text((use) => use(users).length > 1 ? t('SwitchAccounts') : t('Accounts'))),
|
||||
dom.forEach(users, (_user) => {
|
||||
if (_user.id === user.id) { return null; }
|
||||
return menuItem(() => this._switchAccount(_user),
|
||||
@@ -116,10 +119,10 @@ export class AccountWidget extends Disposable {
|
||||
cssOtherEmail(_user.email, testId('usermenu-other-email')),
|
||||
);
|
||||
}),
|
||||
isExternal ? null : menuItemLink({href: getLoginUrl()}, "Add Account", testId('dm-add-account')),
|
||||
isExternal ? null : menuItemLink({href: getLoginUrl()}, t("AddAccount"), testId('dm-add-account')),
|
||||
],
|
||||
|
||||
menuItemLink({href: getLogoutUrl()}, "Sign Out", testId('dm-log-out')),
|
||||
menuItemLink({href: getLogoutUrl()}, t("SignOut"), testId('dm-log-out')),
|
||||
|
||||
maybeAddSiteSwitcherSection(this._appModel),
|
||||
];
|
||||
|
||||
@@ -3,14 +3,14 @@ import {makeT} from 'app/client/lib/localization';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {dom, DomElementArg, Observable, styled} from "grainjs";
|
||||
|
||||
const translate = makeT(`AddNewButton`);
|
||||
const t = makeT(`AddNewButton`);
|
||||
|
||||
export function addNewButton(isOpen: Observable<boolean> | boolean = true, ...args: DomElementArg[]) {
|
||||
return cssAddNewButton(
|
||||
cssAddNewButton.cls('-open', isOpen),
|
||||
// Setting spacing as flex items allows them to shrink faster when there isn't enough space.
|
||||
cssLeftMargin(),
|
||||
cssAddText(translate('AddNew')),
|
||||
cssAddText(t('AddNew')),
|
||||
dom('div', {style: 'flex: 1 1 16px'}),
|
||||
cssPlusButton(cssPlusIcon('Plus')),
|
||||
dom('div', {style: 'flex: 0 1 16px'}),
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { makeT } from 'app/client/lib/localization';
|
||||
import { basicButton, textButton } from 'app/client/ui2018/buttons';
|
||||
import { theme, vars } from 'app/client/ui2018/cssVars';
|
||||
import { icon } from 'app/client/ui2018/icons';
|
||||
import { confirmModal } from 'app/client/ui2018/modals';
|
||||
import { Disposable, dom, IDomArgs, makeTestId, Observable, observable, styled } from 'grainjs';
|
||||
|
||||
const t = makeT('ApiKey');
|
||||
|
||||
interface IWidgetOptions {
|
||||
apiKey: Observable<string>;
|
||||
onDelete: () => Promise<void>;
|
||||
@@ -52,7 +55,7 @@ export class ApiKey extends Disposable {
|
||||
},
|
||||
dom.attr('type', (use) => use(this._isHidden) ? 'password' : 'text'),
|
||||
testId('key'),
|
||||
{title: 'Click to show'},
|
||||
{title: t('ClickToShow')},
|
||||
dom.on('click', (_ev, el) => {
|
||||
this._isHidden.set(false);
|
||||
setTimeout(() => el.select(), 0);
|
||||
@@ -64,7 +67,7 @@ export class ApiKey extends Disposable {
|
||||
this._inputArgs
|
||||
),
|
||||
cssTextBtn(
|
||||
cssTextBtnIcon('Remove'), 'Remove',
|
||||
cssTextBtnIcon('Remove'), t('Remove'),
|
||||
dom.on('click', () => this._showRemoveKeyModal()),
|
||||
testId('delete'),
|
||||
dom.boolAttr('disabled', (use) => use(this._loading) || this._anonymous)
|
||||
@@ -73,10 +76,9 @@ export class ApiKey extends Disposable {
|
||||
description(this._getDescription(), testId('description')),
|
||||
)),
|
||||
dom.maybe((use) => !(use(this._apiKey) || this._anonymous), () => [
|
||||
basicButton('Create', dom.on('click', () => this._onCreate()), testId('create'),
|
||||
basicButton(t('Create'), dom.on('click', () => this._onCreate()), testId('create'),
|
||||
dom.boolAttr('disabled', this._loading)),
|
||||
description('By generating an API key, you will be able to make API calls '
|
||||
+ 'for your own account.', testId('description')),
|
||||
description(t('ByGenerating'), testId('description')),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -101,20 +103,16 @@ export class ApiKey extends Disposable {
|
||||
}
|
||||
|
||||
private _getDescription(): string {
|
||||
if (!this._anonymous) {
|
||||
return 'This API key can be used to access your account via the API. '
|
||||
+ 'Don’t share your API key with anyone.';
|
||||
} else {
|
||||
return 'This API key can be used to access this account anonymously via the API.';
|
||||
}
|
||||
return t(
|
||||
!this._anonymous ? 'OwnAPIKey' : 'AnonymousAPIkey'
|
||||
);
|
||||
}
|
||||
|
||||
private _showRemoveKeyModal(): void {
|
||||
confirmModal(
|
||||
`Remove API Key`, 'Remove',
|
||||
t('RemoveAPIKey'), t('Remove'),
|
||||
() => this._onDelete(),
|
||||
`You're about to delete an API key. This will cause all future ` +
|
||||
`requests using this API key to be rejected. Do you still want to delete?`
|
||||
t("AboutToDeleteAPIKey")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ import {fetchFromHome} from 'app/common/urlUtils';
|
||||
import {ISupportedFeatures} from 'app/common/UserConfig';
|
||||
import {dom} from 'grainjs';
|
||||
import * as ko from 'knockout';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
|
||||
const t = makeT('App');
|
||||
|
||||
// tslint:disable:no-console
|
||||
|
||||
@@ -90,8 +93,8 @@ export class App extends DisposableWithEvents {
|
||||
dom('table.g-help-table',
|
||||
dom('thead',
|
||||
dom('tr',
|
||||
dom('th', 'Key'),
|
||||
dom('th', 'Description')
|
||||
dom('th', t('Key')),
|
||||
dom('th', t('Description'))
|
||||
)
|
||||
),
|
||||
dom.forEach(commandList.groups, (group: any) => {
|
||||
@@ -231,7 +234,7 @@ export class App extends DisposableWithEvents {
|
||||
if (message.match(/MemoryError|unmarshallable object/)) {
|
||||
if (err.message.length > 30) {
|
||||
// TLDR
|
||||
err.message = 'Memory Error';
|
||||
err.message = t('MemoryError');
|
||||
}
|
||||
this._mostRecentDocPageModel?.offerRecovery(err);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ import * as roles from 'app/common/roles';
|
||||
import {manageTeamUsersApp} from 'app/client/ui/OpenUserManager';
|
||||
import {maybeAddSiteSwitcherSection} from 'app/client/ui/SiteSwitcher';
|
||||
import {BindableValue, Disposable, dom, DomContents, styled} from 'grainjs';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
|
||||
const t = makeT('AppHeader');
|
||||
|
||||
// Maps a name of a Product (from app/gen-server/entity/Product.ts) to a tag (pill) to show next
|
||||
// to the org name.
|
||||
@@ -54,11 +57,11 @@ export class AppHeader extends Disposable {
|
||||
this._orgName && cssDropdownIcon('Dropdown'),
|
||||
menu(() => [
|
||||
menuSubHeader(
|
||||
`${this._appModel.isTeamSite ? 'Team' : 'Personal'} Site`
|
||||
+ (this._appModel.isLegacySite ? ' (Legacy)' : ''),
|
||||
this._appModel.isTeamSite ? t('TeamSite') : t('PersonalSite')
|
||||
+ (this._appModel.isLegacySite ? ` (${t('Legacy')})` : ''),
|
||||
testId('orgmenu-title'),
|
||||
),
|
||||
menuItemLink(urlState().setLinkUrl({}), 'Home Page', testId('orgmenu-home-page')),
|
||||
menuItemLink(urlState().setLinkUrl({}), t('HomePage'), testId('orgmenu-home-page')),
|
||||
|
||||
// Show 'Organization Settings' when on a home page of a valid org.
|
||||
(!this._docPageModel && currentOrg && !currentOrg.owner ?
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { allCommands } from 'app/client/components/commands';
|
||||
import { makeT } from 'app/client/lib/localization';
|
||||
import { menuDivider, menuItemCmd } from 'app/client/ui2018/menus';
|
||||
import { IMultiColumnContextMenu } from 'app/client/ui/GridViewMenus';
|
||||
import { IRowContextMenu } from 'app/client/ui/RowContextMenu';
|
||||
import { COMMENTS } from 'app/client/models/features';
|
||||
import { dom } from 'grainjs';
|
||||
|
||||
const t = makeT('CellContextMenu');
|
||||
|
||||
export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiColumnContextMenu) {
|
||||
|
||||
const { disableInsert, disableDelete, isViewSorted } = rowOptions;
|
||||
@@ -16,15 +19,13 @@ export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiC
|
||||
const disableForReadonlyView = dom.cls('disabled', isReadonly);
|
||||
|
||||
const numCols: number = colOptions.numColumns;
|
||||
const nameClearColumns = colOptions.isFiltered ?
|
||||
(numCols > 1 ? `Clear ${numCols} entire columns` : 'Clear entire column') :
|
||||
(numCols > 1 ? `Clear ${numCols} columns` : 'Clear column');
|
||||
const nameDeleteColumns = numCols > 1 ? `Delete ${numCols} columns` : 'Delete column';
|
||||
const nameClearColumns = colOptions.isFiltered ? t("ClearEntireColumns", {count: numCols}) : t("ClearColumns", {count: numCols})
|
||||
const nameDeleteColumns = t("DeleteColumns", {count: numCols})
|
||||
|
||||
const numRows: number = rowOptions.numRows;
|
||||
const nameDeleteRows = numRows > 1 ? `Delete ${numRows} rows` : 'Delete row';
|
||||
const nameDeleteRows = t("DeleteRows", {count: numRows})
|
||||
|
||||
const nameClearCells = (numRows > 1 || numCols > 1) ? 'Clear values' : 'Clear cell';
|
||||
const nameClearCells = (numRows > 1 || numCols > 1) ? t('ClearValues') : t('ClearCell');
|
||||
|
||||
const result: Array<Element|null> = [];
|
||||
|
||||
@@ -40,12 +41,12 @@ export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiC
|
||||
...(
|
||||
(numCols > 1 || numRows > 1) ? [] : [
|
||||
menuDivider(),
|
||||
menuItemCmd(allCommands.copyLink, 'Copy anchor link'),
|
||||
menuItemCmd(allCommands.copyLink, t('CopyAnchorLink')),
|
||||
menuDivider(),
|
||||
menuItemCmd(allCommands.filterByThisCellValue, `Filter by this value`),
|
||||
menuItemCmd(allCommands.filterByThisCellValue, t("FilterByValue")),
|
||||
menuItemCmd(allCommands.openDiscussion, 'Comment', dom.cls('disabled', (
|
||||
isReadonly || numRows === 0 || numCols === 0
|
||||
)), dom.hide(use => !use(COMMENTS())))
|
||||
)), dom.hide(use => !use(COMMENTS()))) //TODO: i18next
|
||||
]
|
||||
),
|
||||
|
||||
@@ -57,19 +58,19 @@ export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiC
|
||||
// When the view is sorted, any newly added records get shifts instantly at the top or
|
||||
// bottom. It could be very confusing for users who might expect the record to stay above or
|
||||
// below the active row. Thus in this case we show a single `insert row` command.
|
||||
[menuItemCmd(allCommands.insertRecordAfter, 'Insert row',
|
||||
[menuItemCmd(allCommands.insertRecordAfter, t("InsertRow"),
|
||||
dom.cls('disabled', disableInsert))] :
|
||||
|
||||
[menuItemCmd(allCommands.insertRecordBefore, 'Insert row above',
|
||||
[menuItemCmd(allCommands.insertRecordBefore, t("InsertRowAbove"),
|
||||
dom.cls('disabled', disableInsert)),
|
||||
menuItemCmd(allCommands.insertRecordAfter, 'Insert row below',
|
||||
menuItemCmd(allCommands.insertRecordAfter, t("InsertRowBelow"),
|
||||
dom.cls('disabled', disableInsert))]
|
||||
),
|
||||
menuItemCmd(allCommands.duplicateRows, `Duplicate ${numRows === 1 ? 'row' : 'rows'}`,
|
||||
menuItemCmd(allCommands.duplicateRows, t("DuplicateRows", {count: numRows}),
|
||||
dom.cls('disabled', disableInsert || numRows === 0)),
|
||||
menuItemCmd(allCommands.insertFieldBefore, 'Insert column to the left',
|
||||
menuItemCmd(allCommands.insertFieldBefore, t("InsertColumnLeft"),
|
||||
disableForReadonlyView),
|
||||
menuItemCmd(allCommands.insertFieldAfter, 'Insert column to the right',
|
||||
menuItemCmd(allCommands.insertFieldAfter, t("InsertColumnRight"),
|
||||
disableForReadonlyView),
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* callback that's triggered on Apply or on Cancel. Changes to the UI result in changes to the underlying model,
|
||||
* but on Cancel the model is reset to its initial state prior to menu closing.
|
||||
*/
|
||||
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {allInclusive, ColumnFilter} from 'app/client/models/ColumnFilter';
|
||||
import {ColumnFilterMenuModel, IFilterCount} from 'app/client/models/ColumnFilterMenuModel';
|
||||
import {ColumnRec, ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
@@ -35,6 +35,8 @@ import {isDateLikeType, isList, isNumberType, isRefListType} from 'app/common/gr
|
||||
import {choiceToken} from 'app/client/widgets/ChoiceToken';
|
||||
import {ChoiceOptions} from 'app/client/widgets/ChoiceTextBox';
|
||||
|
||||
const t = makeT('ColumnFilterMenu');
|
||||
|
||||
export interface IFilterMenuOptions {
|
||||
model: ColumnFilterMenuModel;
|
||||
valueCounts: Map<CellValue, IFilterCount>;
|
||||
@@ -90,7 +92,7 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
|
||||
|
||||
// Filter by range
|
||||
dom.maybe(showRangeFilter, () => [
|
||||
cssRangeHeader('Filter by Range'),
|
||||
cssRangeHeader(t('FilterByRange')),
|
||||
cssRangeContainer(
|
||||
minRangeInput = rangeInput('Min ', columnFilter.min, rangeInputOptions, testId('min')),
|
||||
cssRangeInputSeparator('→'),
|
||||
@@ -104,7 +106,7 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
|
||||
searchInput = cssSearch(
|
||||
searchValueObs, { onInput: true },
|
||||
testId('search-input'),
|
||||
{ type: 'search', placeholder: 'Search values' },
|
||||
{ type: 'search', placeholder: t('SearchValues') },
|
||||
dom.onKeyDown({
|
||||
Enter: () => {
|
||||
if (searchValueObs.get()) {
|
||||
@@ -140,14 +142,14 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
|
||||
const state = use(columnFilter.state);
|
||||
return [
|
||||
cssSelectAll(
|
||||
dom.text(searchValue ? 'All Shown' : 'All'),
|
||||
dom.text(searchValue ? t('AllShown') : t('All')),
|
||||
cssSelectAll.cls('-disabled', isEquivalentFilter(state, allSpec)),
|
||||
dom.on('click', () => columnFilter.setState(allSpec)),
|
||||
testId('bulk-action'),
|
||||
),
|
||||
cssDotSeparator('•'),
|
||||
cssSelectAll(
|
||||
searchValue ? 'All Except' : 'None',
|
||||
searchValue ? t('AllExcept') : t('None'),
|
||||
cssSelectAll.cls('-disabled', isEquivalentFilter(state, noneSpec)),
|
||||
dom.on('click', () => columnFilter.setState(noneSpec)),
|
||||
testId('bulk-action'),
|
||||
@@ -162,7 +164,7 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
|
||||
),
|
||||
cssItemList(
|
||||
testId('list'),
|
||||
dom.maybe(use => use(filteredValues).length === 0, () => cssNoResults('No matching values')),
|
||||
dom.maybe(use => use(filteredValues).length === 0, () => cssNoResults(t('NoMatchingValues'))),
|
||||
dom.domComputed(filteredValues, (values) => values.slice(0, model.limitShown).map(([key, value]) => (
|
||||
cssMenuItem(
|
||||
cssLabel(
|
||||
@@ -189,17 +191,17 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
|
||||
const valuesBeyondLimit = use(model.valuesBeyondLimit);
|
||||
if (isAboveLimit) {
|
||||
return searchValue ? [
|
||||
buildSummary('Other Matching', valuesBeyondLimit, false, model),
|
||||
buildSummary('Other Non-Matching', otherValues, true, model),
|
||||
buildSummary(t('OtherMatching'), valuesBeyondLimit, false, model),
|
||||
buildSummary(t('OtherNonMatching'), otherValues, true, model),
|
||||
] : [
|
||||
buildSummary('Other Values', concat(otherValues, valuesBeyondLimit), false, model),
|
||||
buildSummary('Future Values', [], true, model),
|
||||
buildSummary(t('OtherValues'), concat(otherValues, valuesBeyondLimit), false, model),
|
||||
buildSummary(t('FutureValues'), [], true, model),
|
||||
];
|
||||
} else {
|
||||
return anyOtherValues ? [
|
||||
buildSummary('Others', otherValues, true, model)
|
||||
buildSummary(t('Others'), otherValues, true, model)
|
||||
] : [
|
||||
buildSummary('Future Values', [], true, model)
|
||||
buildSummary(t('FutureValues'), [], true, model)
|
||||
];
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -19,6 +19,9 @@ import {GristLoadConfig} from 'app/common/gristUrls';
|
||||
import {nativeCompare, unwrap} from 'app/common/gutil';
|
||||
import {bundleChanges, Computed, Disposable, dom, fromKo, makeTestId,
|
||||
MultiHolder, Observable, styled, UseCBOwner} from 'grainjs';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
|
||||
const t = makeT('CustomSectionConfig');
|
||||
|
||||
// Custom URL widget id - used as mock id for selectbox.
|
||||
const CUSTOM_ID = 'custom';
|
||||
@@ -58,7 +61,7 @@ class ColumnPicker extends Disposable {
|
||||
return [
|
||||
cssLabel(
|
||||
this._column.title,
|
||||
this._column.optional ? cssSubLabel(" (optional)") : null,
|
||||
this._column.optional ? cssSubLabel(t('Optional')) : null,
|
||||
testId('label-for-' + this._column.name),
|
||||
),
|
||||
this._column.description ? cssHelp(
|
||||
@@ -70,7 +73,7 @@ class ColumnPicker extends Disposable {
|
||||
properValue,
|
||||
options,
|
||||
{
|
||||
defaultLabel: this._column.typeDesc != "any" ? `Pick a ${this._column.typeDesc} column` : 'Pick a column'
|
||||
defaultLabel: this._column.typeDesc != "any" ? t('PickAColumnWithType', {"columnType": this._column.typeDesc}) : t('PickAColumn')
|
||||
}
|
||||
),
|
||||
testId('mapping-for-' + this._column.name),
|
||||
@@ -102,7 +105,7 @@ class ColumnListPicker extends Disposable {
|
||||
return [
|
||||
cssRow(
|
||||
cssAddMapping(
|
||||
cssAddIcon('Plus'), 'Add ' + this._column.title,
|
||||
cssAddIcon('Plus'), t('Add') + ' ' + this._column.title,
|
||||
menu(() => {
|
||||
const otherColumns = this._getNotMappedColumns();
|
||||
const typedColumns = otherColumns.filter(this._typeFilter());
|
||||
@@ -114,7 +117,7 @@ class ColumnListPicker extends Disposable {
|
||||
col.label.peek(),
|
||||
)),
|
||||
wrongTypeCount > 0 ? menuText(
|
||||
`${wrongTypeCount} non-${this._column.type.toLowerCase()} column${wrongTypeCount > 1 ? 's are' : ' is'} not shown`,
|
||||
t("WrongTypesMenuText", {wrongTypeCount, columnType: this._column.type.toLowerCase(), count: wrongTypeCount}),
|
||||
testId('map-message-' + this._column.name)
|
||||
) : null
|
||||
];
|
||||
@@ -367,17 +370,17 @@ export class CustomSectionConfig extends Disposable {
|
||||
return null;
|
||||
}
|
||||
switch(level) {
|
||||
case AccessLevel.none: return cssConfirmLine("Widget does not require any permissions.");
|
||||
case AccessLevel.read_table: return cssConfirmLine("Widget needs to ", dom("b", "read"), " the current table.");
|
||||
case AccessLevel.full: return cssConfirmLine("Widget needs ", dom("b", "full access"), " to this document.");
|
||||
case AccessLevel.none: return cssConfirmLine(t("WidgetNoPermissison"));
|
||||
case AccessLevel.read_table: return cssConfirmLine(t("WidgetNeedRead", {read: dom("b", "read")})); // TODO i18next
|
||||
case AccessLevel.full: return cssConfirmLine(t("WidgetNeedFullAccess", {fullAccess: dom("b", "full access")})); // TODO i18next
|
||||
default: throw new Error(`Unsupported ${level} access level`);
|
||||
}
|
||||
}
|
||||
// Options for access level.
|
||||
const levels: IOptionFull<string>[] = [
|
||||
{label: 'No document access', value: AccessLevel.none},
|
||||
{label: 'Read selected table', value: AccessLevel.read_table},
|
||||
{label: 'Full document access', value: AccessLevel.full},
|
||||
{label: t('NoDocumentAccess'), value: AccessLevel.none},
|
||||
{label: t('ReadSelectedTable'), value: AccessLevel.read_table},
|
||||
{label: t('FullDocumentAccess'), value: AccessLevel.full},
|
||||
];
|
||||
return dom(
|
||||
'div',
|
||||
@@ -385,7 +388,7 @@ export class CustomSectionConfig extends Disposable {
|
||||
this._canSelect
|
||||
? cssRow(
|
||||
select(this._selectedId, options, {
|
||||
defaultLabel: 'Select Custom Widget',
|
||||
defaultLabel: t('SelectCustomWidget'),
|
||||
menuCssClass: cssMenu.className,
|
||||
}),
|
||||
testId('select')
|
||||
@@ -396,7 +399,7 @@ export class CustomSectionConfig extends Disposable {
|
||||
cssTextInput(
|
||||
this._url,
|
||||
async value => this._url.set(value),
|
||||
dom.attr('placeholder', 'Enter Custom URL'),
|
||||
dom.attr('placeholder', t('EnterCustomURL')),
|
||||
testId('url')
|
||||
)
|
||||
),
|
||||
@@ -437,7 +440,7 @@ export class CustomSectionConfig extends Disposable {
|
||||
dom.maybe(this._hasConfiguration, () =>
|
||||
cssSection(
|
||||
textButton(
|
||||
'Open configuration',
|
||||
t('OpenConfiguration'),
|
||||
dom.on('click', () => this._openConfiguration()),
|
||||
testId('open-configuration')
|
||||
)
|
||||
@@ -447,7 +450,7 @@ export class CustomSectionConfig extends Disposable {
|
||||
cssLink(
|
||||
dom.attr('href', 'https://support.getgrist.com/widget-custom'),
|
||||
dom.attr('target', '_blank'),
|
||||
'Learn more about custom widgets'
|
||||
t('LearnMore')
|
||||
)
|
||||
),
|
||||
dom.maybeOwned(use => use(this._section.columnsToMap), (owner, columns) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {createSessionObs} from 'app/client/lib/sessionObs';
|
||||
import {DocPageModel} from 'app/client/models/DocPageModel';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
@@ -14,6 +15,8 @@ import {DocSnapshot} from 'app/common/UserAPI';
|
||||
import {Disposable, dom, IDomComponent, MultiHolder, Observable, styled} from 'grainjs';
|
||||
import moment from 'moment';
|
||||
|
||||
const t = makeT('DocHistory');
|
||||
|
||||
const DocHistorySubTab = StringUnion("activity", "snapshots");
|
||||
|
||||
export class DocHistory extends Disposable implements IDomComponent {
|
||||
@@ -25,8 +28,8 @@ export class DocHistory extends Disposable implements IDomComponent {
|
||||
|
||||
public buildDom() {
|
||||
const tabs = [
|
||||
{value: 'activity', label: 'Activity'},
|
||||
{value: 'snapshots', label: 'Snapshots'},
|
||||
{value: 'activity', label: t('Activity')},
|
||||
{value: 'snapshots', label: t('Snapshots')},
|
||||
];
|
||||
return [
|
||||
cssSubTabs(
|
||||
@@ -87,11 +90,11 @@ export class DocHistory extends Disposable implements IDomComponent {
|
||||
),
|
||||
cssMenuDots(icon('Dots'),
|
||||
menu(() => [
|
||||
menuItemLink(setLink(snapshot), 'Open Snapshot'),
|
||||
menuItemLink(setLink(snapshot, origUrlId), 'Compare to Current',
|
||||
menuAnnotate('Beta')),
|
||||
prevSnapshot && menuItemLink(setLink(prevSnapshot, snapshot.docId), 'Compare to Previous',
|
||||
menuAnnotate('Beta')),
|
||||
menuItemLink(setLink(snapshot), t('OpenSnapshot')),
|
||||
menuItemLink(setLink(snapshot, origUrlId), t('CompareToCurrent'),
|
||||
menuAnnotate(t('Beta'))),
|
||||
prevSnapshot && menuItemLink(setLink(prevSnapshot, snapshot.docId), t('CompareToPrevious'),
|
||||
menuAnnotate(t('Beta'))),
|
||||
],
|
||||
{placement: 'bottom-end', parentSelectorToMark: '.' + cssSnapshotCard.className}
|
||||
),
|
||||
|
||||
@@ -34,7 +34,7 @@ import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
|
||||
import {bigBasicButton} from 'app/client/ui2018/buttons';
|
||||
import sortBy = require('lodash/sortBy');
|
||||
|
||||
const translate = makeT(`DocMenu`);
|
||||
const t = makeT(`DocMenu`);
|
||||
|
||||
const testId = makeTestId('test-dm-');
|
||||
|
||||
@@ -60,8 +60,8 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
|
||||
showWelcomeQuestions(home.app.userPrefsObs),
|
||||
css.docMenu(
|
||||
dom.maybe(!home.app.currentFeatures.workspaces, () => [
|
||||
css.docListHeader('This service is not available right now'),
|
||||
dom('span', '(The organization needs a paid plan)')
|
||||
css.docListHeader(t('ServiceNotAvailable')),
|
||||
dom('span', t('NeedPaidPlan')),
|
||||
]),
|
||||
|
||||
// currentWS and showIntro observables change together. We capture both in one domComputed call.
|
||||
@@ -87,7 +87,7 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
|
||||
// TODO: this is shown on all pages, but there is a hack in currentWSPinnedDocs that
|
||||
// removes all pinned docs when on trash page.
|
||||
dom.maybe((use) => use(home.currentWSPinnedDocs).length > 0, () => [
|
||||
css.docListHeader(css.pinnedDocsIcon('PinBig'), 'Pinned Documents'),
|
||||
css.docListHeader(css.pinnedDocsIcon('PinBig'), t('PinnedDocuments')),
|
||||
createPinnedDocs(home, home.currentWSPinnedDocs),
|
||||
]),
|
||||
|
||||
@@ -95,7 +95,7 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
|
||||
dom.maybe((use) => page === 'templates' && use(home.featuredTemplates).length > 0, () => [
|
||||
css.featuredTemplatesHeader(
|
||||
css.featuredTemplatesIcon('Idea'),
|
||||
'Featured',
|
||||
t('Featured'),
|
||||
testId('featured-templates-header')
|
||||
),
|
||||
createPinnedDocs(home, home.featuredTemplates, true),
|
||||
@@ -107,12 +107,12 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
|
||||
null :
|
||||
css.docListHeader(
|
||||
(
|
||||
page === 'all' ? translate('AllDocuments') :
|
||||
page === 'all' ? t('AllDocuments') :
|
||||
page === 'templates' ?
|
||||
dom.domComputed(use => use(home.featuredTemplates).length > 0, (hasFeaturedTemplates) =>
|
||||
hasFeaturedTemplates ? translate('MoreExamplesAndTemplates') : translate('ExamplesAndTemplates')
|
||||
hasFeaturedTemplates ? t('MoreExamplesAndTemplates') : t('ExamplesAndTemplates')
|
||||
) :
|
||||
page === 'trash' ? 'Trash' :
|
||||
page === 'trash' ? t('Trash') :
|
||||
workspace && [css.docHeaderIcon('Folder'), workspaceName(home.app, workspace)]
|
||||
),
|
||||
testId('doc-header'),
|
||||
@@ -127,9 +127,9 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
|
||||
) :
|
||||
(page === 'trash') ?
|
||||
dom('div',
|
||||
css.docBlock('Documents stay in Trash for 30 days, after which they get deleted permanently.'),
|
||||
css.docBlock(t('DocStayInTrash')),
|
||||
dom.maybe((use) => use(home.trashWorkspaces).length === 0, () =>
|
||||
css.docBlock('Trash is empty.')
|
||||
css.docBlock(t("EmptyTrash"))
|
||||
),
|
||||
buildAllDocsBlock(home, home.trashWorkspaces, false, flashDocId, viewSettings),
|
||||
) :
|
||||
@@ -144,7 +144,7 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
|
||||
) :
|
||||
workspace && !workspace.isSupportWorkspace && workspace.docs?.length === 0 ?
|
||||
buildWorkspaceIntro(home) :
|
||||
css.docBlock('Workspace not found')
|
||||
css.docBlock(t('WorkspaceNotFound'))
|
||||
)
|
||||
]),
|
||||
];
|
||||
@@ -176,7 +176,7 @@ function buildAllDocsBlock(
|
||||
|
||||
(ws.removedAt ?
|
||||
[
|
||||
css.docRowUpdatedAt(`Deleted ${getTimeFromNow(ws.removedAt)}`),
|
||||
css.docRowUpdatedAt(t('Deleted', {at:getTimeFromNow(ws.removedAt)})),
|
||||
css.docMenuTrigger(icon('Dots')),
|
||||
menu(() => makeRemovedWsOptionsMenu(home, ws),
|
||||
{placement: 'bottom-end', parentSelectorToMark: '.' + css.docRowWrapper.className}),
|
||||
@@ -210,7 +210,7 @@ function buildAllDocsTemplates(home: HomeModel, viewSettings: ViewSettings) {
|
||||
dom.autoDispose(hideTemplatesObs),
|
||||
css.templatesHeaderWrap(
|
||||
css.templatesHeader(
|
||||
'Examples & Templates',
|
||||
t('Examples&Templates'),
|
||||
dom.domComputed(hideTemplatesObs, (collapsed) =>
|
||||
collapsed ? css.templatesHeaderIcon('Expand') : css.templatesHeaderIcon('Collapse')
|
||||
),
|
||||
@@ -222,7 +222,7 @@ function buildAllDocsTemplates(home: HomeModel, viewSettings: ViewSettings) {
|
||||
dom.maybe((use) => !use(hideTemplatesObs), () => [
|
||||
buildTemplateDocs(home, templates, viewSettings),
|
||||
bigBasicButton(
|
||||
'Discover More Templates',
|
||||
t('DiscoverMoreTemplates'),
|
||||
urlState().setLinkUrl({homePage: 'templates'}),
|
||||
testId('all-docs-templates-discover-more'),
|
||||
)
|
||||
@@ -270,7 +270,7 @@ function buildOtherSites(home: HomeModel) {
|
||||
return css.otherSitesBlock(
|
||||
dom.autoDispose(hideOtherSitesObs),
|
||||
css.otherSitesHeader(
|
||||
translate('OtherSites'),
|
||||
t('OtherSites'),
|
||||
dom.domComputed(hideOtherSitesObs, (collapsed) =>
|
||||
collapsed ? css.otherSitesHeaderIcon('Expand') : css.otherSitesHeaderIcon('Collapse')
|
||||
),
|
||||
@@ -282,7 +282,7 @@ function buildOtherSites(home: HomeModel) {
|
||||
const siteName = home.app.currentOrgName;
|
||||
return [
|
||||
dom('div',
|
||||
translate('OtherSitesWelcome', { siteName, context: personal ? 'personal' : '' }),
|
||||
t('OtherSitesWelcome', { siteName, context: personal ? 'personal' : '' }),
|
||||
testId('other-sites-message')
|
||||
),
|
||||
css.otherSitesButtons(
|
||||
@@ -318,8 +318,8 @@ function buildPrefs(
|
||||
// The Sort selector.
|
||||
options.hideSort ? null : dom.update(
|
||||
select<SortPref>(viewSettings.currentSort, [
|
||||
{value: 'name', label: 'By Name'},
|
||||
{value: 'date', label: 'By Date Modified'},
|
||||
{value: 'name', label: t('ByName')},
|
||||
{value: 'date', label: t('ByDateModified')},
|
||||
],
|
||||
{ buttonCssClass: css.sortSelector.className },
|
||||
),
|
||||
@@ -375,8 +375,8 @@ function buildWorkspaceDocBlock(home: HomeModel, workspace: Workspace, flashDocI
|
||||
),
|
||||
css.docRowUpdatedAt(
|
||||
(doc.removedAt ?
|
||||
`Deleted ${getTimeFromNow(doc.removedAt)}` :
|
||||
`Edited ${getTimeFromNow(doc.updatedAt)}`),
|
||||
t('Deleted', {at: getTimeFromNow(doc.removedAt)}) :
|
||||
t('Edited', {at: getTimeFromNow(doc.updatedAt)})),
|
||||
testId('doc-time')
|
||||
),
|
||||
(doc.removedAt ?
|
||||
@@ -410,7 +410,7 @@ function buildWorkspaceDocBlock(home: HomeModel, workspace: Workspace, flashDocI
|
||||
save: (val) => doRename(home, doc, val, flashDocId),
|
||||
close: () => renaming.set(null),
|
||||
}, testId('doc-name-editor')),
|
||||
css.docRowUpdatedAt(`Edited ${getTimeFromNow(doc.updatedAt)}`, testId('doc-time')),
|
||||
css.docRowUpdatedAt(t('Edited', {at: getTimeFromNow(doc.updatedAt)}), testId('doc-time')),
|
||||
),
|
||||
),
|
||||
testId('doc')
|
||||
@@ -451,9 +451,9 @@ export function makeDocOptionsMenu(home: HomeModel, doc: Document, renaming: Obs
|
||||
const orgAccess: roles.Role|null = org ? org.access : null;
|
||||
|
||||
function deleteDoc() {
|
||||
confirmModal(`Delete "${doc.name}"?`, 'Delete',
|
||||
confirmModal(t('DeleteDoc', {name: doc.name}), t('Delete'),
|
||||
() => home.deleteDoc(doc.id, false).catch(reportError),
|
||||
'Document will be moved to Trash.');
|
||||
t('DocumentMoveToTrash'));
|
||||
}
|
||||
|
||||
async function manageUsers() {
|
||||
@@ -472,11 +472,11 @@ export function makeDocOptionsMenu(home: HomeModel, doc: Document, renaming: Obs
|
||||
}
|
||||
|
||||
return [
|
||||
menuItem(() => renaming.set(doc), "Rename",
|
||||
menuItem(() => renaming.set(doc), t("Rename"),
|
||||
dom.cls('disabled', !roles.canEdit(doc.access)),
|
||||
testId('rename-doc')
|
||||
),
|
||||
menuItem(() => showMoveDocModal(home, doc), 'Move',
|
||||
menuItem(() => showMoveDocModal(home, doc), t('Move'),
|
||||
// Note that moving the doc requires ACL access on the doc. Moving a doc to a workspace
|
||||
// that confers descendant ACL access could otherwise increase the user's access to the doc.
|
||||
// By requiring the user to have ACL edit access on the doc to move it prevents using this
|
||||
@@ -487,16 +487,16 @@ export function makeDocOptionsMenu(home: HomeModel, doc: Document, renaming: Obs
|
||||
dom.cls('disabled', !roles.canEditAccess(doc.access)),
|
||||
testId('move-doc')
|
||||
),
|
||||
menuItem(deleteDoc, 'Remove',
|
||||
menuItem(deleteDoc, t('Remove'),
|
||||
dom.cls('disabled', !roles.canDelete(doc.access)),
|
||||
testId('delete-doc')
|
||||
),
|
||||
menuItem(() => home.pinUnpinDoc(doc.id, !doc.isPinned).catch(reportError),
|
||||
doc.isPinned ? "Unpin Document" : "Pin Document",
|
||||
doc.isPinned ? t("UnpinDocument"): t("PinDocument"),
|
||||
dom.cls('disabled', !roles.canEdit(orgAccess)),
|
||||
testId('pin-doc')
|
||||
),
|
||||
menuItem(manageUsers, roles.canEditAccess(doc.access) ? "Manage Users" : "Access Details",
|
||||
menuItem(manageUsers, roles.canEditAccess(doc.access) ? t("ManageUsers"): t("AccessDetails"),
|
||||
testId('doc-access')
|
||||
)
|
||||
];
|
||||
@@ -504,22 +504,22 @@ export function makeDocOptionsMenu(home: HomeModel, doc: Document, renaming: Obs
|
||||
|
||||
export function makeRemovedDocOptionsMenu(home: HomeModel, doc: Document, workspace: Workspace) {
|
||||
function hardDeleteDoc() {
|
||||
confirmModal(`Permanently Delete "${doc.name}"?`, 'Delete Forever',
|
||||
confirmModal(t("DeleteForeverDoc", {name: doc.name}), t("DeleteForever"),
|
||||
() => home.deleteDoc(doc.id, true).catch(reportError),
|
||||
'Document will be permanently deleted.');
|
||||
t('DeleteDocPerma'));
|
||||
}
|
||||
|
||||
return [
|
||||
menuItem(() => home.restoreDoc(doc), 'Restore',
|
||||
menuItem(() => home.restoreDoc(doc), t('Restore'),
|
||||
dom.cls('disabled', !roles.canDelete(doc.access) || !!workspace.removedAt),
|
||||
testId('doc-restore')
|
||||
),
|
||||
menuItem(hardDeleteDoc, 'Delete Forever',
|
||||
menuItem(hardDeleteDoc, t('DeleteForever'),
|
||||
dom.cls('disabled', !roles.canDelete(doc.access)),
|
||||
testId('doc-delete-forever')
|
||||
),
|
||||
(workspace.removedAt ?
|
||||
menuText('To restore this document, restore the workspace first.') :
|
||||
menuText(t('RestoreThisDocument')) :
|
||||
null
|
||||
)
|
||||
];
|
||||
@@ -527,16 +527,16 @@ export function makeRemovedDocOptionsMenu(home: HomeModel, doc: Document, worksp
|
||||
|
||||
function makeRemovedWsOptionsMenu(home: HomeModel, ws: Workspace) {
|
||||
return [
|
||||
menuItem(() => home.restoreWorkspace(ws), 'Restore',
|
||||
menuItem(() => home.restoreWorkspace(ws), t('Restore'),
|
||||
dom.cls('disabled', !roles.canDelete(ws.access)),
|
||||
testId('ws-restore')
|
||||
),
|
||||
menuItem(() => home.deleteWorkspace(ws.id, true), 'Delete Forever',
|
||||
menuItem(() => home.deleteWorkspace(ws.id, true), t('DeleteForever'),
|
||||
dom.cls('disabled', !roles.canDelete(ws.access) || ws.docs.length > 0),
|
||||
testId('ws-delete-forever')
|
||||
),
|
||||
(ws.docs.length > 0 ?
|
||||
menuText('You may delete a workspace forever once it has no documents in it.') :
|
||||
menuText(t('DeleteWorkspaceForever')) :
|
||||
null
|
||||
)
|
||||
];
|
||||
@@ -554,8 +554,8 @@ function showMoveDocModal(home: HomeModel, doc: Document) {
|
||||
const disabled = isCurrent || !isEditable;
|
||||
return css.moveDocListItem(
|
||||
css.moveDocListText(workspaceName(home.app, ws)),
|
||||
isCurrent ? css.moveDocListHintText('Current workspace') : null,
|
||||
!isEditable ? css.moveDocListHintText('Requires edit permissions') : null,
|
||||
isCurrent ? css.moveDocListHintText(t('CurrentWorkspace')) : null,
|
||||
!isEditable ? css.moveDocListHintText(t('RequiresEditPermissions')) : null,
|
||||
css.moveDocListItem.cls('-disabled', disabled),
|
||||
css.moveDocListItem.cls('-selected', (use) => use(selected) === ws.id),
|
||||
dom.on('click', () => disabled || selected.set(ws.id)),
|
||||
@@ -565,11 +565,11 @@ function showMoveDocModal(home: HomeModel, doc: Document) {
|
||||
)
|
||||
);
|
||||
return {
|
||||
title: `Move ${doc.name} to workspace`,
|
||||
title: t('MoveDocToWorkspace', {name: doc.name}),
|
||||
body,
|
||||
saveDisabled: Computed.create(owner, (use) => !use(selected)),
|
||||
saveFunc: async () => !selected.get() || home.moveDoc(doc.id, selected.get()!).catch(reportError),
|
||||
saveLabel: 'Move'
|
||||
saveLabel: t('Move'),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Placement} from '@popperjs/core';
|
||||
import {placements} from '@popperjs/core/lib/enums';
|
||||
import {DocComm} from 'app/client/components/DocComm';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {sameDocumentUrlState} from 'app/client/models/gristUrlState';
|
||||
import {cssButtons, cssLinkBtn, cssLinkIcon} from 'app/client/ui/ExampleCard';
|
||||
import {IOnBoardingMsg, startOnBoarding} from 'app/client/ui/OnBoardingPopups';
|
||||
@@ -10,6 +11,7 @@ import {DocData} from 'app/common/DocData';
|
||||
import {dom} from 'grainjs';
|
||||
import sortBy = require('lodash/sortBy');
|
||||
|
||||
const t = makeT('DocTour');
|
||||
|
||||
export async function startDocTour(docData: DocData, docComm: DocComm, onFinishCB: () => void) {
|
||||
const docTour: IOnBoardingMsg[] = await makeDocTour(docData, docComm) || invalidDocTour;
|
||||
@@ -18,9 +20,8 @@ export async function startDocTour(docData: DocData, docComm: DocComm, onFinishC
|
||||
}
|
||||
|
||||
const invalidDocTour: IOnBoardingMsg[] = [{
|
||||
title: 'No valid document tour',
|
||||
body: 'Cannot construct a document tour from the data in this document. ' +
|
||||
'Ensure there is a table named GristDocTour with columns Title, Body, Placement, and Location.',
|
||||
title: t('InvalidDocTourTitle'),
|
||||
body: t('InvalidDocTourBody'),
|
||||
selector: 'document',
|
||||
showHasModal: true,
|
||||
}];
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* This module export a component for editing some document settings consisting of the timezone,
|
||||
* (new settings to be added here ...).
|
||||
*/
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {dom, IDisposableOwner, styled} from 'grainjs';
|
||||
import {Computed, Observable} from 'grainjs';
|
||||
|
||||
@@ -20,6 +21,9 @@ import {EngineCode} from 'app/common/DocumentSettings';
|
||||
import {GristLoadConfig} from 'app/common/gristUrls';
|
||||
import {propertyCompare} from "app/common/gutil";
|
||||
import {getCurrency, locales} from "app/common/Locales";
|
||||
|
||||
const t = makeT('DocumentSettings');
|
||||
|
||||
/**
|
||||
* Builds a simple saveModal for saving settings.
|
||||
*/
|
||||
@@ -38,37 +42,36 @@ export async function showDocSettingsModal(docInfo: DocInfoRec, docPageModel: Do
|
||||
const canChangeEngine = getSupportedEngineChoices().length > 0;
|
||||
|
||||
return {
|
||||
title: 'Document Settings',
|
||||
title: t('DocumentSettings'),
|
||||
body: [
|
||||
cssDataRow("This document's ID (for API use):"),
|
||||
cssDataRow(t('ThisDocumentID')),
|
||||
cssDataRow(dom('tt', docPageModel.currentDocId.get())),
|
||||
cssDataRow('Time Zone:'),
|
||||
cssDataRow(t('TimeZone')),
|
||||
cssDataRow(dom.create(buildTZAutocomplete, moment, timezoneObs, (val) => timezoneObs.set(val))),
|
||||
cssDataRow('Locale:'),
|
||||
cssDataRow(t('Locale')),
|
||||
cssDataRow(dom.create(buildLocaleSelect, localeObs)),
|
||||
cssDataRow('Currency:'),
|
||||
cssDataRow(t('Currency')),
|
||||
cssDataRow(dom.domComputed(localeObs, (l) =>
|
||||
dom.create(buildCurrencyPicker, currencyObs, (val) => currencyObs.set(val),
|
||||
{defaultCurrencyLabel: `Local currency (${getCurrency(l)})`})
|
||||
{defaultCurrencyLabel: t('LocalCurrency', {currency: getCurrency(l)})})
|
||||
)),
|
||||
canChangeEngine ? [
|
||||
// Small easter egg: you can click on the skull-and-crossbones to
|
||||
// force a reload of the document.
|
||||
cssDataRow('Engine (experimental ',
|
||||
dom('span',
|
||||
'☠',
|
||||
dom.style('cursor', 'pointer'),
|
||||
dom.on('click', async () => {
|
||||
await docPageModel.appModel.api.getDocAPI(docPageModel.currentDocId.get()!).forceReload();
|
||||
document.location.reload();
|
||||
})),
|
||||
' change at own risk):'),
|
||||
cssDataRow(t('EngineRisk', {span:
|
||||
dom('span', '☠',
|
||||
dom.style('cursor', 'pointer'),
|
||||
dom.on('click', async () => {
|
||||
await docPageModel.appModel.api.getDocAPI(docPageModel.currentDocId.get()!).forceReload();
|
||||
document.location.reload();
|
||||
}))
|
||||
})),
|
||||
select(engineObs, getSupportedEngineChoices()),
|
||||
] : null,
|
||||
],
|
||||
// Modal label is "Save", unless engine is changed. If engine is changed, the document will
|
||||
// need a reload to switch engines, so we replace the label with "Save and Reload".
|
||||
saveLabel: dom.text((use) => (use(engineObs) === docSettings.engine) ? 'Save' : 'Save and Reload'),
|
||||
saveLabel: dom.text((use) => (use(engineObs) === docSettings.engine) ? t('Save') : t('SaveAndReload')),
|
||||
saveFunc: async () => {
|
||||
await docInfo.updateColValues({
|
||||
timezone: timezoneObs.get(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {cssInput} from 'app/client/ui/cssInput';
|
||||
import {cssField} from 'app/client/ui/MakeCopyMenu';
|
||||
@@ -9,6 +10,8 @@ import {saveModal} from 'app/client/ui2018/modals';
|
||||
import {commonUrls} from 'app/common/gristUrls';
|
||||
import {Computed, Disposable, dom, input, makeTestId, Observable, styled} from 'grainjs';
|
||||
|
||||
const t = makeT('DuplicateTable');
|
||||
|
||||
const testId = makeTestId('test-duplicate-table-');
|
||||
|
||||
/**
|
||||
@@ -71,7 +74,7 @@ class DuplicateTableModal extends Disposable {
|
||||
input(
|
||||
this._newTableName,
|
||||
{onInput: true},
|
||||
{placeholder: 'Name for new table'},
|
||||
{placeholder: t('NewName')},
|
||||
(elem) => { setTimeout(() => { elem.focus(); }, 20); },
|
||||
dom.on('focus', (_ev, elem) => { elem.select(); }),
|
||||
dom.cls(cssInput.className),
|
||||
@@ -80,21 +83,21 @@ class DuplicateTableModal extends Disposable {
|
||||
),
|
||||
cssWarning(
|
||||
cssWarningIcon('Warning'),
|
||||
|
||||
dom('div',
|
||||
"Instead of duplicating tables, it's usually better to segment data using linked views. ",
|
||||
cssLink({href: commonUrls.helpLinkingWidgets, target: '_blank'}, 'Read More.')
|
||||
),
|
||||
t("AdviceWithLink", {link: cssLink({href: commonUrls.helpLinkingWidgets, target: '_blank'}, 'Read More.')})
|
||||
), //TODO: i18next
|
||||
),
|
||||
cssField(
|
||||
cssCheckbox(
|
||||
this._includeData,
|
||||
'Copy all data in addition to the table structure.',
|
||||
t('CopyAllData'),
|
||||
testId('copy-all-data'),
|
||||
),
|
||||
),
|
||||
dom.maybe(this._includeData, () => cssWarning(
|
||||
cssWarningIcon('Warning'),
|
||||
dom('div', 'Only the document default access rules will apply to the copy.'),
|
||||
dom('div', t('WarningACL')),
|
||||
testId('acl-warning'),
|
||||
)),
|
||||
];
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {makeT} from 'app/client/lib/localization'
|
||||
|
||||
const t = makeT('ExampleInfo');
|
||||
|
||||
export interface IExampleInfo {
|
||||
id: number;
|
||||
urlId: string;
|
||||
@@ -13,40 +17,37 @@ interface WelcomeCard {
|
||||
tutorialName: string;
|
||||
}
|
||||
|
||||
export const examples: IExampleInfo[] = [{
|
||||
export const buildExamples = (): IExampleInfo[] => [{
|
||||
id: 1, // Identifies the example in UserPrefs.seenExamples
|
||||
urlId: 'lightweight-crm',
|
||||
title: 'Lightweight CRM',
|
||||
title: t('Title', {context: "CRM"}),
|
||||
imgUrl: 'https://www.getgrist.com/themes/grist/assets/images/use-cases/lightweight-crm.png',
|
||||
tutorialUrl: 'https://support.getgrist.com/lightweight-crm/',
|
||||
welcomeCard: {
|
||||
title: 'Welcome to the Lightweight CRM template',
|
||||
text: 'Check out our related tutorial for how to link data, and create ' +
|
||||
'high-productivity layouts.',
|
||||
tutorialName: 'Tutorial: Create a CRM',
|
||||
title: t('WelcomeTitle', {context: "CRM"}),
|
||||
text: t('WelcomeText', {context: "CRM"}),
|
||||
tutorialName: t('WelcomeTutorialName', {context: "CRM"}),
|
||||
},
|
||||
}, {
|
||||
id: 2, // Identifies the example in UserPrefs.seenExamples
|
||||
urlId: 'investment-research',
|
||||
title: 'Investment Research',
|
||||
title: t('Title', {context: "investmentResearch"}),
|
||||
imgUrl: 'https://www.getgrist.com/themes/grist/assets/images/use-cases/data-visualization.png',
|
||||
tutorialUrl: 'https://support.getgrist.com/investment-research/',
|
||||
welcomeCard: {
|
||||
title: 'Welcome to the Investment Research template',
|
||||
text: 'Check out our related tutorial to learn how to create summary tables and charts, ' +
|
||||
'and to link charts dynamically.',
|
||||
tutorialName: 'Tutorial: Analyze & Visualize',
|
||||
title: t('WelcomeTitle', {context: "investmentResearch"}),
|
||||
text: t('WelcomeText', {context: "investmentResearch"}),
|
||||
tutorialName: t('WelcomeTutorialName', {context: "investmentResearch"}),
|
||||
},
|
||||
}, {
|
||||
id: 3, // Identifies the example in UserPrefs.seenExamples
|
||||
urlId: 'afterschool-program',
|
||||
title: 'Afterschool Program',
|
||||
title: t('Title', {context: "afterschool"}),
|
||||
imgUrl: 'https://www.getgrist.com/themes/grist/assets/images/use-cases/business-management.png',
|
||||
tutorialUrl: 'https://support.getgrist.com/afterschool-program/',
|
||||
welcomeCard: {
|
||||
title: 'Welcome to the Afterschool Program template',
|
||||
text: 'Check out our related tutorial for how to model business data, use formulas, ' +
|
||||
'and manage complexity.',
|
||||
tutorialName: 'Tutorial: Manage Business Data',
|
||||
title: t('WelcomeTitle', {context: "afterschool"}),
|
||||
text: t('WelcomeText', {context: "afterschool"}),
|
||||
tutorialName: t('WelcomeTutorialName', {context: "afterschool"}),
|
||||
},
|
||||
}];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {CursorPos} from 'app/client/components/Cursor';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {BEHAVIOR, ColumnRec} from 'app/client/models/entities/ColumnRec';
|
||||
@@ -18,6 +19,8 @@ import {bundleChanges, Computed, dom, DomContents, DomElementArg, fromKo, MultiH
|
||||
Observable, styled} from 'grainjs';
|
||||
import * as ko from 'knockout';
|
||||
|
||||
const t = makeT('FieldConfig');
|
||||
|
||||
export function buildNameConfig(
|
||||
owner: MultiHolder,
|
||||
origColumn: ColumnRec,
|
||||
@@ -51,7 +54,7 @@ export function buildNameConfig(
|
||||
};
|
||||
|
||||
return [
|
||||
cssLabel('COLUMN LABEL AND ID'),
|
||||
cssLabel(t('ColumnLabel')),
|
||||
cssRow(
|
||||
dom.cls(cssBlockedCursor.className, origColumn.disableModify),
|
||||
cssColLabelBlock(
|
||||
@@ -81,7 +84,7 @@ export function buildNameConfig(
|
||||
)
|
||||
),
|
||||
dom.maybe(isSummaryTable,
|
||||
() => cssRow('Column options are limited in summary tables.'))
|
||||
() => cssRow(t('ColumnOptionsLimited')))
|
||||
];
|
||||
}
|
||||
|
||||
@@ -207,18 +210,19 @@ export function buildFormulaConfig(
|
||||
const behaviorName = Computed.create(owner, behavior, (use, type) => {
|
||||
if (use(isMultiSelect)) {
|
||||
const commonType = use(multiType);
|
||||
if (commonType === 'formula') { return "Formula Columns"; }
|
||||
if (commonType === 'data') { return "Data Columns"; }
|
||||
if (commonType === 'mixed') { return "Mixed Behavior"; }
|
||||
return "Empty Columns";
|
||||
if (commonType === 'formula') { return t('ColumnType', {context: 'formula', count: 2}); }
|
||||
if (commonType === 'data') { return t('ColumnType', {context: 'data', count: 2}); }
|
||||
if (commonType === 'mixed') { return t('ColumnType', {context: 'mixed', count: 2}); }
|
||||
return t('ColumnType', {context: 'empty', count: 2});
|
||||
} else {
|
||||
if (type === 'formula') { return "Formula Column"; }
|
||||
if (type === 'data') { return "Data Column"; }
|
||||
return "Empty Column";
|
||||
if (type === 'formula') { return t('ColumnType', {context: 'formula', count: 1}); }
|
||||
if (type === 'data') { return t('ColumnType', {context: 'data', count: 1}); }
|
||||
return t('ColumnType', {context: 'empty', count: 1});
|
||||
}
|
||||
});
|
||||
const behaviorIcon = Computed.create<IconName>(owner, (use) => {
|
||||
return use(behaviorName).startsWith("Data Column") ? "Database" : "Script";
|
||||
return use(behaviorName) === t('ColumnType', {context: 'data', count: 2}) ||
|
||||
use(behaviorName) === t('ColumnType', {context: 'data', count: 1}) ? "Database" : "Script";
|
||||
});
|
||||
const behaviorLabel = () => selectTitle(behaviorName, behaviorIcon);
|
||||
|
||||
@@ -227,26 +231,26 @@ export function buildFormulaConfig(
|
||||
// Converts data column to formula column.
|
||||
const convertDataColumnToFormulaOption = () => selectOption(
|
||||
() => (maybeFormula.set(true), formulaField?.focus()),
|
||||
'Clear and make into formula', 'Script');
|
||||
t('ConvertColumn', {context: 'formula'}), 'Script');
|
||||
|
||||
// Converts to empty column and opens up the editor. (label is the same, but this is used when we have no formula)
|
||||
const convertTriggerToFormulaOption = () => selectOption(
|
||||
() => gristDoc.convertIsFormula([origColumn.id.peek()], {toFormula: true, noRecalc: true}),
|
||||
'Clear and make into formula', 'Script');
|
||||
t('ConvertColumn', {context: 'formula'}), 'Script');
|
||||
|
||||
// Convert column to data.
|
||||
// This method is also available through a text button.
|
||||
const convertToData = () => gristDoc.convertIsFormula([origColumn.id.peek()], {toFormula: false, noRecalc: true});
|
||||
const convertToDataOption = () => selectOption(
|
||||
convertToData,
|
||||
'Convert column to data', 'Database',
|
||||
t('ConvertColumn', {context: 'data'}), 'Database',
|
||||
dom.cls('disabled', isSummaryTable)
|
||||
);
|
||||
|
||||
// Clears the column
|
||||
const clearAndResetOption = () => selectOption(
|
||||
() => gristDoc.clearColumns([origColumn.id.peek()]),
|
||||
'Clear and reset', 'CrossSmall');
|
||||
t('ClearAndReset'), 'CrossSmall');
|
||||
|
||||
// Actions on text buttons:
|
||||
|
||||
@@ -310,7 +314,7 @@ export function buildFormulaConfig(
|
||||
cssRow(formulaField = buildFormula(
|
||||
origColumn,
|
||||
buildEditor,
|
||||
"Enter formula",
|
||||
t('EnterFormula'),
|
||||
disableOtherActions,
|
||||
onSave,
|
||||
clearState)),
|
||||
@@ -318,21 +322,21 @@ export function buildFormulaConfig(
|
||||
];
|
||||
|
||||
return dom.maybe(behavior, (type: BEHAVIOR) => [
|
||||
cssLabel('COLUMN BEHAVIOR'),
|
||||
cssLabel(t('ColumnBehavior')),
|
||||
...(type === "empty" ? [
|
||||
menu(behaviorLabel(), [
|
||||
convertToDataOption(),
|
||||
]),
|
||||
cssEmptySeparator(),
|
||||
cssRow(textButton(
|
||||
"Set formula",
|
||||
t('SetFormula'),
|
||||
dom.on("click", setFormula),
|
||||
dom.prop("disabled", disableOtherActions),
|
||||
testId("field-set-formula")
|
||||
)),
|
||||
cssRow(withInfoTooltip(
|
||||
textButton(
|
||||
"Set trigger formula",
|
||||
t('SetTriggerFormula'),
|
||||
dom.on("click", setTrigger),
|
||||
dom.prop("disabled", use => use(isSummaryTable) || use(disableOtherActions)),
|
||||
testId("field-set-trigger")
|
||||
@@ -340,7 +344,7 @@ export function buildFormulaConfig(
|
||||
GristTooltips.setTriggerFormula(),
|
||||
)),
|
||||
cssRow(textButton(
|
||||
"Make into data column",
|
||||
t('MakeIntoDataColumn'),
|
||||
dom.on("click", convertToData),
|
||||
dom.prop("disabled", use => use(isSummaryTable) || use(disableOtherActions)),
|
||||
testId("field-set-data")
|
||||
@@ -353,7 +357,7 @@ export function buildFormulaConfig(
|
||||
formulaBuilder(onSaveConvertToFormula),
|
||||
cssEmptySeparator(),
|
||||
cssRow(textButton(
|
||||
"Convert to trigger formula",
|
||||
t('ConvertColumn', {context: 'triggerformula'}),
|
||||
dom.on("click", convertFormulaToTrigger),
|
||||
dom.hide(maybeFormula),
|
||||
dom.prop("disabled", use => use(isSummaryTable) || use(disableOtherActions)),
|
||||
@@ -373,7 +377,7 @@ export function buildFormulaConfig(
|
||||
),
|
||||
// If data column is or wants to be a trigger formula:
|
||||
dom.maybe((use) => use(maybeTrigger) || use(origColumn.hasTriggerFormula), () => [
|
||||
cssLabel('TRIGGER FORMULA'),
|
||||
cssLabel(t('TriggerFormula')),
|
||||
formulaBuilder(onSaveConvertToTrigger),
|
||||
dom.create(buildFormulaTriggers, origColumn, {
|
||||
disabled: disableOtherActions,
|
||||
@@ -385,7 +389,7 @@ export function buildFormulaConfig(
|
||||
cssEmptySeparator(),
|
||||
cssRow(withInfoTooltip(
|
||||
textButton(
|
||||
"Set trigger formula",
|
||||
t("SetTriggerFormula"),
|
||||
dom.on("click", convertDataColumnToTriggerColumn),
|
||||
dom.prop("disabled", disableOtherActions),
|
||||
testId("field-set-trigger")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {menuItem, menuSubHeader} from 'app/client/ui2018/menus';
|
||||
import {dom} from 'grainjs';
|
||||
|
||||
@@ -7,13 +8,15 @@ interface IFieldOptions {
|
||||
revertToCommon: () => void;
|
||||
}
|
||||
|
||||
const t = makeT('FieldMenus');
|
||||
|
||||
export function FieldSettingsMenu(useColOptions: boolean, disableSeparate: boolean, actions: IFieldOptions) {
|
||||
useColOptions = useColOptions || disableSeparate;
|
||||
return [
|
||||
menuSubHeader(`Using ${useColOptions ? 'common' : 'separate'} settings`),
|
||||
useColOptions ? menuItem(actions.useSeparate, 'Use separate settings', dom.cls('disabled', disableSeparate)) : [
|
||||
menuItem(actions.saveAsCommon, 'Save as common settings'),
|
||||
menuItem(actions.revertToCommon, 'Revert to common settings'),
|
||||
menuSubHeader(t('UsingSettings', {context: useColOptions ? 'common' : 'separate'})),
|
||||
useColOptions ? menuItem(actions.useSeparate, t('Settings', {context: 'useseparate'}), dom.cls('disabled', disableSeparate)) : [
|
||||
menuItem(actions.saveAsCommon, t('Settings', {context: 'savecommon'})),
|
||||
menuItem(actions.revertToCommon, t('Settings', {context: 'revertcommon'})),
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { makeT } from "app/client/lib/localization";
|
||||
import { allInclusive } from "app/client/models/ColumnFilter";
|
||||
import { ColumnRec, ViewFieldRec, ViewSectionRec } from "app/client/models/DocModel";
|
||||
import { FilterInfo } from "app/client/models/entities/ViewSectionRec";
|
||||
@@ -9,6 +10,8 @@ import { menu, menuItemAsync } from "app/client/ui2018/menus";
|
||||
import { dom, IDisposableOwner, IDomArgs, styled } from "grainjs";
|
||||
import { IMenuOptions, PopupControl } from "popweasel";
|
||||
|
||||
const t = makeT('FilterBar');
|
||||
|
||||
export function filterBar(_owner: IDisposableOwner, viewSection: ViewSectionRec) {
|
||||
const popupControls = new WeakMap<ColumnRec, PopupControl>();
|
||||
return cssFilterBar(
|
||||
@@ -77,7 +80,7 @@ function makePlusButton(viewSectionRec: ViewSectionRec, popupControls: WeakMap<C
|
||||
cssBtn.cls('-grayed'),
|
||||
cssIcon('Plus'),
|
||||
addFilterMenu(filters, viewSectionRec, popupControls),
|
||||
anyFilter ? null : cssPlusLabel('Add Filter'),
|
||||
anyFilter ? null : cssPlusLabel(t('AddFilter')),
|
||||
testId('add-filter-btn')
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { makeT } from 'app/client/lib/localization';
|
||||
import { ViewSectionRec } from "app/client/models/DocModel";
|
||||
import { KoSaveableObservable, setSaveValue } from "app/client/models/modelUtil";
|
||||
import { cssLabel, cssRow } from "app/client/ui/RightPanelStyles";
|
||||
@@ -5,6 +6,8 @@ import { squareCheckbox } from "app/client/ui2018/checkbox";
|
||||
import { testId } from "app/client/ui2018/cssVars";
|
||||
import { Computed, Disposable, dom, IDisposableOwner, styled } from "grainjs";
|
||||
|
||||
const t = makeT('GridOptions');
|
||||
|
||||
/**
|
||||
* Builds the grid options.
|
||||
*/
|
||||
@@ -17,23 +20,23 @@ export class GridOptions extends Disposable {
|
||||
public buildDom() {
|
||||
const section = this._section;
|
||||
return [
|
||||
cssLabel('Grid Options'),
|
||||
cssLabel(t('GridOptions')),
|
||||
dom('div', [
|
||||
cssRow(
|
||||
checkbox(setSaveValueFromKo(this, section.optionsObj.prop('verticalGridlines'))),
|
||||
'Vertical Gridlines',
|
||||
t('VerticalGridlines'),
|
||||
testId('v-grid-button')
|
||||
),
|
||||
|
||||
cssRow(
|
||||
checkbox(setSaveValueFromKo(this, section.optionsObj.prop('horizontalGridlines'))),
|
||||
'Horizontal Gridlines',
|
||||
t('HorizontalGridlines'),
|
||||
testId('h-grid-button')
|
||||
),
|
||||
|
||||
cssRow(
|
||||
checkbox(setSaveValueFromKo(this, section.optionsObj.prop('zebraStripes'))),
|
||||
'Zebra Stripes',
|
||||
t('ZebraStripes'),
|
||||
testId('zebra-stripe-button')
|
||||
),
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import { allCommands } from 'app/client/components/commands';
|
||||
import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec';
|
||||
import { testId, theme } from 'app/client/ui2018/cssVars';
|
||||
@@ -7,6 +8,8 @@ import { Sort } from 'app/common/SortSpec';
|
||||
import { dom, DomElementArg, styled } from 'grainjs';
|
||||
import isEqual = require('lodash/isEqual');
|
||||
|
||||
const t = makeT('GridViewMenus');
|
||||
|
||||
interface IView {
|
||||
addNewColumn: () => void;
|
||||
showColumn: (colId: number, atIndex: number) => void;
|
||||
@@ -23,13 +26,13 @@ interface IViewSection {
|
||||
*/
|
||||
export function ColumnAddMenu(gridView: IView, viewSection: IViewSection) {
|
||||
return [
|
||||
menuItem(() => gridView.addNewColumn(), 'Add Column'),
|
||||
menuItem(() => gridView.addNewColumn(), t('AddColumn')),
|
||||
menuDivider(),
|
||||
...viewSection.hiddenColumns().map((col: any) => menuItem(
|
||||
() => {
|
||||
gridView.showColumn(col.id(), viewSection.viewFields().peekLength);
|
||||
// .then(() => gridView.scrollPaneRight());
|
||||
}, `Show column ${col.label()}`))
|
||||
}, t('ShowColumn', {label: col.label()})))
|
||||
];
|
||||
}
|
||||
export interface IMultiColumnContextMenu {
|
||||
@@ -65,13 +68,13 @@ export function ColumnContextMenu(options: IColumnContextMenu) {
|
||||
const addToSortLabel = getAddToSortLabel(sortSpec, colId);
|
||||
|
||||
return [
|
||||
menuItemCmd(allCommands.fieldTabOpen, 'Column Options'),
|
||||
menuItem(filterOpenFunc, 'Filter Data'),
|
||||
menuItemCmd(allCommands.fieldTabOpen, t('ColumnOptions')),
|
||||
menuItem(filterOpenFunc, t('FilterData')),
|
||||
menuDivider({style: 'margin-bottom: 0;'}),
|
||||
cssRowMenuItem(
|
||||
customMenuItem(
|
||||
allCommands.sortAsc.run,
|
||||
dom('span', 'Sort', {style: 'flex: 1 0 auto; margin-right: 8px;'},
|
||||
dom('span', t('Sort'), {style: 'flex: 1 0 auto; margin-right: 8px;'},
|
||||
testId('sort-label')),
|
||||
icon('Sort', dom.style('transform', 'scaley(-1)')),
|
||||
'A-Z',
|
||||
@@ -109,9 +112,9 @@ export function ColumnContextMenu(options: IColumnContextMenu) {
|
||||
),
|
||||
] : null,
|
||||
menuDivider({style: 'margin-bottom: 0; margin-top: 0;'}),
|
||||
menuItem(allCommands.sortFilterTabOpen.run, 'More sort options ...', testId('more-sort-options')),
|
||||
menuItem(allCommands.sortFilterTabOpen.run, t('MoreSortOptions'), testId('more-sort-options')),
|
||||
menuDivider({style: 'margin-top: 0;'}),
|
||||
menuItemCmd(allCommands.renameField, 'Rename column', disableForReadonlyColumn),
|
||||
menuItemCmd(allCommands.renameField, t('RenameColumn'), disableForReadonlyColumn),
|
||||
freezeMenuItemCmd(options),
|
||||
menuDivider(),
|
||||
MultiColumnMenu((options.disableFrozenMenu = true, options)),
|
||||
@@ -132,29 +135,29 @@ export function MultiColumnMenu(options: IMultiColumnContextMenu) {
|
||||
const disableForReadonlyView = dom.cls('disabled', options.isReadonly);
|
||||
const num: number = options.numColumns;
|
||||
const nameClearColumns = options.isFiltered ?
|
||||
(num > 1 ? `Clear ${num} entire columns` : 'Clear entire column') :
|
||||
(num > 1 ? `Clear ${num} columns` : 'Clear column');
|
||||
const nameDeleteColumns = num > 1 ? `Delete ${num} columns` : 'Delete column';
|
||||
const nameHideColumns = num > 1 ? `Hide ${num} columns` : 'Hide column';
|
||||
t('ClearEntireColumns', {count: num}) :
|
||||
t('ClearColumns', {count: num});
|
||||
const nameDeleteColumns = t('DeleteColumns', {count: num});
|
||||
const nameHideColumns = t('HideColumns', {count: num});
|
||||
const frozenMenu = options.disableFrozenMenu ? null : freezeMenuItemCmd(options);
|
||||
return [
|
||||
frozenMenu ? [frozenMenu, menuDivider()]: null,
|
||||
// Offered only when selection includes formula columns, and converts only those.
|
||||
(options.isFormula ?
|
||||
menuItemCmd(allCommands.convertFormulasToData, 'Convert formula to data',
|
||||
menuItemCmd(allCommands.convertFormulasToData, t('ConvertFormulaToData'),
|
||||
disableForReadonlyColumn) : null),
|
||||
|
||||
// With data columns selected, offer an additional option to clear out selected cells.
|
||||
(options.isFormula !== true ?
|
||||
menuItemCmd(allCommands.clearValues, 'Clear values', disableForReadonlyColumn) : null),
|
||||
menuItemCmd(allCommands.clearValues, t('ClearValues'), disableForReadonlyColumn) : null),
|
||||
|
||||
(!options.isRaw ? menuItemCmd(allCommands.hideFields, nameHideColumns, disableForReadonlyView) : null),
|
||||
menuItemCmd(allCommands.clearColumns, nameClearColumns, disableForReadonlyColumn),
|
||||
menuItemCmd(allCommands.deleteFields, nameDeleteColumns, disableForReadonlyColumn),
|
||||
|
||||
menuDivider(),
|
||||
menuItemCmd(allCommands.insertFieldBefore, 'Insert column to the left', disableForReadonlyView),
|
||||
menuItemCmd(allCommands.insertFieldAfter, 'Insert column to the right', disableForReadonlyView)
|
||||
menuItemCmd(allCommands.insertFieldBefore, t('InsertColumn', {to: 'left'}), disableForReadonlyView),
|
||||
menuItemCmd(allCommands.insertFieldAfter, t('InsertColumn', {to: 'right'}), disableForReadonlyView)
|
||||
];
|
||||
}
|
||||
|
||||
@@ -203,12 +206,12 @@ export function freezeAction(options: IMultiColumnContextMenu): { text: string;
|
||||
|
||||
// if user clicked the first column or a column just after frozen set
|
||||
if (firstColumnIndex === 0 || firstColumnIndex === numFrozen) {
|
||||
text = 'Freeze this column';
|
||||
text = t('FreezeColumn', {count: 1});
|
||||
} else {
|
||||
// else user clicked any other column that is farther, offer to freeze
|
||||
// proper number of column
|
||||
const properNumber = firstColumnIndex - numFrozen + 1;
|
||||
text = `Freeze ${properNumber} ${numFrozen ? 'more ' : ''}columns`;
|
||||
text = t('FreezeColumn', {count: properNumber, context: numFrozen ? 'more' : '' });
|
||||
}
|
||||
return {
|
||||
text,
|
||||
@@ -217,12 +220,12 @@ export function freezeAction(options: IMultiColumnContextMenu): { text: string;
|
||||
} else if (isFrozenColumn) {
|
||||
// when user clicked last column in frozen set - offer to unfreeze this column
|
||||
if (firstColumnIndex + 1 === numFrozen) {
|
||||
text = `Unfreeze this column`;
|
||||
text = t('UnfreezeColumn', {count: 1});
|
||||
} else {
|
||||
// else user clicked column that is not the last in a frozen set
|
||||
// offer to unfreeze proper number of columns
|
||||
const properNumber = numFrozen - firstColumnIndex;
|
||||
text = `Unfreeze ${properNumber === numFrozen ? 'all' : properNumber} columns`;
|
||||
text = t('UnfreezeColumn', {count: properNumber, context: properNumber === numFrozen ? 'all' : '' });
|
||||
}
|
||||
return {
|
||||
text,
|
||||
@@ -233,20 +236,20 @@ export function freezeAction(options: IMultiColumnContextMenu): { text: string;
|
||||
}
|
||||
} else {
|
||||
if (isLastFrozenSet) {
|
||||
text = `Unfreeze ${length} columns`;
|
||||
text = t('UnfreezeColumn', {count: length});
|
||||
return {
|
||||
text,
|
||||
numFrozen : numFrozen - length
|
||||
};
|
||||
} else if (isFirstNormalSet) {
|
||||
text = `Freeze ${length} columns`;
|
||||
text = t('FreezeColumn', {count: length});
|
||||
return {
|
||||
text,
|
||||
numFrozen : numFrozen + length
|
||||
};
|
||||
} else if (isSpanSet) {
|
||||
const toFreeze = lastColumnIndex + 1 - numFrozen;
|
||||
text = `Freeze ${toFreeze == 1 ? 'one more column' : (`${toFreeze} more columns`)}`;
|
||||
text = t('FreezeColumn', {count: toFreeze, context: 'more'});
|
||||
return {
|
||||
text,
|
||||
numFrozen : numFrozen + toFreeze
|
||||
@@ -275,9 +278,9 @@ function getAddToSortLabel(sortSpec: Sort.SortSpec, colId: number): string|undef
|
||||
if (sortSpec.length !== 0 && !isEqual(columnsInSpec, [colId])) {
|
||||
const index = columnsInSpec.indexOf(colId);
|
||||
if (index > -1) {
|
||||
return `Sorted (#${index + 1})`;
|
||||
return t('AddToSort', {count: index + 1, context: 'added'});
|
||||
} else {
|
||||
return 'Add to sort';
|
||||
return t('AddToSort');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {FullUser} from 'app/common/LoginSessionAPI';
|
||||
import * as roles from 'app/common/roles';
|
||||
import {Computed, dom, DomContents, styled} from 'grainjs';
|
||||
|
||||
const translate = makeT('HomeIntro');
|
||||
const t = makeT('HomeIntro');
|
||||
|
||||
export function buildHomeIntro(homeModel: HomeModel): DomContents {
|
||||
const isViewer = homeModel.app.currentOrg?.access === roles.VIEWER;
|
||||
@@ -37,7 +37,7 @@ export function buildHomeIntro(homeModel: HomeModel): DomContents {
|
||||
export function buildWorkspaceIntro(homeModel: HomeModel): DomContents {
|
||||
const isViewer = homeModel.currentWS.get()?.access === roles.VIEWER;
|
||||
const isAnonym = !homeModel.app.currentValidUser;
|
||||
const emptyLine = cssIntroLine(testId('empty-workspace-info'), "This workspace is empty.");
|
||||
const emptyLine = cssIntroLine(testId('empty-workspace-info'), t('EmptyWorkspace'));
|
||||
if (isAnonym || isViewer) {
|
||||
return emptyLine;
|
||||
} else {
|
||||
@@ -58,39 +58,41 @@ function makeViewerTeamSiteIntro(homeModel: HomeModel) {
|
||||
const docLink = (dom.maybe(personalOrg, org => {
|
||||
return cssLink(
|
||||
urlState().setLinkUrl({org: org.domain ?? undefined}),
|
||||
'personal site',
|
||||
t('PersonalSite'),
|
||||
testId('welcome-personal-url'));
|
||||
}));
|
||||
return [
|
||||
css.docListHeader(
|
||||
dom.autoDispose(personalOrg),
|
||||
`Welcome to ${homeModel.app.currentOrgName}`,
|
||||
t('WelcomeTo', {orgName: homeModel.app.currentOrgName}),
|
||||
productPill(homeModel.app.currentOrg, {large: true}),
|
||||
testId('welcome-title')
|
||||
),
|
||||
cssIntroLine(
|
||||
testId('welcome-info'),
|
||||
"You have read-only access to this site. Currently there are no documents.", dom('br'),
|
||||
"Any documents created in this site will appear here."),
|
||||
t('WelcomeInfoNoDocuments'),
|
||||
dom('br'),
|
||||
t('WelcomeInfoAppearHere'),
|
||||
),
|
||||
cssIntroLine(
|
||||
'Interested in using Grist outside of your team? Visit your free ', docLink, '.',
|
||||
t('WelcomeTextVistGrist'), docLink, '.',
|
||||
testId('welcome-text')
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
function makeTeamSiteIntro(homeModel: HomeModel) {
|
||||
const sproutsProgram = cssLink({href: commonUrls.sproutsProgram, target: '_blank'}, 'Sprouts Program');
|
||||
const sproutsProgram = cssLink({href: commonUrls.sproutsProgram, target: '_blank'}, t('SproutsProgram'));
|
||||
return [
|
||||
css.docListHeader(
|
||||
`Welcome to ${homeModel.app.currentOrgName}`,
|
||||
t('WelcomeTo', {orgName: homeModel.app.currentOrgName}),
|
||||
productPill(homeModel.app.currentOrg, {large: true}),
|
||||
testId('welcome-title')
|
||||
),
|
||||
cssIntroLine('Get started by inviting your team and creating your first Grist document.'),
|
||||
cssIntroLine(t('TeamSiteIntroGetStarted')),
|
||||
(shouldHideUiElement('helpCenter') ? null :
|
||||
cssIntroLine(
|
||||
'Learn more in our ', helpCenterLink(), ', or find an expert via our ', sproutsProgram, '.',
|
||||
'Learn more in our ', helpCenterLink(), ', or find an expert via our ', sproutsProgram, '.', // TODO i18n
|
||||
testId('welcome-text')
|
||||
)
|
||||
),
|
||||
@@ -100,10 +102,10 @@ function makeTeamSiteIntro(homeModel: HomeModel) {
|
||||
|
||||
function makePersonalIntro(homeModel: HomeModel, user: FullUser) {
|
||||
return [
|
||||
css.docListHeader(`Welcome to Grist, ${user.name}!`, testId('welcome-title')),
|
||||
cssIntroLine('Get started by creating your first Grist document.'),
|
||||
css.docListHeader(t('WelcomeUser', {name: user.name}), testId('welcome-title')),
|
||||
cssIntroLine(t('PersonalIntroGetStarted')),
|
||||
(shouldHideUiElement('helpCenter') ? null :
|
||||
cssIntroLine(translate('VisitHelpCenter', { link: helpCenterLink() }),
|
||||
cssIntroLine(t('VisitHelpCenter', { link: helpCenterLink() }),
|
||||
testId('welcome-text'))
|
||||
),
|
||||
makeCreateButtons(homeModel),
|
||||
@@ -111,19 +113,19 @@ function makePersonalIntro(homeModel: HomeModel, user: FullUser) {
|
||||
}
|
||||
|
||||
function makeAnonIntro(homeModel: HomeModel) {
|
||||
const signUp = cssLink({href: getLoginOrSignupUrl()}, translate('SignUp'));
|
||||
const signUp = cssLink({href: getLoginOrSignupUrl()}, t('SignUp'));
|
||||
return [
|
||||
css.docListHeader(translate('Welcome'), testId('welcome-title')),
|
||||
cssIntroLine('Get started by exploring templates, or creating your first Grist document.'),
|
||||
cssIntroLine(signUp, ' to save your work. ',
|
||||
(shouldHideUiElement('helpCenter') ? null : translate('VisitHelpCenter', { link: helpCenterLink() })),
|
||||
css.docListHeader(t('Welcome'), testId('welcome-title')),
|
||||
cssIntroLine(t('AnonIntroGetStarted')),
|
||||
cssIntroLine(signUp, ' to save your work. ', // TODO i18n
|
||||
(shouldHideUiElement('helpCenter') ? null : t('VisitHelpCenter', { link: helpCenterLink() })),
|
||||
testId('welcome-text')),
|
||||
makeCreateButtons(homeModel),
|
||||
];
|
||||
}
|
||||
|
||||
function helpCenterLink() {
|
||||
return cssLink({href: commonUrls.help, target: '_blank'}, cssInlineIcon('Help'), 'Help Center');
|
||||
return cssLink({href: commonUrls.help, target: '_blank'}, cssInlineIcon('Help'), t('HelpCenter'));
|
||||
}
|
||||
|
||||
function buildButtons(homeModel: HomeModel, options: {
|
||||
@@ -134,22 +136,22 @@ function buildButtons(homeModel: HomeModel, options: {
|
||||
}) {
|
||||
return cssBtnGroup(
|
||||
!options.invite ? null :
|
||||
cssBtn(cssBtnIcon('Help'), 'Invite Team Members', testId('intro-invite'),
|
||||
cssBtn(cssBtnIcon('Help'), t('InviteTeamMembers'), testId('intro-invite'),
|
||||
cssButton.cls('-primary'),
|
||||
dom.on('click', () => manageTeamUsersApp(homeModel.app)),
|
||||
),
|
||||
!options.templates ? null :
|
||||
cssBtn(cssBtnIcon('FieldTable'), 'Browse Templates', testId('intro-templates'),
|
||||
cssBtn(cssBtnIcon('FieldTable'), t('BrowseTemplates'), testId('intro-templates'),
|
||||
cssButton.cls('-primary'),
|
||||
dom.hide(shouldHideUiElement("templates")),
|
||||
urlState().setLinkUrl({homePage: 'templates'}),
|
||||
),
|
||||
!options.import ? null :
|
||||
cssBtn(cssBtnIcon('Import'), 'Import Document', testId('intro-import-doc'),
|
||||
cssBtn(cssBtnIcon('Import'), t('ImportDocument'), testId('intro-import-doc'),
|
||||
dom.on('click', () => importDocAndOpen(homeModel)),
|
||||
),
|
||||
!options.empty ? null :
|
||||
cssBtn(cssBtnIcon('Page'), 'Create Empty Document', testId('intro-create-doc'),
|
||||
cssBtn(cssBtnIcon('Page'), t('CreateEmptyDocument'), testId('intro-create-doc'),
|
||||
dom.on('click', () => createDocAndOpen(homeModel)),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {loadUserManager} from 'app/client/lib/imports';
|
||||
import {ImportSourceElement} from 'app/client/lib/ImportSourceElement';
|
||||
import {reportError} from 'app/client/models/AppModel';
|
||||
@@ -20,6 +21,8 @@ import {computed, dom, domComputed, DomElementArg, observable, Observable, style
|
||||
import {createHelpTools, cssLeftPanel, cssScrollPane,
|
||||
cssSectionHeader, cssTools} from 'app/client/ui/LeftPanelCommon';
|
||||
|
||||
const t = makeT('HomeLeftPane');
|
||||
|
||||
export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: HomeModel) {
|
||||
const creating = observable<boolean>(false);
|
||||
const renaming = observable<Workspace|null>(null);
|
||||
@@ -39,13 +42,14 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
|
||||
cssPageEntry(
|
||||
cssPageEntry.cls('-selected', (use) => use(home.currentPage) === "all"),
|
||||
cssPageLink(cssPageIcon('Home'),
|
||||
cssLinkText('All Documents'),
|
||||
cssLinkText(t('AllDocuments')),
|
||||
urlState().setLinkUrl({ws: undefined, homePage: undefined}),
|
||||
testId('dm-all-docs'),
|
||||
),
|
||||
),
|
||||
dom.maybe(use => !use(home.singleWorkspace), () =>
|
||||
cssSectionHeader('Workspaces',
|
||||
cssSectionHeader(
|
||||
t('Workspaces'),
|
||||
// Give it a testId, because it's a good element to simulate "click-away" in tests.
|
||||
testId('dm-ws-label')
|
||||
),
|
||||
@@ -104,14 +108,14 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
|
||||
cssPageEntry(
|
||||
dom.hide(shouldHideUiElement("templates")),
|
||||
cssPageEntry.cls('-selected', (use) => use(home.currentPage) === "templates"),
|
||||
cssPageLink(cssPageIcon('FieldTable'), cssLinkText("Examples & Templates"),
|
||||
cssPageLink(cssPageIcon('FieldTable'), cssLinkText(t("ExamplesAndTemplates")),
|
||||
urlState().setLinkUrl({homePage: "templates"}),
|
||||
testId('dm-templates-page'),
|
||||
),
|
||||
),
|
||||
cssPageEntry(
|
||||
cssPageEntry.cls('-selected', (use) => use(home.currentPage) === "trash"),
|
||||
cssPageLink(cssPageIcon('Remove'), cssLinkText("Trash"),
|
||||
cssPageLink(cssPageIcon('Remove'), cssLinkText(t("Trash")),
|
||||
urlState().setLinkUrl({homePage: "trash"}),
|
||||
testId('dm-trash'),
|
||||
),
|
||||
@@ -172,11 +176,11 @@ function addMenu(home: HomeModel, creating: Observable<boolean>): DomElementArg[
|
||||
const needUpgrade = home.app.currentFeatures.maxWorkspacesPerOrg === 1;
|
||||
|
||||
return [
|
||||
menuItem(() => createDocAndOpen(home), menuIcon('Page'), "Create Empty Document",
|
||||
menuItem(() => createDocAndOpen(home), menuIcon('Page'), t("CreateEmptyDocument"),
|
||||
dom.cls('disabled', !home.newDocWorkspace.get()),
|
||||
testId("dm-new-doc")
|
||||
),
|
||||
menuItem(() => importDocAndOpen(home), menuIcon('Import'), "Import Document",
|
||||
menuItem(() => importDocAndOpen(home), menuIcon('Import'), t("ImportDocument"),
|
||||
dom.cls('disabled', !home.newDocWorkspace.get()),
|
||||
testId("dm-import")
|
||||
),
|
||||
@@ -191,7 +195,7 @@ function addMenu(home: HomeModel, creating: Observable<boolean>): DomElementArg[
|
||||
])),
|
||||
// For workspaces: if ACL says we can create them, but product says we can't,
|
||||
// then offer an upgrade link.
|
||||
upgradableMenuItem(needUpgrade, () => creating.set(true), menuIcon('Folder'), "Create Workspace",
|
||||
upgradableMenuItem(needUpgrade, () => creating.set(true), menuIcon('Folder'), t("CreateWorkspace"),
|
||||
dom.cls('disabled', (use) => !roles.canEdit(orgAccess) || !use(home.available)),
|
||||
testId("dm-new-workspace")
|
||||
),
|
||||
@@ -201,9 +205,9 @@ function addMenu(home: HomeModel, creating: Observable<boolean>): DomElementArg[
|
||||
|
||||
function workspaceMenu(home: HomeModel, ws: Workspace, renaming: Observable<Workspace|null>) {
|
||||
function deleteWorkspace() {
|
||||
confirmModal(`Delete ${ws.name} and all included documents?`, 'Delete',
|
||||
confirmModal(t('WorkspaceDeleteTitle', {workspace: ws.name}), t('Delete'),
|
||||
() => home.deleteWorkspace(ws.id, false),
|
||||
'Workspace will be moved to Trash.');
|
||||
t('WorkspaceDeleteText'));
|
||||
}
|
||||
|
||||
async function manageWorkspaceUsers() {
|
||||
@@ -221,17 +225,17 @@ function workspaceMenu(home: HomeModel, ws: Workspace, renaming: Observable<Work
|
||||
const needUpgrade = home.app.currentFeatures.maxWorkspacesPerOrg === 1;
|
||||
|
||||
return [
|
||||
upgradableMenuItem(needUpgrade, () => renaming.set(ws), "Rename",
|
||||
upgradableMenuItem(needUpgrade, () => renaming.set(ws), t("Rename"),
|
||||
dom.cls('disabled', !roles.canEdit(ws.access)),
|
||||
testId('dm-rename-workspace')),
|
||||
upgradableMenuItem(needUpgrade, deleteWorkspace, "Delete",
|
||||
upgradableMenuItem(needUpgrade, deleteWorkspace, t("Delete"),
|
||||
dom.cls('disabled', user => !roles.canEdit(ws.access)),
|
||||
testId('dm-delete-workspace')),
|
||||
// TODO: Personal plans can't currently share workspaces, but that restriction
|
||||
// should formally be documented and defined in `Features`, with this check updated
|
||||
// to look there instead.
|
||||
home.app.isPersonal ? null : upgradableMenuItem(needUpgrade, manageWorkspaceUsers,
|
||||
roles.canEditAccess(ws.access) ? "Manage Users" : "Access Details",
|
||||
roles.canEditAccess(ws.access) ? t("ManageUsers") : t("AccessDetails"),
|
||||
testId('dm-workspace-access')),
|
||||
upgradeText(needUpgrade, () => home.app.showUpgradeModal()),
|
||||
];
|
||||
|
||||
@@ -14,12 +14,15 @@
|
||||
* )
|
||||
*/
|
||||
import {beaconOpenMessage} from 'app/client/lib/helpScout';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import {testId, theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {commonUrls, shouldHideUiElement} from 'app/common/gristUrls';
|
||||
import {dom, DomContents, Observable, styled} from 'grainjs';
|
||||
|
||||
const t = makeT('LeftPanelCommon');
|
||||
|
||||
/**
|
||||
* Creates the "help tools", a button/link to open HelpScout beacon, and one to open the
|
||||
* HelpCenter in a new tab.
|
||||
@@ -31,7 +34,7 @@ export function createHelpTools(appModel: AppModel): DomContents {
|
||||
return cssSplitPageEntry(
|
||||
cssPageEntryMain(
|
||||
cssPageLink(cssPageIcon('Help'),
|
||||
cssLinkText('Help Center'),
|
||||
cssLinkText(t('HelpCenter')),
|
||||
dom.cls('tour-help-center'),
|
||||
dom.on('click', (ev) => beaconOpenMessage({appModel})),
|
||||
testId('left-feedback'),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* the sample documents (those in the Support user's Examples & Templates workspace).
|
||||
*/
|
||||
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {AppModel, reportError} from 'app/client/models/AppModel';
|
||||
import {getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState';
|
||||
import {getWorkspaceInfo, ownerName, workspaceName} from 'app/client/models/WorkspaceInfo';
|
||||
@@ -19,35 +20,35 @@ import {Document, isTemplatesOrg, Organization, Workspace} from 'app/common/User
|
||||
import {Computed, Disposable, dom, input, Observable, styled, subscribe} from 'grainjs';
|
||||
import sortBy = require('lodash/sortBy');
|
||||
|
||||
const t = makeT('MakeCopyMenu');
|
||||
|
||||
export async function replaceTrunkWithFork(user: FullUser|null, doc: Document, app: AppModel, origUrlId: string) {
|
||||
const trunkAccess = (await app.api.getDoc(origUrlId)).access;
|
||||
if (!roles.canEdit(trunkAccess)) {
|
||||
modal((ctl) => [
|
||||
cssModalBody(`Replacing the original requires editing rights on the original document.`),
|
||||
cssModalBody(t('CannotEditOriginal')),
|
||||
cssModalButtons(
|
||||
bigBasicButton('Cancel', dom.on('click', () => ctl.close())),
|
||||
bigBasicButton(t('Cancel'), dom.on('click', () => ctl.close())),
|
||||
)
|
||||
]);
|
||||
return;
|
||||
}
|
||||
const docApi = app.api.getDocAPI(origUrlId);
|
||||
const cmp = await docApi.compareDoc(doc.id);
|
||||
let titleText = 'Update Original';
|
||||
let buttonText = 'Update';
|
||||
let warningText = 'The original version of this document will be updated.';
|
||||
let titleText = t('UpdateOriginal');
|
||||
let buttonText = t('Update');
|
||||
let warningText = t('WarningOriginalWillBeUpdated');
|
||||
if (cmp.summary === 'left' || cmp.summary === 'both') {
|
||||
titleText = 'Original Has Modifications';
|
||||
buttonText = 'Overwrite';
|
||||
warningText = `${warningText} Be careful, the original has changes not in this document. ` +
|
||||
`Those changes will be overwritten.`;
|
||||
titleText = t('OriginalHasModifications');
|
||||
buttonText = t('Overwrite');
|
||||
warningText = `${warningText} ${t('WarningOverwriteOriginalChanges')}`;
|
||||
} else if (cmp.summary === 'unrelated') {
|
||||
titleText = 'Original Looks Unrelated';
|
||||
buttonText = 'Overwrite';
|
||||
warningText = `${warningText} It will be overwritten, losing any content not in this document.`;
|
||||
titleText = t('OriginalLooksUnrelated');
|
||||
buttonText = t('Overwrite');
|
||||
warningText = `${warningText} ${t('WarningWillBeOverwritten')}`;
|
||||
} else if (cmp.summary === 'same') {
|
||||
titleText = 'Original Looks Identical';
|
||||
warningText = `${warningText} However, it appears to be already identical.`;
|
||||
warningText = `${warningText} ${t('WarningAlreadyIdentical')}`;
|
||||
}
|
||||
confirmModal(titleText, buttonText,
|
||||
async () => {
|
||||
@@ -65,8 +66,8 @@ function signupModal(message: string) {
|
||||
return modal((ctl) => [
|
||||
cssModalBody(message),
|
||||
cssModalButtons(
|
||||
bigPrimaryButtonLink('Sign up', {href: getLoginOrSignupUrl(), target: '_blank'}, testId('modal-signup')),
|
||||
bigBasicButton('Cancel', dom.on('click', () => ctl.close())),
|
||||
bigPrimaryButtonLink(t('SignUp'), {href: getLoginOrSignupUrl(), target: '_blank'}, testId('modal-signup')),
|
||||
bigBasicButton(t('Cancel'), dom.on('click', () => ctl.close())),
|
||||
),
|
||||
cssModalWidth('normal'),
|
||||
]);
|
||||
@@ -95,7 +96,7 @@ function allowOtherOrgs(doc: Document, app: AppModel): boolean {
|
||||
*/
|
||||
export async function makeCopy(doc: Document, app: AppModel, modalTitle: string): Promise<void> {
|
||||
if (!app.currentValidUser) {
|
||||
signupModal('To save your changes, please sign up, then reload this page.');
|
||||
signupModal(t('ToSaveSignUpAndReload'));
|
||||
return;
|
||||
}
|
||||
let orgs = allowOtherOrgs(doc, app) ? await app.api.getOrgs(true) : null;
|
||||
@@ -149,7 +150,7 @@ class SaveCopyModal extends Disposable {
|
||||
|
||||
public async save() {
|
||||
const ws = this._destWS.get();
|
||||
if (!ws) { throw new Error('No destination workspace'); }
|
||||
if (!ws) { throw new Error(t('NoDestinationWorkspace')); }
|
||||
const api = this._app.api;
|
||||
const org = this._destOrg.get();
|
||||
const docWorker = await api.getWorkerAPI('import');
|
||||
@@ -171,8 +172,8 @@ class SaveCopyModal extends Disposable {
|
||||
public buildDom() {
|
||||
return [
|
||||
cssField(
|
||||
cssLabel("Name"),
|
||||
input(this._destName, {onInput: true}, {placeholder: 'Enter document name'}, dom.cls(cssInput.className),
|
||||
cssLabel(t("Name")),
|
||||
input(this._destName, {onInput: true}, {placeholder: t('EnterDocumentName')}, dom.cls(cssInput.className),
|
||||
// modal dialog grabs focus after 10ms delay; so to focus this input, wait a bit longer
|
||||
// (see the TODO in app/client/ui2018/modals.ts about weasel.js and focus).
|
||||
(elem) => { setTimeout(() => { elem.focus(); }, 20); },
|
||||
@@ -180,15 +181,15 @@ class SaveCopyModal extends Disposable {
|
||||
testId('copy-dest-name'))
|
||||
),
|
||||
cssField(
|
||||
cssLabel("As Template"),
|
||||
cssCheckbox(this._asTemplate, 'Include the structure without any of the data.',
|
||||
cssLabel(t("AsTemplate")),
|
||||
cssCheckbox(this._asTemplate, t('IncludeStructureWithoutData'),
|
||||
testId('save-as-template'))
|
||||
),
|
||||
// Show the team picker only when saving to other teams is allowed and there are other teams
|
||||
// accessible.
|
||||
(this._orgs ?
|
||||
cssField(
|
||||
cssLabel("Organization"),
|
||||
cssLabel(t("Organization")),
|
||||
select(this._destOrg, this._orgs.map(value => ({value, label: value.name}))),
|
||||
testId('copy-dest-org'),
|
||||
) : null
|
||||
@@ -198,11 +199,11 @@ class SaveCopyModal extends Disposable {
|
||||
// Show the workspace picker only when destOrg is a team site, because personal orgs do not have workspaces.
|
||||
dom.domComputed((use) => use(this._showWorkspaces) && use(this._workspaces), (wss) =>
|
||||
wss === false ? null :
|
||||
wss && wss.length === 0 ? cssWarningText("You do not have write access to this site",
|
||||
wss && wss.length === 0 ? cssWarningText(t("NoWriteAccessToSite"),
|
||||
testId('copy-warning')) :
|
||||
[
|
||||
cssField(
|
||||
cssLabel("Workspace"),
|
||||
cssLabel(t("Workspace")),
|
||||
(wss === null ?
|
||||
cssSpinner(loadingSpinner()) :
|
||||
select(this._destWS, wss.map(value => ({
|
||||
@@ -215,7 +216,7 @@ class SaveCopyModal extends Disposable {
|
||||
),
|
||||
wss ? dom.domComputed(this._destWS, (destWs) =>
|
||||
destWs && !roles.canEdit(destWs.access) ?
|
||||
cssWarningText("You do not have write access to the selected workspace",
|
||||
cssWarningText(t("NoWriteAccessToWorkspace"),
|
||||
testId('copy-warning')
|
||||
) : null
|
||||
) : null
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {beaconOpenMessage, IBeaconOpenOptions} from 'app/client/lib/helpScout';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import {ConnectState} from 'app/client/models/ConnectState';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
@@ -13,6 +14,8 @@ import {commonUrls, shouldHideUiElement} from 'app/common/gristUrls';
|
||||
import {dom, makeTestId, styled} from 'grainjs';
|
||||
import {cssMenu, defaultMenuOptions, IOpenController, setPopupToCreateDom} from 'popweasel';
|
||||
|
||||
const t = makeT('NotifyUI');
|
||||
|
||||
const testId = makeTestId('test-notifier-');
|
||||
|
||||
|
||||
@@ -21,10 +24,10 @@ function buildAction(action: NotifyAction, item: Notification, options: IBeaconO
|
||||
switch (action) {
|
||||
case 'upgrade':
|
||||
if (appModel) {
|
||||
return cssToastAction('Upgrade Plan', dom.on('click', () =>
|
||||
return cssToastAction(t('UpgradePlan'), dom.on('click', () =>
|
||||
appModel.showUpgradeModal()));
|
||||
} else {
|
||||
return dom('a', cssToastAction.cls(''), 'Upgrade Plan', {target: '_blank'},
|
||||
return dom('a', cssToastAction.cls(''), t('UpgradePlan'), {target: '_blank'},
|
||||
{href: commonUrls.plans});
|
||||
}
|
||||
case 'renew':
|
||||
@@ -34,22 +37,22 @@ function buildAction(action: NotifyAction, item: Notification, options: IBeaconO
|
||||
if (appModel && appModel.currentOrg && appModel.currentOrg.billingAccount &&
|
||||
!appModel.currentOrg.billingAccount.isManager) { return null; }
|
||||
// Otherwise return a link to the billing page.
|
||||
return dom('a', cssToastAction.cls(''), 'Renew', {target: '_blank'},
|
||||
return dom('a', cssToastAction.cls(''), t('Renew'), {target: '_blank'},
|
||||
{href: urlState().makeUrl({billing: 'billing'})});
|
||||
|
||||
case 'personal':
|
||||
if (!appModel) { return null; }
|
||||
return cssToastAction('Go to your free personal site', dom.on('click', async () => {
|
||||
return cssToastAction(t('GoToPersonalSite'), dom.on('click', async () => {
|
||||
const info = await appModel.api.getSessionAll();
|
||||
const orgs = info.orgs.filter(org => org.owner && org.owner.id === appModel.currentUser?.id);
|
||||
if (orgs.length !== 1) {
|
||||
throw new Error('Cannot find personal site, sorry!');
|
||||
throw new Error(t('ErrorCannotFindPersonalSite'));
|
||||
}
|
||||
window.location.assign(urlState().makeUrl({org: orgs[0].domain || undefined}));
|
||||
}));
|
||||
|
||||
case 'report-problem':
|
||||
return cssToastAction('Report a problem', testId('toast-report-problem'),
|
||||
return cssToastAction(t('ReportProblem'), testId('toast-report-problem'),
|
||||
dom.on('click', () => beaconOpenMessage({...options, includeAppErrors: true})));
|
||||
|
||||
case 'ask-for-help': {
|
||||
@@ -57,7 +60,7 @@ function buildAction(action: NotifyAction, item: Notification, options: IBeaconO
|
||||
error: new Error(item.options.message as string),
|
||||
timestamp: item.options.timestamp,
|
||||
}];
|
||||
return cssToastAction('Ask for help',
|
||||
return cssToastAction(t('AskForHelp'),
|
||||
dom.on('click', () => beaconOpenMessage({...options, includeAppErrors: true, errors})));
|
||||
}
|
||||
|
||||
@@ -151,11 +154,11 @@ function buildNotifyDropdown(ctl: IOpenController, notifier: Notifier, appModel:
|
||||
|
||||
cssDropdownContent(
|
||||
cssDropdownHeader(
|
||||
cssDropdownHeaderTitle('Notifications'),
|
||||
cssDropdownHeaderTitle(t('Notifications')),
|
||||
shouldHideUiElement("helpCenter") ? null :
|
||||
cssDropdownFeedbackLink(
|
||||
cssDropdownFeedbackIcon('Feedback'),
|
||||
'Give feedback',
|
||||
t('GiveFeedback'),
|
||||
dom.on('click', () => beaconOpenMessage({appModel, onOpen: () => ctl.close(), route: '/ask/message/'})),
|
||||
testId('feedback'),
|
||||
)
|
||||
@@ -168,7 +171,7 @@ function buildNotifyDropdown(ctl: IOpenController, notifier: Notifier, appModel:
|
||||
),
|
||||
dom.maybe((use) => use(dropdownItems).length === 0 && !use(disconnectMsg), () =>
|
||||
cssDropdownStatus(
|
||||
dom('div', cssDropdownStatusText('No notifications')),
|
||||
dom('div', cssDropdownStatusText(t('NoNotifications'))),
|
||||
)
|
||||
),
|
||||
dom.forEach(dropdownItems, item =>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
import { Disposable, dom, DomElementArg, Holder, makeTestId, styled, svg } from "grainjs";
|
||||
import { createPopper, Placement } from '@popperjs/core';
|
||||
import { FocusLayer } from 'app/client/lib/FocusLayer';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import * as Mousetrap from 'app/client/lib/Mousetrap';
|
||||
import { bigBasicButton, bigPrimaryButton } from "app/client/ui2018/buttons";
|
||||
import { theme, vars } from "app/client/ui2018/cssVars";
|
||||
@@ -35,6 +36,8 @@ import {delay} from "app/common/delay";
|
||||
import {reportError} from "app/client/models/errors";
|
||||
import {cssBigIcon, cssCloseButton} from "./ExampleCard";
|
||||
|
||||
const t = makeT('OnBoardingPopups');
|
||||
|
||||
const testId = makeTestId('test-onboarding-');
|
||||
|
||||
// Describes an onboarding popup. Each popup is uniquely identified by its id.
|
||||
@@ -296,7 +299,7 @@ class OnBoardingPopupsCtl extends Disposable {
|
||||
{style: `margin-right: 8px; visibility: ${isFirstStep ? 'hidden' : 'visible'}`},
|
||||
),
|
||||
bigPrimaryButton(
|
||||
isLastStep ? 'Finish' : 'Next', testId('next'),
|
||||
isLastStep ? t('Finish') : t('Next'), testId('next'),
|
||||
dom.on('click', () => this._move(+1, true)),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as commands from 'app/client/components/commands';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {cssLinkText, cssPageEntryMain, cssPageIcon, cssPageLink} from 'app/client/ui/LeftPanelCommon';
|
||||
import {theme} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
@@ -6,6 +7,8 @@ import {modal} from 'app/client/ui2018/modals';
|
||||
import {commonUrls, shouldHideUiElement} from 'app/common/gristUrls';
|
||||
import {dom, makeTestId, styled} from 'grainjs';
|
||||
|
||||
const t = makeT('OpenVideoTour');
|
||||
|
||||
const testId = makeTestId('test-video-tour-');
|
||||
|
||||
/**
|
||||
@@ -25,7 +28,7 @@ const testId = makeTestId('test-video-tour-');
|
||||
cssVideo(
|
||||
{
|
||||
src: commonUrls.videoTour,
|
||||
title: 'YouTube video player',
|
||||
title: t('YouTubeVideoPlayer'),
|
||||
frameborder: '0',
|
||||
allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
|
||||
allowfullscreen: '',
|
||||
@@ -48,7 +51,7 @@ const testId = makeTestId('test-video-tour-');
|
||||
export function createVideoTourTextButton(): HTMLDivElement {
|
||||
const elem: HTMLDivElement = cssVideoTourTextButton(
|
||||
cssVideoIcon('Video'),
|
||||
'Grist Video Tour',
|
||||
t('GristVideoTour'),
|
||||
dom.on('click', () => openVideoTour(elem)),
|
||||
testId('text-button'),
|
||||
);
|
||||
@@ -74,7 +77,7 @@ export function createVideoTourToolsButton(): HTMLDivElement | null {
|
||||
dom.autoDispose(commandsGroup),
|
||||
cssPageLink(
|
||||
iconElement = cssPageIcon('Video'),
|
||||
cssLinkText('Video Tour'),
|
||||
cssLinkText(t('VideoTour')),
|
||||
dom.cls('tour-help-center'),
|
||||
dom.on('click', () => openVideoTour(iconElement)),
|
||||
testId('tools-button'),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import { reportError } from 'app/client/models/AppModel';
|
||||
import { ColumnRec, DocModel, TableRec, ViewSectionRec } from 'app/client/models/DocModel';
|
||||
import { GristTooltips } from 'app/client/ui/GristTooltips';
|
||||
@@ -15,6 +16,8 @@ import without = require('lodash/without');
|
||||
import Popper from 'popper.js';
|
||||
import { IOpenController, popupOpen, setPopupToCreateDom } from 'popweasel';
|
||||
|
||||
const t = makeT('PageWidgetPicker');
|
||||
|
||||
type TableId = number|'New Table'|null;
|
||||
|
||||
// Describes a widget selection.
|
||||
@@ -177,7 +180,7 @@ export function buildPageWidgetPicker(
|
||||
// should be handle by the caller.
|
||||
if (await isLongerThan(savePromise, DELAY_BEFORE_SPINNER_MS)) {
|
||||
const label = getWidgetTypes(type).label;
|
||||
await spinnerModal(`Building ${label} widget`, savePromise);
|
||||
await spinnerModal(t('BuildingWidget', { label }), savePromise);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,7 +282,7 @@ export class PageWidgetSelect extends Disposable {
|
||||
testId('container'),
|
||||
cssBody(
|
||||
cssPanel(
|
||||
header('Select Widget'),
|
||||
header(t('SelectWidget')),
|
||||
sectionTypes.map((value) => {
|
||||
const {label, icon: iconName} = getWidgetTypes(value);
|
||||
const disabled = computed(this._value.table, (use, tid) => this._isTypeDisabled(value, tid));
|
||||
@@ -296,7 +299,7 @@ export class PageWidgetSelect extends Disposable {
|
||||
),
|
||||
cssPanel(
|
||||
testId('data'),
|
||||
header('Select Data'),
|
||||
header(t('SelectData')),
|
||||
cssEntry(
|
||||
cssIcon('TypeTable'), 'New Table',
|
||||
// prevent the selection of 'New Table' if it is disabled
|
||||
@@ -324,7 +327,7 @@ export class PageWidgetSelect extends Disposable {
|
||||
)),
|
||||
),
|
||||
cssPanel(
|
||||
header('Group by'),
|
||||
header(t('GroupBy')),
|
||||
dom.hide((use) => !use(this._value.summarize)),
|
||||
domComputed(
|
||||
(use) => use(this._columns)
|
||||
@@ -359,7 +362,7 @@ export class PageWidgetSelect extends Disposable {
|
||||
bigPrimaryButton(
|
||||
// TODO: The button's label of the page widget picker should read 'Close' instead when
|
||||
// there are no changes.
|
||||
this._options.buttonLabel || 'Add to Page',
|
||||
this._options.buttonLabel || t('AddToPage'),
|
||||
dom.prop('disabled', (use) => !isValidSelection(
|
||||
use(this._value.table), use(this._value.type), this._options.isNewPage)
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {createGroup} from 'app/client/components/commands';
|
||||
import {duplicatePage} from 'app/client/components/duplicatePage';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {PageRec} from 'app/client/models/DocModel';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import MetaTableModel from 'app/client/models/MetaTableModel';
|
||||
@@ -15,6 +16,8 @@ import {buildPageDom, PageActions} from 'app/client/ui2018/pages';
|
||||
import {mod} from 'app/common/gutil';
|
||||
import {Computed, Disposable, dom, DomContents, fromKo, makeTestId, observable, Observable, styled} from 'grainjs';
|
||||
|
||||
const t = makeT('Pages');
|
||||
|
||||
// build dom for the tree view of pages
|
||||
export function buildPagesDom(owner: Disposable, activeDoc: GristDoc, isOpen: Observable<boolean>) {
|
||||
const pagesTable = activeDoc.docModel.pages;
|
||||
@@ -128,14 +131,14 @@ function buildPrompt(tableNames: string[], onSave: (option: RemoveOption) => Pro
|
||||
const saveDisabled = Computed.create(owner, use => use(selected) === '');
|
||||
const saveFunc = () => onSave(selected.get());
|
||||
return {
|
||||
title: `The following table${tableNames.length > 1 ? 's' : ''} will no longer be visible`,
|
||||
title: t('TableWillNoLongerBeVisible', { count: tableNames.length }),
|
||||
body: dom('div',
|
||||
testId('popup'),
|
||||
buildWarning(tableNames),
|
||||
cssOptions(
|
||||
buildOption(selected, 'data', `Delete data and this page.`),
|
||||
buildOption(selected, 'data', t('DeleteDataAndPage')),
|
||||
buildOption(selected, 'page',
|
||||
[
|
||||
[ // TODO i18n
|
||||
`Keep data and delete page. `,
|
||||
`Table will remain available in `,
|
||||
cssLink(urlState().setHref({docPage: 'data'}), 'raw data page', { target: '_blank'}),
|
||||
@@ -144,7 +147,7 @@ function buildPrompt(tableNames: string[], onSave: (option: RemoveOption) => Pro
|
||||
)
|
||||
),
|
||||
saveDisabled,
|
||||
saveLabel: 'Delete',
|
||||
saveLabel: t('Delete'),
|
||||
saveFunc,
|
||||
width: 'fixed-wide',
|
||||
extraButtons: [],
|
||||
|
||||
@@ -20,6 +20,7 @@ import {RefSelect} from 'app/client/components/RefSelect';
|
||||
import ViewConfigTab from 'app/client/components/ViewConfigTab';
|
||||
import {domAsync} from 'app/client/lib/domAsync';
|
||||
import * as imports from 'app/client/lib/imports';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {createSessionObs} from 'app/client/lib/sessionObs';
|
||||
import {reportError} from 'app/client/models/AppModel';
|
||||
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
@@ -42,23 +43,25 @@ import {bundleChanges, Computed, Disposable, dom, domComputed, DomContents,
|
||||
import {MultiHolder, Observable, styled, subscribe} from 'grainjs';
|
||||
import * as ko from 'knockout';
|
||||
|
||||
const t = makeT('RightPanel');
|
||||
|
||||
// Represents a top tab of the right side-pane.
|
||||
const TopTab = StringUnion("pageWidget", "field");
|
||||
|
||||
// Represents a subtab of pageWidget in the right side-pane.
|
||||
const PageSubTab = StringUnion("widget", "sortAndFilter", "data");
|
||||
|
||||
// A map of widget type to the icon and label to use for a field of that widget.
|
||||
const fieldTypes = new Map<IWidgetType, {label: string, icon: IconName, pluralLabel: string}>([
|
||||
['record', {label: 'Column', icon: 'TypeCell', pluralLabel: 'Columns'}],
|
||||
['detail', {label: 'Field', icon: 'TypeCell', pluralLabel: 'Fields'}],
|
||||
['single', {label: 'Field', icon: 'TypeCell', pluralLabel: 'Fields'}],
|
||||
['chart', {label: 'Series', icon: 'ChartLine', pluralLabel: 'Series'}],
|
||||
['custom', {label: 'Column', icon: 'TypeCell', pluralLabel: 'Columns'}],
|
||||
]);
|
||||
|
||||
// Returns the icon and label of a type, default to those associate to 'record' type.
|
||||
export function getFieldType(widgetType: IWidgetType|null) {
|
||||
// A map of widget type to the icon and label to use for a field of that widget.
|
||||
const fieldTypes = new Map<IWidgetType, {label: string, icon: IconName, pluralLabel: string}>([
|
||||
['record', {label: t('Column', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Column', { count: 2 })}],
|
||||
['detail', {label: t('Field', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Field', { count: 2 })}],
|
||||
['single', {label: t('Field', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Field', { count: 2 })}],
|
||||
['chart', {label: t('Series', { count: 1 }), icon: 'ChartLine', pluralLabel: t('Series', { count: 2 })}],
|
||||
['custom', {label: t('Column', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Column', { count: 2 })}],
|
||||
]);
|
||||
|
||||
return fieldTypes.get(widgetType || 'record') || fieldTypes.get('record')!;
|
||||
}
|
||||
|
||||
@@ -234,7 +237,7 @@ export class RightPanel extends Disposable {
|
||||
),
|
||||
cssSeparator(),
|
||||
dom.maybe<FieldBuilder|null>(fieldBuilder, builder => [
|
||||
cssLabel('COLUMN TYPE'),
|
||||
cssLabel(t('ColumnType')),
|
||||
cssSection(
|
||||
builder.buildSelectTypeDom(),
|
||||
),
|
||||
@@ -257,7 +260,7 @@ export class RightPanel extends Disposable {
|
||||
cssRow(refSelect.buildDom()),
|
||||
cssSeparator()
|
||||
]),
|
||||
cssLabel('TRANSFORM'),
|
||||
cssLabel(t('Transform')),
|
||||
dom.maybe<FieldBuilder|null>(fieldBuilder, builder => builder.buildTransformDom()),
|
||||
dom.maybe(isMultiSelect, () => disabledSection()),
|
||||
testId('panel-transform'),
|
||||
@@ -287,15 +290,15 @@ export class RightPanel extends Disposable {
|
||||
private _buildPageWidgetContent(_owner: MultiHolder) {
|
||||
return [
|
||||
cssSubTabContainer(
|
||||
cssSubTab('Widget',
|
||||
cssSubTab(t('Widget'),
|
||||
cssSubTab.cls('-selected', (use) => use(this._subTab) === 'widget'),
|
||||
dom.on('click', () => this._subTab.set("widget")),
|
||||
testId('config-widget')),
|
||||
cssSubTab('Sort & Filter',
|
||||
cssSubTab(t('SortAndFilter'),
|
||||
cssSubTab.cls('-selected', (use) => use(this._subTab) === 'sortAndFilter'),
|
||||
dom.on('click', () => this._subTab.set("sortAndFilter")),
|
||||
testId('config-sortAndFilter')),
|
||||
cssSubTab('Data',
|
||||
cssSubTab(t('Data'),
|
||||
cssSubTab.cls('-selected', (use) => use(this._subTab) === 'data'),
|
||||
dom.on('click', () => this._subTab.set("data")),
|
||||
testId('config-data')),
|
||||
@@ -337,7 +340,7 @@ export class RightPanel extends Disposable {
|
||||
});
|
||||
return dom.maybe(viewConfigTab, (vct) => [
|
||||
this._disableIfReadonly(),
|
||||
cssLabel(dom.text(use => use(activeSection.isRaw) ? 'DATA TABLE NAME' : 'WIDGET TITLE'),
|
||||
cssLabel(dom.text(use => use(activeSection.isRaw) ? t('DataTableName') : t('WidgetTitle')),
|
||||
dom.style('margin-bottom', '14px'),
|
||||
),
|
||||
cssRow(cssTextInput(
|
||||
@@ -354,7 +357,7 @@ export class RightPanel extends Disposable {
|
||||
dom.maybe(
|
||||
(use) => !use(activeSection.isRaw),
|
||||
() => cssRow(
|
||||
primaryButton('Change Widget', this._createPageWidgetPicker()),
|
||||
primaryButton(t('ChangeWidget'), this._createPageWidgetPicker()),
|
||||
cssRow.cls('-top-space')
|
||||
),
|
||||
),
|
||||
@@ -362,7 +365,7 @@ export class RightPanel extends Disposable {
|
||||
cssSeparator(),
|
||||
|
||||
dom.maybe((use) => ['detail', 'single'].includes(use(this._pageWidgetType)!), () => [
|
||||
cssLabel('Theme'),
|
||||
cssLabel(t('Theme')),
|
||||
dom('div',
|
||||
vct._buildThemeDom(),
|
||||
vct._buildLayoutDom())
|
||||
@@ -377,22 +380,22 @@ export class RightPanel extends Disposable {
|
||||
if (use(this._pageWidgetType) !== 'record') { return null; }
|
||||
return [
|
||||
cssSeparator(),
|
||||
cssLabel('ROW STYLE'),
|
||||
cssLabel(t('RowStyleUpper')),
|
||||
domAsync(imports.loadViewPane().then(ViewPane =>
|
||||
dom.create(ViewPane.ConditionalStyle, "Row Style", activeSection, this._gristDoc)
|
||||
dom.create(ViewPane.ConditionalStyle, t("RowStyle"), activeSection, this._gristDoc)
|
||||
))
|
||||
];
|
||||
}),
|
||||
|
||||
dom.maybe((use) => use(this._pageWidgetType) === 'chart', () => [
|
||||
cssLabel('CHART TYPE'),
|
||||
cssLabel(t('ChartType')),
|
||||
vct._buildChartConfigDom(),
|
||||
]),
|
||||
|
||||
dom.maybe((use) => use(this._pageWidgetType) === 'custom', () => {
|
||||
const parts = vct._buildCustomTypeItems() as any[];
|
||||
return [
|
||||
cssLabel('CUSTOM'),
|
||||
cssLabel(t('Custom')),
|
||||
// If 'customViewPlugin' feature is on, show the toggle that allows switching to
|
||||
// plugin mode. Note that the default mode for a new 'custom' view is 'url', so that's
|
||||
// the only one that will be shown without the feature flag.
|
||||
@@ -423,11 +426,11 @@ export class RightPanel extends Disposable {
|
||||
private _buildPageSortFilterConfig(owner: MultiHolder) {
|
||||
const viewConfigTab = this._createViewConfigTab(owner);
|
||||
return [
|
||||
cssLabel('SORT'),
|
||||
cssLabel(t('Sort')),
|
||||
dom.maybe(viewConfigTab, (vct) => vct.buildSortDom()),
|
||||
cssSeparator(),
|
||||
|
||||
cssLabel('FILTER'),
|
||||
cssLabel(t('Filter')),
|
||||
dom.maybe(viewConfigTab, (vct) => dom('div', vct._buildFilterDom())),
|
||||
];
|
||||
}
|
||||
@@ -464,15 +467,15 @@ export class RightPanel extends Disposable {
|
||||
link.onWrite((val) => this._gristDoc.saveLink(val));
|
||||
return [
|
||||
this._disableIfReadonly(),
|
||||
cssLabel('DATA TABLE'),
|
||||
cssLabel(t('DataTable')),
|
||||
cssRow(
|
||||
cssIcon('TypeTable'), cssDataLabel('SOURCE DATA'),
|
||||
cssIcon('TypeTable'), cssDataLabel(t('SourceData')),
|
||||
cssContent(dom.text((use) => use(use(table).primaryTableId)),
|
||||
testId('pwc-table'))
|
||||
),
|
||||
dom(
|
||||
'div',
|
||||
cssRow(cssIcon('Pivot'), cssDataLabel('GROUPED BY')),
|
||||
cssRow(cssIcon('Pivot'), cssDataLabel(t('GroupedBy'))),
|
||||
cssRow(domComputed(groupedBy, (cols) => cssList(cols.map((c) => (
|
||||
cssListItem(dom.text(c.label),
|
||||
testId('pwc-groupedBy-col'))
|
||||
@@ -484,12 +487,12 @@ export class RightPanel extends Disposable {
|
||||
),
|
||||
|
||||
dom.maybe((use) => !use(activeSection.isRaw), () =>
|
||||
cssButtonRow(primaryButton('Edit Data Selection', this._createPageWidgetPicker(),
|
||||
cssButtonRow(primaryButton(t('EditDataSelection'), this._createPageWidgetPicker(),
|
||||
testId('pwc-editDataSelection')),
|
||||
dom.maybe(
|
||||
use => Boolean(use(use(activeSection.table).summarySourceTable)),
|
||||
() => basicButton(
|
||||
'Detach',
|
||||
t('Detach'),
|
||||
dom.on('click', () => this._gristDoc.docData.sendAction(
|
||||
["DetachSummaryViewSection", activeSection.getRowId()])),
|
||||
testId('detach-button'),
|
||||
@@ -506,10 +509,10 @@ export class RightPanel extends Disposable {
|
||||
cssSeparator(),
|
||||
|
||||
dom.maybe((use) => !use(activeSection.isRaw), () => [
|
||||
cssLabel('SELECT BY'),
|
||||
cssLabel(t('SelectBy')),
|
||||
cssRow(
|
||||
dom.update(
|
||||
select(link, linkOptions, {defaultLabel: 'Select Widget'}),
|
||||
select(link, linkOptions, {defaultLabel: t('SelectWidget')}),
|
||||
dom.on('click', () => {
|
||||
refreshTrigger.set(!refreshTrigger.get());
|
||||
})
|
||||
@@ -525,7 +528,7 @@ export class RightPanel extends Disposable {
|
||||
// TODO: sections should be listed following the order of appearance in the view layout (ie:
|
||||
// left/right - top/bottom);
|
||||
return selectorFor.length ? [
|
||||
cssLabel('SELECTOR FOR', testId('selector-for')),
|
||||
cssLabel(t('SelectorFor'), testId('selector-for')),
|
||||
cssRow(cssList(selectorFor.map((sec) => this._buildSectionItem(sec))))
|
||||
] : null;
|
||||
}),
|
||||
@@ -537,7 +540,7 @@ export class RightPanel extends Disposable {
|
||||
const section = gristDoc.viewModel.activeSection;
|
||||
const onSave = (val: IPageWidget) => gristDoc.saveViewSection(section.peek(), val);
|
||||
return (elem) => { attachPageWidgetPicker(elem, gristDoc.docModel, onSave, {
|
||||
buttonLabel: 'Save',
|
||||
buttonLabel: t('Save'),
|
||||
value: () => toPageWidget(section.peek()),
|
||||
selectBy: (val) => gristDoc.selectBy(val),
|
||||
}); };
|
||||
@@ -558,7 +561,7 @@ export class RightPanel extends Disposable {
|
||||
return dom.maybe(this._gristDoc.docPageModel.isReadonly, () => (
|
||||
cssOverlay(
|
||||
testId('disable-overlay'),
|
||||
cssBottomText('You do not have edit access to this document'),
|
||||
cssBottomText(t('NoEditAccess')),
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { allCommands } from 'app/client/components/commands';
|
||||
import { makeT } from 'app/client/lib/localization';
|
||||
import { menuDivider, menuItemCmd } from 'app/client/ui2018/menus';
|
||||
import { dom } from 'grainjs';
|
||||
|
||||
const t = makeT('RowContextMenu');
|
||||
|
||||
export interface IRowContextMenu {
|
||||
disableInsert: boolean;
|
||||
disableDelete: boolean;
|
||||
@@ -16,29 +19,29 @@ export function RowContextMenu({ disableInsert, disableDelete, isViewSorted, num
|
||||
// bottom. It could be very confusing for users who might expect the record to stay above or
|
||||
// below the active row. Thus in this case we show a single `insert row` command.
|
||||
result.push(
|
||||
menuItemCmd(allCommands.insertRecordAfter, 'Insert row',
|
||||
menuItemCmd(allCommands.insertRecordAfter, t('InsertRow'),
|
||||
dom.cls('disabled', disableInsert)),
|
||||
);
|
||||
} else {
|
||||
result.push(
|
||||
menuItemCmd(allCommands.insertRecordBefore, 'Insert row above',
|
||||
menuItemCmd(allCommands.insertRecordBefore, t('InsertRowAbove'),
|
||||
dom.cls('disabled', disableInsert)),
|
||||
menuItemCmd(allCommands.insertRecordAfter, 'Insert row below',
|
||||
menuItemCmd(allCommands.insertRecordAfter, t('InsertRowBelow'),
|
||||
dom.cls('disabled', disableInsert)),
|
||||
);
|
||||
}
|
||||
result.push(
|
||||
menuItemCmd(allCommands.duplicateRows, `Duplicate ${numRows === 1 ? 'row' : 'rows'}`,
|
||||
menuItemCmd(allCommands.duplicateRows, t('DuplicateRows', { count: numRows }),
|
||||
dom.cls('disabled', disableInsert || numRows === 0)),
|
||||
);
|
||||
result.push(
|
||||
menuDivider(),
|
||||
// TODO: should show `Delete ${num} rows` when multiple are selected
|
||||
menuItemCmd(allCommands.deleteRecords, 'Delete',
|
||||
menuItemCmd(allCommands.deleteRecords, t('Delete'),
|
||||
dom.cls('disabled', disableDelete)),
|
||||
);
|
||||
result.push(
|
||||
menuDivider(),
|
||||
menuItemCmd(allCommands.copyLink, 'Copy anchor link'));
|
||||
menuItemCmd(allCommands.copyLink, t('CopyAnchorLink')));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ import * as roles from 'app/common/roles';
|
||||
import {Document} from 'app/common/UserAPI';
|
||||
import {dom, DomContents, styled} from 'grainjs';
|
||||
import {MenuCreateFunc} from 'popweasel';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
|
||||
const t = makeT('ShareMenu');
|
||||
|
||||
function buildOriginalUrlId(urlId: string, isSnapshot: boolean): string {
|
||||
const parts = parseUrlId(urlId);
|
||||
@@ -32,18 +35,18 @@ export function buildShareMenuButton(pageModel: DocPageModel): DomContents {
|
||||
// available (a user quick enough to open the menu in this state would have to re-open it).
|
||||
return dom.maybe(pageModel.currentDoc, (doc) => {
|
||||
const appModel = pageModel.appModel;
|
||||
const saveCopy = () => makeCopy(doc, appModel, 'Save Document').catch(reportError);
|
||||
const saveCopy = () => makeCopy(doc, appModel, t('SaveDocument')).catch(reportError);
|
||||
if (doc.idParts.snapshotId) {
|
||||
const backToCurrent = () => urlState().pushUrl({doc: buildOriginalUrlId(doc.id, true)});
|
||||
return shareButton('Back to Current', () => [
|
||||
return shareButton(t('BackToCurrent'), () => [
|
||||
menuManageUsers(doc, pageModel),
|
||||
menuSaveCopy('Save Copy', doc, appModel),
|
||||
menuSaveCopy(t('SaveCopy'), doc, appModel),
|
||||
menuOriginal(doc, appModel, true),
|
||||
menuExports(doc, pageModel),
|
||||
], {buttonAction: backToCurrent});
|
||||
} else if (doc.isPreFork || doc.isBareFork) {
|
||||
// A new unsaved document, or a fiddle, or a public example.
|
||||
const saveActionTitle = doc.isBareFork ? 'Save Document' : 'Save Copy';
|
||||
const saveActionTitle = doc.isBareFork ? t('SaveDocument') : t('SaveCopy');
|
||||
return shareButton(saveActionTitle, () => [
|
||||
menuManageUsers(doc, pageModel),
|
||||
menuSaveCopy(saveActionTitle, doc, appModel),
|
||||
@@ -55,16 +58,16 @@ export function buildShareMenuButton(pageModel: DocPageModel): DomContents {
|
||||
// Copy" primary and keep it as an action button on top. Otherwise, show a tag without a
|
||||
// default action; click opens the menu where the user can choose.
|
||||
if (!roles.canEdit(doc.trunkAccess || null)) {
|
||||
return shareButton('Save Copy', () => [
|
||||
return shareButton(t('SaveCopy'), () => [
|
||||
menuManageUsers(doc, pageModel),
|
||||
menuSaveCopy('Save Copy', doc, appModel),
|
||||
menuSaveCopy(t('SaveCopy'), doc, appModel),
|
||||
menuOriginal(doc, appModel, false),
|
||||
menuExports(doc, pageModel),
|
||||
], {buttonAction: saveCopy});
|
||||
} else {
|
||||
return shareButton('Unsaved', () => [
|
||||
return shareButton(t('Unsaved'), () => [
|
||||
menuManageUsers(doc, pageModel),
|
||||
menuSaveCopy('Save Copy', doc, appModel),
|
||||
menuSaveCopy(t('SaveCopy'), doc, appModel),
|
||||
menuOriginal(doc, appModel, false),
|
||||
menuExports(doc, pageModel),
|
||||
]);
|
||||
@@ -72,7 +75,7 @@ export function buildShareMenuButton(pageModel: DocPageModel): DomContents {
|
||||
} else {
|
||||
return shareButton(null, () => [
|
||||
menuManageUsers(doc, pageModel),
|
||||
menuSaveCopy('Duplicate Document', doc, appModel),
|
||||
menuSaveCopy(t('DuplicateDocument'), doc, appModel),
|
||||
menuWorkOnCopy(pageModel),
|
||||
menuExports(doc, pageModel),
|
||||
]);
|
||||
@@ -129,7 +132,7 @@ function shareButton(buttonText: string|null, menuCreateFunc: MenuCreateFunc,
|
||||
function menuManageUsers(doc: DocInfo, pageModel: DocPageModel) {
|
||||
return [
|
||||
menuItem(() => manageUsers(doc, pageModel),
|
||||
roles.canEditAccess(doc.access) ? 'Manage Users' : 'Access Details',
|
||||
roles.canEditAccess(doc.access) ? t('ManageUsers') : t('AccessDetails'),
|
||||
dom.cls('disabled', doc.isFork),
|
||||
testId('tb-share-option')
|
||||
),
|
||||
@@ -140,7 +143,7 @@ function menuManageUsers(doc: DocInfo, pageModel: DocPageModel) {
|
||||
// Renders "Return to Original" and "Replace Original" menu items. When used with snapshots, we
|
||||
// say "Current Version" in place of the word "Original".
|
||||
function menuOriginal(doc: Document, appModel: AppModel, isSnapshot: boolean) {
|
||||
const termToUse = isSnapshot ? "Current Version" : "Original";
|
||||
const termToUse = isSnapshot ? t("CurrentVersion") : t("Original");
|
||||
const origUrlId = buildOriginalUrlId(doc.id, isSnapshot);
|
||||
const originalUrl = urlState().makeUrl({doc: origUrlId});
|
||||
|
||||
@@ -163,18 +166,18 @@ function menuOriginal(doc: Document, appModel: AppModel, isSnapshot: boolean) {
|
||||
}
|
||||
return [
|
||||
cssMenuSplitLink({href: originalUrl},
|
||||
cssMenuSplitLinkText(`Return to ${termToUse}`), testId('return-to-original'),
|
||||
cssMenuSplitLinkText(t('ReturnToTermToUse', {termToUse})), testId('return-to-original'),
|
||||
cssMenuIconLink({href: originalUrl, target: '_blank'}, testId('open-original'),
|
||||
cssMenuIcon('FieldLink'),
|
||||
)
|
||||
),
|
||||
menuItem(replaceOriginal, `Replace ${termToUse}...`,
|
||||
menuItem(replaceOriginal, t('ReplaceTermToUse', {termToUse}),
|
||||
// Disable if original is not writable, and also when comparing snapshots (since it's
|
||||
// unclear which of the versions to use).
|
||||
dom.cls('disabled', !roles.canEdit(doc.trunkAccess || null) || comparingSnapshots),
|
||||
testId('replace-original'),
|
||||
),
|
||||
menuItemLink(compareHref, {target: '_blank'}, `Compare to ${termToUse}`,
|
||||
menuItemLink(compareHref, {target: '_blank'}, t('CompareTermToUse', {termToUse}),
|
||||
menuAnnotate('Beta'),
|
||||
testId('compare-original'),
|
||||
),
|
||||
@@ -202,10 +205,10 @@ function menuWorkOnCopy(pageModel: DocPageModel) {
|
||||
};
|
||||
|
||||
return [
|
||||
menuItem(makeUnsavedCopy, 'Work on a Copy', testId('work-on-copy')),
|
||||
menuItem(makeUnsavedCopy, t('WorkOnCopy'), testId('work-on-copy')),
|
||||
menuText(
|
||||
withInfoTooltip(
|
||||
'Edit without affecting the original',
|
||||
t('EditWithoutAffecting'),
|
||||
GristTooltips.workOnACopy(),
|
||||
{tooltipMenuOptions: {attach: null}}
|
||||
)
|
||||
@@ -226,21 +229,21 @@ function menuExports(doc: Document, pageModel: DocPageModel) {
|
||||
menuDivider(),
|
||||
(isElectron ?
|
||||
menuItem(() => gristDoc.app.comm.showItemInFolder(doc.name),
|
||||
'Show in folder', testId('tb-share-option')) :
|
||||
t('ShowInFolder'), testId('tb-share-option')) :
|
||||
menuItemLink({
|
||||
href: pageModel.appModel.api.getDocAPI(doc.id).getDownloadUrl(),
|
||||
target: '_blank', download: ''
|
||||
},
|
||||
menuIcon('Download'), 'Download', testId('tb-share-option'))
|
||||
menuIcon('Download'), t('Download'), testId('tb-share-option'))
|
||||
),
|
||||
menuItemLink({ href: gristDoc.getCsvLink(), target: '_blank', download: ''},
|
||||
menuIcon('Download'), 'Export CSV', testId('tb-share-option')),
|
||||
menuIcon('Download'), t('ExportCSV'), testId('tb-share-option')),
|
||||
menuItemLink({
|
||||
href: pageModel.appModel.api.getDocAPI(doc.id).getDownloadXlsxUrl(),
|
||||
target: '_blank', download: ''
|
||||
}, menuIcon('Download'), 'Export XLSX', testId('tb-share-option')),
|
||||
}, menuIcon('Download'), t('ExportXLSX'), testId('tb-share-option')),
|
||||
menuItem(() => sendToDrive(doc, pageModel),
|
||||
menuIcon('Download'), 'Send to Google Drive', testId('tb-share-option')),
|
||||
menuIcon('Download'), t('SendToGoogleDrive'), testId('tb-share-option')),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import {dom, makeTestId, styled} from 'grainjs';
|
||||
import {getSingleOrg, shouldHideUiElement} from 'app/common/gristUrls';
|
||||
import {getOrgName} from 'app/common/UserAPI';
|
||||
import {dom, makeTestId, styled} from 'grainjs';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {theme} from 'app/client/ui2018/cssVars';
|
||||
import {menuDivider, menuIcon, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
|
||||
const t = makeT('SiteSwitcher');
|
||||
|
||||
const testId = makeTestId('test-site-switcher-');
|
||||
|
||||
/**
|
||||
@@ -30,7 +33,7 @@ export function buildSiteSwitcher(appModel: AppModel) {
|
||||
const orgs = appModel.topAppModel.orgs;
|
||||
|
||||
return [
|
||||
menuSubHeader('Switch Sites'),
|
||||
menuSubHeader(t('SwitchSites')),
|
||||
dom.forEach(orgs, (org) =>
|
||||
menuItemLink(urlState().setLinkUrl({ org: org.domain || undefined }),
|
||||
cssOrgSelected.cls('', appModel.currentOrg ? org.id === appModel.currentOrg.id : false),
|
||||
@@ -42,7 +45,7 @@ export function buildSiteSwitcher(appModel: AppModel) {
|
||||
menuItem(
|
||||
() => appModel.showNewSiteModal(),
|
||||
menuIcon('Plus'),
|
||||
'Create new team site',
|
||||
t('CreateNewTeamSite'),
|
||||
testId('create-new-site'),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import * as css from 'app/client/ui/AccountPageCss';
|
||||
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
|
||||
@@ -6,6 +7,7 @@ import {ThemeAppearance} from 'app/common/ThemePrefs';
|
||||
import {Computed, Disposable, dom, makeTestId, styled} from 'grainjs';
|
||||
|
||||
const testId = makeTestId('test-theme-config-');
|
||||
const t = makeT('ThemeConfig');
|
||||
|
||||
export class ThemeConfig extends Disposable {
|
||||
private _themePrefs = this._appModel.themePrefs;
|
||||
@@ -24,7 +26,7 @@ export class ThemeConfig extends Disposable {
|
||||
|
||||
public buildDom() {
|
||||
return dom('div',
|
||||
css.subHeader('Appearance ', css.betaTag('Beta')),
|
||||
css.subHeader(t('Appearance'), css.betaTag('Beta')),
|
||||
css.dataRow(
|
||||
cssAppearanceSelect(
|
||||
select(
|
||||
@@ -40,7 +42,7 @@ export class ThemeConfig extends Disposable {
|
||||
css.dataRow(
|
||||
labeledSquareCheckbox(
|
||||
this._syncWithOS,
|
||||
'Switch appearance automatically to match system',
|
||||
t('SyncWithOS'),
|
||||
testId('sync-with-os'),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {getUserOrgPrefObs, markAsSeen} from 'app/client/models/UserPrefs';
|
||||
import {showExampleCard} from 'app/client/ui/ExampleCard';
|
||||
import {examples} from 'app/client/ui/ExampleInfo';
|
||||
import {buildExamples} from 'app/client/ui/ExampleInfo';
|
||||
import {createHelpTools, cssLinkText, cssPageEntry, cssPageEntryMain, cssPageEntrySmall,
|
||||
cssPageIcon, cssPageLink, cssSectionHeader, cssSpacer, cssSplitPageEntry,
|
||||
cssTools} from 'app/client/ui/LeftPanelCommon';
|
||||
@@ -17,6 +18,7 @@ import {isOwner} from 'app/common/roles';
|
||||
import {Disposable, dom, makeTestId, Observable, observable, styled} from 'grainjs';
|
||||
|
||||
const testId = makeTestId('test-tools-');
|
||||
const t = makeT('Tools');
|
||||
|
||||
export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Observable<boolean>): Element {
|
||||
const docPageModel = gristDoc.docPageModel;
|
||||
@@ -31,14 +33,14 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse
|
||||
updateCanViewAccessRules();
|
||||
return cssTools(
|
||||
cssTools.cls('-collapsed', (use) => !use(leftPanelOpen)),
|
||||
cssSectionHeader("TOOLS"),
|
||||
cssSectionHeader(t("Tools")),
|
||||
cssPageEntry(
|
||||
cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'acl'),
|
||||
cssPageEntry.cls('-disabled', (use) => !use(canViewAccessRules)),
|
||||
dom.domComputed(canViewAccessRules, (_canViewAccessRules) => {
|
||||
return cssPageLink(
|
||||
cssPageIcon('EyeShow'),
|
||||
cssLinkText('Access Rules',
|
||||
cssLinkText(t('AccessRules'),
|
||||
menuAnnotate('Beta', cssBetaTag.cls(''))
|
||||
),
|
||||
_canViewAccessRules ? urlState().setLinkUrl({docPage: 'acl'}) : null,
|
||||
@@ -51,35 +53,35 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse
|
||||
cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'data'),
|
||||
cssPageLink(
|
||||
cssPageIcon('Database'),
|
||||
cssLinkText('Raw Data'),
|
||||
cssLinkText(t('RawData')),
|
||||
testId('raw'),
|
||||
urlState().setLinkUrl({docPage: 'data'})
|
||||
)
|
||||
),
|
||||
cssPageEntry(
|
||||
cssPageLink(cssPageIcon('Log'), cssLinkText('Document History'), testId('log'),
|
||||
cssPageLink(cssPageIcon('Log'), cssLinkText(t('DocumentHistory')), testId('log'),
|
||||
dom.on('click', () => gristDoc.showTool('docHistory')))
|
||||
),
|
||||
// TODO: polish validation and add it back
|
||||
dom.maybe((use) => use(gristDoc.app.features).validationsTool, () =>
|
||||
cssPageEntry(
|
||||
cssPageLink(cssPageIcon('Validation'), cssLinkText('Validate Data'), testId('validate'),
|
||||
cssPageLink(cssPageIcon('Validation'), cssLinkText(t('ValidateData')), testId('validate'),
|
||||
dom.on('click', () => gristDoc.showTool('validations'))))
|
||||
),
|
||||
cssPageEntry(
|
||||
cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'code'),
|
||||
cssPageLink(cssPageIcon('Code'),
|
||||
cssLinkText('Code View'),
|
||||
cssLinkText(t('CodeView')),
|
||||
urlState().setLinkUrl({docPage: 'code'})
|
||||
),
|
||||
testId('code'),
|
||||
),
|
||||
cssSpacer(),
|
||||
dom.maybe(docPageModel.currentDoc, (doc) => {
|
||||
const ex = examples.find(e => e.urlId === doc.urlId);
|
||||
const ex = buildExamples().find(e => e.urlId === doc.urlId);
|
||||
if (!ex || !ex.tutorialUrl) { return null; }
|
||||
return cssPageEntry(
|
||||
cssPageLink(cssPageIcon('Page'), cssLinkText('How-to Tutorial'), testId('tutorial'),
|
||||
cssPageLink(cssPageIcon('Page'), cssLinkText(t('HowToTutorial')), testId('tutorial'),
|
||||
{href: ex.tutorialUrl, target: '_blank'},
|
||||
cssExampleCardOpener(
|
||||
icon('TypeDetails'),
|
||||
@@ -99,14 +101,14 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse
|
||||
cssSplitPageEntry(
|
||||
cssPageEntryMain(
|
||||
cssPageLink(cssPageIcon('Page'),
|
||||
cssLinkText('Tour of this Document'),
|
||||
cssLinkText(t('DocumentTour')),
|
||||
urlState().setLinkUrl({docTour: true}),
|
||||
testId('doctour'),
|
||||
),
|
||||
),
|
||||
!isDocOwner ? null : cssPageEntrySmall(
|
||||
cssPageLink(cssPageIcon('Remove'),
|
||||
dom.on('click', () => confirmModal('Delete document tour?', 'Delete', () =>
|
||||
dom.on('click', () => confirmModal(t('DeleteDocumentTour'), t('Delete'), () =>
|
||||
gristDoc.docData.sendAction(['RemoveTable', 'GristDocTour']))
|
||||
),
|
||||
testId('remove-doctour')
|
||||
@@ -193,7 +195,7 @@ function addRevertViewAsUI() {
|
||||
// A tooltip that allows reverting back to yourself.
|
||||
hoverTooltip((ctl) =>
|
||||
cssConvertTooltip(icon('Convert'),
|
||||
cssLink('Return to viewing as yourself',
|
||||
cssLink(t('ViewingAsYourself'),
|
||||
urlState().setHref(userOverrideParams(null, {docPage: 'acl'})),
|
||||
),
|
||||
tooltipCloseButton(ctl),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {loadSearch} from 'app/client/lib/imports';
|
||||
import {AppModel, reportError} from 'app/client/models/AppModel';
|
||||
@@ -19,6 +20,8 @@ import {waitGrainObs} from 'app/common/gutil';
|
||||
import * as roles from 'app/common/roles';
|
||||
import {Computed, dom, DomElementArg, makeTestId, MultiHolder, Observable, styled} from 'grainjs';
|
||||
|
||||
const t = makeT('TopBar');
|
||||
|
||||
export function createTopBarHome(appModel: AppModel) {
|
||||
return [
|
||||
cssFlexSpace(),
|
||||
@@ -26,7 +29,7 @@ export function createTopBarHome(appModel: AppModel) {
|
||||
(appModel.isTeamSite && roles.canEditAccess(appModel.currentOrg?.access || null) ?
|
||||
[
|
||||
basicButton(
|
||||
'Manage Team',
|
||||
t('ManageTeam'),
|
||||
dom.on('click', () => manageTeamUsersApp(appModel)),
|
||||
testId('topbar-manage-team')
|
||||
),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import type {ColumnRec} from 'app/client/models/entities/ColumnRec';
|
||||
import type {TableRec} from 'app/client/models/entities/TableRec';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
@@ -17,6 +18,8 @@ import {Computed, dom, IDisposableOwner, MultiHolder, Observable, styled} from '
|
||||
import {cssMenu, cssMenuItem, defaultMenuOptions, IOpenController, setPopupToCreateDom} from "popweasel";
|
||||
import isEqual = require('lodash/isEqual');
|
||||
|
||||
const t = makeT('TriggerFormulas');
|
||||
|
||||
/**
|
||||
* Build UI to select triggers for formulas in data columns (such for default values).
|
||||
*/
|
||||
@@ -70,7 +73,7 @@ export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec, opti
|
||||
const docModel = column._table.docModel;
|
||||
const summaryText = Computed.create(owner, use => {
|
||||
if (use(column.recalcWhen) === RecalcWhen.MANUAL_UPDATES) {
|
||||
return 'Any field';
|
||||
return t('AnyField');
|
||||
}
|
||||
const deps = decodeObject(use(column.recalcDeps)) as number[]|null;
|
||||
if (!deps || deps.length === 0) { return ''; }
|
||||
@@ -95,7 +98,7 @@ export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec, opti
|
||||
cssRow(
|
||||
labeledSquareCheckbox(
|
||||
applyToNew,
|
||||
'Apply to new records',
|
||||
t('NewRecords'),
|
||||
dom.boolAttr('disabled', newRowsDisabled),
|
||||
testId('field-formula-apply-to-new'),
|
||||
),
|
||||
@@ -104,8 +107,8 @@ export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec, opti
|
||||
labeledSquareCheckbox(
|
||||
applyOnChanges,
|
||||
dom.text(use => use(applyOnChanges) ?
|
||||
'Apply on changes to:' :
|
||||
'Apply on record changes'
|
||||
t('ChangesTo') :
|
||||
t('RecordChanges')
|
||||
),
|
||||
dom.boolAttr('disabled', changesDisabled),
|
||||
testId('field-formula-apply-on-changes'),
|
||||
@@ -197,14 +200,14 @@ function buildTriggerSelectors(ctl: IOpenController, tableRec: TableRec, column:
|
||||
cssItemsFixed(
|
||||
cssSelectorItem(
|
||||
labeledSquareCheckbox(current,
|
||||
['Current field ', cssSelectorNote('(data cleaning)')],
|
||||
[t('CurrentField'), cssSelectorNote('(data cleaning)')],
|
||||
dom.boolAttr('disabled', allUpdates),
|
||||
),
|
||||
),
|
||||
menuDivider(),
|
||||
cssSelectorItem(
|
||||
labeledSquareCheckbox(allUpdates,
|
||||
['Any field ', cssSelectorNote('(except formulas)')]
|
||||
[`${t('AnyField')} `, cssSelectorNote('(except formulas)')]
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -221,12 +224,12 @@ function buildTriggerSelectors(ctl: IOpenController, tableRec: TableRec, column:
|
||||
cssItemsFixed(
|
||||
cssSelectorFooter(
|
||||
dom.maybe(isChanged, () =>
|
||||
primaryButton('OK',
|
||||
primaryButton(t('OK'),
|
||||
dom.on('click', () => close(true)),
|
||||
testId('trigger-deps-apply')
|
||||
),
|
||||
),
|
||||
basicButton(dom.text(use => use(isChanged) ? 'Cancel' : 'Close'),
|
||||
basicButton(dom.text(use => use(isChanged) ? t('Cancel') : t('Close')),
|
||||
dom.on('click', () => close(false)),
|
||||
testId('trigger-deps-cancel')
|
||||
),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {allCommands} from 'app/client/components/commands';
|
||||
import {ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
@@ -5,6 +6,8 @@ import {testId} from 'app/client/ui2018/cssVars';
|
||||
import {menuDivider, menuItemCmd, menuItemLink} from 'app/client/ui2018/menus';
|
||||
import {dom} from 'grainjs';
|
||||
|
||||
const t = makeT('ViewLayoutMenu');
|
||||
|
||||
/**
|
||||
* Returns a list of menu items for a view section.
|
||||
*/
|
||||
@@ -21,11 +24,11 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
|
||||
|
||||
const contextMenu = [
|
||||
menuItemCmd(allCommands.deleteRecords,
|
||||
'Delete record',
|
||||
t('DeleteRecord'),
|
||||
testId('section-delete-card'),
|
||||
dom.cls('disabled', isReadonly || isAddRow)),
|
||||
menuItemCmd(allCommands.copyLink,
|
||||
'Copy anchor link',
|
||||
t('CopyAnchorLink'),
|
||||
testId('section-card-link'),
|
||||
),
|
||||
menuDivider(),
|
||||
@@ -36,30 +39,30 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
|
||||
return [
|
||||
dom.maybe((use) => ['single'].includes(use(viewSection.parentKey)), () => contextMenu),
|
||||
dom.maybe((use) => !use(viewSection.isRaw) && !isLight,
|
||||
() => menuItemCmd(allCommands.showRawData, 'Show raw data', testId('show-raw-data')),
|
||||
() => menuItemCmd(allCommands.showRawData, t('ShowRawData'), testId('show-raw-data')),
|
||||
),
|
||||
menuItemCmd(allCommands.printSection, 'Print widget', testId('print-section')),
|
||||
menuItemCmd(allCommands.printSection, t('PrintWidget'), testId('print-section')),
|
||||
menuItemLink({ href: gristDoc.getCsvLink(), target: '_blank', download: ''},
|
||||
'Download as CSV', testId('download-section')),
|
||||
t('DownloadCSV'), testId('download-section')),
|
||||
menuItemLink({ href: gristDoc.getXlsxActiveViewLink(), target: '_blank', download: ''},
|
||||
'Download as XLSX', testId('download-section')),
|
||||
t('DownloadXLSX'), testId('download-section')),
|
||||
dom.maybe((use) => ['detail', 'single'].includes(use(viewSection.parentKey)), () =>
|
||||
menuItemCmd(allCommands.editLayout, 'Edit Card Layout',
|
||||
menuItemCmd(allCommands.editLayout, t('EditCardLayout'),
|
||||
dom.cls('disabled', isReadonly))),
|
||||
|
||||
dom.maybe(!isLight, () => [
|
||||
menuDivider(),
|
||||
menuItemCmd(allCommands.viewTabOpen, 'Widget options', testId('widget-options')),
|
||||
menuItemCmd(allCommands.sortFilterTabOpen, 'Advanced Sort & Filter'),
|
||||
menuItemCmd(allCommands.dataSelectionTabOpen, 'Data selection'),
|
||||
menuItemCmd(allCommands.viewTabOpen, t('WidgetOptions'), testId('widget-options')),
|
||||
menuItemCmd(allCommands.sortFilterTabOpen, t('AdvancedSortFilter')),
|
||||
menuItemCmd(allCommands.dataSelectionTabOpen, t('DataSelection')),
|
||||
]),
|
||||
|
||||
menuDivider(),
|
||||
dom.maybe((use) => use(viewSection.parentKey) === 'custom' && use(viewSection.hasCustomOptions), () =>
|
||||
menuItemCmd(allCommands.openWidgetConfiguration, 'Open configuration',
|
||||
menuItemCmd(allCommands.openWidgetConfiguration, t('OpenConfiguration'),
|
||||
testId('section-open-configuration')),
|
||||
),
|
||||
menuItemCmd(allCommands.deleteSection, 'Delete widget',
|
||||
menuItemCmd(allCommands.deleteSection, t('DeleteWidget'),
|
||||
dom.cls('disabled', !viewRec.getRowId() || viewRec.viewSections().peekLength <= 1 || isReadonly),
|
||||
testId('section-delete')),
|
||||
];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {reportError} from 'app/client/models/AppModel';
|
||||
import {ColumnRec, DocModel, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {FilterInfo} from 'app/client/models/entities/ViewSectionRec';
|
||||
@@ -16,10 +17,11 @@ import {PopupControl} from 'popweasel';
|
||||
import difference = require('lodash/difference');
|
||||
|
||||
const testId = makeTestId('test-section-menu-');
|
||||
const t = makeT('ViewSectionMenu');
|
||||
|
||||
// Handler for [Save] button.
|
||||
async function doSave(docModel: DocModel, viewSection: ViewSectionRec): Promise<void> {
|
||||
await docModel.docData.bundleActions("Update Sort&Filter settings", () => Promise.all([
|
||||
await docModel.docData.bundleActions(t("UpdateSortFilterSettings"), () => Promise.all([
|
||||
viewSection.activeSortJson.save(), // Save sort
|
||||
viewSection.saveFilters(), // Save filter
|
||||
viewSection.activeFilterBar.save(), // Save bar
|
||||
@@ -92,10 +94,10 @@ export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, vie
|
||||
// [Save] [Revert] buttons
|
||||
dom.domComputed(displaySaveObs, displaySave => [
|
||||
displaySave ? cssMenuInfoHeader(
|
||||
cssSaveButton('Save', testId('btn-save'),
|
||||
cssSaveButton(t('Save'), testId('btn-save'),
|
||||
dom.on('click', () => { save(); ctl.close(); }),
|
||||
dom.boolAttr('disabled', isReadonly)),
|
||||
basicButton('Revert', testId('btn-revert'),
|
||||
basicButton(t('Revert'), testId('btn-revert'),
|
||||
dom.on('click', () => { revert(); ctl.close(); }))
|
||||
) : null,
|
||||
]),
|
||||
@@ -160,7 +162,7 @@ function makeSortPanel(section: ViewSectionRec, sortSpec: Sort.SortSpec, getColu
|
||||
});
|
||||
|
||||
return [
|
||||
cssMenuInfoHeader('Sorted by', testId('heading-sorted')),
|
||||
cssMenuInfoHeader(t('SortedBy'), testId('heading-sorted')),
|
||||
sortColumns.length > 0 ? sortColumns : cssGrayedMenuText('(Default)')
|
||||
];
|
||||
}
|
||||
@@ -181,7 +183,7 @@ export function makeAddFilterButton(viewSectionRec: ViewSectionRec, popupControl
|
||||
testId('plus-button'),
|
||||
dom.on('click', (ev) => ev.stopPropagation()),
|
||||
),
|
||||
cssMenuTextLabel('Add Filter'),
|
||||
cssMenuTextLabel(t('AddFilter')),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -201,7 +203,7 @@ export function makeFilterBarToggle(activeFilterBar: CustomComputed<boolean>) {
|
||||
}),
|
||||
),
|
||||
dom.on('click', () => activeFilterBar(!activeFilterBar.peek())),
|
||||
cssMenuTextLabel("Toggle Filter Bar"),
|
||||
cssMenuTextLabel(t("ToggleFilterBar")),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -235,7 +237,7 @@ function makeFilterPanel(section: ViewSectionRec, activeFilters: FilterInfo[],
|
||||
});
|
||||
|
||||
return [
|
||||
cssMenuInfoHeader('Filtered by', {style: 'margin-top: 4px'}, testId('heading-filtered')),
|
||||
cssMenuInfoHeader(t('FilteredBy'), {style: 'margin-top: 4px'}, testId('heading-filtered')),
|
||||
activeFilters.length > 0 ? filters : cssGrayedMenuText('(Not filtered)')
|
||||
];
|
||||
}
|
||||
@@ -247,13 +249,13 @@ function makeCustomOptions(section: ViewSectionRec) {
|
||||
const color = Computed.create(null, use => use(section.activeCustomOptions.isSaved) ? "-gray" : "-green");
|
||||
const text = Computed.create(null, use => {
|
||||
if (use(section.activeCustomOptions)) {
|
||||
return use(section.activeCustomOptions.isSaved) ? "(customized)" : "(modified)";
|
||||
return use(section.activeCustomOptions.isSaved) ? t("Customized") : t("Modified");
|
||||
} else {
|
||||
return "(empty)";
|
||||
return t("Empty");
|
||||
}
|
||||
});
|
||||
return [
|
||||
cssMenuInfoHeader('Custom options', testId('heading-widget-options')),
|
||||
cssMenuInfoHeader(t('CustomOptions'), testId('heading-widget-options')),
|
||||
cssMenuText(
|
||||
dom.autoDispose(text),
|
||||
dom.autoDispose(color),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { GristDoc } from "app/client/components/GristDoc";
|
||||
import { KoArray, syncedKoArray } from "app/client/lib/koArray";
|
||||
import * as kf from 'app/client/lib/koForm';
|
||||
import { makeT } from 'app/client/lib/localization';
|
||||
import * as tableUtil from 'app/client/lib/tableUtil';
|
||||
import { ColumnRec, ViewFieldRec, ViewSectionRec } from "app/client/models/DocModel";
|
||||
import { getFieldType } from "app/client/ui/RightPanel";
|
||||
@@ -16,6 +17,7 @@ import difference = require("lodash/difference");
|
||||
import isEqual = require("lodash/isEqual");
|
||||
|
||||
const testId = makeTestId('test-vfc-');
|
||||
const t = makeT('VisibleFieldsConfig');
|
||||
|
||||
export type IField = ViewFieldRec|ColumnRec;
|
||||
|
||||
@@ -161,8 +163,8 @@ export class VisibleFieldsConfig extends Disposable {
|
||||
options.hiddenFields.itemCreateFunc,
|
||||
{
|
||||
itemClass: cssDragRow.className,
|
||||
reorder() { throw new Error('Hidden Fields cannot be reordered'); },
|
||||
receive() { throw new Error('Cannot drop items into Hidden Fields'); },
|
||||
reorder() { throw new Error(t('NoReorderHiddenField')); },
|
||||
receive() { throw new Error(t('NoDropInHiddenField')); },
|
||||
remove(item: ColumnRec) {
|
||||
// Return the column object. This value is passed to the viewFields
|
||||
// receive function as its respective item parameter
|
||||
@@ -202,7 +204,7 @@ export class VisibleFieldsConfig extends Disposable {
|
||||
() => (
|
||||
cssControlLabel(
|
||||
icon('Tick'),
|
||||
'Select All',
|
||||
t('SelectAll'),
|
||||
dom.on('click', () => this._setVisibleCheckboxes(fieldsDraggable, true)),
|
||||
testId('visible-fields-select-all'),
|
||||
)
|
||||
@@ -217,7 +219,7 @@ export class VisibleFieldsConfig extends Disposable {
|
||||
dom.on('click', () => this._removeSelectedFields()),
|
||||
),
|
||||
basicButton(
|
||||
'Clear',
|
||||
t('Clear'),
|
||||
dom.on('click', () => this._setVisibleCheckboxes(fieldsDraggable, false)),
|
||||
),
|
||||
testId('visible-batch-buttons')
|
||||
@@ -238,7 +240,7 @@ export class VisibleFieldsConfig extends Disposable {
|
||||
() => (
|
||||
cssControlLabel(
|
||||
icon('Tick'),
|
||||
'Select All',
|
||||
t('SelectAll'),
|
||||
dom.on('click', () => this._setHiddenCheckboxes(hiddenFieldsDraggable, true)),
|
||||
testId('hidden-fields-select-all'),
|
||||
)
|
||||
@@ -259,7 +261,7 @@ export class VisibleFieldsConfig extends Disposable {
|
||||
dom.on('click', () => this._addSelectedFields()),
|
||||
),
|
||||
basicButton(
|
||||
'Clear',
|
||||
t('Clear'),
|
||||
dom.on('click', () => this._setHiddenCheckboxes(hiddenFieldsDraggable, false)),
|
||||
),
|
||||
testId('hidden-batch-buttons')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import * as commands from 'app/client/components/commands';
|
||||
import {getUserPrefObs} from 'app/client/models/UserPrefs';
|
||||
import {colors, testId} from 'app/client/ui2018/cssVars';
|
||||
@@ -9,6 +10,8 @@ import {UserPrefs} from 'app/common/Prefs';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import {dom, input, Observable, styled, subscribeElem} from 'grainjs';
|
||||
|
||||
const t = makeT('WelcomeQuestions');
|
||||
|
||||
export function showWelcomeQuestions(userPrefsObs: Observable<UserPrefs>) {
|
||||
if (!(getGristConfig().survey && userPrefsObs.get()?.showNewUserQuestions)) {
|
||||
return null;
|
||||
@@ -20,9 +23,9 @@ export function showWelcomeQuestions(userPrefsObs: Observable<UserPrefs>) {
|
||||
const showQuestions = getUserPrefObs(userPrefsObs, 'showNewUserQuestions');
|
||||
|
||||
async function onConfirm() {
|
||||
const selected = choices.filter((c, i) => selection[i].get()).map(c => c.text);
|
||||
const selected = choices.filter((c, i) => selection[i].get()).map(c => t(c.textKey));
|
||||
const use_cases = ['L', ...selected]; // Format to populate a ChoiceList column
|
||||
const use_other = selected.includes('Other') ? otherText.get() : '';
|
||||
const use_other = selected.includes(t('Other')) ? otherText.get() : '';
|
||||
|
||||
const submitUrl = new URL(window.location.href);
|
||||
submitUrl.pathname = '/welcome/info';
|
||||
@@ -42,7 +45,7 @@ export function showWelcomeQuestions(userPrefsObs: Observable<UserPrefs>) {
|
||||
});
|
||||
|
||||
return {
|
||||
title: [cssLogo(), dom('div', 'Welcome to Grist!')],
|
||||
title: [cssLogo(), dom('div', t('WelcomeToGrist'))],
|
||||
body: buildInfoForm(selection, otherText),
|
||||
saveLabel: 'Start using Grist',
|
||||
saveFunc: onConfirm,
|
||||
@@ -53,32 +56,32 @@ export function showWelcomeQuestions(userPrefsObs: Observable<UserPrefs>) {
|
||||
});
|
||||
}
|
||||
|
||||
const choices: Array<{icon: IconName, color: string, text: string}> = [
|
||||
{icon: 'UseProduct', color: `${colors.lightGreen}`, text: 'Product Development' },
|
||||
{icon: 'UseFinance', color: '#0075A2', text: 'Finance & Accounting'},
|
||||
{icon: 'UseMedia', color: '#F7B32B', text: 'Media Production' },
|
||||
{icon: 'UseMonitor', color: '#F2545B', text: 'IT & Technology' },
|
||||
{icon: 'UseChart', color: '#7141F9', text: 'Marketing' },
|
||||
{icon: 'UseScience', color: '#231942', text: 'Research' },
|
||||
{icon: 'UseSales', color: '#885A5A', text: 'Sales' },
|
||||
{icon: 'UseEducate', color: '#4A5899', text: 'Education' },
|
||||
{icon: 'UseHr', color: '#688047', text: 'HR & Management' },
|
||||
{icon: 'UseOther', color: '#929299', text: 'Other' },
|
||||
const choices: Array<{icon: IconName, color: string, textKey: string}> = [
|
||||
{icon: 'UseProduct', color: `${colors.lightGreen}`, textKey: 'ProductDevelopment' },
|
||||
{icon: 'UseFinance', color: '#0075A2', textKey: 'FinanceAccounting' },
|
||||
{icon: 'UseMedia', color: '#F7B32B', textKey: 'MediaProduction' },
|
||||
{icon: 'UseMonitor', color: '#F2545B', textKey: 'ITTechnology' },
|
||||
{icon: 'UseChart', color: '#7141F9', textKey: 'Marketing' },
|
||||
{icon: 'UseScience', color: '#231942', textKey: 'Research' },
|
||||
{icon: 'UseSales', color: '#885A5A', textKey: 'Sales' },
|
||||
{icon: 'UseEducate', color: '#4A5899', textKey: 'Education' },
|
||||
{icon: 'UseHr', color: '#688047', textKey: 'HRManagement' },
|
||||
{icon: 'UseOther', color: '#929299', textKey: 'Other' },
|
||||
];
|
||||
|
||||
function buildInfoForm(selection: Observable<boolean>[], otherText: Observable<string>) {
|
||||
return [
|
||||
dom('span', 'What brings you to Grist? Please help us serve you better.'),
|
||||
dom('span', t('WhatBringsYouToGrist')),
|
||||
cssChoices(
|
||||
choices.map((item, i) => cssChoice(
|
||||
cssIcon(icon(item.icon), {style: `--icon-color: ${item.color}`}),
|
||||
cssChoice.cls('-selected', selection[i]),
|
||||
dom.on('click', () => selection[i].set(!selection[i].get())),
|
||||
(item.icon !== 'UseOther' ?
|
||||
item.text :
|
||||
t(item.textKey) :
|
||||
[
|
||||
cssOtherLabel(item.text),
|
||||
cssOtherInput(otherText, {}, {type: 'text', placeholder: 'Type here'},
|
||||
cssOtherLabel(t(item.textKey)),
|
||||
cssOtherInput(otherText, {}, {type: 'text', placeholder: t('TypeHere')},
|
||||
// The following subscribes to changes to selection observable, and focuses the input when
|
||||
// this item is selected.
|
||||
(elem) => subscribeElem(elem, selection[i], val => val && setTimeout(() => elem.focus(), 0)),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {FocusLayer} from 'app/client/lib/FocusLayer';
|
||||
import {ViewSectionRec} from 'app/client/models/entities/ViewSectionRec';
|
||||
import {basicButton, cssButton, primaryButton} from 'app/client/ui2018/buttons';
|
||||
@@ -9,6 +10,7 @@ import {Computed, dom, DomElementArg, IInputOptions, input, makeTestId, Observab
|
||||
import {IOpenController, setPopupToCreateDom} from 'popweasel';
|
||||
|
||||
const testId = makeTestId('test-widget-title-');
|
||||
const t = makeT('WidgetTitle');
|
||||
|
||||
interface WidgetTitleOptions {
|
||||
tableNameHidden?: boolean,
|
||||
@@ -65,7 +67,7 @@ function buildWidgetRenamePopup(ctrl: IOpenController, vs: ViewSectionRec, optio
|
||||
// Placeholder for widget title:
|
||||
// - when widget title is empty shows a default widget title (what would be shown when title is empty)
|
||||
// - when widget title is set, shows just a text to override it.
|
||||
const inputWidgetPlaceholder = !vs.title.peek() ? 'Override widget title' : vs.defaultWidgetTitle.peek();
|
||||
const inputWidgetPlaceholder = !vs.title.peek() ? t('OverrideTitle') : vs.defaultWidgetTitle.peek();
|
||||
|
||||
const disableSave = Computed.create(ctrl, (use) => {
|
||||
const newTableName = use(inputTableName)?.trim() ?? '';
|
||||
@@ -135,29 +137,29 @@ function buildWidgetRenamePopup(ctrl: IOpenController, vs: ViewSectionRec, optio
|
||||
testId('popup'),
|
||||
dom.cls(menuCssClass),
|
||||
dom.maybe(!options.tableNameHidden, () => [
|
||||
cssLabel('DATA TABLE NAME'),
|
||||
cssLabel(t('DataTableName')),
|
||||
// Update tableName on key stroke - this will show the default widget name as we type.
|
||||
// above this modal.
|
||||
tableInput = cssInput(
|
||||
inputTableName,
|
||||
updateOnKey,
|
||||
{disabled: isSummary, placeholder: 'Provide a table name'},
|
||||
{disabled: isSummary, placeholder: t('NewTableName')},
|
||||
testId('table-name-input')
|
||||
),
|
||||
]),
|
||||
dom.maybe(!options.widgetNameHidden, () => [
|
||||
cssLabel('WIDGET TITLE'),
|
||||
cssLabel(t('WidgetTitle')),
|
||||
widgetInput = cssInput(inputWidgetTitle, updateOnKey, {placeholder: inputWidgetPlaceholder},
|
||||
testId('section-name-input')
|
||||
),
|
||||
]),
|
||||
cssButtons(
|
||||
primaryButton('Save',
|
||||
primaryButton(t('Save'),
|
||||
dom.on('click', doSave),
|
||||
dom.boolAttr('disabled', use => use(disableSave) || use(modalCtl.workInProgress)),
|
||||
testId('save'),
|
||||
),
|
||||
basicButton('Cancel',
|
||||
basicButton(t('Cancel'),
|
||||
testId('cancel'),
|
||||
dom.on('click', () => modalCtl.close())
|
||||
),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import {getLoginUrl, getMainOrgUrl, urlState} from 'app/client/models/gristUrlState';
|
||||
import {AppHeader} from 'app/client/ui/AppHeader';
|
||||
@@ -12,6 +13,8 @@ import {dom, DomElementArg, makeTestId, observable, styled} from 'grainjs';
|
||||
|
||||
const testId = makeTestId('test-');
|
||||
|
||||
const t = makeT('errorPages');
|
||||
|
||||
export function createErrPage(appModel: AppModel) {
|
||||
const gristConfig: GristLoadConfig = (window as any).gristConfig || {};
|
||||
const message = gristConfig.errMessage;
|
||||
@@ -25,25 +28,24 @@ export function createErrPage(appModel: AppModel) {
|
||||
* Creates a page to show that the user has no access to this org.
|
||||
*/
|
||||
export function createForbiddenPage(appModel: AppModel, message?: string) {
|
||||
document.title = `Access denied${getPageTitleSuffix(getGristConfig())}`;
|
||||
document.title = t('AccessDenied', {suffix: getPageTitleSuffix(getGristConfig())});
|
||||
|
||||
const isAnonym = () => !appModel.currentValidUser;
|
||||
const isExternal = () => appModel.currentValidUser?.loginMethod === 'External';
|
||||
return pagePanelsError(appModel, 'Access denied', [
|
||||
return pagePanelsError(appModel, t('AccessDenied', {suffix: ''}), [
|
||||
dom.domComputed(appModel.currentValidUser, user => user ? [
|
||||
cssErrorText(message || "You do not have access to this organization's documents."),
|
||||
cssErrorText("You are signed in as ", dom('b', user.email),
|
||||
". You can sign in with a different account, or ask an administrator for access."),
|
||||
cssErrorText(message || t("DeniedOrganizationDocuments")),
|
||||
cssErrorText(t("SignInWithDifferentAccount", {email: dom('b', user.email)})), // TODO: i18next
|
||||
] : [
|
||||
// This page is not normally shown because a logged out user with no access will get
|
||||
// redirected to log in. But it may be seen if a user logs out and returns to a cached
|
||||
// version of this page or is an external user (connected through GristConnect).
|
||||
cssErrorText("Sign in to access this organization's documents."),
|
||||
cssErrorText(t("SignInToAccess")),
|
||||
]),
|
||||
cssButtonWrap(bigPrimaryButtonLink(
|
||||
isExternal() ? 'Go to main page' :
|
||||
isAnonym() ? 'Sign in' :
|
||||
'Add account',
|
||||
isExternal() ? t("GoToMainPage") :
|
||||
isAnonym() ? t("SignIn") :
|
||||
t("AddAcount"),
|
||||
{href: isExternal() ? getMainOrgUrl() : getLoginUrl()},
|
||||
testId('error-signin'),
|
||||
))
|
||||
@@ -54,12 +56,12 @@ export function createForbiddenPage(appModel: AppModel, message?: string) {
|
||||
* Creates a page that shows the user is logged out.
|
||||
*/
|
||||
export function createSignedOutPage(appModel: AppModel) {
|
||||
document.title = `Signed out${getPageTitleSuffix(getGristConfig())}`;
|
||||
document.title = t('SignedOut', {suffix: getPageTitleSuffix(getGristConfig())});
|
||||
|
||||
return pagePanelsError(appModel, 'Signed out', [
|
||||
cssErrorText("You are now signed out."),
|
||||
return pagePanelsError(appModel, t('SignedOut', {suffix: ''}), [
|
||||
cssErrorText(t('SignedOutNow')),
|
||||
cssButtonWrap(bigPrimaryButtonLink(
|
||||
'Sign in again', {href: getLoginUrl()}, testId('error-signin')
|
||||
t('SignedInAgain'), {href: getLoginUrl()}, testId('error-signin')
|
||||
))
|
||||
]);
|
||||
}
|
||||
@@ -68,14 +70,13 @@ export function createSignedOutPage(appModel: AppModel) {
|
||||
* Creates a "Page not found" page.
|
||||
*/
|
||||
export function createNotFoundPage(appModel: AppModel, message?: string) {
|
||||
document.title = `Page not found${getPageTitleSuffix(getGristConfig())}`;
|
||||
document.title = t('PageNotFound', {suffix: getPageTitleSuffix(getGristConfig())});
|
||||
|
||||
return pagePanelsError(appModel, 'Page not found', [
|
||||
cssErrorText(message || "The requested page could not be found.", dom('br'),
|
||||
"Please check the URL and try again."),
|
||||
cssButtonWrap(bigPrimaryButtonLink('Go to main page', testId('error-primary-btn'),
|
||||
return pagePanelsError(appModel, t('PageNotFound', {suffix: ''}), [
|
||||
cssErrorText(message || t('NotFoundMainText', {separator: dom('br')})), // TODO: i18next
|
||||
cssButtonWrap(bigPrimaryButtonLink(t('GoToMainPage'), testId('error-primary-btn'),
|
||||
urlState().setLinkUrl({}))),
|
||||
cssButtonWrap(bigBasicButtonLink('Contact support', {href: 'https://getgrist.com/contact'})),
|
||||
cssButtonWrap(bigBasicButtonLink(t('ContactSupport'), {href: 'https://getgrist.com/contact'})),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -83,14 +84,14 @@ export function createNotFoundPage(appModel: AppModel, message?: string) {
|
||||
* Creates a generic error page with the given message.
|
||||
*/
|
||||
export function createOtherErrorPage(appModel: AppModel, message?: string) {
|
||||
document.title = `Error${getPageTitleSuffix(getGristConfig())}`;
|
||||
document.title = t('GenericError', {suffix: getPageTitleSuffix(getGristConfig())});
|
||||
|
||||
return pagePanelsError(appModel, 'Something went wrong', [
|
||||
cssErrorText(message ? `There was an error: ${addPeriod(message)}` :
|
||||
"There was an unknown error."),
|
||||
cssButtonWrap(bigPrimaryButtonLink('Go to main page', testId('error-primary-btn'),
|
||||
return pagePanelsError(appModel, t('SomethingWentWrong'), [
|
||||
cssErrorText(message ? t('ErrorHappened', {context: 'message', message: addPeriod(message)}) :
|
||||
t('ErrorHappened', {context: 'unknown'})),
|
||||
cssButtonWrap(bigPrimaryButtonLink(t('GoToMainPage'), testId('error-primary-btn'),
|
||||
urlState().setLinkUrl({}))),
|
||||
cssButtonWrap(bigBasicButtonLink('Contact support', {href: 'https://getgrist.com/contact'})),
|
||||
cssButtonWrap(bigBasicButtonLink(t('ContactSupport'), {href: 'https://getgrist.com/contact'})),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import type {DocPageModel} from 'app/client/models/DocPageModel';
|
||||
import type {Document} from 'app/common/UserAPI';
|
||||
import { getGoogleCodeForSending } from "app/client/ui/googleAuth";
|
||||
const G = getBrowserGlobals('window');
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
|
||||
const t = makeT('sendToDrive');
|
||||
|
||||
/**
|
||||
* Sends xlsx file to Google Drive. It first authenticates with Google to get encrypted access
|
||||
@@ -21,7 +24,7 @@ export async function sendToDrive(doc: Document, pageModel: DocPageModel) {
|
||||
// Create send to google drive handler (it will return a spreadsheet url).
|
||||
const send = (code: string) =>
|
||||
// Decorate it with a spinner
|
||||
spinnerModal('Sending file to Google Drive',
|
||||
spinnerModal(t('SendingToGoogleDrive'),
|
||||
pageModel.appModel.api.getDocAPI(doc.id)
|
||||
.sendToDrive(code, pageModel.currentDocTitle.get())
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user