Make a good part of the app localizable and add French translations (#325)

Co-authored-by: Yohan Boniface <yohanboniface@free.fr>
pull/337/head
Arnaud Peich 1 year ago committed by GitHub
parent ec20e7fb68
commit 79deeca640
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -51,8 +51,11 @@ import {
Observable,
styled
} from 'grainjs';
import {makeT} from 'app/client/lib/localization';
import isEqual = require('lodash/isEqual');
const t = makeT('aclui.AccessRules');
// tslint:disable:max-classes-per-file no-console
// Types for the rows in the ACL tables we use.
@ -317,21 +320,21 @@ export class AccessRules extends Disposable {
bigBasicButton({disabled: true}, dom.hide(this._savingEnabled),
dom.text((use) => {
const s = use(this._ruleStatus);
return s === RuleStatus.CheckPending ? 'Checking...' :
s === RuleStatus.Unchanged ? 'Saved' : 'Invalid';
return s === RuleStatus.CheckPending ? t('Checking') :
s === RuleStatus.Unchanged ? t('Saved') : t('Invalid');
}),
testId('rules-non-save')
),
bigPrimaryButton('Save', dom.show(this._savingEnabled),
bigPrimaryButton(t('Save'), dom.show(this._savingEnabled),
dom.on('click', () => this.save()),
testId('rules-save'),
),
bigBasicButton('Reset', dom.show(use => use(this._ruleStatus) !== RuleStatus.Unchanged),
bigBasicButton(t('Reset'), dom.show(use => use(this._ruleStatus) !== RuleStatus.Unchanged),
dom.on('click', () => this.update()),
testId('rules-revert'),
),
bigBasicButton('Add Table Rules', cssDropdownIcon('Dropdown'), {style: 'margin-left: auto'},
bigBasicButton(t('AddTableRules'), cssDropdownIcon('Dropdown'), {style: 'margin-left: auto'},
menu(() =>
this.allTableIds.map((tableId) =>
// Add the table on a timeout, to avoid disabling the clicked menu item
@ -343,8 +346,8 @@ export class AccessRules extends Disposable {
),
),
),
bigBasicButton('Add User Attributes', dom.on('click', () => this._addUserAttributes())),
bigBasicButton('Users', cssDropdownIcon('Dropdown'), elem => this._aclUsersPopup.attachPopup(elem),
bigBasicButton(t('AddUserAttributes'), dom.on('click', () => this._addUserAttributes())),
bigBasicButton(t('Users'), cssDropdownIcon('Dropdown'), elem => this._aclUsersPopup.attachPopup(elem),
dom.style('visibility', use => use(this._aclUsersPopup.isInitialized) ? '' : 'hidden')),
),
cssConditionError({style: 'margin-left: 16px'},
@ -354,15 +357,15 @@ export class AccessRules extends Disposable {
shadowScroll(
dom.maybe(use => use(this._userAttrRules).length, () =>
cssSection(
cssSectionHeading('User Attributes'),
cssSectionHeading(t('UserAttributes')),
cssTableRounded(
cssTableHeaderRow(
cssCell1(cssCell.cls('-rborder'), cssCell.cls('-center'), cssColHeaderCell('Name')),
cssCell4(
cssColumnGroup(
cssCell1(cssColHeaderCell('Attribute to Look Up')),
cssCell1(cssColHeaderCell('Lookup Table')),
cssCell1(cssColHeaderCell('Lookup Column')),
cssCell1(cssColHeaderCell(t('AttributeToLookUp'))),
cssCell1(cssColHeaderCell(t('LookupTable'))),
cssCell1(cssColHeaderCell(t('LookupColumn'))),
cssCellIcon(),
),
),
@ -373,15 +376,15 @@ export class AccessRules extends Disposable {
),
dom.forEach(this._tableRules, (tableRules) => tableRules.buildDom()),
cssSection(
cssSectionHeading('Default Rules', testId('rule-table-header')),
cssSectionHeading(t("DefaultRules"), testId('rule-table-header')),
cssTableRounded(
cssTableHeaderRow(
cssCell1(cssCell.cls('-rborder'), cssCell.cls('-center'), cssColHeaderCell('Columns')),
cssCell4(
cssColumnGroup(
cssCellIcon(),
cssCell2(cssColHeaderCell('Condition')),
cssCell1(cssColHeaderCell('Permissions')),
cssCell2(cssColHeaderCell(t('Condition'))),
cssCell1(cssColHeaderCell(t('Permissions'))),
cssCellIcon(),
)
)
@ -521,13 +524,13 @@ class TableRules extends Disposable {
public buildDom() {
return cssSection(
cssSectionHeading(
dom('span', 'Rules for table ', cssTableName(this._accessRules.getTableTitle(this.tableId))),
dom('span', t('RulesForTable'), cssTableName(this._accessRules.getTableTitle(this.tableId))),
cssIconButton(icon('Dots'), {style: 'margin-left: auto'},
menu(() => [
menuItemAsync(() => this._addColumnRuleSet(), 'Add Column Rule'),
menuItemAsync(() => this._addDefaultRuleSet(), 'Add Default Rule',
menuItemAsync(() => this._addColumnRuleSet(), t('AddColumnRule')),
menuItemAsync(() => this._addDefaultRuleSet(), t('AddDefaultRule'),
dom.cls('disabled', use => Boolean(use(this._defaultRuleSet)))),
menuItemAsync(() => this._accessRules.removeTableRules(this), 'Delete Table Rules'),
menuItemAsync(() => this._accessRules.removeTableRules(this), t('DeleteTableRules')),
]),
testId('rule-table-menu-btn'),
),
@ -539,8 +542,8 @@ class TableRules extends Disposable {
cssCell4(
cssColumnGroup(
cssCellIcon(),
cssCell2(cssColHeaderCell('Condition')),
cssCell1(cssColHeaderCell('Permissions')),
cssCell2(cssColHeaderCell(t('Condition'))),
cssCell1(cssColHeaderCell(t('Permissions'))),
cssCellIcon(),
)
),
@ -654,7 +657,7 @@ class TableRules extends Disposable {
class SpecialRules extends TableRules {
public buildDom() {
return cssSection(
cssSectionHeading('Special Rules', testId('rule-table-header')),
cssSectionHeading(t('SpecialRules'), testId('rule-table-header')),
this.buildColumnRuleSets(),
this.buildErrors(),
testId('rule-table'),
@ -893,18 +896,17 @@ class DefaultObsRuleSet extends ObsRuleSet {
function getSpecialRuleDescription(type: string): string {
switch (type) {
case 'AccessRules':
return 'Allow everyone to view Access Rules.';
return t('AccessRulesDescription');
case 'FullCopies':
return 'Allow everyone to copy the entire document, or view it in full in fiddle mode.\n' +
'Useful for examples and templates, but not for sensitive data.';
return t('FullCopiesDescription');
default: return type;
}
}
function getSpecialRuleName(type: string): string {
switch (type) {
case 'AccessRules': return 'Permission to view Access Rules';
case 'FullCopies': return 'Permission to access the document in full when needed';
case 'AccessRules': return t('AccessRulesName');
case 'FullCopies': return t('FullCopies');
default: return type;
}
}
@ -1037,7 +1039,7 @@ class ObsUserAttributeRule extends Disposable {
cssCell1(cssCell.cls('-rborder'),
cssCellContent(
cssInput(this._name, async (val) => this._name.set(val),
{placeholder: 'Attribute name'},
{placeholder: t('AttributeNamePlaceholder')},
(this._options.focus ? (elem) => { setTimeout(() => elem.focus(), 0); } : null),
testId('rule-userattr-name'),
),
@ -1253,9 +1255,9 @@ class ObsRulePart extends Disposable {
setValue: (value) => this._setAclFormula(value),
placeholder: dom.text((use) => {
return (
this._ruleSet.isSoleCondition(use, this) ? 'Everyone' :
this._ruleSet.isLastCondition(use, this) ? 'Everyone Else' :
'Enter Condition'
this._ruleSet.isSoleCondition(use, this) ? t('Everyone') :
this._ruleSet.isLastCondition(use, this) ? t('EveryoneElse') :
t('EnterCondition')
);
}),
getSuggestions: (prefix) => this._completions.get(),

@ -10,10 +10,13 @@ import {ALL_PERMISSION_PROPS, emptyPermissionSet} from 'app/common/ACLPermission
import {capitalize} from 'app/common/gutil';
import {dom, DomElementArg, Observable, styled} from 'grainjs';
import isEqual = require('lodash/isEqual');
import {makeT} from 'app/client/lib/localization';
// One of the strings 'read', 'update', etc.
export type PermissionKey = keyof PartialPermissionSet;
const t = makeT('aclui.PermissionsWidget');
/**
* Renders a box for each of availableBits, and a dropdown with a description and some shortcuts.
*/
@ -61,13 +64,13 @@ export function permissionsWidget(
null
),
// If the set matches any recognized pattern, mark that item with a tick (checkmark).
cssMenuItem(() => setPermissions(allowAll), tick(isEqual(pset.get(), allowAll)), 'Allow All',
cssMenuItem(() => setPermissions(allowAll), tick(isEqual(pset.get(), allowAll)), t('AllowAll'),
dom.cls('disabled', options.disabled)
),
cssMenuItem(() => setPermissions(denyAll), tick(isEqual(pset.get(), denyAll)), 'Deny All',
cssMenuItem(() => setPermissions(denyAll), tick(isEqual(pset.get(), denyAll)), t('DenyAll'),
dom.cls('disabled', options.disabled)
),
cssMenuItem(() => setPermissions(readOnly), tick(isEqual(pset.get(), readOnly)), 'Read Only',
cssMenuItem(() => setPermissions(readOnly), tick(isEqual(pset.get(), readOnly)), t('ReadOnly'),
dom.cls('disabled', options.disabled)
),
cssMenuItem(() => setPermissions(empty),

@ -19,6 +19,7 @@ import {ActionSummary, asTabularDiffs, defunctTableName, getAffectedTables,
LabelDelta} from 'app/common/ActionSummary';
import {CellDelta} from 'app/common/TabularDiff';
import {IDomComponent} from 'grainjs';
import {makeT} from 'app/client/lib/localization';
/**
*
@ -46,6 +47,8 @@ const state = {
DEFAULT: 'default'
};
const t = makeT('components.ActionLog');
export class ActionLog extends dispose.Disposable implements IDomComponent {
private _displayStack: KoArray<ActionGroupWithState>;
@ -224,7 +227,7 @@ export class ActionLog extends dispose.Disposable implements IDomComponent {
}
private _buildLogDom() {
this._loadActionSummaries().catch((error) => gristNotify(`Action Log failed to load`));
this._loadActionSummaries().catch((error) => gristNotify(t("ActionLogFailed")));
return dom('div.action_log',
dom('div.preference_item',
koForm.checkbox(this._showAllTables,
@ -392,7 +395,7 @@ export class ActionLog extends dispose.Disposable implements IDomComponent {
const newName = tableRename[1];
if (!newName) {
// TODO - find a better way to send informative notifications.
gristNotify(`Table ${tableId} was subsequently removed in action #${action.actionNum}`);
gristNotify(t('TableRemovedInAction', {tableId:tableId, actionNum: action.actionNum}));
return;
}
tableId = newName;
@ -403,7 +406,7 @@ export class ActionLog extends dispose.Disposable implements IDomComponent {
// Check is this row was removed - if so there's no reason to go on.
if (td.removeRows.indexOf(rowId) >= 0) {
// TODO - find a better way to send informative notifications.
gristNotify(`This row was subsequently removed in action #${action.actionNum}`);
gristNotify(t("RowRemovedInAction", {actionNum}));
return;
}
@ -413,7 +416,7 @@ export class ActionLog extends dispose.Disposable implements IDomComponent {
const newName = columnRename[1];
if (!newName) {
// TODO - find a better way to send informative notifications.
gristNotify(`Column ${colId} was subsequently removed in action #${action.actionNum}`);
gristNotify(t("ColumnRemovedInAction", {colId, actionNum: action.actionNum}));
return;
}
colId = newName;

@ -38,6 +38,7 @@ import sum = require('lodash/sum');
import union = require('lodash/union');
import type {Annotations, Config, Datum, ErrorBar, Layout, LayoutAxis, Margin,
PlotData as PlotlyPlotData} from 'plotly.js';
import {makeT} from 'app/client/lib/localization';
let Plotly: PlotlyType;
@ -49,6 +50,8 @@ const DONUT_DEFAULT_TEXT_SIZE = 24;
const testId = makeTestId('test-chart-');
const t = makeT('components.ChartView');
function isPieLike(chartType: string) {
return ['pie', 'donut'].includes(chartType);
}
@ -652,8 +655,8 @@ export class ChartConfig extends GrainJSDisposable {
testId('error-bars'),
),
dom.domComputed(this._optionsObj.prop('errorBars'), (value: ChartOptions["errorBars"]) =>
value === 'symmetric' ? cssRowHelp('Each Y series is followed by a series for the length of error bars.') :
value === 'separate' ? cssRowHelp('Each Y series is followed by two series, for top and bottom error bars.') :
value === 'symmetric' ? cssRowHelp(t('EachYFollowedByOne')) :
value === 'separate' ? cssRowHelp(t('EachYFollowedByTwo')) :
null
),
]),
@ -666,7 +669,7 @@ export class ChartConfig extends GrainJSDisposable {
select(this._groupDataColId, this._groupDataOptions),
testId('group-by-column'),
),
cssHintRow('Create separate series for each value of the selected column.'),
cssHintRow(t('CreateSeparateSeries')),
]),
// TODO: user should select x axis before widget reach page
@ -674,7 +677,7 @@ export class ChartConfig extends GrainJSDisposable {
cssRow(
select(
this._xAxis, this._columnsOptions,
{ defaultLabel: 'Pick a column' }
{ defaultLabel: t('PickColumn') }
),
testId('x-axis'),
),
@ -770,7 +773,7 @@ export class ChartConfig extends GrainJSDisposable {
private async _setGroupDataColumn(colId: string) {
const viewFields = this._section.viewFields.peek().peek();
await this._gristDoc.docData.bundleActions('selected new group data columnd', async () => {
await this._gristDoc.docData.bundleActions(t('SelectedNewGroupDataColumns'), async () => {
this._freezeXAxis.set(true);
this._freezeYAxis.set(true);
try {
@ -869,7 +872,7 @@ export class ChartConfig extends GrainJSDisposable {
private async _setAggregation(val: boolean) {
try {
this._freezeXAxis.set(true);
await this._gristDoc.docData.bundleActions(`Toggle chart aggregation`, async () => {
await this._gristDoc.docData.bundleActions(t("ToggleChartAggregation"), async () => {
if (val) {
await this._doAggregation();
} else {

@ -2,12 +2,15 @@ import {GristDoc} from 'app/client/components/GristDoc';
import {reportError} from 'app/client/models/errors';
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {dom, Observable} from 'grainjs';
import {makeT} from 'app/client/lib/localization';
// Rather than require the whole of highlight.js, require just the core with the one language we
// need, to keep our bundle smaller and the build faster.
const hljs = require('highlight.js/lib/core');
hljs.registerLanguage('python', require('highlight.js/lib/languages/python'));
const t = makeT('components.CodeEditorPanel');
export class CodeEditorPanel extends DisposableWithEvents {
private _schema = Observable.create(this, '');
private _denied = Observable.create(this, false);
@ -25,8 +28,8 @@ export class CodeEditorPanel extends DisposableWithEvents {
return dom('div.g-code-panel.clipboard',
{tabIndex: "-1"},
dom.maybe(this._denied, () => dom('div.g-code-panel-denied',
dom('h2', dom.text('Access denied')),
dom('div', dom.text('Code View is available only when you have full document access.')),
dom('h2', dom.text(t('AccessDenied'))),
dom('div', dom.text(t('CodeViewOnlyFullAccess'))),
)),
dom.maybe(this._schema, (schema) => {
// The reason to scope and rebuild instead of using `kd.text(schema)` is because

@ -12,9 +12,12 @@ import {loadingDots} from 'app/client/ui2018/loaders';
import {menu, menuItem, menuText} from 'app/client/ui2018/menus';
import {confirmModal} from 'app/client/ui2018/modals';
import {Computed, Disposable, dom, fromKo, makeTestId, Observable, styled} from 'grainjs';
import {makeT} from 'app/client/lib/localization';
const testId = makeTestId('test-raw-data-');
const t = makeT('components.DataTables');
export class DataTables extends Disposable {
private _tables: Observable<TableRec[]>;
@ -33,7 +36,7 @@ export class DataTables extends Disposable {
const dataTables = use(_gristDoc.docModel.rawDataTables.getObservable());
const summaryTables = use(_gristDoc.docModel.rawSummaryTables.getObservable());
// Remove tables that we don't have access to. ACL will remove tableId from those tables.
return [...dataTables, ...summaryTables].filter(t => Boolean(use(t.tableId)));
return [...dataTables, ...summaryTables].filter(table => Boolean(use(table.tableId)));
});
}
@ -42,7 +45,7 @@ export class DataTables extends Disposable {
cssTableList(
/*************** List section **********/
testId('list'),
docListHeader('Raw Data Tables'),
docListHeader(t('RawDataTables')),
cssList(
dom.forEach(this._tables, tableRec =>
cssItem(
@ -62,11 +65,11 @@ export class DataTables extends Disposable {
testId('table-id'),
dom.text(tableRec.tableId),
),
{ title : 'Click to copy' },
dom.on('click', async (e, t) => {
{ title : t('ClickToCopy') },
dom.on('click', async (e, d) => {
e.stopImmediatePropagation();
e.preventDefault();
showTransientTooltip(t, 'Table ID copied to clipboard', {
showTransientTooltip(d, t('TableIDCopied'), {
key: 'copy-table-id'
});
await copyToClipboard(tableRec.tableId.peek());
@ -124,7 +127,7 @@ export class DataTables extends Disposable {
return [
menuItem(
() => this._duplicateTable(table),
'Duplicate Table',
t('DuplicateTable'),
testId('menu-duplicate-table'),
dom.cls('disabled', use =>
use(isReadonly) ||
@ -141,23 +144,23 @@ export class DataTables extends Disposable {
use(docModel.visibleTables.getObservable()).length <= 1 && !use(table.isHidden)
))
),
dom.maybe(isReadonly, () => menuText('You do not have edit access to this document')),
dom.maybe(isReadonly, () => menuText(t("NoEditAccess"))),
];
}
private _duplicateTable(t: TableRec) {
duplicateTable(this._gristDoc, t.tableId(), {
private _duplicateTable(r: TableRec) {
duplicateTable(this._gristDoc, r.tableId(), {
onSuccess: ({raw_section_id}: DuplicateTableResponse) =>
this._gristDoc.viewModel.activeSectionId(raw_section_id),
});
}
private _removeTable(t: TableRec) {
private _removeTable(r: TableRec) {
const {docModel} = this._gristDoc;
function doRemove() {
return docModel.docData.sendAction(['RemoveTable', t.tableId()]);
return docModel.docData.sendAction(['RemoveTable', r.tableId()]);
}
confirmModal(`Delete ${t.formattedTableName()} data, and remove it from all pages?`, 'Delete', doRemove);
confirmModal(t("DeleteData", {formattedTableName : r.formattedTableName()}), 'Delete', doRemove);
}
private _tableRows(table: TableRec) {

@ -11,6 +11,9 @@ import {Features, isFreePlan} from 'app/common/Features';
import {capitalizeFirstWord} from 'app/common/gutil';
import {canUpgradeOrg} from 'app/common/roles';
import {Computed, Disposable, dom, DomContents, DomElementArg, makeTestId, styled} from 'grainjs';
import {makeT} from 'app/client/lib/localization';
const t = makeT('components.DocumentUsage');
const testId = makeTestId('test-doc-usage-');
@ -23,9 +26,6 @@ const DEFAULT_MAX_DATA_SIZE = DEFAULT_MAX_ROWS * 2 * 1024; // 40MB (2KiB per row
// Default used by the progress bar to visually indicate attachments size usage.
const DEFAULT_MAX_ATTACHMENTS_SIZE = 1 * 1024 * 1024 * 1024; // 1GiB
const ACCESS_DENIED_MESSAGE = 'Usage statistics are only available to users with '
+ 'full access to the document data.';
/**
* Displays statistics about document usage, such as number of rows used.
*/
@ -60,7 +60,7 @@ export class DocumentUsage extends Disposable {
// Invalid row limits are currently treated as if they are undefined.
const maxValue = maxRows && maxRows > 0 ? maxRows : undefined;
return {
name: 'Rows',
name: t('Rows'),
currentValue: typeof rowCount !== 'object' ? undefined : rowCount.total,
maximumValue: maxValue ?? DEFAULT_MAX_ROWS,
unit: 'rows',
@ -75,7 +75,7 @@ export class DocumentUsage extends Disposable {
// Invalid data size limits are currently treated as if they are undefined.
const maxValue = maxSize && maxSize > 0 ? maxSize : undefined;
return {
name: 'Data Size',
name: t('DataSize'),
currentValue: typeof dataSize !== 'number' ? undefined : dataSize,
maximumValue: maxValue ?? DEFAULT_MAX_DATA_SIZE,
unit: 'MB',
@ -97,7 +97,7 @@ export class DocumentUsage extends Disposable {
// Invalid attachments size limits are currently treated as if they are undefined.
const maxValue = maxSize && maxSize > 0 ? maxSize : undefined;
return {
name: 'Attachments Size',
name: t('AttachmentsSize'),
currentValue: typeof attachmentsSize !== 'number' ? undefined : attachmentsSize,
maximumValue: maxValue ?? DEFAULT_MAX_ATTACHMENTS_SIZE,
unit: 'GB',
@ -135,7 +135,7 @@ export class DocumentUsage extends Disposable {
public buildDom() {
return dom('div',
cssHeader('Usage', testId('heading')),
cssHeader(t('Usage'), testId('heading')),
dom.domComputed(this._areAllMetricsPending, (isLoading) => {
if (isLoading) { return cssSpinner(loadingSpinner(), testId('loading')); }
@ -149,7 +149,7 @@ export class DocumentUsage extends Disposable {
return dom.domComputed((use) => {
const isAccessDenied = use(this._isAccessDenied);
if (isAccessDenied === null) { return null; }
if (isAccessDenied) { return buildMessage(ACCESS_DENIED_MESSAGE); }
if (isAccessDenied) { return buildMessage(t('UsageStatisticsOnlyFullAccess')); }
const org = use(this._currentOrg);
const product = use(this._currentProduct);
@ -237,11 +237,12 @@ export function buildUpgradeMessage(
variant: 'short' | 'long',
onUpgrade: () => void,
) {
if (!canUpgrade) { return 'Contact the site owner to upgrade the plan to raise limits.'; }
if (!canUpgrade) { return t('LimitContactSiteOwner'); }
const upgradeLinkText = 'start your 30-day free trial of the Pro plan.';
const upgradeLinkText = t('UpgradeLinkText')
// TODO i18next
return [
variant === 'short' ? null : 'For higher limits, ',
variant === 'short' ? null : t('ForHigherLimits'),
buildUpgradeLink(
variant === 'short' ? capitalizeFirstWord(upgradeLinkText) : upgradeLinkText,
() => onUpgrade(),

@ -4,11 +4,14 @@ import {
IDomArgs, MultiHolder, styled, TagElem
} from "grainjs";
import { GristDoc } from "app/client/components/GristDoc";
import { makeT } from 'app/client/lib/localization';
import { ITooltipControl, showTooltip, tooltipCloseButton } from "app/client/ui/tooltips";
import { FieldEditorStateEvent } from "app/client/widgets/FieldEditor";
import { testId, theme } from "app/client/ui2018/cssVars";
import { cssLink } from "app/client/ui2018/links";
const t = makeT('components.Drafts');
/**
* Component that keeps track of editor's state (draft value). If user hits an escape button
* by accident, this component will provide a way to continue the work.
@ -270,7 +273,7 @@ class NotificationAdapter extends Disposable implements Notification {
}
public showUndoDiscard() {
const notifier = this._doc.app.topAppModel.notifier;
const notification = notifier.createUserMessage("Undo discard", {
const notification = notifier.createUserMessage(t("UndoDiscard"), {
message: () =>
discardNotification(
dom.on("click", () => {
@ -418,7 +421,7 @@ const styledTooltip = styled('div', `
function cellTooltip(clb: () => any) {
return function (ctl: ITooltipControl) {
return styledTooltip(
cssLink('Restore last edit',
cssLink(t('RestoreLastEdit'),
dom.on('mousedown', (ev) => { ev.preventDefault(); ctl.close(); clb(); }),
testId('draft-tooltip'),
),
@ -437,7 +440,7 @@ const styledNotification = styled('div', `
`);
function discardNotification(...args: IDomArgs<TagElem<"div">>) {
return styledNotification(
"Undo Discard",
t("UndoDiscard"),
testId("draft-notification"),
...args
);

@ -24,6 +24,7 @@ import {ViewLayout} from 'app/client/components/ViewLayout';
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {DocPluginManager} from 'app/client/lib/DocPluginManager';
import {ImportSourceElement} from 'app/client/lib/ImportSourceElement';
import {makeT} from 'app/client/lib/localization';
import {createSessionObs} from 'app/client/lib/sessionObs';
import {setTestState} from 'app/client/lib/testState';
import {selectFiles} from 'app/client/lib/uploads';
@ -84,6 +85,8 @@ import * as ko from 'knockout';
import cloneDeepWith = require('lodash/cloneDeepWith');
import isEqual = require('lodash/isEqual');
const t = makeT('components.GristDoc');
const G = getBrowserGlobals('document', 'window');
// Re-export some tools to move them from main webpack bundle to the one with GristDoc.
@ -307,7 +310,7 @@ export class GristDoc extends DisposableWithEvents {
const importSourceElems = ImportSourceElement.fromArray(this.docPluginManager.pluginsList);
const importMenuItems = [
{
label: 'Import from file',
label: t('ImportFromFile'),
action: () => Importer.selectAndImport(this, importSourceElems, null, createPreview),
},
...importSourceElems.map(importSourceElem => ({
@ -592,7 +595,7 @@ export class GristDoc extends DisposableWithEvents {
}
}
const res = await docData.bundleActions(
`Added new linked section to view ${viewName}`,
t("AddedNewLinkedSection", {viewName}),
() => this.addWidgetToPageImpl(val, tableId ?? null)
);
@ -669,7 +672,7 @@ export class GristDoc extends DisposableWithEvents {
}
return await this._viewLayout!.freezeUntil(docData.bundleActions(
`Saved linked section ${section.title()} in view ${viewModel.name()}`,
t("SavedLinkedSectionIn", {title:section.title(), name: viewModel.name()}),
async () => {
// if table changes or a table is made a summary table, let's replace the view section by a

@ -9,6 +9,7 @@ import {buildParseOptionsForm, ParseOptionValues} from 'app/client/components/Pa
import {PluginScreen} from 'app/client/components/PluginScreen';
import {FocusLayer} from 'app/client/lib/FocusLayer';
import {ImportSourceElement} from 'app/client/lib/ImportSourceElement';
import {makeT} from 'app/client/lib/localization';
import {fetchURL, isDriveUrl, selectFiles, uploadFiles} from 'app/client/lib/uploads';
import {reportError} from 'app/client/models/AppModel';
import {ColumnRec, ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
@ -35,6 +36,8 @@ import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
import {ACCESS_DENIED, AUTH_INTERRUPTED, canReadPrivateFiles, getGoogleCodeForReading} from 'app/client/ui/googleAuth';
import debounce = require('lodash/debounce');
const t = makeT('components.Importer');
// We expect a function for creating the preview GridView, to avoid the need to require the
// GridView module here. That brings many dependencies, making a simple test fixture difficult.
@ -628,7 +631,7 @@ export class Importer extends DisposableWithEvents {
cssMergeOptions(
cssMergeOptionsToggle(labeledSquareCheckbox(
updateExistingRecords,
'Update existing records',
t('UpdateExistingRecords'),
dom.autoDispose(updateRecordsListener),
testId('importer-update-existing-records')
)),
@ -643,14 +646,14 @@ export class Importer extends DisposableWithEvents {
return [
cssMergeOptionsMessage(
'Merge rows that match these fields:',
t('MergeRowsThatMatch'),
testId('importer-merge-fields-message')
),
multiSelect(
mergeCols,
section.viewFields().peek().map(f => ({label: f.label(), value: f.colId()})) ?? [],
{
placeholder: 'Select fields to match on',
placeholder: t("SelectFieldsToMatch"),
error: hasInvalidMergeCols
},
dom.autoDispose(mergeColsListener),

@ -1,3 +1,4 @@
import { makeT } from 'app/client/lib/localization';
import { bigBasicButton } from 'app/client/ui2018/buttons';
import { testId } from 'app/client/ui2018/cssVars';
import { loadingSpinner } from 'app/client/ui2018/loaders';
@ -6,6 +7,8 @@ import { PluginInstance } from 'app/common/PluginInstance';
import { RenderTarget } from 'app/plugin/RenderOptions';
import { Disposable, dom, DomContents, Observable, styled } from 'grainjs';
const t = makeT('components.PluginScreen');
/**
* Rendering options for the PluginScreen modal.
*/
@ -52,7 +55,7 @@ export class PluginScreen extends Disposable {
public renderError(message: string) {
this.render([
this._buildModalTitle(),
cssModalBody('Import failed: ', message, testId('importer-error')),
cssModalBody(t('ImportFailed'), message, testId('importer-error')),
cssModalButtons(
bigBasicButton('Close',
dom.on('click', () => this.close()),

@ -32,6 +32,7 @@ var dispose = require('../lib/dispose');
var dom = require('../lib/dom');
var {Delay} = require('../lib/Delay');
var kd = require('../lib/koDom');
var {makeT} = require('../lib/localization');
var Layout = require('./Layout');
var RecordLayoutEditor = require('./RecordLayoutEditor');
var commands = require('./commands');
@ -40,6 +41,8 @@ var {menu} = require('../ui2018/menus');
var {testId} = require('app/client/ui2018/cssVars');
var {contextMenu} = require('app/client/ui/contextMenu');
const t = makeT('components.RecordLayout');
/**
* Construct a RecordLayout.
* @param {MetaRowModel} options.viewSection: The model for the viewSection represented.
@ -260,7 +263,7 @@ RecordLayout.prototype.saveLayoutSpec = async function(layoutSpec) {
// Use separate copies of addColAction, since sendTableActions modified each in-place.
let addActions = gutil.arrayRepeat(addColNum, 0).map(() => addColAction.slice());
await docData.bundleActions('Updating record layout.', () => {
await docData.bundleActions(t('UpdatingRecordLayout'), () => {
return Promise.try(() => {
return addColNum > 0 ? docModel.dataTables[tableId].sendTableActions(addActions) : [];
})

@ -2,8 +2,11 @@ var _ = require('underscore');
var BackboneEvents = require('backbone').Events;
var dispose = require('app/client/lib/dispose');
var {makeT} = require('app/client/lib/localization');
var commands = require('./commands');
var LayoutEditor = require('./LayoutEditor');
const t = makeT('components.RecordLayoutEditor');
const {basicButton, cssButton, primaryButton} = require('app/client/ui2018/buttons');
const {icon} = require('app/client/ui2018/icons');
const {menu, menuDivider, menuItem} = require('app/client/ui2018/menus');
@ -90,13 +93,13 @@ RecordLayoutEditor.prototype.buildEditorDom = function() {
};
return cssControls(
basicButton('Add Field', cssCollapseIcon('Collapse'),
basicButton(t('AddField'), cssCollapseIcon('Collapse'),
menu((ctl) => [
menuItem(() => addNewField(), 'Create New Field'),
menuItem(() => addNewField(), t('CreateNewField')),
dom.maybe((use) => use(this._hiddenColumns).length > 0,
() => menuDivider()),
dom.forEach(this._hiddenColumns, (col) =>
menuItem(() => showField(col), `Show field ${col.label()}`)
menuItem(() => showField(col), t("ShowField", {label:col.label()}))
),
testId('edit-layout-add-menu'),
]),
@ -110,10 +113,10 @@ RecordLayoutEditor.prototype.buildEditorDom = function() {
RecordLayoutEditor.prototype.buildFinishButtons = function() {
return [
primaryButton('Save Layout',
primaryButton(t('SaveLayout'),
dom.on('click', () => commands.allCommands.accept.run()),
),
basicButton('Cancel',
basicButton(t('Cancel'),
dom.on('click', () => commands.allCommands.cancel.run()),
{style: 'margin-left: 8px'},
),

@ -12,6 +12,9 @@ import * as gutil from 'app/common/gutil';
import {Disposable, dom, fromKo, styled} from 'grainjs';
import ko from 'knockout';
import {menu, menuItem} from 'popweasel';
import {makeT} from 'app/client/lib/localization';
const t = makeT('components.RefSelect');
interface Item {
label: string;
@ -44,8 +47,8 @@ export class RefSelect extends Disposable {
// Indicates whether this is a ref col that references a different table.
// (That's the only time when RefSelect is offered.)
this.isForeignRefCol = this.autoDispose(ko.computed(() => {
const t = this._origColumn.refTable();
return Boolean(t && t.getRowId() !== this._origColumn.parentId());
const table = this._origColumn.refTable();
return Boolean(table && table.getRowId() !== this._origColumn.parentId());
}));
// Computed for the current fieldBuilder's field, if it exists.
@ -94,7 +97,7 @@ export class RefSelect extends Disposable {
testId('ref-select-item'),
)
),
cssAddLink(cssAddIcon('Plus'), 'Add Column',
cssAddLink(cssAddIcon('Plus'), t('AddColumn'),
menu(() => [
...this._validCols.peek()
.filter((col) => !this._addedSet.peek().has(col.colId.peek()))
@ -102,7 +105,7 @@ export class RefSelect extends Disposable {
menuItem(() => this._addFormulaField({ label: col.label(), value: col.colId() }),
col.label.peek())
),
cssEmptyMenuText("No columns to add"),
cssEmptyMenuText(t("NoColumnsAdd")),
testId('ref-select-menu'),
]),
testId('ref-select-add'),

@ -14,6 +14,9 @@ import {TableData} from "app/common/TableData";
import {BaseFormatter} from 'app/common/ValueFormatter';
import ko from 'knockout';
import {Computed, Disposable, dom, makeTestId, Observable, styled, subscribe} from 'grainjs';
import {makeT} from 'app/client/lib/localization';
const t = makeT('components.SelectionSummary');
/**
* A beginning and end index for a range of columns or rows.
@ -263,7 +266,7 @@ export class SelectionSummary extends Disposable {
async function doCopy(value: string, elem: Element) {
await copyToClipboard(value);
showTransientTooltip(elem, 'Copied to clipboard', {key: 'copy-selection-summary'});
showTransientTooltip(elem, t('CopiedClipboard'), {key: 'copy-selection-summary'});
}
const cssSummary = styled('div', `

@ -17,6 +17,9 @@ import {FieldBuilder} from 'app/client/widgets/FieldBuilder';
import {NewAbstractWidget} from 'app/client/widgets/NewAbstractWidget';
import {UserAction} from 'app/common/DocActions';
import {Computed, dom, fromKo, Observable} from 'grainjs';
import {makeT} from 'app/client/lib/localization';
const t = makeT('components.TypeTransformation');
// To simplify diff (avoid rearranging methods to satisfy private/public order).
/* eslint-disable @typescript-eslint/member-ordering */
@ -61,25 +64,25 @@ export class TypeTransform extends ColumnTransform {
),
cssButtonRow(
basicButton(dom.on('click', () => { this.cancel().catch(reportError); disableButtons.set(true); }),
'Cancel', testId("type-transform-cancel"),
t('Cancel'), testId("type-transform-cancel"),
dom.cls('disabled', disableButtons)
),
dom.domComputed(this._reviseTypeChange, revising => {
if (revising) {
return basicButton(dom.on('click', () => this.editor.writeObservable()),
'Preview', testId("type-transform-update"),
t('Preview'), testId("type-transform-update"),
dom.cls('disabled', (use) => use(disableButtons) || use(this.formulaUpToDate)),
{ title: 'Update formula (Shift+Enter)' }
{ title: t('UpdateFormula') }
);
} else {
return basicButton(dom.on('click', () => { this._reviseTypeChange.set(true); }),
'Revise', testId("type-transform-revise"),
t('Revise'), testId("type-transform-revise"),
dom.cls('disabled', disableButtons)
);
}
}),
primaryButton(dom.on('click', () => { this.execute().catch(reportError); disableButtons.set(true); }),
'Apply', testId("type-transform-apply"),
t('Apply'), testId("type-transform-apply"),
dom.cls('disabled', disableButtons)
)
)

@ -5,6 +5,9 @@ var dom = require('../lib/dom');
var kd = require('../lib/koDom');
var kf = require('../lib/koForm');
var AceEditor = require('./AceEditor');
var {makeT} = require('app/client/lib/localization');
const t = makeT('components.ValidationPanel');
/**
* Document level configuration settings.
@ -30,7 +33,7 @@ dispose.makeDisposable(ValidationPanel);
ValidationPanel.prototype.onAddRule = function() {
this.validationsTable.sendTableAction(["AddRecord", null, {
tableRef: this.docTables.at(0).id(),
name: "Rule " + (this.validations.peekLength + 1),
name: t("RuleLength", {length: this.validations.peekLength + 1}),
formula: ""
}])
.then(function() {
@ -83,7 +86,7 @@ ValidationPanel.prototype.buildDom = function() {
2, '',
1, kf.buttonGroup(
kf.button(() => editor.writeObservable(),
'Apply', { title: 'Update formula (Shift+Enter)' },
'Apply', { title: t('UpdateFormula')},
kd.toggleClass('disabled', editorUpToDate)
)
)

@ -24,9 +24,12 @@ const {confirmModal} = require('app/client/ui2018/modals');
const {Sort} = require('app/common/SortSpec');
const isEqual = require('lodash/isEqual');
const {cssMenuItem} = require('popweasel');
const {makeT} = require('app/client/lib/localization');
const testId = makeTestId('test-vconfigtab-');
const t = makeT('components.ViewConfigTab');
/**
* Helper class that combines one ViewSection's data for building dom.
*/
@ -132,21 +135,21 @@ ViewConfigTab.prototype.buildSortDom = function() {
cssRow(
cssExtraMarginTop.cls(''),
grainjsDom.maybe(hasChanged, () => [
primaryButton('Save', {style: 'margin-right: 8px;'},
primaryButton(t('Save'), {style: 'margin-right: 8px;'},
grainjsDom.on('click', () => { section.activeSortJson.save(); }),
testId('sort-save'),
grainjsDom.boolAttr('disabled', this.gristDoc.isReadonly),
),
// Let's use same label (revert) as the similar button which appear in the view section.
// menu.
basicButton('Revert',
basicButton(t('Revert'),
grainjsDom.on('click', () => { section.activeSortJson.revert(); }),
testId('sort-reset')
)
]),
cssFlex(),
grainjsDom.maybe(section.isSorted, () =>
basicButton('Update Data', {style: 'margin-left: 8px; white-space: nowrap;'},
basicButton(t('UpdateData'), {style: 'margin-left: 8px; white-space: nowrap;'},
grainjsDom.on('click', () => { updatePositions(this.gristDoc, section); }),
testId('sort-update'),
grainjsDom.show((use) => use(use(section.table).supportsManualSort)),
@ -200,9 +203,9 @@ ViewConfigTab.prototype._buildSortRow = function(colRef, sortSpec, columns) {
});
return {computed, allowedTypes, flag, label};
}
const orderByChoice = computedFlag('orderByChoice', ['Choice'], 'Use choice position');
const naturalSort = computedFlag('naturalSort', ['Text'], 'Natural sort');
const emptyLast = computedFlag('emptyLast', null, 'Empty values last');
const orderByChoice = computedFlag('orderByChoice', ['Choice'], t('UseChoicePosition'));
const naturalSort = computedFlag('naturalSort', ['Text'], t('NaturalSort'));
const emptyLast = computedFlag('emptyLast', null, t('EmptyValuesLast'));
const flags = [orderByChoice, emptyLast, naturalSort];
const column = columns.get().find(col => col.value === Sort.getColRef(colRef));
@ -278,7 +281,7 @@ ViewConfigTab.prototype._buildAddToSortBtn = function(columns) {
grainjsDom.autoDispose(showAddNew),
grainjsDom.autoDispose(available),
cssTextBtn(
cssPlusIcon('Plus'), 'Add Column',
cssPlusIcon('Plus'), t('AddColumn'),
testId('sort-add')
),
grainjsDom.hide((use) => use(showAddNew) || !use(available).length),
@ -304,7 +307,7 @@ ViewConfigTab.prototype._buildAddToSortBtn = function(columns) {
return cssRow(cssSortRow(
dom.autoDispose(col),
cssSortSelect(
select(col, [], {defaultLabel: 'Add Column'}),
select(col, [], {defaultLabel: t('AddColumn')}),
menu(() => [
menuCols,
grainjsDom.onDispose(() => { showAddNew.set(false); })
@ -372,9 +375,9 @@ ViewConfigTab.prototype._buildAdvancedSettingsDom = function() {
const table = sectionData.section.table();
const isCollapsed = ko.observable(true);
return [
kf.collapserLabel(isCollapsed, 'Advanced settings', dom.testId('ViewConfig_advanced')),
kf.collapserLabel(isCollapsed, t('AdvancedSettings'), dom.testId('ViewConfig_advanced')),
kf.helpRow(kd.hide(isCollapsed),
'Big tables may be marked as "on-demand" to avoid loading them into the data engine.',
t('BigTablesMayBeMarked'),
kd.style('text-align', 'left'),
kd.style('margin-top', '1.5rem')
),
@ -383,7 +386,7 @@ ViewConfigTab.prototype._buildAdvancedSettingsDom = function() {
),
kf.row(kd.hide(isCollapsed),
kf.buttonGroup(kf.button(() => this._makeOnDemand(table),
kd.text(() => table.onDemand() ? 'Unmark On-Demand' : 'Make On-Demand'),
kd.text(() => table.onDemand() ? t('UnmarkOnDemandButton') : t('MakeOnDemandButton')),
dom.testId('ViewConfig_onDemandBtn')
))
),
@ -401,7 +404,7 @@ ViewConfigTab.prototype._buildFilterDom = function() {
const hasChangedObs = Computed.create(null, (use) => use(section.filterSpecChanged) || !use(section.activeFilterBar.isSaved))
async function save() {
await docModel.docData.bundleActions("Update Filter settings", () => Promise.all([
await docModel.docData.bundleActions(t("UpdateFilterSettings"), () => Promise.all([
section.saveFilters(), // Save filter
section.activeFilterBar.save(), // Save bar
]));
@ -438,7 +441,7 @@ ViewConfigTab.prototype._buildFilterDom = function() {
grainjsDom.domComputed((use) => {
const filters = use(section.filters);
return cssTextBtn(
cssPlusIcon('Plus'), 'Add Filter',
cssPlusIcon('Plus'), t('AddFilter'),
addFilterMenu(filters, section, popupControls, {placement: 'bottom-end'}),
testId('add-filter-btn'),
);
@ -462,12 +465,12 @@ ViewConfigTab.prototype._buildFilterDom = function() {
cssExtraMarginTop.cls(''),
testId('save-filter-btns'),
primaryButton(
'Save', {style: 'margin-right: 8px'},
t('Save'), {style: 'margin-right: 8px'},
grainjsDom.on('click', save),
grainjsDom.boolAttr('disabled', this.gristDoc.isReadonly),
),
basicButton(
'Revert',
t('Revert'),
grainjsDom.on('click', revert),
)
))
@ -484,9 +487,9 @@ ViewConfigTab.prototype._buildThemeDom = function() {
return cssRow(
dom.autoDispose(theme),
select(theme, [
{label: 'Form', value: 'form' },
{label: 'Compact', value: 'compact'},
{label: 'Blocks', value: 'blocks' },
{label: t('Form'), value: 'form' },
{label: t('Compact'), value: 'compact'},
{label: t('Blocks'), value: 'blocks' },
]),
testId('detail-theme')
);
@ -505,7 +508,7 @@ ViewConfigTab.prototype._buildLayoutDom = function() {
const layoutEditorObs = ko.computed(() => view && view.recordLayout && view.recordLayout.layoutEditor());
return cssRow({style: 'margin-top: 16px;'},
kd.maybe(layoutEditorObs, (editor) => editor.buildFinishButtons()),
primaryButton('Edit Card Layout',
primaryButton(t('EditCardLayout'),
dom.autoDispose(layoutEditorObs),
dom.on('click', () => commands.allCommands.editLayout.run()),
grainjsDom.hide(layoutEditorObs),
@ -553,8 +556,8 @@ ViewConfigTab.prototype._buildCustomTypeItems = function() {
// 3)
showObs: () => activeSection().customDef.mode() === "plugin",
buildDom: () => kd.scope(activeSection, ({customDef}) => dom('div',
kf.row(5, "Plugin: ", 13, kf.text(customDef.pluginId, {}, {list: "list_plugin"}, dom.testId('ViewConfigTab_customView_pluginId'))),
kf.row(5, "Section: ", 13, kf.text(customDef.sectionId, {}, {list: "list_section"}, dom.testId('ViewConfigTab_customView_sectionId'))),
kf.row(5, t("PluginColon"), 13, kf.text(customDef.pluginId, {}, {list: "list_plugin"}, dom.testId('ViewConfigTab_customView_pluginId'))),
kf.row(5, t("SectionColon"), 13, kf.text(customDef.sectionId, {}, {list: "list_section"}, dom.testId('ViewConfigTab_customView_sectionId'))),
// For both `customPlugin` and `selectedSection` it is possible for the value not to be in the
// list of options. Combining <datalist> and <input> allows both to freely edit the value with
// keyboard and to select it from a list. Although the content of the list seems to be

@ -13,6 +13,9 @@ import flatten = require('lodash/flatten');
import forEach = require('lodash/forEach');
import zip = require('lodash/zip');
import zipObject = require('lodash/zipObject');
import {makeT} from 'app/client/lib/localization';
const t = makeT('components.duplicatePage');
// Duplicate page with pageId. Starts by prompting user for a new name.
export async function duplicatePage(gristDoc: GristDoc, pageId: number) {
@ -27,8 +30,7 @@ export async function duplicatePage(gristDoc: GristDoc, pageId: number) {
cssLabel("Name"),
inputEl = cssInput({value: pageName + ' (copy)'}),
),
"Note that this does not copy data, ",
"but creates another view of the same data.",
t("DoesNotCopyData"),
])
));
}
@ -39,7 +41,7 @@ async function makeDuplicate(gristDoc: GristDoc, pageId: number, pageName: strin
const viewSections = sourceView.viewSections.peek().peek();
let viewRef = 0;
await gristDoc.docData.bundleActions(
`Duplicate page ${pageName}`,
t("DuplicatePageName", {pageName}),
async () => {
// create new view and new sections
const results = await createNewViewSections(gristDoc.docData, viewSections);

@ -1,3 +1,4 @@
import {makeT} from 'app/client/lib/localization';
import {ACIndex, ACItem, ACResults, buildHighlightedDom, normalizeText} from "app/client/lib/ACIndex";
import {cssSelectItem} from "app/client/lib/ACSelect";
import {Autocomplete, IAutocompleteOptions} from "app/client/lib/autocomplete";
@ -17,6 +18,8 @@ import {createUserImage, cssUserImage} from "app/client/ui/UserImage";
import {Computed, computed, dom, DomElementArg, Holder, IDisposableOwner, Observable, styled} from "grainjs";
import {cssMenuItem} from "popweasel";
const t = makeT('lib.ACUserManager');
export interface ACUserItem extends ACItem {
value: string;
label: string;
@ -106,9 +109,10 @@ export function buildACMemberEmail(
cssUserImagePlus.cls('-invalid', (use) => !use(enableAdd),
)),
cssMemberText(
cssMemberPrimaryPlus("Invite new member"),
cssMemberPrimaryPlus(t("InviteNewMember")),
cssMemberSecondaryPlus(
dom.text(use => `We'll email an invite to ${use(emailObs)}`)
// dom.text(use => `We'll email an invite to ${use(emailObs)}`)
dom.text(use => t('InviteEmail', {email: use(emailObs)})) // TODO i18next
)
),
testId("um-add-email")
@ -139,7 +143,7 @@ export function buildACMemberEmail(
(emailInput = cssEmailInput(
emailObs,
{onInput: true, isValid},
{type: "email", placeholder: "Enter email address"},
{type: "email", placeholder: t("EmailInputPlaceholder")},
dom.on("input", acOpen),
dom.on("focus", acOpen),
dom.on("click", acOpen),

@ -1,4 +1,5 @@
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {makeT} from 'app/client/lib/localization';
import {error} from 'app/client/lib/log';
import {reportError, setErrorNotifier} from 'app/client/models/errors';
import {urlState} from 'app/client/models/gristUrlState';
@ -22,6 +23,8 @@ import {getOrgName, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/comm
import {getUserPrefObs, getUserPrefsObs} from 'app/client/models/UserPrefs';
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
const t = makeT('models.AppModel')
// Reexported for convenience.
export {reportError} from 'app/client/models/errors';
@ -192,7 +195,7 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
if (org.billingAccount && org.billingAccount.product &&
org.billingAccount.product.name === 'suspended') {
this.notifier.createUserMessage(
'This team site is suspended. Documents can be read, but not modified.',
t('TeamSiteSuspended'),
{actions: ['renew', 'personal']}
);
}

@ -25,9 +25,12 @@ import {canEdit, isOwner} from 'app/common/roles';
import {Document, NEW_DOCUMENT_CODE, Organization, UserAPI, Workspace} from 'app/common/UserAPI';
import {Holder, Observable, subscribe} from 'grainjs';
import {Computed, Disposable, dom, DomArg, DomElementArg} from 'grainjs';
import {makeT} from 'app/client/lib/localization';
// tslint:disable:no-console
const t = makeT('models.DocPageModel');
export interface DocInfo extends Document {
isReadonly: boolean;
isPreFork: boolean;
@ -233,17 +236,13 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
const isDenied = (err as any).code === 'ACL_DENY';
const isDocOwner = isOwner(this.currentDoc.get());
confirmModal(
"Error accessing document",
"Reload",
t("ErrorAccessingDocument"),
t("Reload"),
async () => window.location.reload(true),
isDocOwner ? `You can try reloading the document, or using recovery mode. ` +
`Recovery mode opens the document to be fully accessible to owners, and ` +
`inaccessible to others. It also disables formulas. ` +
`[${err.message}]` :
isDenied ? `Sorry, access to this document has been denied. [${err.message}]` :
`Document owners can attempt to recover the document. [${err.message}]`,
isDocOwner ? t('ReloadingOrRecoveryMode', {error: err.message}) :
t('AccessError', {context: isDenied ? 'denied' : 'recover', error: err.message}),
{ hideCancel: true,
extraButtons: (isDocOwner && !isDenied) ? bigBasicButton('Enter recovery mode', dom.on('click', async () => {
extraButtons: (isDocOwner && !isDenied) ? bigBasicButton(t('EnterRecoveryMode'), dom.on('click', async () => {
await this._api.getDocAPI(this.currentDocId.get()!).recover(true);
window.location.reload(true);
}), testId('modal-recovery-mode')) : null,
@ -339,18 +338,18 @@ function addMenu(importSources: ImportSource[], gristDoc: GristDoc, isReadonly:
menuItem(
(elem) => openPageWidgetPicker(elem, gristDoc.docModel, (val) => gristDoc.addNewPage(val).catch(reportError),
{isNewPage: true, buttonLabel: 'Add Page'}),
menuIcon("Page"), "Add Page", testId('dp-add-new-page'),
menuIcon("Page"), t("AddPage"), testId('dp-add-new-page'),
dom.cls('disabled', isReadonly)
),
menuItem(
(elem) => openPageWidgetPicker(elem, gristDoc.docModel, (val) => gristDoc.addWidgetToPage(val).catch(reportError),
{isNewPage: false, selectBy}),
menuIcon("Widget"), "Add Widget to Page", testId('dp-add-widget-to-page'),
menuIcon("Widget"), t("AddWidgetToPage"), testId('dp-add-widget-to-page'),
// disable for readonly doc and all special views
dom.cls('disabled', (use) => typeof use(gristDoc.activeViewId) !== 'number' || isReadonly),
),
menuItem(() => gristDoc.addEmptyTable().catch(reportError),
menuIcon("TypeTable"), "Add Empty Table", testId('dp-empty-table'),
menuIcon("TypeTable"), t("AddEmptyTable"), testId('dp-empty-table'),
dom.cls('disabled', isReadonly)
),
menuDivider(),
@ -362,7 +361,7 @@ function addMenu(importSources: ImportSource[], gristDoc: GristDoc, isReadonly:
dom.cls('disabled', isReadonly)
)
),
isReadonly ? menuText('You do not have edit access to this document') : null,
isReadonly ? menuText(t('NoEditAccess')) : null,
testId('dp-add-new-menu')
];
}

@ -1,3 +1,4 @@
import {makeT} from 'app/client/lib/localization';
import {GristDoc} from 'app/client/components/GristDoc';
import {AppModel} from 'app/client/models/AppModel';
import {DocPageModel} from 'app/client/models/DocPageModel';
@ -10,6 +11,8 @@ import {ANONYMOUS_USER_EMAIL, Document, EVERYONE_EMAIL, FullUser, getRealAccess,
import {computed, Computed, Disposable, obsArray, ObsArray, observable, Observable} from 'grainjs';
import some = require('lodash/some');
const t = makeT('models.UserManagerModel');
export interface UserManagerModel {
initData: PermissionData; // PermissionData used to initialize the UserManager
resource: Resource|null; // The access resource.
@ -97,28 +100,28 @@ interface IBuildMemberOptions {
export class UserManagerModelImpl extends Disposable implements UserManagerModel {
// Select options for each individual user's role dropdown.
public readonly userSelectOptions: IMemberSelectOption[] = [
{ value: roles.OWNER, label: 'Owner' },
{ value: roles.EDITOR, label: 'Editor' },
{ value: roles.VIEWER, label: 'Viewer' }
{ value: roles.OWNER, label: t('Owner') },
{ value: roles.EDITOR, label: t('Editor') },
{ value: roles.VIEWER, label: t('Viewer') }
];
// Select options for each individual user's role dropdown in the org.
public readonly orgUserSelectOptions: IOrgMemberSelectOption[] = [
{ value: roles.OWNER, label: 'Owner' },
{ value: roles.EDITOR, label: 'Editor' },
{ value: roles.VIEWER, label: 'Viewer' },
{ value: roles.MEMBER, label: 'No Default Access' },
{ value: roles.OWNER, label: t('Owner') },
{ value: roles.EDITOR, label: t('Editor') },
{ value: roles.VIEWER, label: t('Viewer') },
{ value: roles.MEMBER, label: t('NoDefaultAccess') },
];
// Select options for the resource's maxInheritedRole dropdown.
public readonly inheritSelectOptions: IMemberSelectOption[] = [
{ value: roles.OWNER, label: 'In Full' },
{ value: roles.EDITOR, label: 'View & Edit' },
{ value: roles.VIEWER, label: 'View Only' },
{ value: null, label: 'None' }
{ value: roles.OWNER, label: t('InFull') },
{ value: roles.EDITOR, label: t('ViewAndEdit') },
{ value: roles.VIEWER, label: t('ViewOnly') },
{ value: null, label: t('None') }
];
// Select options for the public member's role dropdown.
public readonly publicUserSelectOptions: IMemberSelectOption[] = [
{ value: roles.EDITOR, label: 'Editor' },
{ value: roles.VIEWER, label: 'Viewer' },
{ value: roles.EDITOR, label: t('Editor') },
{ value: roles.VIEWER, label: t('Viewer') },
];
public activeUser: FullUser|null = this._options.activeUser ?? null;

@ -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. '
+ 'Dont 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())
);

@ -8,6 +8,9 @@ import {cssSelectBtn} from 'app/client/ui2018/select';
import {isValidHex} from 'app/common/gutil';
import {BindableValue, Computed, Disposable, dom, Observable, onKeyDown, styled} from 'grainjs';
import {defaultMenuOptions, IOpenController, setPopupToCreateDom} from 'popweasel';
import {makeT} from 'app/client/lib/localization';
const t = makeT('ui2018.ColorSelect');
export interface StyleOptions {
textColor: ColorOption,
@ -61,7 +64,7 @@ export function colorSelect(
onSave,
onOpen,
onRevert,
placeholder = 'Default cell style',
placeholder = t('DefaultCellStyle'),
} = options;
const selectBtn = cssSelectBtn(
cssContent(
@ -185,12 +188,12 @@ function buildColorPicker(ctl: IOpenController,
}),
cssButtonRow(
primaryButton('Apply',
primaryButton(t('Apply'),
dom.on('click', () => ctl.close()),
dom.boolAttr("disabled", notChanged),
testId('colors-save')
),
basicButton('Cancel',
basicButton(t('Cancel'),
dom.on('click', () => revert()),
testId('colors-cancel')
)

@ -5,6 +5,7 @@
*
* Workspace is a clickable link and document and page names are editable labels.
*/
import {makeT} from 'app/client/lib/localization';
import { urlState } from 'app/client/models/gristUrlState';
import { cssHideForNarrowScreen, mediaNotSmall, testId, theme } from 'app/client/ui2018/cssVars';
import { editableLabel } from 'app/client/ui2018/editableLabel';
@ -15,6 +16,8 @@ import { userOverrideParams } from 'app/common/gristUrls';
import { BindableValue, dom, Observable, styled } from 'grainjs';
import { tooltip } from 'popweasel';
const t = makeT('ui2018.breadcrumbs');
export const cssBreadcrumbs = styled('div', `
color: ${theme.lightText};
white-space: nowrap;
@ -82,11 +85,6 @@ interface PartialWorkspace {
name: string;
}
const fiddleExplanation = (
'You may make edits, but they will create a new copy and will\n' +
'not affect the original document.'
);
export function docBreadcrumbs(
workspace: Observable<PartialWorkspace|null>,
docName: Observable<string>,
@ -143,20 +141,20 @@ export function docBreadcrumbs(
dom.maybe(options.isPublic, () => cssPublicIcon('PublicFilled', testId('bc-is-public'))),
dom.domComputed((use) => {
if (options.isSnapshot && use(options.isSnapshot)) {
return cssTag('snapshot', testId('snapshot-tag'));
return cssTag(t('Snapshot'), testId('snapshot-tag'));
}
if (use(options.isFork)) {
return cssTag('unsaved', testId('unsaved-tag'));
return cssTag(t('Unsaved'), testId('unsaved-tag'));
}
if (use(options.isRecoveryMode)) {
return cssAlertTag('recovery mode',
return cssAlertTag(t('RecoveryMode'),
dom('a', dom.on('click', () => options.cancelRecoveryMode()),
icon('CrossSmall')),
testId('recovery-mode-tag'));
}
const userOverride = use(options.userOverride);
if (userOverride) {
return cssAlertTag(userOverride.user?.email || 'override',
return cssAlertTag(userOverride.user?.email || t('Override'),
dom('a',
urlState().setHref(userOverrideParams(null)),
icon('CrossSmall')
@ -165,7 +163,7 @@ export function docBreadcrumbs(
);
}
if (use(options.isFiddle)) {
return cssTag('fiddle', tooltip({title: fiddleExplanation}), testId('fiddle-tag'));
return cssTag(t('Fiddle'), tooltip({title: t('FiddleExplanation')}), testId('fiddle-tag'));
}
}),
separator(' / ',

@ -1,4 +1,5 @@
import { Command } from 'app/client/components/commands';
import { makeT } from 'app/client/lib/localization';
import { NeedUpgradeError, reportError } from 'app/client/models/errors';
import { textButton } from 'app/client/ui2018/buttons';
import { cssCheckboxSquare, cssLabel, cssLabelText } from 'app/client/ui2018/checkbox';
@ -10,6 +11,8 @@ import { BindableValue, Computed, dom, DomElementArg, DomElementMethod, IDomArgs
MaybeObsArray, MutableObsArray, Observable, styled } from 'grainjs';
import * as weasel from 'popweasel';
const t = makeT('ui2018.menus');
export interface IOptionFull<T> {
value: T;
label: string;
@ -175,7 +178,7 @@ export function multiSelect<T>(selectedOptions: MutableObsArray<T>,
const selectedOptionsText = Computed.create(null, selectedOptionsSet, (use, selectedOpts) => {
if (selectedOpts.size === 0) {
return options.placeholder ?? 'Select fields';
return options.placeholder ?? t('SelectFields');
}
const optionArray = Array.isArray(availableOptions) ? availableOptions : use(availableOptions);
@ -309,8 +312,8 @@ export function upgradableMenuItem(needUpgrade: boolean, action: () => void, ...
export function upgradeText(needUpgrade: boolean, onClick: () => void) {
if (!needUpgrade) { return null; }
return menuText(dom('span', '* Workspaces are available on team plans. ',
cssUpgradeTextButton('Upgrade now', dom.on('click', () => onClick()))));
return menuText(dom('span', t('WorkspacesAvailableOnTeamPlans'),
cssUpgradeTextButton(t('UpgradeNow'), dom.on('click', () => onClick()))));
}
/**

@ -1,4 +1,5 @@
import {FocusLayer} from 'app/client/lib/FocusLayer';
import {makeT} from 'app/client/lib/localization';
import {reportError} from 'app/client/models/errors';
import {cssInput} from 'app/client/ui/cssInput';
import {prepareForTransition, TransitionWatcher} from 'app/client/ui/transitions';
@ -11,6 +12,8 @@ import {Computed, Disposable, dom, DomContents, DomElementArg, input, keyframes,
MultiHolder, Observable, styled} from 'grainjs';
import {cssMenuElem} from 'app/client/ui2018/menus';
const t = makeT('ui2018.modals');
// IModalControl is passed into the function creating the body of the modal.
export interface IModalControl {
// Observable for whether there is work in progress that's delaying the closing of the modal. It
@ -303,13 +306,13 @@ export function saveModal(createFunc: (ctl: IModalControl, owner: MultiHolder) =
cssModalTitle(options.title, testId('modal-title')),
cssModalBody(options.body),
cssModalButtons(
bigPrimaryButton(options.saveLabel || 'Save',
bigPrimaryButton(options.saveLabel || t('Save'),
dom.boolAttr('disabled', isSaveDisabled),
dom.on('click', save),
testId('modal-confirm'),
),
options.extraButtons,
options.hideCancel ? null : bigBasicButton('Cancel',
options.hideCancel ? null : bigBasicButton(t('Cancel'),
dom.on('click', () => ctl.close()),
testId('modal-cancel'),
),
@ -423,7 +426,7 @@ export function invokePrompt(
const prom = new Promise<string|undefined>((resolve) => {
onResolve = resolve;
});
promptModal(title, onResolve!, btnText ?? 'Ok', initial, placeholder, () => {
promptModal(title, onResolve!, btnText ?? t('Ok'), initial, placeholder, () => {
if (onResolve) {
onResolve(undefined);
}

@ -1,4 +1,5 @@
import { isDesktop } from 'app/client/lib/browserInfo';
import { makeT } from 'app/client/lib/localization';
import { cssEditorInput } from "app/client/ui/HomeLeftPane";
import { itemHeader, itemHeaderWrapper, treeViewContainer } from "app/client/ui/TreeViewComponentCss";
import { theme } from "app/client/ui2018/cssVars";
@ -6,6 +7,8 @@ import { icon } from "app/client/ui2018/icons";
import { menu, menuItem, menuText } from "app/client/ui2018/menus";
import { dom, domComputed, DomElementArg, makeTestId, observable, Observable, styled } from "grainjs";
const t = makeT('ui2018.pages');
const testId = makeTestId('test-docpage-');
// the actions a page can do
@ -31,13 +34,13 @@ export function buildPageDom(name: Observable<string>, actions: PageActions, ...
const isRenaming = observable(false);
const pageMenu = () => [
menuItem(() => isRenaming.set(true), "Rename", testId('rename'),
menuItem(() => isRenaming.set(true), t("Rename"), testId('rename'),
dom.cls('disabled', actions.isReadonly)),
menuItem(actions.onRemove, 'Remove', testId('remove'),
menuItem(actions.onRemove, t('Remove'), testId('remove'),
dom.cls('disabled', (use) => use(actions.isReadonly) || actions.isRemoveDisabled())),
menuItem(actions.onDuplicate, 'Duplicate Page', testId('duplicate'),
menuItem(actions.onDuplicate, t('DuplicatePage'), testId('duplicate'),
dom.cls('disabled', actions.isReadonly)),
dom.maybe(actions.isReadonly, () => menuText('You do not have edit access to this document')),
dom.maybe(actions.isReadonly, () => menuText(t('NoEditAccess'))),
];
let pageElem: HTMLElement;

@ -3,6 +3,7 @@
* Takes a `SearchModel` that controls the search behavior.
*/
import { allCommands, createGroup } from 'app/client/components/commands';
import { makeT } from 'app/client/lib/localization';
import { reportError } from 'app/client/models/AppModel';
import { SearchModel } from 'app/client/models/SearchModel';
import { hoverTooltip } from 'app/client/ui/tooltips';
@ -16,6 +17,8 @@ import debounce = require('lodash/debounce');
export * from 'app/client/models/SearchModel';
const t = makeT('ui2018.search');
const EXPAND_TIME = .5;
const searchWrapper = styled('div', `
@ -143,7 +146,7 @@ export function searchBar(model: SearchModel, testId: TestId = noTestId) {
model.isOpen.set(_value === undefined ? !model.isOpen.get() : _value);
}, 100);
const inputElem: HTMLInputElement = searchInput(model.value, {onInput: true},
{type: 'text', placeholder: 'Search in document'},
{type: 'text', placeholder: t('SearchInDocument')},
dom.on('blur', () => (
keepExpanded ?
setTimeout(() => inputElem.focus(), 0) :
@ -182,7 +185,7 @@ export function searchBar(model: SearchModel, testId: TestId = noTestId) {
const noMatch = use(model.noMatch);
const isEmpty = use(model.isEmpty);
if (isEmpty) { return null; }
if (noMatch) { return cssLabel("No results"); }
if (noMatch) { return cssLabel(t("NoResults")); }
return [
cssArrowBtn(
icon('Dropdown'),
@ -192,7 +195,7 @@ export function searchBar(model: SearchModel, testId: TestId = noTestId) {
dom.on('click', () => model.findNext()),
hoverTooltip(
[
'Find Next ',
t('FindNext'),
cssShortcut(`(${['Enter', allCommands.findNext.humanKeys].join(', ')})`),
],
{key: 'searchArrowBtnTooltip'}
@ -206,7 +209,7 @@ export function searchBar(model: SearchModel, testId: TestId = noTestId) {
dom.on('click', () => model.findPrev()),
hoverTooltip(
[
'Find Previous ',
t('FindPrevious'),
cssShortcut(allCommands.findPrev.getKeysDesc()),
],
{key: 'searchArrowBtnTooltip'}

@ -0,0 +1,5 @@
files:
- source: /static/locales/en.*.json
translation: /static/locales/%two_letters_code%.%original_file_name%
translation_replace:
"en.": ''

@ -1,18 +1,763 @@
{
"AccountPage": {
"AccountSettings":"Account settings",
"API":"API",
"Edit":"Edit",
"Email":"Email",
"Name":"Name",
"Save":"Save",
"PasswordSecurity":"Password & Security",
"LoginMethod":"Login Method",
"ChangePassword":"Change Password",
"AllowGoogleSigning":"Allow signing in to this account with Google",
"TwoFactorAuth": "Two-factor authentication",
"TwoFactorAuthDescription":"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.",
"Theme":"Theme",
"APIKey":"API Key",
"WarningUsername":"Names only allow letters, numbers and certain special characters"
},
"AccountWidget":{
"SignIn":"Sign in",
"DocumentSettings":"Document Settings",
"ToggleMobileMode":"Toggle Mobile Mode",
"Pricing":"Pricing",
"ProfileSettings":"Profile Settings",
"ManageTeam":"Manage Team",
"AccessDetails":"Access Details",
"SwitchAccounts":"Switch Accounts",
"Accounts":"Accounts",
"AddAccount":"Add Account",
"SignOut":"Sign Out"
},
"AddNewButton": {
"AddNew": "Add New"
},
"AppHeader": {
"HomePage": "Home Page",
"Legacy": "Legacy",
"PersonalSite": "Personal Site",
"TeamSite": "Team Site"
},
"ApiKey": {
"AboutToDeleteAPIkey": "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?",
"AnonymousAPIkey": "This API key can be used to access this account anonymously via the API.",
"ByGenerating": "By generating an API key, you will be able to make API calls for your own account.",
"ClickToShow": "Click to show",
"Create": "Create",
"OwnAPIKey": "This API key can be used to access your account via the API. Dont share your API key with anyone.",
"Remove": "Remove",
"RemoveAPIKey": "Remove API Key"
},
"App": {
"Description": "Description",
"Key": "Key",
"MemoryError": "Memory Error"
},
"CellContextMenu": {
"ClearEntireColumns_one": "Clear entire column",
"ClearEntireColumns_other": "Clear {{count}} entire columns",
"ClearColumns_one": "Clear column",
"ClearColumns_other": "Clear {{count}} columns",
"DeleteColumns_one": "Delete column",
"DeleteColumns_other": "Delete {{count}} columns",
"DeleteRows_one": "Delete row",
"DeleteRows_other": "Delete {{count}} rows",
"ClearValues": "Clear values",
"ClearCell": "Clear cell",
"CopyAnchorLink": "Copy anchor link",
"FilterByValue": "Filter by this value",
"InsertRow": "Insert row",
"InsertRowAbove": "Insert row above",
"InsertRowBelow": "Insert row below",
"DuplicateRows_one": "Duplicate row",
"DuplicateRows_other": "Duplicate rows",
"InsertColumnRight": "Insert column to the right",
"InsertColumnLeft": "Insert column to the left"
},
"ColumnFilterMenu": {
"FilterByRange": "Filter by Range",
"Search": "Search",
"SearchValues": "Search values",
"All": "All",
"AllShown": "All Shown",
"AllExcept": "All Except",
"None": "None",
"NoMatchingValues": "No matching values",
"OtherMatching": "Other Matching",
"OtherNonMatching": "Other Non-Matching",
"OtherValues": "Other Values",
"FutureValues": "Future Values",
"Others": "Others"
},
"CustomSectionConfig": {
"Add": "Add",
"EnterCustomURL": "Enter Custom URL",
"FullDocumentAccess": "Full document access",
"LearnMore": "Learn more about custom widgets",
"PickAColumn": "Pick a column",
"PickAColumnWithType": "Pick a {{columnType}} column",
"NoDocumentAccess": "No document access",
"OpenConfiguration": "Open configuration",
"Optional": " (optional)",
"ReadSelectedTable": "Read selected table",
"SelectCustomWidget": "Select Custom Widget",
"WidgetNeedFullAccess": "Widget needs {{fullAccess}} to this document.",
"WidgetNeedRead": "Widget needs to {{read}} the current table.",
"WidgetNoPermissison": "Widget does not require any permissions.",
"WrongTypesMenuText_one": "{{wrongTypeCount}} non-{{columnType}} column is not shown",
"WrongTypesMenuText_others": "{{wrongTypeCount}} non-{{columnType}} columns are not shown"
},
"DocHistory": {
"Activity": "Activity",
"Snapshots": "Snapshots",
"OpenSnapshot": "Open Snapshot",
"CompareToCurrent": "Compare to Current",
"CompareToPrevious": "Compare to Previous",
"Beta": "Beta"
},
"DocMenu": {
"OtherSites": "Other Sites",
"OtherSitesWelcome": "You are on the {{siteName}} site. You also have access to the following sites:",
"OtherSitesWelcome_personal": "You are on your personal site. You also have access to the following sites:",
"AllDocuments": "All Documents",
"ExamplesAndTemplates": "Examples and Templates",
"MoreExamplesAndTemplates": "More Examples and Templates"
"MoreExamplesAndTemplates": "More Examples and Templates",
"ServiceNotAvailable": "This service is not available right now",
"NeedPaidPlan": "(The organization needs a paid plan)",
"PinnedDocuments": "Pinned Documents",
"Featured": "Featured",
"Trash": "Trash",
"DocStayInTrash": "Documents stay in Trash for 30 days, after which they get deleted permanently.",
"EmptyTrash": "Trash is empty.",
"WorkspaceNotFound": "Workspace not found",
"Delete": "Delete",
"DeleteDoc": "Delete {{name}}",
"Deleted": "Deleted {{at}}",
"Edited": "Edited {{at}}",
"Examples&Templates": "Examples & Templates",
"DiscoverMoreTemplates": "Discover More Templates",
"ByName": "By Name",
"ByDateModified": "By Date Modified",
"DocumentMoveToTrash": "Document will be moved to Trash.",
"Rename": "Rename",
"Move": "Move",
"Remove": "Remove",
"UnpinDocument": "Unpin Document",
"PinDocument": "Pin Document",
"AccessDetails": "Access Details",
"ManageUsers": "Manage Users",
"DeleteForeverDoc": "Permanently Delete \"{{name}}\"?",
"DeleteForever": "Delete Forever",
"DeleteDocPerma": "Document will be permanently deleted.",
"Restore": "Restore",
"RestoreThisDocument": "To restore this document, restore the workspace first.",
"DeleteWorkspaceForever": "You may delete a workspace forever once it has no documents in it.",
"CurrentWorkspace": "Current workspace",
"RequiresEditPermissions": "Requires edit permissions",
"MoveDocToWorkspace": "Move {{name}} to workspace"
},
"DocTour": {
"InvalidDocTourTitle": "No valid document tour",
"InvalidDocTourBody": "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."
},
"DocumentSettings": {
"DocumentSettings": "Document Settings",
"ThisDocumentID": "This document's ID (for API use):",
"TimeZone": "Time Zone:",
"Locale": "Locale:",
"Currency": "Currency:",
"LocalCurrency": "Local currency ({{currency}})",
"EngineRisk": "Engine (experimental {{span}} change at own risk):",
"Save": "Save",
"SaveAndReload": "Save and Reload"
},
"DuplicateTable": {
"NewName": "Name for new table",
"AdviceWithLink": "Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}",
"CopyAllData": "Copy all data in addition to the table structure.",
"WarningACL": "Only the document default access rules will apply to the copy."
},
"errorPages": {
"AccessDenied": "Access denied{{suffix}}",
"DeniedOrganizationDocuments": "You do not have access to this organization's documents.",
"SignInWithDifferentAccount": "You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.",
"SignInToAccess": "Sign in to access this organization's documents.",
"GoToMainPage": "Go to main page",
"SignIn": "Sign in",
"AddAcount": "Add account",
"SignedOut": "Signed out{{suffix}}",
"GenericError": "Error{{suffix}}",
"SignedOutNow": "You are now signed out.",
"SignedInAgain": "Sign in again",
"PageNotFound": "Page not found{{suffix}}",
"NotFoundMainText": "The requested page could not be found.{{separator}}Please check the URL and try again.",
"ContactSupport": "Contact support",
"SomethingWentWrong": "Something went wrong",
"ErrorHappened_message": "There was an error: {{message}}",
"ErrorHappened_unknown": "There was an unknown error."
},
"FieldConfig": {
"ColumnLabel": "COLUMN LABEL AND ID",
"ColumnOptionsLimited": "Column options are limited in summary tables.",
"ColumnType_formula_one": "Formula Column",
"ColumnType_data_one": "Data Column",
"ColumnType_empty_one": "Empty Column",
"ColumnType_formula_other": "Formula Columns",
"ColumnType_data_other": "Data Columns",
"ColumnType_empty_other": "Empty Columns",
"ColumnType_mixed_other": "Mixed Behavior",
"ConvertColumn_formula": "Clear and make into formula",
"ConvertColumn_data": "Convert column to data",
"ConvertColumn_triggerformula": "Convert to trigger formula",
"ClearAndReset": "Clear and reset",
"EnterFormula": "Enter formula",
"ColumnBehavior": "COLUMN BEHAVIOR",
"SetFormula": "Set formula",
"SetTriggerFormula": "Set trigger formula",
"MakeIntoDataColumn": "Make into data column",
"TriggerFormula": "TRIGGER FORMULA"
},
"ExampleInfo": {
"Title_CRM": "Lightweight CRM",
"WelcomeTitle_CRM": "Welcome to the Lightweight CRM template",
"WelcomeText_CRM": "Check out our related tutorial for how to link data, and create high-productivity layouts.",
"WelcomeTutorialName_CRM": "Tutorial: Create a CRM",
"Title_investmentResearch": "Investment Research",
"WelcomeTitle_investmentResearch": "Welcome to the Investment Research template",
"WelcomeText_investmentResearch": "Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.",
"WelcomeTutorialName_investmentResearch": "Tutorial: Analyze & Visualize",
"Title_afterschool": "Afterschool Program",
"WelcomeTitle_afterschool": "Welcome to the Afterschool Program template",
"WelcomeText_afterschool": "Check out our related tutorial for how to model business data, use formulas, and manage complexity.",
"WelcomeTutorialName_afterschool": "Tutorial: Manage Business Data"
},
"FieldMenus": {
"UsingSettings_common": "Using common settings",
"UsingSettings_separate": "Using separate settings",
"Settings_useseparate": "Use separate settings",
"Settings_savecommon": "Save as common settings",
"Settings_revertcommon": "Revert to common settings"
},
"FilterBar": {
"AddFilter": "Add Filter"
},
"GridOptions": {
"GridOptions": "Grid Options",
"VerticalGridlines": "Vertical Gridlines",
"HorizontalGridlines": "Horizontal Gridlines",
"ZebraStripes": "Zebra Stripes"
},
"GridViewMenus": {
"AddColumn": "Add Column",
"ShowColumn": "Show column {{- label}}",
"ColumnOptions": "Column Options",
"FilterData": "Filter Data",
"Sort": "Sort",
"MoreSortOptions": "More sort options ...",
"RenameColumn": "Rename column",
"ClearEntireColumns_one": "Clear entire column",
"ClearEntireColumns_other": "Clear {{count}} entire columns",
"ClearColumns_one": "Clear column",
"ClearColumns_other": "Clear {{count}} columns",
"DeleteColumns_one": "Delete column",
"DeleteColumns_other": "Delete {{count}} columns",
"HideColumns_one": "Hide column",
"HideColumns_other": "Hide {{count}} columns",
"ConvertFormulaToData": "Convert formula to data",
"ClearValues": "Clear values",
"InsertColumn": "Insert column to the {{to}}",
"FreezeColumn_one": "Freeze this column",
"FreezeColumn_other": "Freeze {{count}} columns",
"FreezeColumn_more_one": "Freeze one more columns",
"FreezeColumn_more_other": "Freeze {{count}} more columns",
"UnfreezeColumn_one": "Unfreeze this column",
"UnfreezeColumn_other": "Unfreeze {{count}} columns",
"UnfreezeColumn_all_other": "Unfreeze all columns",
"AddToSort": "Add to sort",
"AddToSort_added": "Sorted (#{{count}})"
},
"HomeIntro": {
"SignUp": "Sign up",
"EmptyWorkspace": "This workspace is empty.",
"PersonalSite": "personal site",
"WelcomeTo": "Welcome to {{orgName}}",
"WelcomeInfoNoDocuments": "You have read-only access to this site. Currently there are no documents.",
"WelcomeInfoAppearHere": "Any documents created in this site will appear here.",
"WelcomeTextVistGrist": "Interested in using Grist outside of your team? Visit your free ",
"SproutsProgram": "Sprouts Program",
"WelcomeUser": "Welcome to Grist, {{name}}!",
"TeamSiteIntroGetStarted": "Get started by inviting your team and creating your first Grist document.",
"OrFindAndExpert": ", or find an expert via our ",
"PersonalIntroGetStarted": "Get started by creating your first Grist document.",
"AnonIntroGetStarted": "Get started by exploring templates, or creating your first Grist document.",
"Welcome": "Welcome to Grist!",
"VisitHelpCenter": "Visit our {{link}} to learn more.",
"HelpCenter": "Help Center",
"InviteTeamMembers": "Invite Team Members",
"BrowseTemplates": "Browse Templates",
"CreateEmptyDocument": "Create Empty Document",
"ImportDocument": "Import Document"
},
"HomeLeftPane": {
"AllDocuments": "All Documents",
"ExamplesAndTemplates": "Examples & Templates",
"CreateEmptyDocument": "Create Empty Document",
"ImportDocument": "Import Document",
"CreateWorkspace": "Create Workspace",
"Trash": "Trash",
"Rename": "Rename",
"Delete": "Delete",
"Workspaces": "Workspaces",
"WorkspaceDeleteTitle": "Delete {{workspace}} and all included documents?",
"WorkspaceDeleteText": "Workspace will be moved to Trash.",
"ManageUsers": "Manage Users",
"AccessDetails": "Access Details"
},
"LeftPanelCommon": {
"HelpCenter": "Help Center"
},
"MakeCopyMenu": {
"CannotEditOriginal": "Replacing the original requires editing rights on the original document.",
"Cancel": "Cancel",
"UpdateOriginal": "Update Original",
"Update": "Update",
"WarningOriginalWillBeUpdated": "The original version of this document will be updated.",
"OriginalHasModifications": "Original Has Modifications",
"Overwrite": "Overwrite",
"WarningOverwriteOriginalChanges": "Be careful, the original has changes not in this document. Those changes will be overwritten.",
"OriginalLooksUnrelated": "Original Looks Unrelated",
"WarningWillBeOverwritten": "It will be overwritten, losing any content not in this document.",
"OriginalLooksIdentical": "Original Looks Identical",
"WarningAlreadyIdentical": "However, it appears to be already identical.",
"SignUp": "Sign up",
"VisitHelpCenter": "Visit our {{link}} to learn more."
"ToSaveSignUpAndReload": "To save your changes, please sign up, then reload this page.",
"NoDestinationWorkspace": "No destination workspace",
"Name": "Name",
"EnterDocumentName": "Enter document name",
"AsTemplate": "As Template",
"IncludeStructureWithoutData": "Include the structure without any of the data.",
"Organization": "Organization",
"NoWriteAccessToSite": "You do not have write access to this site",
"Workspace": "Workspace",
"NoWriteAccessToWorkspace": "You do not have write access to the selected workspace"
},
"NotifyUI": {
"UpgradePlan": "Upgrade Plan",
"Renew": "Renew",
"GoToPersonalSite": "Go to your free personal site",
"ErrorCannotFindPersonalSite": "Cannot find personal site, sorry!",
"ReportProblem": "Report a problem",
"AskForHelp": "Ask for help",
"Notifications": "Notifications",
"GiveFeedback": "Give feedback",
"NoNotifications": "No notifications"
},
"OnBoardingPopups": {
"Finish": "Finish",
"Next": "Next"
},
"WidgetTitle": {
"OverrideTitle": "Override widget title",
"DataTableName": "DATA TABLE NAME",
"NewTableName": "Provide a table name",
"WidgetTitle": "WIDGET TITLE",
"Save": "Save",
"Cancel": "Cancel"
},
"WelcomeQuestions": {
"WelcomeToGrist": "Welcome to Grist!",
"ProductDevelopment": "Product Development",
"FinanceAccounting": "Finance & Accounting",
"MediaProduction": "Media Production",
"ITTechnology": "IT & Technology",
"Marketing": "Marketing",
"Research": "Research",
"Sales": "Sales",
"Education": "Education",
"HRManagement": "HR & Management",
"Other": "Other",
"WhatBringsYouToGrist": "What brings you to Grist? Please help us serve you better.",
"TypeHere": "Type here"
},
"OpenVideoTour": {
"YouTubeVideoPlayer": "YouTube video player",
"GristVideoTour": "Grist Video Tour",
"VideoTour": "Video Tour"
},
"Pages": {
"TableWillNoLongerBeVisible_one": "The following table will no longer be visible",
"TableWillNoLongerBeVisible_other": "The following tables will no longer be visible",
"DeleteDataAndPage": "Delete data and this page.",
"Delete": "Delete"
},
"PageWidgetPicker": {
"BuildingWidget": "Building {{- label}} widget",
"SelectWidget": "Select Widget",
"SelectData": "Select Data",
"GroupBy": "Group by",
"AddToPage": "Add to Page"
},
"RightPanel": {
"Column_one": "Column",
"Column_other": "Columns",
"Field_one": "Field",
"Field_other": "Fields",
"Series_one": "Series",
"Series_other": "Series",
"ColumnType": "COLUMN TYPE",
"Transform": "TRANSFORM",
"Widget": "Widget",
"SortAndFilter": "Sort & Filter",
"Data": "Data",
"DataTableName": "DATA TABLE NAME",
"WidgetTitle": "WIDGET TITLE",
"ChangeWidget": "Change Widget",
"Theme": "Theme",
"RowStyleUpper": "ROW STYLE",
"RowStyle": "Row Style",
"ChartType": "CHART TYPE",
"Custom": "CUSTOM",
"Sort": "SORT",
"Filter": "FILTER",
"DataTable": "DATA TABLE",
"SourceData": "SOURCE DATA",
"GroupedBy": "GROUPED BY",
"EditDataSelection": "Edit Data Selection",
"Detach": "Detach",
"SelectBy": "SELECT BY",
"SelectWidget": "Select Widget",
"SelectorFor": "SELECTOR FOR",
"Save": "Save",
"NoEditAccess": "You do not have edit access to this document"
},
"RowContextMenu": {
"InsertRow": "Insert row",
"InsertRowAbove": "Insert row above",
"InsertRowBelow": "Insert row below",
"DuplicateRows_one": "Duplicate row",
"DuplicateRows_other": "Duplicate rows",
"Delete": "Delete",
"CopyAnchorLink": "Copy anchor link"
},
"sendToDrive":{
"SendingToGoogleDrive":"Sending file to Google Drive"
},
"ShareMenu":{
"BackToCurrent": "Back to Current",
"SaveDocument":"Save Document",
"SaveCopy":"Save Copy",
"Unsaved":"Unsaved",
"DuplicateDocument":"Duplicate Document",
"ManageUsers":"Manage Users",
"AccessDetails":"Access Details",
"CurrentVersion":"Current Version",
"Original":"Original",
"ReturnToTermToUse":"Return to {{termToUse}}",
"ReplaceTermToUse":"Replace {{termToUse}}...",
"CompareTermToUse":"Compare to {{termToUse}}",
"WorkOnCopy":"Work on a Copy",
"EditWithoutAffecting":"Edit without affecting the original",
"ShowInFolder":"Show in folder",
"Download":"Download",
"ExportCSV":"Export CSV",
"ExportXLSX":"Export XLSX",
"SendToGoogleDrive":"Send to Google Drive"
},
"SiteSwitcher":{
"SwitchSites":"Switch Sites",
"CreateNewTeamSite":"Create new team site"
},
"ThemeConfig": {
"Appearance": "Appearance ",
"SyncWithOS": "Switch appearance automatically to match system"
},
"Tools": {
"Tools": "TOOLS",
"AccessRules": "Access Rules",
"Data": "Raw Data",
"DocumentHistory": "Document History",
"ValidateData": "Validate Data",
"CodeView": "Code View",
"HowToTutorial": "How-to Tutorial",
"DocumentTour": "Tour of this Document",
"DeleteDocumentTour": "Delete document tour?",
"Delete": "Delete",
"ViewingAsYourself": "Return to viewing as yourself",
"RawData": "Raw Data"
},
"TopBar": {
"ManageTeam": "Manage Team"
},
"TriggerFormulas": {
"AnyField": "Any field",
"NewRecords": "Apply to new records",
"ChangesTo": "Apply on changes to:",
"RecordChanges": "Apply on record changes",
"CurrentField": "Current field ",
"DataCleaning": "(data cleaning)",
"ExceptFormulas": "(except formulas)",
"OK": "OK",
"Cancel": "Cancel",
"Close": "Close"
},
"VisibleFieldsConfig": {
"NoReorderHiddenField": "Hidden Fields cannot be reordered",
"NoDropInHiddenField": "Cannot drop items into Hidden Fields",
"SelectAll": "Select All",
"Clear": "Clear"
},
"ViewLayoutMenu": {
"DeleteRecord": "Delete record",
"CopyAnchorLink": "Copy anchor link",
"ShowRawData": "Show raw data",
"PrintWidget": "Print widget",
"DownloadCSV": "Download as CSV",
"DownloadXLSX": "Download as XLSX",
"EditCardLayout": "Edit Card Layout",
"WidgetOptions": "Widget options",
"AdvancedSortFilter": "Advanced Sort & Filter",
"DataSelection": "Data selection",
"OpenConfiguration": "Open configuration",
"DeleteWidget": "Delete widget"
},
"ViewSectionMenu": {
"UpdateSortFilterSettings": "Update Sort&Filter settings",
"Save": "Save",
"Revert": "Revert",
"SortedBy": "Sorted by",
"AddFilter": "Add Filter",
"ToggleFilterBar": "Toggle Filter Bar",
"FilteredBy": "Filtered by",
"Customized":"(customized)",
"Modified":"(modified)",
"Empty":"(empty)",
"CustomOptions":"Custom options"
},
"aclui": {
"AccessRules": {
"Checking": "Checking...",
"Saved": "Saved",
"Invalid": "Invalid",
"Save": "Save",
"Reset": "Reset",
"AddTableRules": "Add Table Rules",
"AddUserAttributes": "Add User Attributes",
"Users": "Users",
"UserAttributes": "User Attributes",
"AttributeToLookUp": "Attribute to Look Up",
"LookupTable": "Lookup Table",
"LookupColumn": "Lookup Column",
"DefaultRules": "Default Rules",
"Condition": "Condition",
"Permissions": "Permissions",
"RulesForTable": "Rules for table ",
"AddColumnRule": "Add Column Rule",
"AddDefaultRule": "Add Default Rule",
"DeleteTableRules": "Delete Table Rules",
"SpecialRules": "Special Rules",
"AccessRulesDescription": "Allow everyone to view Access Rules.",
"FullCopiesDescription": "Allow everyone to copy the entire document, or view it in full in fiddle mode.\nUseful for examples and templates, but not for sensitive data.",
"AccessRulesName": "Permission to view Access Rules",
"FullCopies": "Permission to access the document in full when needed",
"AttributeNamePlaceholder": "Attribute name",
"Everyone": "Everyone",
"EveryoneElse": "Everyone Else",
"EnterCondition": "Enter Condition"
},
"PermissionsWidget": {
"AllowAll": "Allow All",
"DenyAll": "Deny All",
"ReadOnly": "Read Only"
}
},
"lib": {
"ACUserManager": {
"InviteNewMember": "Invite new member",
"EmailInputPlaceholder": "Enter email address",
"InviteEmail": "We'll email an invite to {{email}}"
}
},
"models": {
"AppModel": {
"TeamSiteSuspended": "This team site is suspended. Documents can be read, but not modified."
},
"DocPageModel": {
"ErrorAccessingDocument": "Error accessing document",
"Reload": "Reload",
"ReloadingOrRecoveryMode": "You can try reloading the document, or using recovery mode. Recovery mode opens the document to be fully accessible to owners, and inaccessible to others. It also disables formulas. [{{error}}]",
"AccessError_denied": "Sorry, access to this document has been denied. [{{error}}]",
"AccessError_recover": "Document owners can attempt to recover the document. [{{error}}]",
"EnterRecoveryMode": "Enter recovery mode",
"AddPage": "Add Page",
"AddWidgetToPage": "Add Widget to Page",
"AddEmptyTable": "Add Empty Table",
"NoEditAccess": "You do not have edit access to this document"
},
"UserManagerModel": {
"Owner": "Owner",
"Editor": "Editor",
"Viewer": "Viewer",
"NoDefaultAccess": "No Default Access",
"InFull": "In Full",
"ViewAndEdit": "View & Edit",
"ViewOnly": "View Only",
"None": "None"
}
},
"ui2018": {
"breadcrumbs": {
"FiddleExplanation": "You may make edits, but they will create a new copy and will\nnot affect the original document.",
"Snapshot": "snapshot",
"Unsaved": "unsaved",
"RecoveryMode": "recovery mode",
"Override": "override",
"Fiddle": "fiddle"
},
"ColorSelect": {
"DefaultCellStyle": "Default cell style",
"Apply": "Apply",
"Cancel": "Cancel"
},
"menus": {
"SelectFields": "Select fields",
"WorkspacesAvailableOnTeamPlans": "* Workspaces are available on team plans. ",
"UpgradeNow": "Upgrade now"
},
"modals": {
"Save": "Save",
"Cancel": "Cancel",
"Ok": "Ok"
},
"pages": {
"Rename": "Rename",
"Remove": "Remove",
"DuplicatePage": "Duplicate Page",
"NoEditAccess": "You do not have edit access to this document"
},
"search": {
"SearchInDocument": "Search in document",
"NoResults": "No results",
"FindNext": "Find Next ",
"FindPrevious": "Find Previous "
}
},
"components": {
"ActionLog": {
"ActionLogFailed":"Action Log failed to load",
"TableRemovedInAction":"Table {{tableId}} was subsequently removed in action #{{actionNum}}",
"RowRemovedInAction":"This row was subsequently removed in action {{action.actionNum}}",
"ColumnRemovedInAction":"Column {{colId}} was subsequently removed in action #{{action.actionNum}}"
},
"ChartView": {
"EachYFollowedByOne":"Each Y series is followed by a series for the length of error bars.",
"EachYFollowedByTwo":"Each Y series is followed by two series, for top and bottom error bars.",
"CreateSeparateSeries":"Create separate series for each value of the selected column.",
"PickColumn":"Pick a column",
"SelectedNewGroupDataColumns":"selected new group data columns",
"ToggleChartAggregation":"Toggle chart aggregation"
},
"CodeEditorPanel": {
"AccessDenied":"Access denied",
"CodeViewOnlyFullAccess.":"Code View is available only when you have full document access."
},
"DataTables": {
"RawDataTables":"Raw Data Tables",
"ClickToCopy":"Click to copy",
"TableIDCopied":"Table ID copied to clipboard",
"DuplicateTable":"Duplicate Table",
"NoEditAccess":"You do not have edit access to this document",
"DeleteData":"Delete {{formattedTableName}} data, and remove it from all pages?"
},
"DocumentUsage": {
"UsageStatisticsOnlyFullAccess":"Usage statistics are only available to users with full access to the document data.",
"TotalSize":"The total size of all data in this document, excluding attachments.",
"Updates":"Updates every 5 minutes.",
"AttachmentsSize": "Attachments Size",
"DataSize": "Data Size",
"Usage": "Usage",
"LimitContactSiteOwner": "Contact the site owner to upgrade the plan to raise limits.",
"UpgradeLinkText": "start your 30-day free trial of the Pro plan.",
"ForHigherLimits": "For higher limits, ",
"StatusMessageApproachingLimit": "This document is {{- link}} free plan limits.",
"StatusMessageGracePeriod": "Document limits {{- link}}.",
"StatusMessageGracePeriodElse": "Document limits {{- link}}. In {{gracePeriodDays}} days, this document will be read-only.",
"StatusMessageDeleteOnly": "This document {{- link}} free plan limits and is now read-only, but you can delete rows.",
"Rows": "Rows"
},
"ViewConfigTab": {
"Save": "Save",
"Revert": "Revert",
"UpdateData": "Update Data",
"UseChoicePosition": "Use choice position",
"NaturalSort": "Natural sort",
"EmptyValuesLast": "Empty values last",
"AddColumn": "Add Column",
"UnmarkOnDemandTitle": "Unmark table On-Demand?",
"UnmarkOnDemandButton": "Unmark On-Demand",
"UnmarkOnDemandText": "If you unmark table {{- table}}' as On-Demand, its data will be loaded into the calculation engine and will be available for use in formulas. For a big table, this may greatly increase load times.{{- br}}{{-br}}Changing this setting will reload the document for all users.",
"MakeOnDemandTitle": "Make table On-Demand?",
"MakeOnDemandButton": "Make On-Demand",
"MakeOnDemandText": "If you make table {{table}} On-Demand, its data will no longer be loaded into the calculation engine and will not be available for use in formulas. It will remain available for viewing and editing.",
"AdvancedSettings": "Advanced settings",
"BigTablesMayBeMarked": "Big tables may be marked as \"on-demand\" to avoid loading them into the data engine.",
"UpdateFilterSettings": "Update Filter settings",
"AddFilter": "Add Filter",
"Form": "Form",
"Compact": "Compact",
"Blocks": "Blocks",
"EditCardLayout": "Edit Card Layout",
"PluginColon": "Plugin: ",
"SectionColon": "Section: "
},
"Drafts": {
"UndoDiscard":"Undo discard",
"RestoreLastEdit":"Restore last edit"
},
"duplicatePage": {
"DoesNotCopyData":"Note that this does not copy data, but creates another view of the same data.",
"DuplicatePageName":"Duplicate page {{pageName}}"
},
"GristDoc": {
"ImportFromFile":"Import from file",
"AddedNewLinkedSection":"Added new linked section to view {{viewName}}",
"SavedLinkedSectionIn":"Saved linked section {{title}} in view {{name}}"
},
"Importer": {
"UpdateExistingRecords":"Update existing records",
"MergeRowsThatMatch":"Merge rows that match these fields:",
"SelectFieldsToMatch":"Select fields to match on"
},
"PluginScreen": {
"ImportFailed":"Import failed: "
},
"RecordLayout": {
"UpdatingRecordLayout":"Updating record layout."
},
"RecordLayoutEditor": {
"AddField":"Add Field",
"CreateNewField":"Create New Field",
"ShowField":"Show field {{- label}}",
"SaveLayout":"Save Layout",
"Cancel":"Cancel"
},
"RefSelect": {
"AddColumn":"Add Column",
"NoColumnsAdd":"No columns to add"
},
"SelectionSummary": {
"CopiedClipboard":"Copied to clipboard"
},
"TypeTransformation": {
"Cancel":"Cancel",
"Preview":"Preview",
"UpdateFormula":"Update formula (Shift+Enter)",
"Revise":"Revise",
"Apply":"Apply"
},
"ValidationPanel": {
"RuleLength":"Rule {{length}}",
"UpdateFormula":"Update formula (Shift+Enter)"
}
}
}

@ -0,0 +1,761 @@
{
"AccountPage": {
"AccountSettings": "Paramètres du compte",
"API": "API",
"Edit": "Modifier",
"Email": "E-mail",
"Name": "Nom",
"Save": "Enregistrer",
"PasswordSecurity": "Mot de passe & Sécurité",
"LoginMethod": "Mode de connexion",
"ChangePassword": "Modifier le mot de passe",
"AllowGoogleSigning": "Autoriser la connexion à ce compte avec Google",
"TwoFactorAuth": "Authentification à deux facteurs",
"TwoFactorAuthDescription": "L'authentification à double facteur est une couche additionnelle de sécurité pour votre compte Grist qui permet de s'assurer que vous êtes la seule personne qui peut accéder à votre compte, même si quelqu'un d'autre connait votre mot de passe",
"Theme": "Thème",
"APIKey": "Clé dAPI",
"WarningUsername": "Les noms d'utilisateurs ne doivent contenir que des lettres, des chiffres, et certains caractères spéciaux"
},
"AccountWidget": {
"SignIn": "Connexion",
"DocumentSettings": "Paramètres du document",
"ToggleMobileMode": "Activer/désactiver le mode mobile",
"Pricing": "Tarifs",
"ProfileSettings": "Paramètres du profil",
"ManageTeam": "Gestion de l'équipe",
"AccessDetails": "Informations daccès",
"SwitchAccounts": "Changer de compte",
"Accounts": "Comptes",
"AddAccount": "Ajouter un compte",
"SignOut": "Se déconnecter"
},
"AddNewButton": {
"AddNew": "Nouveau"
},
"AppHeader": {
"HomePage": "Page daccueil",
"Legacy": "Ancienne version",
"PersonalSite": "Espace personnel",
"TeamSite": "Espace d'équipe"
},
"ApiKey": {
"AboutToDeleteAPIkey": "Vous êtes sur le point de supprimer une clé API. Cela causera le rejet de toutes les requêtes futures utilisant cette clé API. Voulez-vous toujours la supprimer ?",
"AnonymousAPIkey": "Cette clé API peut être utilisée pour accéder à ce compte de manière anonyme via l'API.",
"ByGenerating": "En générant une clé API, vous pourrez faire des appels API pour votre propre compte.",
"ClickToShow": "Cliquer pour afficher",
"Create": "Créer",
"OwnAPIKey": "Cette clé API peut être utilisée pour accéder à votre compte via l'API. Ne partagez pas votre clé API avec qui que ce soit.",
"Remove": "Supprimer",
"RemoveAPIKey": "Supprimer la clé d'API"
},
"App": {
"Description": "Description",
"Key": "Clé",
"MemoryError": "Erreur mémoire"
},
"CellContextMenu": {
"ClearEntireColumns_one": "Effacer la colonne entière",
"ClearEntireColumns_other": "Effacer ces {{count}} colonnes entières",
"ClearColumns_one": "Effacer la colonne",
"ClearColumns_other": "Effacer {{count}} colonnes",
"DeleteColumns_one": "Supprimer la colonne",
"DeleteColumns_other": "Supprimer {{count}} colonnes",
"DeleteRows_one": "Supprimer la ligne",
"DeleteRows_other": "Supprimer {{count}} lignes",
"ClearValues": "Effacer les valeurs",
"ClearCell": "Effacer la cellule",
"CopyAnchorLink": "Copier l'ancre",
"FilterByValue": "Filtrer par cette valeur",
"InsertRow": "Insérer une ligne",
"InsertRowAbove": "Insérer une ligne au-dessus",
"InsertRowBelow": "Insérer une ligne au-dessous",
"DuplicateRows_one": "Dupliquer la ligne",
"DuplicateRows_other": "Dupliquer les lignes",
"InsertColumnRight": "Insérer une colonne à droite",
"InsertColumnLeft": "Insérer une colonne à gauche"
},
"ColumnFilterMenu": {
"FilterByRange": "Filtrer par intervalle",
"Search": "Rechercher",
"SearchValues": "Chercher",
"All": "Tous",
"AllShown": "Ces valeurs",
"AllExcept": "Pas ces valeurs",
"None": "Aucun",
"NoMatchingValues": "Aucune valeur trouvée",
"OtherMatching": "Autres correspondances",
"OtherNonMatching": "Autres non-correspondances",
"OtherValues": "Autres valeurs",
"FutureValues": "Futures valeurs",
"Others": "Autres"
},
"CustomSectionConfig": {
"Add": "Ajouter",
"EnterCustomURL": "Entrer une URL personnalisée",
"FullDocumentAccess": "Accès complet au document",
"LearnMore": "En savoir plus sur les vues personnalisées",
"PickAColumn": "Choisir une colonne",
"PickAColumnWithType": "Choisir une colonne de type {{columnType}}",
"NoDocumentAccess": "Pas daccès au document",
"OpenConfiguration": "Ouvrir la configuration",
"Optional": " (facultatif)",
"ReadSelectedTable": "Lire les données source sélectionnées",
"SelectCustomWidget": "Sélectionner une vue personnalisée",
"WidgetNeedFullAccess": "Le widget a besoin de {{fullAccess}} à ce document.",
"WidgetNeedRead": "Le widget a besoin de {{read}} la table actuelle.",
"WidgetNoPermissison": "La vue ne nécessite aucune autorisation.",
"WrongTypesMenuText_one": "{{wrongTypeCount}} non-{{columnType}} column is not shown",
"WrongTypesMenuText_others": "{{wrongTypeCount}} non-{{columnType}} columns are not shown"
},
"DocHistory": {
"Activity": "Activité",
"Snapshots": "Instantanés",
"OpenSnapshot": "Ouvrir cet instantané",
"CompareToCurrent": "Comparer au document en cours",
"CompareToPrevious": "Comparer au précédent",
"Beta": "Bêta"
},
"DocMenu": {
"OtherSites": "Autres espaces",
"OtherSitesWelcome": "Tu es sur l'espace de {{siteName}}. Tu as aussi accès aux espaces suivants :",
"OtherSitesWelcome_personal": "Tu es sur ton espace personnel. Tu as aussi accès aux espaces suivants :",
"AllDocuments": "Tous les documents",
"ExamplesAndTemplates": "Exemples et modèles",
"MoreExamplesAndTemplates": "Plus dexemples et de modèles",
"ServiceNotAvailable": "Ce service n'est pas disponible pour le moment",
"NeedPaidPlan": "(L'organisation a besoin d'un plan payant)",
"PinnedDocuments": "Documents épinglés",
"Featured": "A la une",
"Trash": "Corbeille",
"DocStayInTrash": "Les documents restent dans la corbeille pendant 30 jours, après quoi ils seront supprimés définitivement.",
"EmptyTrash": "La corbeille est vide.",
"WorkspaceNotFound": "Dossier introuvable",
"Delete": "Supprimer",
"DeleteDoc": "Supprimer « {{name}}»",
"Deleted": "Supprimé {{at}}",
"Edited": "Modifié {{at}}",
"Examples&Templates": "Exemples et modèles",
"DiscoverMoreTemplates": "Découvrir plus de modèles",
"ByName": "Par nom",
"ByDateModified": "Par date de modification",
"DocumentMoveToTrash": "Le document sera déplacé vers la corbeille.",
"Rename": "Renommer",
"Move": "Déplacer",
"Remove": "Supprimer",
"UnpinDocument": "Désépingler le document",
"PinDocument": "Épingler le document",
"AccessDetails": "Informations daccès",
"ManageUsers": "Gérer les utilisateurs",
"DeleteForeverDoc": "Supprimer définitivement « {{name}} » ?",
"DeleteForever": "Supprimer définitivement",
"DeleteDocPerma": "Le document sera supprimé définitivement.",
"Restore": "Restaurer",
"RestoreThisDocument": "Pour restaurer ce document, il faut restaurer le dossier d'abord.",
"DeleteWorkspaceForever": "Vous pouvez supprimer définitivement un dossier une fois quil ne contient plus de documents.",
"CurrentWorkspace": "Dossier courant",
"RequiresEditPermissions": "Nécessite des droits d'édition",
"MoveDocToWorkspace": "Déplacer {{name}} vers le dossier"
},
"DocTour": {
"InvalidDocTourTitle": "No valid document tour",
"InvalidDocTourBody": "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."
},
"DocumentSettings": {
"DocumentSettings": "Paramètres du document",
"ThisDocumentID": "ID du document (pour lAPI seulement) :",
"TimeZone": "Fuseau horaire :",
"Locale": "Langue :",
"Currency": "Devise :",
"LocalCurrency": "Devise locale ({{currency}})",
"EngineRisk": "Engine (experimental {{span}} change at own risk):",
"Save": "Enregistrer",
"SaveAndReload": "Enregistrer et recharger"
},
"DuplicateTable": {
"NewName": "Nom de la nouvelle table",
"AdviceWithLink": "Au lieu de dupliquer les tables, il est généralement préférable de segmenter les données en utilisant des vues liées. {{link}}",
"CopyAllData": "Copier toutes les données en plus de la structure de la table.",
"WarningACL": "Seules les règles d'accès par défaut du document sappliqueront à la copie."
},
"errorPages": {
"AccessDenied": "Accès refusé{{suffix}}",
"DeniedOrganizationDocuments": "Vous navez pas accès aux documents de cette organisation.",
"SignInWithDifferentAccount": "Vous êtes connecté en tant que {{email}}. Vous pouvez vous connecter avec un autre compte ou demander un accès à un administrateur.",
"SignInToAccess": "Connectez-vous pour accéder aux documents de cette organisation.",
"GoToMainPage": "Aller à la page principale",
"SignIn": "Connexion",
"AddAcount": "Ajouter un compte",
"SignedOut": "Déconnexion{{suffix}}",
"SignedOutNow": "Vous êtes maintenant déconnecté.",
"GenericError": "Erreur{{suffix}}",
"SignedInAgain": "Reconnectez-vous",
"PageNotFound": "Page non trouvée{{suffix}}",
"NotFoundMainText": "La page demandée na pas pu être trouvée.{{separator}}Veuillez vérifier lURL et réessayer.",
"ContactSupport": "Contacter le support",
"SomethingWentWrong": "Une erreur sest produite",
"ErrorHappened_message": "Une erreur sest produite : {{message}}",
"ErrorHappened_unknown": "Une erreur inconnue sest produite."
},
"FieldConfig": {
"ColumnLabel": "LABEL ET ID DE LA COLONNE",
"ColumnOptionsLimited": "Les options des colonnes sont limitées dans les tableaux récapitulatifs.",
"ColumnType_formula_one": "Colonne formule",
"ColumnType_data_one": "Colonne de données",
"ColumnType_empty_one": "Colonne vide",
"ColumnType_formula_other": "Colonnes formule",
"ColumnType_data_other": "Colonnes de données",
"ColumnType_empty_other": "Colonnes vides",
"ColumnType_mixed_other": "Comportement mixte",
"ConvertColumn_formula": "Effacer et transformer en formule",
"ConvertColumn_data": "Convertir la colonne en données",
"ConvertColumn_triggerformula": "Convert to trigger formula",
"ClearAndReset": "Effacer et réinitialiser",
"EnterFormula": "Saisir la formule",
"ColumnBehavior": "NATURE DE COLONNE",
"SetFormula": "Définir la formule",
"SetTriggerFormula": "Définir une formule dinitialisation",
"MakeIntoDataColumn": "Transformer en colonne de données",
"TriggerFormula": "TRIGGER FORMULA"
},
"ExampleInfo": {
"Title_CRM": "CRM léger",
"WelcomeTitle_CRM": "Bienvenue dans le modèle de CRM léger",
"WelcomeText_CRM": "Consultez le tutoriel associé pour savoir comment lier des données et créer des mises en page de haute productivité.",
"WelcomeTutorialName_CRM": "Tutoriel : créer un CRM",
"Title_investmentResearch": "Recherche dinvestissements",
"WelcomeTitle_investmentResearch": "Bienvenue sur le modèle de recherche d'investissements",
"WelcomeText_investmentResearch": "Consulter le tutoriel associé pour apprendre à créer des tableaux récapitulatifs et des graphiques, et pour relier les graphiques de façon dynamique.",
"WelcomeTutorialName_investmentResearch": "Tutoriel : analyser et visualiser",
"Title_afterschool": "Afterschool Program",
"WelcomeTitle_afterschool": "Welcome to the Afterschool Program template",
"WelcomeText_afterschool": "Consultez e tutoriel associé pour savoir comment modéliser des données d'entreprise, utiliser les formules et gérer la complexité.",
"WelcomeTutorialName_afterschool": "Tutorial: Manage Business Data"
},
"FieldMenus": {
"UsingSettings_common": "Using common settings",
"UsingSettings_separate": "Using separate settings",
"Settings_useseparate": "Use separate settings",
"Settings_savecommon": "Save common settings",
"Settings_revertcommon": "Revert common settings"
},
"FilterBar": {
"AddFilter": "Ajouter un filtre"
},
"GridOptions": {
"GridOptions": "Options de la grille",
"VerticalGridlines": "Grille verticale",
"HorizontalGridlines": "Grille horizontale",
"ZebraStripes": "Couleurs alternées"
},
"GridViewMenus": {
"AddColumn": "Ajouter une colonne",
"ShowColumn": "Afficher la colonne {{- label}}",
"ColumnOptions": "Options de la colonne",
"FilterData": "Filtrer les données",
"Sort": "Trier",
"MoreSortOptions": "Plus doptions de tri…",
"RenameColumn": "Renommer la colonne",
"ClearEntireColumns_one": "Effacer la colonne entière",
"ClearEntireColumns_other": "Effacer {{count}} colonnes entières",
"ClearColumns_one": "Effacer la colonne",
"ClearColumns_other": "Effacer {{count}} colonnes",
"DeleteColumns_one": "Supprimer la colonne",
"DeleteColumns_other": "Supprimer {{count}} colonnes",
"HideColumns_one": "Masquer la colonne",
"HideColumns_other": "Masquer {{count}} colonnes",
"ConvertFormulaToData": "Convertir la formule en données",
"ClearValues": "Effacer les valeurs",
"InsertColumn": "Insérer une colonne {{to}}",
"FreezeColumn_one": "Figer cette colonne",
"FreezeColumn_other": "Figer {{count}} colonnes",
"FreezeColumn_more_one": "Figer une colonne de plus",
"FreezeColumn_more_other": "Figer {{count}} colonnes",
"UnfreezeColumn_one": "Figer cette colonne",
"UnfreezeColumn_other": "Figer {{count}} colonnes",
"UnfreezeColumn_all_other": "Libérer toutes les colonnes",
"AddToSort": "Ajouter au tri",
"AddToSort_added": "Trié (#{{count}})"
},
"HomeIntro": {
"SignUp": "S'inscrire",
"EmptyWorkspace": "Ce dossier est vide.",
"PersonalSite": "espace personnel",
"WelcomeTo": "Bienvenue sur {{orgName}}",
"WelcomeInfoNoDocuments": "Vous avez un accès en lecture seule à ce site. Il n'y a actuellement aucun document.",
"WelcomeInfoAppearHere": "Tous les documents créés dans ce site apparaîtront ici.",
"WelcomeTextVistGrist": "Vous souhaitez utiliser Grist en dehors de votre équipe ? Visitez votre site gratuit ",
"SproutsProgram": "Sprouts Program",
"WelcomeUser": "Bienvenue sur Grist, {{name}} !",
"TeamSiteIntroGetStarted": "Pour commencer, inviter votre équipe et créer votre premier document Grist.",
"OrFindAndExpert": ", or find an expert via our ",
"PersonalIntroGetStarted": "Commencez en créant votre premier document Grist.",
"AnonIntroGetStarted": "Get started by exploring templates, or creating your first Grist document.",
"Welcome": "Bienvenue sur Grist !",
"HelpCenter": "Centre d'aide",
"InviteTeamMembers": "Inviter un nouveau membre",
"BrowseTemplates": "Parcourir les modèles",
"CreateEmptyDocument": "Créer un document vide",
"ImportDocument": "Importer un Fichier"
},
"HomeLeftPane": {
"AllDocuments": "Tous les documents",
"ExamplesAndTemplates": "Exemples & Templates",
"CreateEmptyDocument": "Créer un document vide",
"ImportDocument": "Importer un Fichier",
"CreateWorkspace": "Créer un nouveau dossier",
"Trash": "Corbeille",
"Rename": "Renommer",
"Delete": "Supprimer",
"Workspaces": "Dossiers",
"WorkspaceDeleteTitle": "Supprimer le dossier {{workspace}} et tous les documents qu'il contient ?",
"WorkspaceDeleteText": "Le dossier va être mis à la corbeille.",
"ManageUsers": "Gérer les utilisateurs",
"AccessDetails": "Access Details"
},
"LeftPanelCommon": {
"HelpCenter": "Centre d'aide"
},
"MakeCopyMenu": {
"CannotEditOriginal": "Replacing the original requires editing rights on the original document.",
"Cancel": "Annuler",
"UpdateOriginal": "Mettre à jour l'original",
"Update": "Mettre à jour",
"WarningOriginalWillBeUpdated": "La version originale de ce document sera mise à jour.",
"OriginalHasModifications": "L'original a été modifié",
"Overwrite": "Remplacer",
"WarningOverwriteOriginalChanges": "Attention, l'original a des modifications qui ne sont pas dans ce document. Ces modifications seront écrasées.",
"OriginalLooksUnrelated": "Original Looks Unrelated",
"WarningWillBeOverwritten": "It will be overwritten, losing any content not in this document.",
"OriginalLooksIdentical": "Original Looks Identical",
"WarningAlreadyIdentical": "However, it appears to be already identical.",
"SignUp": "Inscription",
"ToSaveSignUpAndReload": "Pour enregistrer vos modifications, veuillez vous inscrire, puis recharger cette page.",
"NoDestinationWorkspace": "Aucun dossier destination",
"Name": "Nom",
"EnterDocumentName": "Saisir le nom du document",
"AsTemplate": "Comme modèle",
"IncludeStructureWithoutData": "Inclure la structure sans les données.",
"Organization": "Organisation",
"NoWriteAccessToSite": "Vous navez pas d'accès en écriture à cet espace",
"Workspace": "Dossier",
"NoWriteAccessToWorkspace": "Vous navez pas accès en écriture à ce dossier"
},
"NotifyUI": {
"UpgradePlan": "Upgrade Plan",
"Renew": "Renouveler",
"GoToPersonalSite": "Accéder à votre espace personnel",
"ErrorCannotFindPersonalSite": "Espace personnel introuvable, désolé !",
"ReportProblem": "Signaler un problème",
"AskForHelp": "Demander de laide",
"Notifications": "Notifications",
"GiveFeedback": "Donnez votre avis",
"NoNotifications": "Aucune notification"
},
"OnBoardingPopups": {
"Finish": "Terminer",
"Next": "Suivant"
},
"WidgetTitle": {
"OverrideTitle": "Renommer la vue",
"DataTableName": "NOM DE LA TABLE",
"NewTableName": "Indiquer un nom de table",
"WidgetTitle": "TITRE DE LA VUE",
"Save": "Enregistrer",
"Cancel": "Annuler"
},
"WelcomeQuestions": {
"WelcomeToGrist": "Bienvenue sur Grist !",
"ProductDevelopment": "Développement de produit",
"FinanceAccounting": "Finance & comptabilité",
"MediaProduction": "Production de média",
"ITTechnology": "Technologie informatique",
"Marketing": "Marketing",
"Research": "Recherche",
"Sales": "Ventes",
"Education": "Éducation",
"HRManagement": "RH & Gestion",
"Other": "Autres",
"WhatBringsYouToGrist": "Pourquoi utilisez-vous Grist ? Aidez-nous à laméliorer.",
"TypeHere": "Écrire ici"
},
"OpenVideoTour": {
"YouTubeVideoPlayer": "Lecteur vidéo YouTube",
"GristVideoTour": "Visite guidée de Grist en vidéo",
"VideoTour": "Visite guidée en vidéo"
},
"Pages": {
"TableWillNoLongerBeVisible_one": "La donnée source ne sera plus visible",
"TableWillNoLongerBeVisible_other": "Les données source suivantes ne seront plus visibles",
"DeleteDataAndPage": "Supprimer les données source et la page.",
"Delete": "Supprimer"
},
"PageWidgetPicker": {
"BuildingWidget": "Vue {{- label}} en construction",
"SelectWidget": "Choisir la vue",
"SelectData": "Choisir les données source",
"GroupBy": "Grouper par",
"AddToPage": "Ajouter à la page"
},
"RightPanel": {
"Column_one": "Colonne",
"Column_other": "Colonnes",
"Field_one": "Champ",
"Field_other": "Champs",
"Series_one": "Séries",
"Series_other": "Séries",
"ColumnType": "TYPE DE COLONNE",
"Transform": "TRANSFORMER",
"Widget": "Vue",
"SortAndFilter": "Trier et Filtrer",
"Data": "Données source",
"DataTableName": "NOM DE LA TABLE",
"WidgetTitle": "TITRE DE LA VUE",
"ChangeWidget": "Modifier la vue",
"Theme": "Thème",
"RowStyleUpper": "ASPECT DE LA LIGNE",
"RowStyle": "Aspect de la ligne",
"ChartType": "TYPE DE GRAPHIQUE",
"Custom": "PERSONNALISÉ",
"Sort": "TRI",
"Filter": "FILTRE",
"DataTable": "DONNÉES SOURCE",
"SourceData": "DONNÉES SOURCE",
"GroupedBy": "GROUPER PAR",
"EditDataSelection": "Données source",
"Detach": "Detach",
"SelectBy": "SÉLECTIONNER PAR",
"SelectWidget": "Choisir la vue",
"SelectorFor": "SÉLECTEUR",
"Save": "Enregistrer",
"NoEditAccess": "Vous navez pas accès en écriture à ce document"
},
"RowContextMenu": {
"InsertRow": "Insérer une ligne",
"InsertRowAbove": "Insérer une ligne au-dessus",
"InsertRowBelow": "Insérer une ligne au-dessous",
"DuplicateRows_one": "Dupliquer la ligne",
"DuplicateRows_other": "Dupliquer les lignes",
"Delete": "Supprimer",
"CopyAnchorLink": "Copier l'ancre"
},
"sendToDrive": {
"SendingToGoogleDrive": "Envoi en cours vers Google Drive"
},
"ShareMenu": {
"BackToCurrent": "Retour à la version active",
"SaveDocument": "Enregistrer le document",
"SaveCopy": "Enregistrer une copie",
"Unsaved": "Non enregistré",
"DuplicateDocument": "Dupliquer le document",
"ManageUsers": "Gérer les utilisateurs",
"AccessDetails": "Informations daccès",
"CurrentVersion": "Version actuelle",
"Original": "Original",
"ReturnToTermToUse": "Revenir à {{termToUse}}",
"ReplaceTermToUse": "Remplacer {{termToUse}}...",
"CompareTermToUse": "Comparer avec {{termToUse}}",
"WorkOnCopy": "Travailler sur une copie",
"EditWithoutAffecting": "Éditer sans affecter l'original",
"ShowInFolder": "Afficher dans le répertoire",
"Download": "Télécharger",
"ExportCSV": "Exporter en CSV",
"ExportXLSX": "Exporter en XLSX",
"SendToGoogleDrive": "Envoyer vers Google Drive"
},
"SiteSwitcher": {
"SwitchSites": "Changer despace",
"CreateNewTeamSite": "Créer un nouvel espace d'équipe"
},
"ThemeConfig": {
"Appearance": "Apparence ",
"SyncWithOS": "Adapter l'apparence au système"
},
"Tools": {
"Tools": "OUTILS",
"AccessRules": "Permissions avancées",
"Data": "Données source",
"DocumentHistory": "Historique du document",
"ValidateData": "Valider les données",
"CodeView": "Vue du code",
"HowToTutorial": "Tutoriel pratique",
"DocumentTour": "Découvrir le document",
"DeleteDocumentTour": "Delete document tour?",
"Delete": "Supprimer",
"ViewingAsYourself": "Revenir à une vue en propre",
"RawData": "Données source"
},
"TopBar": {
"ManageTeam": "Gestion de l'équipe"
},
"TriggerFormulas": {
"AnyField": "N'importe quel champ",
"NewRecords": "Nouveaux enregistrements",
"ChangesTo": "Appliquer sur les modifications à :",
"RecordChanges": "Réappliquer en cas de modification de la ligne",
"CurrentField": "Champ actif ",
"DataCleaning": "(nettoyage des données)",
"ExceptFormulas": "(sauf les formules)",
"OK": "OK",
"Cancel": "Annuler",
"Close": "Fermer"
},
"VisibleFieldsConfig": {
"NoReorderHiddenField": "Les champs masqués ne peuvent pas être réordonnés",
"NoDropInHiddenField": "Impossible de mettre des éléments dans les champs cachés",
"SelectAll": "Sélectionner tout",
"Clear": "Effacer"
},
"ViewLayoutMenu": {
"DeleteRecord": "Supprimer la ligne",
"CopyAnchorLink": "Copier l'ancre",
"ShowRawData": "Afficher les données source",
"PrintWidget": "Imprimer la vue",
"DownloadCSV": "Télécharger en CSV",
"DownloadXLSX": "Télécharger en XLSX",
"EditCardLayout": "Disposition de la carte",
"WidgetOptions": "Options de la vue",
"AdvancedSortFilter": "Tri et filtre avancés",
"DataSelection": "Sélection des données",
"OpenConfiguration": "Ouvrir la configuration",
"DeleteWidget": "Supprimer la vue"
},
"ViewSectionMenu": {
"UpdateSortFilterSettings": "Mettre à jour le tri et le filtre",
"Save": "Enregistrer",
"Revert": "Restaurer",
"SortedBy": "Trier par",
"AddFilter": "Ajouter un filtre",
"ToggleFilterBar": "Toggle Filter Bar",
"FilteredBy": "Filtré par",
"Customized": "(personnalisé)",
"Modified": "(modifié)",
"Empty": "(vide)",
"CustomOptions": "Options personnalisées"
},
"aclui": {
"AccessRules": {
"Checking": "Vérification en cours…",
"Saved": "Enregistré",
"Invalid": "Invalide",
"Save": "Enregistrer",
"Reset": "Réinitialiser",
"AddTableRules": "Ajouter des règles pour la table",
"AddUserAttributes": "Ajouter des propriétés d'utilisateur",
"Users": "Utilisateurs",
"UserAttributes": "Propriétés de l'utilisateur",
"AttributeToLookUp": "Propriété d'appairage",
"LookupTable": "Table d'appairage",
"LookupColumn": "Colonne cible",
"DefaultRules": "Règles par défaut",
"Condition": "Condition",
"Permissions": "Permissions",
"RulesForTable": "Règles pour la table ",
"AddColumnRule": "Ajouter une règle de colonne",
"AddDefaultRule": "Ajouter une règle par défaut",
"DeleteTableRules": "Supprimer les règles de la table",
"SpecialRules": "Règles avancées",
"AccessRulesDescription": "Autoriser tout le monde à voir les permissions avancées.",
"FullCopiesDescription": "Permettre à tout le monde de copier le document entier ou de le voir en mode «bac à sable».\nUtile pour faire des exemples et des modèles, mais pas pour des données sensibles.",
"AccessRulesName": "Permission de voir les règles d'accès",
"FullCopies": "Permission d'accéder au document dans son intégralité si nécessaire",
"AttributeNamePlaceholder": "Nom de lattribut",
"Everyone": "Tout le monde",
"EveryoneElse": "Tous les autres",
"EnterCondition": "Entrer la condition"
},
"PermissionsWidget": {
"AllowAll": "Tout autoriser",
"DenyAll": "Tout refuser",
"ReadOnly": "Lecture seule"
}
},
"lib": {
"ACUserManager": {
"InviteNewMember": "Inviter un nouveau membre",
"EmailInputPlaceholder": "Entrer votre adresse e-mail",
"InviteEmail": "Nous allons envoyer une invitation à {{email}}"
}
},
"models": {
"AppModel": {
"TeamSiteSuspended": "Le site de cette équipe est suspendu. Les documents peuvent être lus, mais pas modifiés."
},
"DocPageModel": {
"ErrorAccessingDocument": "Erreur lors de l'accès au document",
"Reload": "Recharger",
"ReloadingOrRecoveryMode": "Vous pouvez essayer de recharger le document ou de le passer mode récupération. Le mode de récupération ouvre le document pour être entièrement accessible aux propriétaires, et inaccessible aux autres. Il désactive également les formules. [{{error}}]",
"AccessError_denied": "Désolé, laccès à ce document a été refusé. [{{error}}]",
"AccessError_recover": "Les propriétaires de documents peuvent tenter de récupérer le document. [{{error}}]",
"EnterRecoveryMode": "Passer en mode récupération",
"AddPage": "Ajouter une page",
"AddWidgetToPage": "Ajouter une vue à la page",
"AddEmptyTable": "Ajouter une table vide",
"NoEditAccess": "Vous navez pas accès en écriture à ce document"
},
"UserManagerModel": {
"Owner": "Propriétaire",
"Editor": "Éditeur",
"Viewer": "Lecture seule",
"NoDefaultAccess": "Pas daccès par défaut",
"InFull": "En entier",
"ViewAndEdit": "Afficher et éditer",
"ViewOnly": "Afficher seulement",
"None": "Aucun"
}
},
"ui2018": {
"breadcrumbs": {
"FiddleExplanation": "Vous pouvez faire des modifications, mais une nouvelle copie sera créée et ces modifications naffecteront pas le document original.",
"Snapshot": "instantané",
"Unsaved": "non enregistré",
"RecoveryMode": "mode récupération",
"Override": "remplacer",
"Fiddle": "bac à sable"
},
"ColorSelect": {
"DefaultCellStyle": "Style de cellule par défaut",
"Apply": "Appliquer",
"Cancel": "Annuler"
},
"menus": {
"SelectFields": "Sélectionner les champs",
"WorkspacesAvailableOnTeamPlans": "* Les dossiers sont disponibles avec une offre équipe. ",
"UpgradeNow": "Mettre à jour maintenant"
},
"modals": {
"Save": "Enregistrer",
"Cancel": "Annuler",
"Ok": "Ok"
},
"pages": {
"Rename": "Renommer",
"Remove": "Supprimer",
"DuplicatePage": "Dupliquer la page",
"NoEditAccess": "Vous navez pas accès en écriture à ce document"
},
"search": {
"SearchInDocument": "Rechercher dans le document",
"NoResults": "Aucun résultat",
"FindNext": "Rechercher suivant ",
"FindPrevious": "Rechercher le précédent "
}
},
"components": {
"ActionLog": {
"ActionLogFailed": "Impossible de charger le journal des actions",
"TableRemovedInAction": "La table {{tableId}} a été ensuite supprimée dans l'action #{{actionNum}}",
"RowRemovedInAction": "Cette ligne a été ensuite supprimée dans l'action {{action.actionNum}}",
"ColumnRemovedInAction": "La colonne {{colId}} a ensuite été supprimée dans l'action #{{action.actionNum}}"
},
"ChartView": {
"EachYFollowedByOne": "Each Y series is followed by a series for the length of error bars.",
"EachYFollowedByTwo": "Each Y series is followed by two series, for top and bottom error bars.",
"CreateSeparateSeries": "Créer une série séparée pour chaque valeur de la colonne sélectionnée.",
"PickColumn": "Choisir une colonne",
"SelectedNewGroupDataColumns": "selected new group data columns",
"ToggleChartAggregation": "Activer/désactiver l'agrégation des graphiques"
},
"CodeEditorPanel": {
"AccessDenied": "Accès refusé",
"CodeViewOnlyFullAccess.": "La vue code nest disponible que lorsque vous avez un accès complet au document."
},
"DataTables": {
"RawDataTables": "Données sources",
"ClickToCopy": "Cliquez ici pour copier",
"TableIDCopied": "Identifiant de table copié",
"DuplicateTable": "Dupliquer la page",
"NoEditAccess": "Vous navez pas accès en écriture à ce document",
"DeleteData": "Supprimer les données de {{formattedTableName}} et les supprimer de toutes les pages ?"
},
"DocumentUsage": {
"UsageStatisticsOnlyFullAccess": "Les statistiques d'utilisation ne sont disponibles qu'aux utilisateurs ayant un accès complet aux données du document.",
"TotalSize": "La taille totale de toutes les données de ce document, à l'exception des pièces jointes.",
"Updates": "Mise à jour toutes les 5 minutes.",
"AttachmentsSize": "Taille des pièces jointes",
"DataSize": "Taille des données",
"Usage": "Utilisation",
"LimitContactSiteOwner": "Contactez ladministrateur pour mettre à niveau le plan afin de relever les limites.",
"UpgradeLinkText": "débutez votre essai gratuit de 30 jours du forfait Pro.",
"ForHigherLimits": "Pour des limites plus élevées, ",
"StatusMessageApproachingLimit": "This document is {{- link}} free plan limits.",
"StatusMessageGracePeriod": "Limites du document {{- link}}.",
"StatusMessageGracePeriodElse": "Document limits {{- link}}. In {{gracePeriodDays}} days, this document will be read-only.",
"StatusMessageDeleteOnly": "This document {{- link}} free plan limits and is now read-only, but you can delete rows.",
"Rows": "Lignes"
},
"ViewConfigTab": {
"Save": "Enregistrer",
"Revert": "Restaurer",
"UpdateData": "Mettre à jour les données",
"UseChoicePosition": "Use choice position",
"NaturalSort": "Natural sort",
"EmptyValuesLast": "Valeurs vides en dernier",
"AddColumn": "Ajouter une colonne",
"UnmarkOnDemandTitle": "Unmark table On-Demand?",
"UnmarkOnDemandButton": "Unmark On-Demand",
"UnmarkOnDemandText": "If you unmark table {{- table}}' as On-Demand, its data will be loaded into the calculation engine and will be available for use in formulas. For a big table, this may greatly increase load times.{{- br}}{{-br}}Changing this setting will reload the document for all users.",
"MakeOnDemandTitle": "Rendre la table dynamique ?",
"MakeOnDemandButton": "Rendre dynamique",
"MakeOnDemandText": "If you make table {{table}} On-Demand, its data will no longer be loaded into the calculation engine and will not be available for use in formulas. It will remain available for viewing and editing.",
"AdvancedSettings": "Paramètres avancés",
"BigTablesMayBeMarked": "Big tables may be marked as \"on-demand\" to avoid loading them into the data engine.",
"UpdateFilterSettings": "Régler les filtres",
"AddFilter": "Ajouter un filtre",
"Form": "Formulaire",
"Compact": "Compact",
"Blocks": "Blocs",
"EditCardLayout": "Disposition de la carte",
"PluginColon": "Plugin: ",
"SectionColon": "Section: "
},
"Drafts": {
"UndoDiscard": "Annuler la suppression",
"RestoreLastEdit": "Restaurer la dernière modification"
},
"duplicatePage": {
"DoesNotCopyData": "Note that this does not copy data, but creates another view of the same data.",
"DuplicatePageName": "Dupliquer la page {{pageName}}"
},
"GristDoc": {
"ImportFromFile": "Importer depuis un fichier",
"AddedNewLinkedSection": "Added new linked section to view {{viewName}}",
"SavedLinkedSectionIn": "Saved linked section {{title}} in view {{name}}"
},
"Importer": {
"UpdateExistingRecords": "Update existing records",
"MergeRowsThatMatch": "Fusionner les lignes si ces champs correspondent:",
"SelectFieldsToMatch": "Sélectionner les champs pour l'appairage"
},
"PluginScreen": {
"ImportFailed": "Échec de l'importation: "
},
"RecordLayout": {
"UpdatingRecordLayout": "Updating record layout."
},
"RecordLayoutEditor": {
"AddField": "Ajouter un champ",
"CreateNewField": "Créer un nouveau champ",
"ShowField": "Afficher le champ {{- label}}",
"SaveLayout": "Enregistrer cette disposition",
"Cancel": "Annuler"
},
"RefSelect": {
"AddColumn": "Ajouter une colonne",
"NoColumnsAdd": "Aucune colonne à ajouter"
},
"SelectionSummary": {
"CopiedClipboard": "Copié dans le presse-papier"
},
"TypeTransformation": {
"Cancel": "Annuler",
"Preview": "Aperçu",
"UpdateFormula": "Mettre à jour la formule (Shift+Entrée)",
"Revise": "Réviser",
"Apply": "Appliquer"
},
"ValidationPanel": {
"RuleLength": "Règle {{length}}",
"UpdateFormula": "Mettre à jour la formule (Maj+Entrée)"
}
}
}

@ -0,0 +1,5 @@
{
"sendAppPage": {
"Loading": "Chargement"
}
}

@ -55,10 +55,13 @@ describe("Localization", function() {
describe("with Polish language file", function() {
let oldEnv: testUtils.EnvironmentSnapshot;
let tempLocale: string;
let existingLocales: string[];
before(async function() {
if (server.isExternalServer()) {
this.skip();
}
const gristConfig: any = await driver.executeScript("return window.gristConfig");
existingLocales = gristConfig.supportedLngs;
oldEnv = new testUtils.EnvironmentSnapshot();
// Add another language to the list of supported languages.
tempLocale = makeCopy();
@ -113,7 +116,7 @@ describe("Localization", function() {
await driver.navigate().refresh();
assert.equal(await driver.findWait('.test-welcome-title', 3000).getText(), 'TestMessage');
const gristConfig: any = await driver.executeScript("return window.gristConfig");
assert.deepEqual(gristConfig.supportedLngs, ['en', 'pl']);
assert.deepEqual(gristConfig.supportedLngs, [...existingLocales, 'pl']);
});
});
@ -144,8 +147,10 @@ describe("Localization", function() {
*/
function createLanguage(localesPath: string, code: string) {
for (const file of fs.readdirSync(localesPath)) {
const newFile = file.replace('en', code);
fs.copyFileSync(path.join(localesPath, file), path.join(localesPath, newFile));
if (file.startsWith('en.')) {
const newFile = file.replace('en', code);
fs.copyFileSync(path.join(localesPath, file), path.join(localesPath, newFile));
}
}
}

Loading…
Cancel
Save