mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Simple localization support and currency selector.
Summary: - Grist document has a associated "locale" setting that affects how currency is formatted. - Currency selector for number format. Test Plan: not done Reviewers: dsagal Reviewed By: dsagal Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D2977
This commit is contained in:
parent
e492dfdb22
commit
a6e08883e0
@ -19,7 +19,10 @@ function CopySelection(tableData, rowIds, fields, options) {
|
|||||||
this.colStyle = options.colStyle;
|
this.colStyle = options.colStyle;
|
||||||
this.columns = fields.map((f, i) => {
|
this.columns = fields.map((f, i) => {
|
||||||
let formatter = ValueFormatter.createFormatter(
|
let formatter = ValueFormatter.createFormatter(
|
||||||
f.displayColModel().type(), f.widgetOptionsJson());
|
f.displayColModel().type(),
|
||||||
|
f.widgetOptionsJson(),
|
||||||
|
f.documentSettings()
|
||||||
|
);
|
||||||
let _fmtGetter = tableData.getRowPropFunc(this.displayColIds[i]);
|
let _fmtGetter = tableData.getRowPropFunc(this.displayColIds[i]);
|
||||||
let _rawGetter = tableData.getRowPropFunc(this.colIds[i]);
|
let _rawGetter = tableData.getRowPropFunc(this.colIds[i]);
|
||||||
|
|
||||||
|
@ -158,7 +158,7 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
app.topAppModel.getUntrustedContentOrigin(), this.docComm, app.clientScope);
|
app.topAppModel.getUntrustedContentOrigin(), this.docComm, app.clientScope);
|
||||||
|
|
||||||
// Maintain the MetaRowModel for the global document info, including docId and peers.
|
// Maintain the MetaRowModel for the global document info, including docId and peers.
|
||||||
this.docInfo = this.docModel.docInfo.getRowModel(1);
|
this.docInfo = this.docModel.docInfoRow;
|
||||||
|
|
||||||
const defaultViewId = this.docInfo.newDefaultViewId;
|
const defaultViewId = this.docInfo.newDefaultViewId;
|
||||||
|
|
||||||
|
@ -267,7 +267,7 @@ class Searcher {
|
|||||||
|
|
||||||
this._fieldStepper.array = section.viewFields().peek();
|
this._fieldStepper.array = section.viewFields().peek();
|
||||||
this._fieldFormatters = this._fieldStepper.array.map(
|
this._fieldFormatters = this._fieldStepper.array.map(
|
||||||
f => createFormatter(f.displayColModel().type(), f.widgetOptionsJson()));
|
f => createFormatter(f.displayColModel().type(), f.widgetOptionsJson(), f.documentSettings()));
|
||||||
return tableModel;
|
return tableModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -35,7 +35,7 @@ export function addColTypeSuffix(type: string, column: ColumnRec, docModel: DocM
|
|||||||
return `${type}:${refTableId}`;
|
return `${type}:${refTableId}`;
|
||||||
}
|
}
|
||||||
case "DateTime":
|
case "DateTime":
|
||||||
return 'DateTime:' + docModel.docInfo.getRowModel(1).timezone();
|
return 'DateTime:' + docModel.docInfoRow.timezone();
|
||||||
default:
|
default:
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
|
@ -111,6 +111,8 @@ export class DocModel {
|
|||||||
public pages: MTM<PageRec> = this._metaTableModel("_grist_Pages", createPageRec);
|
public pages: MTM<PageRec> = this._metaTableModel("_grist_Pages", createPageRec);
|
||||||
public rules: MTM<ACLRuleRec> = this._metaTableModel("_grist_ACLRules", createACLRuleRec);
|
public rules: MTM<ACLRuleRec> = this._metaTableModel("_grist_ACLRules", createACLRuleRec);
|
||||||
|
|
||||||
|
public docInfoRow: DocInfoRec;
|
||||||
|
|
||||||
public allTables: KoArray<TableRec>;
|
public allTables: KoArray<TableRec>;
|
||||||
public allTableIds: KoArray<string>;
|
public allTableIds: KoArray<string>;
|
||||||
|
|
||||||
@ -135,6 +137,8 @@ export class DocModel {
|
|||||||
model.loadData();
|
model.loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.docInfoRow = this.docInfo.getRowModel(1);
|
||||||
|
|
||||||
// An observable array of user-visible tables, sorted by tableId, excluding summary tables.
|
// An observable array of user-visible tables, sorted by tableId, excluding summary tables.
|
||||||
// This is a publicly exposed member.
|
// This is a publicly exposed member.
|
||||||
this.allTables = createUserTablesArray(this.tables);
|
this.allTables = createUserTablesArray(this.tables);
|
||||||
|
@ -217,7 +217,7 @@ class FinderImpl implements IFinder {
|
|||||||
|
|
||||||
this._fieldStepper.array = section.viewFields().peek();
|
this._fieldStepper.array = section.viewFields().peek();
|
||||||
this._fieldFormatters = this._fieldStepper.array.map(
|
this._fieldFormatters = this._fieldStepper.array.map(
|
||||||
f => createFormatter(f.displayColModel().type(), f.widgetOptionsJson()));
|
f => createFormatter(f.displayColModel().type(), f.widgetOptionsJson(), f.documentSettings()));
|
||||||
return tableModel;
|
return tableModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,13 +1,18 @@
|
|||||||
import {DocModel, IRowModel} from 'app/client/models/DocModel';
|
import {DocModel, IRowModel} from 'app/client/models/DocModel';
|
||||||
|
import * as modelUtil from 'app/client/models/modelUtil';
|
||||||
|
import {jsonObservable} from 'app/client/models/modelUtil';
|
||||||
|
import {DocumentSettings} from 'app/common/DocumentSettings';
|
||||||
import * as ko from 'knockout';
|
import * as ko from 'knockout';
|
||||||
|
|
||||||
// The document-wide metadata. It's all contained in a single record with id=1.
|
// The document-wide metadata. It's all contained in a single record with id=1.
|
||||||
export interface DocInfoRec extends IRowModel<"_grist_DocInfo"> {
|
export interface DocInfoRec extends IRowModel<"_grist_DocInfo"> {
|
||||||
|
documentSettingsJson: modelUtil.SaveableObjObservable<DocumentSettings>
|
||||||
defaultViewId: ko.Computed<number>;
|
defaultViewId: ko.Computed<number>;
|
||||||
newDefaultViewId: ko.Computed<number>;
|
newDefaultViewId: ko.Computed<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDocInfoRec(this: DocInfoRec, docModel: DocModel): void {
|
export function createDocInfoRec(this: DocInfoRec, docModel: DocModel): void {
|
||||||
|
this.documentSettingsJson = jsonObservable(this.documentSettings);
|
||||||
this.defaultViewId = this.autoDispose(ko.pureComputed(() => {
|
this.defaultViewId = this.autoDispose(ko.pureComputed(() => {
|
||||||
const tab = docModel.allTabs.at(0);
|
const tab = docModel.allTabs.at(0);
|
||||||
return tab ? tab.viewRef() : 0;
|
return tab ? tab.viewRef() : 0;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import {ColumnRec, DocModel, IRowModel, refRecord, ViewSectionRec} from 'app/client/models/DocModel';
|
import {ColumnRec, DocModel, IRowModel, refRecord, ViewSectionRec} from 'app/client/models/DocModel';
|
||||||
import * as modelUtil from 'app/client/models/modelUtil';
|
import * as modelUtil from 'app/client/models/modelUtil';
|
||||||
import * as UserType from 'app/client/widgets/UserType';
|
import * as UserType from 'app/client/widgets/UserType';
|
||||||
|
import {DocumentSettings} from 'app/common/DocumentSettings';
|
||||||
import {BaseFormatter, createFormatter} from 'app/common/ValueFormatter';
|
import {BaseFormatter, createFormatter} from 'app/common/ValueFormatter';
|
||||||
import {Computed, fromKo} from 'grainjs';
|
import {Computed, fromKo} from 'grainjs';
|
||||||
import * as ko from 'knockout';
|
import * as ko from 'knockout';
|
||||||
@ -55,7 +56,6 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field"> {
|
|||||||
|
|
||||||
// Observable for the object with the current options, either for the field or for the column,
|
// Observable for the object with the current options, either for the field or for the column,
|
||||||
// which takes into account the default options for column's type.
|
// which takes into account the default options for column's type.
|
||||||
|
|
||||||
widgetOptionsJson: modelUtil.SaveableObjObservable<any>;
|
widgetOptionsJson: modelUtil.SaveableObjObservable<any>;
|
||||||
|
|
||||||
// Whether lines should wrap in a cell.
|
// Whether lines should wrap in a cell.
|
||||||
@ -73,6 +73,8 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field"> {
|
|||||||
textColor: modelUtil.KoSaveableObservable<string|undefined>;
|
textColor: modelUtil.KoSaveableObservable<string|undefined>;
|
||||||
fillColor: modelUtil.KoSaveableObservable<string>;
|
fillColor: modelUtil.KoSaveableObservable<string>;
|
||||||
|
|
||||||
|
documentSettings: ko.PureComputed<DocumentSettings>;
|
||||||
|
|
||||||
// Helper which adds/removes/updates field's displayCol to match the formula.
|
// Helper which adds/removes/updates field's displayCol to match the formula.
|
||||||
saveDisplayFormula(formula: string): Promise<void>|undefined;
|
saveDisplayFormula(formula: string): Promise<void>|undefined;
|
||||||
|
|
||||||
@ -167,8 +169,8 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
|
|||||||
this.createVisibleColFormatter = function() {
|
this.createVisibleColFormatter = function() {
|
||||||
const vcol = this.visibleColModel();
|
const vcol = this.visibleColModel();
|
||||||
return (vcol.getRowId() !== 0) ?
|
return (vcol.getRowId() !== 0) ?
|
||||||
createFormatter(vcol.type(), vcol.widgetOptionsJson()) :
|
createFormatter(vcol.type(), vcol.widgetOptionsJson(), this.documentSettings()) :
|
||||||
createFormatter(this.column().type(), this.widgetOptionsJson());
|
createFormatter(this.column().type(), this.widgetOptionsJson(), this.documentSettings());
|
||||||
};
|
};
|
||||||
|
|
||||||
// The widgetOptions to read and write: either the column's or the field's own.
|
// The widgetOptions to read and write: either the column's or the field's own.
|
||||||
@ -179,7 +181,6 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
|
|||||||
|
|
||||||
// Observable for the object with the current options, either for the field or for the column,
|
// Observable for the object with the current options, either for the field or for the column,
|
||||||
// which takes into account the default options for this column's type.
|
// which takes into account the default options for this column's type.
|
||||||
|
|
||||||
this.widgetOptionsJson = modelUtil.jsonObservable(this._widgetOptionsStr,
|
this.widgetOptionsJson = modelUtil.jsonObservable(this._widgetOptionsStr,
|
||||||
(opts: any) => UserType.mergeOptions(opts || {}, this.column().pureType()));
|
(opts: any) => UserType.mergeOptions(opts || {}, this.column().pureType()));
|
||||||
|
|
||||||
@ -211,4 +212,6 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
|
|||||||
read: () => fillColorProp(),
|
read: () => fillColorProp(),
|
||||||
write: (setter, val) => setter(fillColorProp, val.toUpperCase() === '#FFFFFF' ? '' : val),
|
write: (setter, val) => setter(fillColorProp, val.toUpperCase() === '#FFFFFF' ? '' : val),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.documentSettings = ko.pureComputed(() => docModel.docInfoRow.documentSettingsJson());
|
||||||
}
|
}
|
||||||
|
@ -2,15 +2,21 @@
|
|||||||
* This module export a component for editing some document settings consisting of the timezone,
|
* This module export a component for editing some document settings consisting of the timezone,
|
||||||
* (new settings to be added here ...).
|
* (new settings to be added here ...).
|
||||||
*/
|
*/
|
||||||
import { dom, styled } from 'grainjs';
|
import {dom, IDisposableOwner, styled} from 'grainjs';
|
||||||
import {Computed, Observable} from 'grainjs';
|
import {Computed, Observable} from 'grainjs';
|
||||||
|
|
||||||
|
|
||||||
|
import {ACSelectItem, buildACSelect} from "app/client/lib/ACSelect";
|
||||||
|
import {ACIndexImpl} from "app/client/lib/ACIndex";
|
||||||
import {loadMomentTimezone} from 'app/client/lib/imports';
|
import {loadMomentTimezone} from 'app/client/lib/imports';
|
||||||
import {DocInfoRec} from 'app/client/models/DocModel';
|
import {DocInfoRec} from 'app/client/models/DocModel';
|
||||||
import {DocPageModel} from 'app/client/models/DocPageModel';
|
import {DocPageModel} from 'app/client/models/DocPageModel';
|
||||||
import { vars } from 'app/client/ui2018/cssVars';
|
import {testId, vars} from 'app/client/ui2018/cssVars';
|
||||||
import {saveModal} from 'app/client/ui2018/modals';
|
import {saveModal} from 'app/client/ui2018/modals';
|
||||||
import {buildTZAutocomplete} from 'app/client/widgets/TZAutocomplete';
|
import {buildTZAutocomplete} from 'app/client/widgets/TZAutocomplete';
|
||||||
|
import {locales} from "app/common/Locales";
|
||||||
|
import {buildCurrencyPicker} from 'app/client/widgets/CurrencyPicker';
|
||||||
|
import * as LocaleCurrency from "locale-currency";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a simple saveModal for saving settings.
|
* Builds a simple saveModal for saving settings.
|
||||||
@ -18,23 +24,82 @@ import { buildTZAutocomplete } from 'app/client/widgets/TZAutocomplete';
|
|||||||
export async function showDocSettingsModal(docInfo: DocInfoRec, docPageModel: DocPageModel): Promise<void> {
|
export async function showDocSettingsModal(docInfo: DocInfoRec, docPageModel: DocPageModel): Promise<void> {
|
||||||
const moment = await loadMomentTimezone();
|
const moment = await loadMomentTimezone();
|
||||||
return saveModal((ctl, owner) => {
|
return saveModal((ctl, owner) => {
|
||||||
const timezone = Observable.create(owner, docInfo.timezone.peek());
|
const timezoneObs = Observable.create(owner, docInfo.timezone.peek());
|
||||||
|
|
||||||
|
const {locale, currency} = docInfo.documentSettingsJson.peek();
|
||||||
|
const localeObs = Observable.create(owner, locale);
|
||||||
|
const currencyObs = Observable.create(owner, currency);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: 'Document Settings',
|
title: 'Document Settings',
|
||||||
body: [
|
body: [
|
||||||
cssDataRow("This document's ID (for API use):"),
|
cssDataRow("This document's ID (for API use):"),
|
||||||
cssDataRow(dom('tt', docPageModel.currentDocId.get())),
|
cssDataRow(dom('tt', docPageModel.currentDocId.get())),
|
||||||
cssDataRow('Time Zone:'),
|
cssDataRow('Time Zone:'),
|
||||||
cssDataRow(dom.create(buildTZAutocomplete, moment, timezone, (val) => timezone.set(val))),
|
cssDataRow(dom.create(buildTZAutocomplete, moment, timezoneObs, (val) => timezoneObs.set(val))),
|
||||||
|
cssDataRow('Locale:'),
|
||||||
|
cssDataRow(dom.create(buildLocaleSelect, localeObs)),
|
||||||
|
cssDataRow('Currency:'),
|
||||||
|
cssDataRow(dom.domComputed(localeObs, (l) =>
|
||||||
|
dom.create(buildCurrencyPicker, currencyObs, (val) => currencyObs.set(val),
|
||||||
|
{defaultCurrencyLabel: `Local currency (${LocaleCurrency.getCurrency(l)})`})
|
||||||
|
)),
|
||||||
],
|
],
|
||||||
// At this point, we only need to worry about saving this one setting.
|
saveFunc: () => docInfo.updateColValues({
|
||||||
saveFunc: () => docInfo.timezone.saveOnly(timezone.get()),
|
timezone: timezoneObs.get(),
|
||||||
// If timezone hasn't changed, there is nothing to save, so disable the Save button.
|
documentSettings: JSON.stringify({
|
||||||
saveDisabled: Computed.create(owner, (use) => use(timezone) === docInfo.timezone.peek()),
|
...docInfo.documentSettingsJson.peek(),
|
||||||
|
locale: localeObs.get(),
|
||||||
|
currency: currencyObs.get()
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
// If timezone, locale, or currency hasn't changed, disable the Save button.
|
||||||
|
saveDisabled: Computed.create(owner,
|
||||||
|
(use) => {
|
||||||
|
const docSettings = docInfo.documentSettingsJson.peek();
|
||||||
|
return (
|
||||||
|
use(timezoneObs) === docInfo.timezone.peek() &&
|
||||||
|
use(localeObs) === docSettings.locale &&
|
||||||
|
use(currencyObs) === docSettings.currency
|
||||||
|
);
|
||||||
|
})
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type LocaleItem = ACSelectItem & {locale?: string};
|
||||||
|
|
||||||
|
function buildLocaleSelect(
|
||||||
|
owner: IDisposableOwner,
|
||||||
|
locale: Observable<string>
|
||||||
|
) {
|
||||||
|
const localeList: LocaleItem[] = locales.map(l => ({
|
||||||
|
value: l.name, // Use name as a value, we will translate the name into the locale on save
|
||||||
|
label: l.name,
|
||||||
|
locale: l.code,
|
||||||
|
cleanText: l.name.trim().toLowerCase(),
|
||||||
|
}));
|
||||||
|
const acIndex = new ACIndexImpl<LocaleItem>(localeList, 200, true);
|
||||||
|
// AC select will show the value (in this case locale) not a label when something is selected.
|
||||||
|
// To show the label - create another observable that will be in sync with the value, but
|
||||||
|
// will contain text.
|
||||||
|
const localeCode = locale.get();
|
||||||
|
const localeName = locales.find(l => l.code === localeCode)?.name || localeCode;
|
||||||
|
const textObs = Observable.create(owner, localeName);
|
||||||
|
return buildACSelect(owner,
|
||||||
|
{
|
||||||
|
acIndex, valueObs: textObs,
|
||||||
|
save(value, item: LocaleItem | undefined) {
|
||||||
|
if (!item) { throw new Error("Invalid locale"); }
|
||||||
|
textObs.set(value);
|
||||||
|
locale.set(item.locale!);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
testId("locale-autocomplete")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// This matches the style used in showProfileModal in app/client/ui/AccountWidget.
|
// This matches the style used in showProfileModal in app/client/ui/AccountWidget.
|
||||||
const cssDataRow = styled('div', `
|
const cssDataRow = styled('div', `
|
||||||
margin: 16px 0px;
|
margin: 16px 0px;
|
||||||
|
@ -17,9 +17,8 @@ function AbstractWidget(field, opts = {}) {
|
|||||||
this.options = field.widgetOptionsJson;
|
this.options = field.widgetOptionsJson;
|
||||||
const {defaultTextColor = '#000000'} = opts;
|
const {defaultTextColor = '#000000'} = opts;
|
||||||
|
|
||||||
this.valueFormatter = this.autoDispose(ko.computed(() => {
|
this.valueFormatter = this.autoDispose(ko.computed(() =>
|
||||||
return ValueFormatter.createFormatter(field.displayColModel().type(), this.options());
|
ValueFormatter.createFormatter(field.displayColModel().type(), this.options(), field.documentSettings())));
|
||||||
}));
|
|
||||||
|
|
||||||
this.textColor = Computed.create(this, (use) => use(this.field.textColor) || defaultTextColor)
|
this.textColor = Computed.create(this, (use) => use(this.field.textColor) || defaultTextColor)
|
||||||
.onWrite((val) => this.field.textColor(val === defaultTextColor ? undefined : val));
|
.onWrite((val) => this.field.textColor(val === defaultTextColor ? undefined : val));
|
||||||
|
49
app/client/widgets/CurrencyPicker.ts
Normal file
49
app/client/widgets/CurrencyPicker.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import {ACSelectItem, buildACSelect} from "app/client/lib/ACSelect";
|
||||||
|
import {Computed, IDisposableOwner, Observable} from "grainjs";
|
||||||
|
import {ACIndexImpl} from "app/client/lib/ACIndex";
|
||||||
|
import {testId} from 'app/client/ui2018/cssVars';
|
||||||
|
import {currencies} from 'app/common/Locales';
|
||||||
|
|
||||||
|
interface CurrencyPickerOptions {
|
||||||
|
// The label to use in the select menu for the default option.
|
||||||
|
defaultCurrencyLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCurrencyPicker(
|
||||||
|
owner: IDisposableOwner,
|
||||||
|
currency: Observable<string|undefined>,
|
||||||
|
onSave: (value: string|undefined) => void,
|
||||||
|
{defaultCurrencyLabel}: CurrencyPickerOptions
|
||||||
|
) {
|
||||||
|
const currencyItems: ACSelectItem[] = currencies
|
||||||
|
.map(item => ({
|
||||||
|
value: item.code,
|
||||||
|
label: `${item.code} ${item.name}`,
|
||||||
|
cleanText: `${item.code} ${item.name}`.trim().toLowerCase(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add default currency label option to the very front.
|
||||||
|
currencyItems.unshift({
|
||||||
|
label : defaultCurrencyLabel,
|
||||||
|
value : defaultCurrencyLabel,
|
||||||
|
cleanText : defaultCurrencyLabel.toLowerCase(),
|
||||||
|
});
|
||||||
|
// Create a computed that will display 'Local currency' as a value and label
|
||||||
|
// when `currency` is undefined.
|
||||||
|
const valueObs = Computed.create(owner, (use) => use(currency) || defaultCurrencyLabel);
|
||||||
|
const acIndex = new ACIndexImpl<ACSelectItem>(currencyItems, 200, true);
|
||||||
|
return buildACSelect(owner,
|
||||||
|
{
|
||||||
|
acIndex, valueObs,
|
||||||
|
save(_, item: ACSelectItem | undefined) {
|
||||||
|
// Save only if we have found a match
|
||||||
|
if (!item) {
|
||||||
|
throw new Error("Invalid currency");
|
||||||
|
}
|
||||||
|
// For default value, return undefined to use default currency for document.
|
||||||
|
onSave(item.value === defaultCurrencyLabel ? undefined : item.value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
testId("currency-autocomplete")
|
||||||
|
);
|
||||||
|
}
|
@ -30,7 +30,9 @@ Object.defineProperty(Datepicker.prototype, 'isInput', {
|
|||||||
function DateEditor(options) {
|
function DateEditor(options) {
|
||||||
// A string that is always `UTC` in the DateEditor, eases DateTimeEditor inheritance.
|
// A string that is always `UTC` in the DateEditor, eases DateTimeEditor inheritance.
|
||||||
this.timezone = options.timezone || 'UTC';
|
this.timezone = options.timezone || 'UTC';
|
||||||
|
|
||||||
this.dateFormat = options.field.widgetOptionsJson.peek().dateFormat;
|
this.dateFormat = options.field.widgetOptionsJson.peek().dateFormat;
|
||||||
|
this.locale = options.field.documentSettings.peek().locale;
|
||||||
|
|
||||||
// Strip moment format string to remove markers unsupported by the datepicker.
|
// Strip moment format string to remove markers unsupported by the datepicker.
|
||||||
this.safeFormat = DateEditor.parseMomentToSafe(this.dateFormat);
|
this.safeFormat = DateEditor.parseMomentToSafe(this.dateFormat);
|
||||||
@ -68,6 +70,10 @@ function DateEditor(options) {
|
|||||||
todayHighlight: true,
|
todayHighlight: true,
|
||||||
todayBtn: 'linked',
|
todayBtn: 'linked',
|
||||||
assumeNearbyYear: TWO_DIGIT_YEAR_THRESHOLD,
|
assumeNearbyYear: TWO_DIGIT_YEAR_THRESHOLD,
|
||||||
|
// Datepicker supports most of the languages. They just need to be included in the bundle
|
||||||
|
// or by script tag, i.e.
|
||||||
|
// <script src="bootstrap-datepicker/dist/locales/bootstrap-datepicker.pl.min.js"></script>
|
||||||
|
language : this.getLanguage(),
|
||||||
// Convert the stripped format string to one suitable for the datepicker.
|
// Convert the stripped format string to one suitable for the datepicker.
|
||||||
format: DateEditor.parseSafeToCalendar(this.safeFormat)
|
format: DateEditor.parseSafeToCalendar(this.safeFormat)
|
||||||
});
|
});
|
||||||
@ -168,5 +174,13 @@ DateEditor.parseSafeToCalendar = function(sFormat) {
|
|||||||
return sFormat.replace(/\bdddd\b/g, 'DD'); // dddd -> DD
|
return sFormat.replace(/\bdddd\b/g, 'DD'); // dddd -> DD
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Gets the language based on the current locale.
|
||||||
|
DateEditor.prototype.getLanguage = function() {
|
||||||
|
// this requires a polyfill, i.e. https://www.npmjs.com/package/@formatjs/intl-locale
|
||||||
|
// more info about ts: https://github.com/microsoft/TypeScript/issues/37326
|
||||||
|
// return new Intl.Locale(locale).language;
|
||||||
|
return this.locale.substr(0, this.locale.indexOf("-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
module.exports = DateEditor;
|
module.exports = DateEditor;
|
||||||
|
@ -47,7 +47,7 @@ export abstract class NewAbstractWidget extends Disposable {
|
|||||||
// Note that its easier to create a knockout computed from the several knockout observables,
|
// Note that its easier to create a knockout computed from the several knockout observables,
|
||||||
// but then we turn it into a grainjs observable.
|
// but then we turn it into a grainjs observable.
|
||||||
const formatter = this.autoDispose(ko.computed(() =>
|
const formatter = this.autoDispose(ko.computed(() =>
|
||||||
createFormatter(field.displayColModel().type(), this.options())));
|
createFormatter(field.displayColModel().type(), this.options(), field.documentSettings())));
|
||||||
this.valueFormatter = fromKo(formatter);
|
this.valueFormatter = fromKo(formatter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,6 +11,8 @@ import {NTextBox} from 'app/client/widgets/NTextBox';
|
|||||||
import {clamp} from 'app/common/gutil';
|
import {clamp} from 'app/common/gutil';
|
||||||
import {buildNumberFormat, NumberFormatOptions, NumMode, NumSign} from 'app/common/NumberFormat';
|
import {buildNumberFormat, NumberFormatOptions, NumMode, NumSign} from 'app/common/NumberFormat';
|
||||||
import {Computed, dom, DomContents, DomElementArg, fromKo, MultiHolder, Observable, styled} from 'grainjs';
|
import {Computed, dom, DomContents, DomElementArg, fromKo, MultiHolder, Observable, styled} from 'grainjs';
|
||||||
|
import {buildCurrencyPicker} from "app/client/widgets/CurrencyPicker";
|
||||||
|
import * as LocaleCurrency from "locale-currency";
|
||||||
|
|
||||||
|
|
||||||
const modeOptions: Array<ISelectorOption<NumMode>> = [
|
const modeOptions: Array<ISelectorOption<NumMode>> = [
|
||||||
@ -37,17 +39,25 @@ export class NumericTextBox extends NTextBox {
|
|||||||
const holder = new MultiHolder();
|
const holder = new MultiHolder();
|
||||||
|
|
||||||
// Resolved options, to show default min/max decimals, which change depending on numMode.
|
// Resolved options, to show default min/max decimals, which change depending on numMode.
|
||||||
const resolved = Computed.create<Intl.ResolvedNumberFormatOptions>(holder, (use) =>
|
const resolved = Computed.create<Intl.ResolvedNumberFormatOptions>(holder, (use) => {
|
||||||
buildNumberFormat({numMode: use(this.options).numMode}).resolvedOptions());
|
const {numMode} = use(this.options);
|
||||||
|
const docSettings = use(this.field.documentSettings);
|
||||||
|
return buildNumberFormat({numMode}, docSettings).resolvedOptions();
|
||||||
|
});
|
||||||
|
|
||||||
// Prepare various observables that reflect the options in the UI.
|
// Prepare various observables that reflect the options in the UI.
|
||||||
const options = fromKo(this.options);
|
const options = fromKo(this.options);
|
||||||
const numMode = Computed.create(holder, options, (use, opts) => opts.numMode || null);
|
const docSettings = fromKo(this.field.documentSettings);
|
||||||
|
const numMode = Computed.create(holder, options, (use, opts) => (opts.numMode as NumMode) || null);
|
||||||
const numSign = Computed.create(holder, options, (use, opts) => opts.numSign || null);
|
const numSign = Computed.create(holder, options, (use, opts) => opts.numSign || null);
|
||||||
|
const currency = Computed.create(holder, options, (use, opts) => opts.currency);
|
||||||
const minDecimals = Computed.create(holder, options, (use, opts) => numberOrDefault(opts.decimals, ''));
|
const minDecimals = Computed.create(holder, options, (use, opts) => numberOrDefault(opts.decimals, ''));
|
||||||
const maxDecimals = Computed.create(holder, options, (use, opts) => numberOrDefault(opts.maxDecimals, ''));
|
const maxDecimals = Computed.create(holder, options, (use, opts) => numberOrDefault(opts.maxDecimals, ''));
|
||||||
const defaultMin = Computed.create(holder, resolved, (use, res) => res.minimumFractionDigits);
|
const defaultMin = Computed.create(holder, resolved, (use, res) => res.minimumFractionDigits);
|
||||||
const defaultMax = Computed.create(holder, resolved, (use, res) => res.maximumFractionDigits);
|
const defaultMax = Computed.create(holder, resolved, (use, res) => res.maximumFractionDigits);
|
||||||
|
const docCurrency = Computed.create(holder, docSettings, (use, settings) =>
|
||||||
|
settings.currency ?? LocaleCurrency.getCurrency(settings.locale)
|
||||||
|
);
|
||||||
|
|
||||||
// Save a value as the given property in this.options() observable. Set it, save, and revert
|
// Save a value as the given property in this.options() observable. Set it, save, and revert
|
||||||
// on save error. This is similar to what modelUtil.setSaveValue() does.
|
// on save error. This is similar to what modelUtil.setSaveValue() does.
|
||||||
@ -66,6 +76,7 @@ export class NumericTextBox extends NTextBox {
|
|||||||
// Mode and Sign behave as toggles: clicking a selected on deselects it.
|
// Mode and Sign behave as toggles: clicking a selected on deselects it.
|
||||||
const setMode = (val: NumMode) => setSave('numMode', val !== numMode.get() ? val : undefined);
|
const setMode = (val: NumMode) => setSave('numMode', val !== numMode.get() ? val : undefined);
|
||||||
const setSign = (val: NumSign) => setSave('numSign', val !== numSign.get() ? val : undefined);
|
const setSign = (val: NumSign) => setSave('numSign', val !== numSign.get() ? val : undefined);
|
||||||
|
const setCurrency = (val: string|undefined) => setSave('currency', val);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
super.buildConfigDom(),
|
super.buildConfigDom(),
|
||||||
@ -75,6 +86,16 @@ export class NumericTextBox extends NTextBox {
|
|||||||
makeButtonSelect(numMode, modeOptions, setMode, {}, cssModeSelect.cls(''), testId('numeric-mode')),
|
makeButtonSelect(numMode, modeOptions, setMode, {}, cssModeSelect.cls(''), testId('numeric-mode')),
|
||||||
makeButtonSelect(numSign, signOptions, setSign, {}, cssSignSelect.cls(''), testId('numeric-sign')),
|
makeButtonSelect(numSign, signOptions, setSign, {}, cssSignSelect.cls(''), testId('numeric-sign')),
|
||||||
),
|
),
|
||||||
|
dom.maybe((use) => use(numMode) === 'currency', () => [
|
||||||
|
cssLabel('Currency'),
|
||||||
|
cssRow(
|
||||||
|
dom.domComputed(docCurrency, (defaultCurrency) =>
|
||||||
|
buildCurrencyPicker(holder, currency, setCurrency,
|
||||||
|
{defaultCurrencyLabel: `Default currency (${defaultCurrency})`})
|
||||||
|
),
|
||||||
|
testId("numeric-currency")
|
||||||
|
)
|
||||||
|
]),
|
||||||
cssLabel('Decimals'),
|
cssLabel('Decimals'),
|
||||||
cssRow(
|
cssRow(
|
||||||
decimals('min', minDecimals, defaultMin, setMinDecimals, testId('numeric-min-decimals')),
|
decimals('min', minDecimals, defaultMin, setMinDecimals, testId('numeric-min-decimals')),
|
||||||
@ -84,12 +105,13 @@ export class NumericTextBox extends NTextBox {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function numberOrDefault<T>(value: unknown, def: T): number | T {
|
function numberOrDefault<T>(value: unknown, def: T): number | T {
|
||||||
return typeof value !== 'undefined' ? Number(value) : def;
|
return typeof value !== 'undefined' ? Number(value) : def;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper used by setSave() above to reset some properties when switching modes.
|
// Helper used by setSave() above to reset some properties when switching modes.
|
||||||
function updateOptions(prop: keyof NumberFormatOptions, value: unknown): NumberFormatOptions {
|
function updateOptions(prop: keyof NumberFormatOptions, value: unknown): Partial<NumberFormatOptions> {
|
||||||
// Reset the numSign to default when toggling mode to percent or scientific.
|
// Reset the numSign to default when toggling mode to percent or scientific.
|
||||||
if (prop === 'numMode' && (!value || value === 'scientific' || value === 'percent')) {
|
if (prop === 'numMode' && (!value || value === 'scientific' || value === 'percent')) {
|
||||||
return {numSign: undefined};
|
return {numSign: undefined};
|
||||||
|
@ -14,8 +14,11 @@ export class Spinner extends NumericTextBox {
|
|||||||
|
|
||||||
constructor(field: ViewFieldRec) {
|
constructor(field: ViewFieldRec) {
|
||||||
super(field);
|
super(field);
|
||||||
const resolved = this.autoDispose(ko.computed(() =>
|
const resolved = this.autoDispose(ko.computed(() => {
|
||||||
buildNumberFormat({numMode: this.options().numMode}).resolvedOptions()));
|
const {numMode} = this.options();
|
||||||
|
const docSettings = this.field.documentSettings();
|
||||||
|
return buildNumberFormat({numMode}, docSettings).resolvedOptions();
|
||||||
|
}));
|
||||||
this._stepSize = this.autoDispose(ko.computed(() => {
|
this._stepSize = this.autoDispose(ko.computed(() => {
|
||||||
const extraScaling = (this.options().numMode === 'percent') ? 2 : 0;
|
const extraScaling = (this.options().numMode === 'percent') ? 2 : 0;
|
||||||
return Math.pow(10, -(this.options().decimals || resolved().minimumFractionDigits) - extraScaling);
|
return Math.pow(10, -(this.options().decimals || resolved().minimumFractionDigits) - extraScaling);
|
||||||
|
@ -4,4 +4,6 @@
|
|||||||
export interface BrowserSettings {
|
export interface BrowserSettings {
|
||||||
// The browser's timezone, must be one of `momet.tz.names()`.
|
// The browser's timezone, must be one of `momet.tz.names()`.
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
|
// The browser's locale, should be read from Accept-Language header.
|
||||||
|
locale?: string;
|
||||||
}
|
}
|
||||||
|
4
app/common/DocumentSettings.ts
Normal file
4
app/common/DocumentSettings.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export interface DocumentSettings {
|
||||||
|
locale: string;
|
||||||
|
currency?: string;
|
||||||
|
}
|
53
app/common/Locales.ts
Normal file
53
app/common/Locales.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import LocaleCurrency = require('locale-currency/map');
|
||||||
|
import {nativeCompare} from 'app/common/gutil';
|
||||||
|
|
||||||
|
const localeCodes = [
|
||||||
|
"es-AR", "hy-AM", "en-AU", "az-AZ", "be-BY", "quz-BO", "pt-BR",
|
||||||
|
"bg-BG", "en-CA", "arn-CL", "es-CO", "hr-HR", "cs-CZ", "da-DK",
|
||||||
|
"es-EC", "ar-EG", "fi-FI", "fr-FR", "ka-GE", "de-DE", "el-GR", "en-HK",
|
||||||
|
"hu-HU", "hi-IN", "id-ID", "ga-IE", "ar-IL", "it-IT", "ja-JP", "kk-KZ",
|
||||||
|
"lv-LV", "lt-LT", "es-MX", "mn-MN", "my-MM", "nl-NL", "nb-NO",
|
||||||
|
"es-PY", "ceb-PH", "pl-PL", "pt-PT", "ro-RO", "ru-RU", "sr-RS",
|
||||||
|
"sk-SK", "sl-SI", "ko-KR", "es-ES", "sv-SE", "de-CH", "zh-TW", "th-TH",
|
||||||
|
"tr-TR", "uk-UA", "en-GB", "en-US", "es-UY", "es-VE", "vi-VN"
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface Locale {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export let locales: Readonly<Locale[]>;
|
||||||
|
|
||||||
|
// Intl.DisplayNames is only supported on recent browsers, so proceed with caution.
|
||||||
|
try {
|
||||||
|
const localeDisplay = new Intl.DisplayNames('en', {type: 'region'});
|
||||||
|
locales = localeCodes.map(code => {
|
||||||
|
return { name: localeDisplay.of(new Intl.Locale(code).region), code };
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Fall back to using the locale code as the display name.
|
||||||
|
locales = localeCodes.map(code => ({ name: code, code }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Currency {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export let currencies: Readonly<Currency[]>;
|
||||||
|
|
||||||
|
// Intl.DisplayNames is only supported on recent browsers, so proceed with caution.
|
||||||
|
try {
|
||||||
|
const currencyDisplay = new Intl.DisplayNames('en', {type: 'currency'});
|
||||||
|
currencies = [...new Set(Object.values(LocaleCurrency))].map(code => {
|
||||||
|
return { name: currencyDisplay.of(code), code };
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Fall back to using the currency code as the display name.
|
||||||
|
currencies = [...new Set(Object.values(LocaleCurrency))].map(code => {
|
||||||
|
return { name: code, code };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
currencies = [...currencies].sort((a, b) => nativeCompare(a.code, b.code));
|
@ -19,26 +19,25 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {clamp} from 'app/common/gutil';
|
import {clamp} from 'app/common/gutil';
|
||||||
|
import * as LocaleCurrency from "locale-currency";
|
||||||
|
import {FormatOptions} from 'app/common/ValueFormatter';
|
||||||
|
import {DocumentSettings} from 'app/common/DocumentSettings';
|
||||||
|
|
||||||
// Options for number formatting.
|
// Options for number formatting.
|
||||||
export type NumMode = 'currency' | 'decimal' | 'percent' | 'scientific';
|
export type NumMode = 'currency' | 'decimal' | 'percent' | 'scientific';
|
||||||
export type NumSign = 'parens';
|
export type NumSign = 'parens';
|
||||||
|
|
||||||
// TODO: In the future, locale should be a value associated with the document or the user.
|
export interface NumberFormatOptions extends FormatOptions {
|
||||||
const defaultLocale = 'en-US';
|
|
||||||
|
|
||||||
// TODO: The currency to use for currency formatting could be made configurable.
|
|
||||||
const defaultCurrency = 'USD';
|
|
||||||
|
|
||||||
export interface NumberFormatOptions {
|
|
||||||
numMode?: NumMode;
|
numMode?: NumMode;
|
||||||
numSign?: NumSign;
|
numSign?: NumSign;
|
||||||
decimals?: number; // aka minimum fraction digits
|
decimals?: number; // aka minimum fraction digits
|
||||||
maxDecimals?: number;
|
maxDecimals?: number;
|
||||||
|
currency?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildNumberFormat(options: NumberFormatOptions): Intl.NumberFormat {
|
export function buildNumberFormat(options: NumberFormatOptions, docSettings: DocumentSettings): Intl.NumberFormat {
|
||||||
const nfOptions: Intl.NumberFormatOptions = parseNumMode(options.numMode);
|
const currency = options.currency || docSettings.currency || LocaleCurrency.getCurrency(docSettings.locale);
|
||||||
|
const nfOptions: Intl.NumberFormatOptions = parseNumMode(options.numMode, currency);
|
||||||
|
|
||||||
// numSign is implemented outside of Intl.NumberFormat since the latter's similar 'currencySign'
|
// numSign is implemented outside of Intl.NumberFormat since the latter's similar 'currencySign'
|
||||||
// option is not well-supported, and doesn't apply to non-currency formats.
|
// option is not well-supported, and doesn't apply to non-currency formats.
|
||||||
@ -50,7 +49,7 @@ export function buildNumberFormat(options: NumberFormatOptions): Intl.NumberForm
|
|||||||
|
|
||||||
// maximumFractionDigits must not be less than the minimum, so we need to know the minimum
|
// maximumFractionDigits must not be less than the minimum, so we need to know the minimum
|
||||||
// implied by numMode.
|
// implied by numMode.
|
||||||
const tmp = new Intl.NumberFormat(defaultLocale, nfOptions).resolvedOptions();
|
const tmp = new Intl.NumberFormat(docSettings.locale, nfOptions).resolvedOptions();
|
||||||
|
|
||||||
if (options.maxDecimals !== undefined) {
|
if (options.maxDecimals !== undefined) {
|
||||||
// Should be at least 0 and at least minimumFractionDigits.
|
// Should be at least 0 and at least minimumFractionDigits.
|
||||||
@ -60,12 +59,12 @@ export function buildNumberFormat(options: NumberFormatOptions): Intl.NumberForm
|
|||||||
nfOptions.maximumFractionDigits = clamp(10, tmp.minimumFractionDigits || 0, 20);
|
nfOptions.maximumFractionDigits = clamp(10, tmp.minimumFractionDigits || 0, 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Intl.NumberFormat(defaultLocale, nfOptions);
|
return new Intl.NumberFormat(docSettings.locale, nfOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseNumMode(numMode?: NumMode): Intl.NumberFormatOptions {
|
function parseNumMode(numMode?: NumMode, currency?: string): Intl.NumberFormatOptions {
|
||||||
switch (numMode) {
|
switch (numMode) {
|
||||||
case 'currency': return {style: 'currency', currency: defaultCurrency};
|
case 'currency': return {style: 'currency', currency, currencyDisplay: 'narrowSymbol' };
|
||||||
case 'decimal': return {useGrouping: true};
|
case 'decimal': return {useGrouping: true};
|
||||||
case 'percent': return {style: 'percent'};
|
case 'percent': return {style: 'percent'};
|
||||||
// TODO 'notation' option (and therefore numMode 'scientific') works on recent Firefox and
|
// TODO 'notation' option (and therefore numMode 'scientific') works on recent Firefox and
|
||||||
|
@ -7,9 +7,14 @@ import {buildNumberFormat, NumberFormatOptions} from 'app/common/NumberFormat';
|
|||||||
import {decodeObject, GristDateTime} from 'app/plugin/objtypes';
|
import {decodeObject, GristDateTime} from 'app/plugin/objtypes';
|
||||||
import isPlainObject = require('lodash/isPlainObject');
|
import isPlainObject = require('lodash/isPlainObject');
|
||||||
import * as moment from 'moment-timezone';
|
import * as moment from 'moment-timezone';
|
||||||
|
import {DocumentSettings} from 'app/common/DocumentSettings';
|
||||||
|
|
||||||
export {PENDING_DATA_PLACEHOLDER} from 'app/plugin/objtypes';
|
export {PENDING_DATA_PLACEHOLDER} from 'app/plugin/objtypes';
|
||||||
|
|
||||||
|
export interface FormatOptions {
|
||||||
|
[option: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a value of any type generically (with no type-specific options).
|
* Formats a value of any type generically (with no type-specific options).
|
||||||
*/
|
*/
|
||||||
@ -46,7 +51,7 @@ export type IsRightTypeFunc = (value: CellValue) => boolean;
|
|||||||
export class BaseFormatter {
|
export class BaseFormatter {
|
||||||
public readonly isRightType: IsRightTypeFunc;
|
public readonly isRightType: IsRightTypeFunc;
|
||||||
|
|
||||||
constructor(public type: string, public opts: object) {
|
constructor(public type: string, public widgetOpts: object, public docSettings: DocumentSettings) {
|
||||||
this.isRightType = gristTypes.isRightType(gristTypes.extractTypeFromColType(type)) ||
|
this.isRightType = gristTypes.isRightType(gristTypes.extractTypeFromColType(type)) ||
|
||||||
gristTypes.isRightType('Any')!;
|
gristTypes.isRightType('Any')!;
|
||||||
}
|
}
|
||||||
@ -78,9 +83,9 @@ export class NumericFormatter extends BaseFormatter {
|
|||||||
private _numFormat: Intl.NumberFormat;
|
private _numFormat: Intl.NumberFormat;
|
||||||
private _formatter: (val: number) => string;
|
private _formatter: (val: number) => string;
|
||||||
|
|
||||||
constructor(type: string, options: NumberFormatOptions) {
|
constructor(type: string, options: NumberFormatOptions, docSettings: DocumentSettings) {
|
||||||
super(type, options);
|
super(type, options, docSettings);
|
||||||
this._numFormat = buildNumberFormat(options);
|
this._numFormat = buildNumberFormat(options, docSettings);
|
||||||
this._formatter = (options.numSign === 'parens') ? this._formatParens : this._formatPlain;
|
this._formatter = (options.numSign === 'parens') ? this._formatParens : this._formatPlain;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,18 +106,22 @@ export class NumericFormatter extends BaseFormatter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class IntFormatter extends NumericFormatter {
|
class IntFormatter extends NumericFormatter {
|
||||||
constructor(type: string, opts: object) {
|
constructor(type: string, opts: FormatOptions, docSettings: DocumentSettings) {
|
||||||
super(type, {decimals: 0, ...opts});
|
super(type, {decimals: 0, ...opts}, docSettings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DateFormatOptions {
|
||||||
|
dateFormat?: string;
|
||||||
|
}
|
||||||
|
|
||||||
class DateFormatter extends BaseFormatter {
|
class DateFormatter extends BaseFormatter {
|
||||||
private _dateTimeFormat: string;
|
private _dateTimeFormat: string;
|
||||||
private _timezone: string;
|
private _timezone: string;
|
||||||
|
|
||||||
constructor(type: string, opts: {dateFormat?: string}, timezone: string = 'UTC') {
|
constructor(type: string, widgetOpts: DateFormatOptions, docSettings: DocumentSettings, timezone: string = 'UTC') {
|
||||||
super(type, opts);
|
super(type, widgetOpts, docSettings);
|
||||||
this._dateTimeFormat = opts.dateFormat || 'YYYY-MM-DD';
|
this._dateTimeFormat = widgetOpts.dateFormat || 'YYYY-MM-DD';
|
||||||
this._timezone = timezone;
|
this._timezone = timezone;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,12 +132,16 @@ class DateFormatter extends BaseFormatter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DateTimeFormatOptions extends DateFormatOptions {
|
||||||
|
timeFormat?: string;
|
||||||
|
}
|
||||||
|
|
||||||
class DateTimeFormatter extends DateFormatter {
|
class DateTimeFormatter extends DateFormatter {
|
||||||
constructor(type: string, opts: {dateFormat?: string; timeFormat?: string}) {
|
constructor(type: string, widgetOpts: DateTimeFormatOptions, docSettings: DocumentSettings) {
|
||||||
const timezone = gutil.removePrefix(type, "DateTime:") || '';
|
const timezone = gutil.removePrefix(type, "DateTime:") || '';
|
||||||
const timeFormat = opts.timeFormat === undefined ? 'h:mma' : opts.timeFormat;
|
const timeFormat = widgetOpts.timeFormat === undefined ? 'h:mma' : widgetOpts.timeFormat;
|
||||||
const dateFormat = (opts.dateFormat || 'YYYY-MM-DD') + " " + timeFormat;
|
const dateFormat = (widgetOpts.dateFormat || 'YYYY-MM-DD') + " " + timeFormat;
|
||||||
super(type, {dateFormat}, timezone);
|
super(type, {dateFormat}, docSettings, timezone);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -142,10 +155,11 @@ const formatters: {[name: string]: typeof BaseFormatter} = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Takes column type and widget options and returns a constructor with a format function that can
|
* Takes column type, widget options and document settings, and returns a constructor
|
||||||
* properly convert a value passed to it into the right format for that column.
|
* with a format function that can properly convert a value passed to it into the
|
||||||
|
* right format for that column.
|
||||||
*/
|
*/
|
||||||
export function createFormatter(type: string, opts: object): BaseFormatter {
|
export function createFormatter(type: string, widgetOpts: FormatOptions, docSettings: DocumentSettings): BaseFormatter {
|
||||||
const ctor = formatters[gristTypes.extractTypeFromColType(type)] || AnyFormatter;
|
const ctor = formatters[gristTypes.extractTypeFromColType(type)] || AnyFormatter;
|
||||||
return new ctor(type, opts);
|
return new ctor(type, widgetOpts, docSettings);
|
||||||
}
|
}
|
||||||
|
18
app/common/declarations.d.ts
vendored
18
app/common/declarations.d.ts
vendored
@ -3,3 +3,21 @@ declare module "app/common/MemBuffer" {
|
|||||||
type MemBuffer = any;
|
type MemBuffer = any;
|
||||||
export = MemBuffer;
|
export = MemBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module "locale-currency/map" {
|
||||||
|
const Map: Record<string, string>;
|
||||||
|
type Map = Record<string, string>;
|
||||||
|
export = Map;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare namespace Intl {
|
||||||
|
class DisplayNames {
|
||||||
|
constructor(locales?: string, options?: object);
|
||||||
|
public of(code: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Locale {
|
||||||
|
public region: string;
|
||||||
|
constructor(locale: string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -9,6 +9,7 @@ export const schema = {
|
|||||||
basketId : "Text",
|
basketId : "Text",
|
||||||
schemaVersion : "Int",
|
schemaVersion : "Int",
|
||||||
timezone : "Text",
|
timezone : "Text",
|
||||||
|
documentSettings : "Text",
|
||||||
},
|
},
|
||||||
|
|
||||||
"_grist_Tables": {
|
"_grist_Tables": {
|
||||||
@ -182,6 +183,7 @@ export interface SchemaTypes {
|
|||||||
basketId: string;
|
basketId: string;
|
||||||
schemaVersion: number;
|
schemaVersion: number;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
|
documentSettings: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
"_grist_Tables": {
|
"_grist_Tables": {
|
||||||
|
@ -85,6 +85,7 @@ bluebird.promisifyAll(tmp);
|
|||||||
const MAX_RECENT_ACTIONS = 100;
|
const MAX_RECENT_ACTIONS = 100;
|
||||||
|
|
||||||
const DEFAULT_TIMEZONE = (process.versions as any).electron ? moment.tz.guess() : "UTC";
|
const DEFAULT_TIMEZONE = (process.versions as any).electron ? moment.tz.guess() : "UTC";
|
||||||
|
const DEFAULT_LOCALE = "en-US";
|
||||||
|
|
||||||
// Number of seconds an ActiveDoc is retained without any clients.
|
// Number of seconds an ActiveDoc is retained without any clients.
|
||||||
// In dev environment, it is convenient to keep this low for quick tests.
|
// In dev environment, it is convenient to keep this low for quick tests.
|
||||||
@ -338,10 +339,11 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
await this._docManager.storageManager.prepareToCreateDoc(this.docName);
|
await this._docManager.storageManager.prepareToCreateDoc(this.docName);
|
||||||
await this.docStorage.createFile();
|
await this.docStorage.createFile();
|
||||||
await this._rawPyCall('load_empty');
|
await this._rawPyCall('load_empty');
|
||||||
const timezone = docSession.browserSettings ? docSession.browserSettings.timezone : DEFAULT_TIMEZONE;
|
const timezone = docSession.browserSettings?.timezone ?? DEFAULT_TIMEZONE;
|
||||||
|
const locale = docSession.browserSettings?.locale ?? DEFAULT_LOCALE;
|
||||||
// This init action is special. It creates schema tables, and is used to init the DB, but does
|
// This init action is special. It creates schema tables, and is used to init the DB, but does
|
||||||
// not go through other steps of a regular action (no ActionHistory or broadcasting).
|
// not go through other steps of a regular action (no ActionHistory or broadcasting).
|
||||||
const initBundle = await this._rawPyCall('apply_user_actions', [["InitNewDoc", timezone]]);
|
const initBundle = await this._rawPyCall('apply_user_actions', [["InitNewDoc", timezone, locale]]);
|
||||||
await this.docStorage.execTransaction(() =>
|
await this.docStorage.execTransaction(() =>
|
||||||
this.docStorage.applyStoredActions(getEnvContent(initBundle.stored)));
|
this.docStorage.applyStoredActions(getEnvContent(initBundle.stored)));
|
||||||
|
|
||||||
|
@ -84,7 +84,8 @@ export class Client {
|
|||||||
constructor(
|
constructor(
|
||||||
private _comm: any,
|
private _comm: any,
|
||||||
private _methods: any,
|
private _methods: any,
|
||||||
private _host: string
|
private _host: string,
|
||||||
|
private _locale?: string,
|
||||||
) {
|
) {
|
||||||
this.clientId = generateClientId();
|
this.clientId = generateClientId();
|
||||||
}
|
}
|
||||||
@ -102,6 +103,10 @@ export class Client {
|
|||||||
return this._host;
|
return this._host;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get locale(): string|undefined {
|
||||||
|
return this._locale;
|
||||||
|
}
|
||||||
|
|
||||||
public setConnection(websocket: any, reqHost: string, browserSettings: BrowserSettings) {
|
public setConnection(websocket: any, reqHost: string, browserSettings: BrowserSettings) {
|
||||||
this._websocket = websocket;
|
this._websocket = websocket;
|
||||||
// Set this._loginState, used by CognitoClient to construct login/logout URLs.
|
// Set this._loginState, used by CognitoClient to construct login/logout URLs.
|
||||||
|
@ -49,6 +49,7 @@ var gutil = require('app/common/gutil');
|
|||||||
const {parseFirstUrlPart} = require('app/common/gristUrls');
|
const {parseFirstUrlPart} = require('app/common/gristUrls');
|
||||||
const version = require('app/common/version');
|
const version = require('app/common/version');
|
||||||
const {Client} = require('./Client');
|
const {Client} = require('./Client');
|
||||||
|
const {localeFromRequest} = require('app/server/lib/ServerLocale');
|
||||||
|
|
||||||
// Bluebird promisification, to be able to use e.g. websocket.sendAsync method.
|
// Bluebird promisification, to be able to use e.g. websocket.sendAsync method.
|
||||||
Promise.promisifyAll(ws.prototype);
|
Promise.promisifyAll(ws.prototype);
|
||||||
@ -216,7 +217,7 @@ Comm.prototype._onWebSocketConnection = async function(websocket, req) {
|
|||||||
}
|
}
|
||||||
client.setConnection(websocket, req.headers.host, browserSettings);
|
client.setConnection(websocket, req.headers.host, browserSettings);
|
||||||
} else {
|
} else {
|
||||||
client = new Client(this, this.methods, req.headers.host);
|
client = new Client(this, this.methods, req.headers.host, localeFromRequest(req));
|
||||||
client.setCounter(counter);
|
client.setCounter(counter);
|
||||||
client.setConnection(websocket, req.headers.host, browserSettings);
|
client.setConnection(websocket, req.headers.host, browserSettings);
|
||||||
this._clients[client.clientId] = client;
|
this._clients[client.clientId] = client;
|
||||||
|
@ -32,6 +32,7 @@ import { exportToDrive } from "app/server/lib/GoogleExport";
|
|||||||
import { googleAuthTokenMiddleware } from "app/server/lib/GoogleAuth";
|
import { googleAuthTokenMiddleware } from "app/server/lib/GoogleAuth";
|
||||||
import * as _ from "lodash";
|
import * as _ from "lodash";
|
||||||
import {isRaisedException} from "app/common/gristTypes";
|
import {isRaisedException} from "app/common/gristTypes";
|
||||||
|
import {localeFromRequest} from "app/server/lib/ServerLocale";
|
||||||
|
|
||||||
// Cap on the number of requests that can be outstanding on a single document via the
|
// Cap on the number of requests that can be outstanding on a single document via the
|
||||||
// rest doc api. When this limit is exceeded, incoming requests receive an immediate
|
// rest doc api. When this limit is exceeded, incoming requests receive an immediate
|
||||||
@ -590,6 +591,7 @@ export class DocWorkerApi {
|
|||||||
if (parameters.workspaceId) { throw new Error('workspaceId not supported'); }
|
if (parameters.workspaceId) { throw new Error('workspaceId not supported'); }
|
||||||
const browserSettings: BrowserSettings = {};
|
const browserSettings: BrowserSettings = {};
|
||||||
if (parameters.timezone) { browserSettings.timezone = parameters.timezone; }
|
if (parameters.timezone) { browserSettings.timezone = parameters.timezone; }
|
||||||
|
browserSettings.locale = localeFromRequest(req);
|
||||||
if (uploadId !== undefined) {
|
if (uploadId !== undefined) {
|
||||||
const result = await this._docManager.importDocToWorkspace(userId, uploadId, null,
|
const result = await this._docManager.importDocToWorkspace(userId, uploadId, null,
|
||||||
browserSettings);
|
browserSettings);
|
||||||
|
@ -24,6 +24,7 @@ export interface OptDocSession {
|
|||||||
|
|
||||||
export function makeOptDocSession(client: Client|null, browserSettings?: BrowserSettings): OptDocSession {
|
export function makeOptDocSession(client: Client|null, browserSettings?: BrowserSettings): OptDocSession {
|
||||||
if (client && !browserSettings) { browserSettings = client.browserSettings; }
|
if (client && !browserSettings) { browserSettings = client.browserSettings; }
|
||||||
|
if (client && browserSettings && !browserSettings.locale) { browserSettings.locale = client.locale; }
|
||||||
return {client, browserSettings};
|
return {client, browserSettings};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@ import {GristType} from 'app/common/gristTypes';
|
|||||||
import * as gutil from 'app/common/gutil';
|
import * as gutil from 'app/common/gutil';
|
||||||
import * as gristTypes from 'app/common/gristTypes';
|
import * as gristTypes from 'app/common/gristTypes';
|
||||||
import {NumberFormatOptions} from 'app/common/NumberFormat';
|
import {NumberFormatOptions} from 'app/common/NumberFormat';
|
||||||
import {formatUnknown, IsRightTypeFunc} from 'app/common/ValueFormatter';
|
import {FormatOptions, formatUnknown, IsRightTypeFunc} from 'app/common/ValueFormatter';
|
||||||
import {decodeObject} from 'app/plugin/objtypes';
|
import {decodeObject} from 'app/plugin/objtypes';
|
||||||
import {Style} from 'exceljs';
|
import {Style} from 'exceljs';
|
||||||
import * as moment from 'moment-timezone';
|
import * as moment from 'moment-timezone';
|
||||||
@ -17,9 +17,9 @@ interface WidgetOptions extends NumberFormatOptions {
|
|||||||
}
|
}
|
||||||
class BaseFormatter {
|
class BaseFormatter {
|
||||||
protected isRightType: IsRightTypeFunc;
|
protected isRightType: IsRightTypeFunc;
|
||||||
protected widgetOptions: WidgetOptions = {};
|
protected widgetOptions: WidgetOptions;
|
||||||
|
|
||||||
constructor(public type: string, public opts: object) {
|
constructor(public type: string, public opts: FormatOptions) {
|
||||||
this.isRightType = gristTypes.isRightType(gristTypes.extractTypeFromColType(type)) ||
|
this.isRightType = gristTypes.isRightType(gristTypes.extractTypeFromColType(type)) ||
|
||||||
gristTypes.isRightType('Any')!;
|
gristTypes.isRightType('Any')!;
|
||||||
this.widgetOptions = opts;
|
this.widgetOptions = opts;
|
||||||
@ -164,11 +164,11 @@ const formatters: Partial<Record<GristType, typeof BaseFormatter>> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Takes column type and widget options and returns a constructor with a format function that can
|
* Takes column type and format options and returns a constructor with a format function that can
|
||||||
* properly convert a value passed to it into the right javascript object for that column.
|
* properly convert a value passed to it into the right javascript object for that column.
|
||||||
* Exceljs library is using javascript primitives to specify correct excel type.
|
* Exceljs library is using javascript primitives to specify correct excel type.
|
||||||
*/
|
*/
|
||||||
export function createExcelFormatter(type: string, opts: object): BaseFormatter {
|
export function createExcelFormatter(type: string, opts: FormatOptions): BaseFormatter {
|
||||||
const ctor = formatters[gristTypes.extractTypeFromColType(type) as GristType] || AnyFormatter;
|
const ctor = formatters[gristTypes.extractTypeFromColType(type) as GristType] || AnyFormatter;
|
||||||
return new ctor(type, opts);
|
return new ctor(type, opts);
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ import {buildRowFilter} from 'app/common/RowFilterFunc';
|
|||||||
import {SchemaTypes} from 'app/common/schema';
|
import {SchemaTypes} from 'app/common/schema';
|
||||||
import {SortFunc} from 'app/common/SortFunc';
|
import {SortFunc} from 'app/common/SortFunc';
|
||||||
import {TableData} from 'app/common/TableData';
|
import {TableData} from 'app/common/TableData';
|
||||||
|
import {DocumentSettings} from 'app/common/DocumentSettings';
|
||||||
import {ActiveDoc} from 'app/server/lib/ActiveDoc';
|
import {ActiveDoc} from 'app/server/lib/ActiveDoc';
|
||||||
import {RequestWithLogin} from 'app/server/lib/Authorizer';
|
import {RequestWithLogin} from 'app/server/lib/Authorizer';
|
||||||
import {docSessionFromRequest} from 'app/server/lib/DocSession';
|
import {docSessionFromRequest} from 'app/server/lib/DocSession';
|
||||||
@ -60,6 +61,10 @@ export interface ExportData {
|
|||||||
* Columns information (primary used for formatting).
|
* Columns information (primary used for formatting).
|
||||||
*/
|
*/
|
||||||
columns: ExportColumn[];
|
columns: ExportColumn[];
|
||||||
|
/**
|
||||||
|
* Document settings
|
||||||
|
*/
|
||||||
|
docSettings: DocumentSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -194,12 +199,15 @@ export async function exportTable(
|
|||||||
tableName = view.name;
|
tableName = view.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const docInfo = safeRecord(safeTable(docData, '_grist_DocInfo'), 1) as DocInfo;
|
||||||
|
const docSettings = gutil.safeJsonParse(docInfo.documentSettings, {});
|
||||||
return {
|
return {
|
||||||
tableName,
|
tableName,
|
||||||
docName: activeDoc.docName,
|
docName: activeDoc.docName,
|
||||||
rowIds,
|
rowIds,
|
||||||
access,
|
access,
|
||||||
columns
|
columns,
|
||||||
|
docSettings
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -277,11 +285,15 @@ export async function exportSection(
|
|||||||
// filter rows numbers
|
// filter rows numbers
|
||||||
rowIds = rowIds.filter(rowFilter);
|
rowIds = rowIds.filter(rowFilter);
|
||||||
|
|
||||||
|
const docInfo = safeRecord(safeTable(docData, '_grist_DocInfo'), 1) as DocInfo;
|
||||||
|
const docSettings = gutil.safeJsonParse(docInfo.documentSettings, {});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tableName: table.tableId,
|
tableName: table.tableId,
|
||||||
docName: activeDoc.docName,
|
docName: activeDoc.docName,
|
||||||
rowIds,
|
rowIds,
|
||||||
access,
|
access,
|
||||||
|
docSettings,
|
||||||
columns: viewColumns
|
columns: viewColumns
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -295,6 +307,7 @@ type GristTables = RowModel<'_grist_Tables'>
|
|||||||
type GristViewsSectionField = RowModel<'_grist_Views_section_field'>
|
type GristViewsSectionField = RowModel<'_grist_Views_section_field'>
|
||||||
type GristTablesColumn = RowModel<'_grist_Tables_column'>
|
type GristTablesColumn = RowModel<'_grist_Tables_column'>
|
||||||
type GristView = RowModel<'_grist_Views'>
|
type GristView = RowModel<'_grist_Views'>
|
||||||
|
type DocInfo = RowModel<'_grist_DocInfo'>
|
||||||
|
|
||||||
// Type for filters passed from the client
|
// Type for filters passed from the client
|
||||||
export interface Filter { colRef: number, filter: string }
|
export interface Filter { colRef: number, filter: string }
|
||||||
|
@ -33,11 +33,12 @@ export async function makeCSV(
|
|||||||
function convertToCsv({
|
function convertToCsv({
|
||||||
rowIds,
|
rowIds,
|
||||||
access,
|
access,
|
||||||
columns: viewColumns
|
columns: viewColumns,
|
||||||
|
docSettings
|
||||||
}: ExportData) {
|
}: ExportData) {
|
||||||
|
|
||||||
// create formatters for columns
|
// create formatters for columns
|
||||||
const formatters = viewColumns.map(col => createFormatter(col.type, col.widgetOptions));
|
const formatters = viewColumns.map(col => createFormatter(col.type, col.widgetOptions, docSettings));
|
||||||
// Arrange the data into a row-indexed matrix, starting with column headers.
|
// Arrange the data into a row-indexed matrix, starting with column headers.
|
||||||
const csvMatrix = [viewColumns.map(col => col.label)];
|
const csvMatrix = [viewColumns.map(col => col.label)];
|
||||||
// populate all the rows with values as strings
|
// populate all the rows with values as strings
|
||||||
|
16
app/server/lib/ServerLocale.ts
Normal file
16
app/server/lib/ServerLocale.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import {parse as languageParser} from "accept-language-parser";
|
||||||
|
import {Request} from 'express';
|
||||||
|
import {locales} from 'app/common/Locales';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the locale from a request, falling back to `defaultLocale`
|
||||||
|
* if unable to determine the locale.
|
||||||
|
*/
|
||||||
|
export function localeFromRequest(req: Request, defaultLocale: string = 'en-US') {
|
||||||
|
const language = languageParser(req.headers["accept-language"] as string)[0];
|
||||||
|
if (!language) { return defaultLocale; }
|
||||||
|
|
||||||
|
const locale = `${language.code}-${language.region}`;
|
||||||
|
const supports = locales.some(l => l.code === locale);
|
||||||
|
return supports ? locale : defaultLocale;
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../buildtools/tsconfig-base.json",
|
"extends": "../../buildtools/tsconfig-base.json",
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "../common" }
|
{ "path": "../common" },
|
||||||
],
|
],
|
||||||
"include": [
|
"include": [
|
||||||
"**/*",
|
"**/*",
|
||||||
|
@ -788,3 +788,10 @@ def migration22(tdset):
|
|||||||
add_column('_grist_Tables_column', 'recalcWhen', 'Int'),
|
add_column('_grist_Tables_column', 'recalcWhen', 'Int'),
|
||||||
add_column('_grist_Tables_column', 'recalcDeps', 'RefList:_grist_Tables_column'),
|
add_column('_grist_Tables_column', 'recalcDeps', 'RefList:_grist_Tables_column'),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
@migration(schema_version=23)
|
||||||
|
def migration23(tdset):
|
||||||
|
return tdset.apply_doc_actions([
|
||||||
|
add_column('_grist_DocInfo', 'documentSettings', 'Text'),
|
||||||
|
actions.UpdateRecord('_grist_DocInfo', 1, {'documentSettings': '{"locale":"en-US"}'})
|
||||||
|
])
|
||||||
|
@ -15,7 +15,7 @@ import six
|
|||||||
|
|
||||||
import actions
|
import actions
|
||||||
|
|
||||||
SCHEMA_VERSION = 22
|
SCHEMA_VERSION = 23
|
||||||
|
|
||||||
def make_column(col_id, col_type, formula='', isFormula=False):
|
def make_column(col_id, col_type, formula='', isFormula=False):
|
||||||
return {
|
return {
|
||||||
@ -40,6 +40,9 @@ def schema_create_actions():
|
|||||||
|
|
||||||
# Document timezone.
|
# Document timezone.
|
||||||
make_column("timezone", "Text"),
|
make_column("timezone", "Text"),
|
||||||
|
|
||||||
|
# Document settings (excluding timezone).
|
||||||
|
make_column("documentSettings", "Text"), # JSON string describing document settings
|
||||||
]),
|
]),
|
||||||
|
|
||||||
# The names of the user tables. This does NOT include built-in tables.
|
# The names of the user tables. This does NOT include built-in tables.
|
||||||
|
@ -193,13 +193,14 @@ class UserActions(object):
|
|||||||
#----------------------------------------
|
#----------------------------------------
|
||||||
|
|
||||||
@useraction
|
@useraction
|
||||||
def InitNewDoc(self, timezone):
|
def InitNewDoc(self, timezone, locale):
|
||||||
creation_actions = schema.schema_create_actions()
|
creation_actions = schema.schema_create_actions()
|
||||||
self._engine.out_actions.stored.extend(creation_actions)
|
self._engine.out_actions.stored.extend(creation_actions)
|
||||||
self._engine.out_actions.direct += [True] * len(creation_actions)
|
self._engine.out_actions.direct += [True] * len(creation_actions)
|
||||||
self._do_doc_action(actions.AddRecord("_grist_DocInfo", 1,
|
self._do_doc_action(actions.AddRecord("_grist_DocInfo", 1,
|
||||||
{'schemaVersion': schema.SCHEMA_VERSION,
|
{'schemaVersion': schema.SCHEMA_VERSION,
|
||||||
'timezone': timezone}))
|
'timezone': timezone,
|
||||||
|
'documentSettings': json.dumps({'locale': locale})}))
|
||||||
|
|
||||||
# Set up initial ACL data.
|
# Set up initial ACL data.
|
||||||
# NOTE The special records below are not actually used. They were intended for obsolete ACL
|
# NOTE The special records below are not actually used. They were intended for obsolete ACL
|
||||||
|
@ -1621,17 +1621,27 @@ export function addSamplesForSuite() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openUserProfile() {
|
async function openAccountMenu() {
|
||||||
await driver.findWait('.test-dm-account', 1000).click();
|
await driver.findWait('.test-dm-account', 1000).click();
|
||||||
// Since the AccountWidget loads orgs and the user data asynchronously, the menu
|
// Since the AccountWidget loads orgs and the user data asynchronously, the menu
|
||||||
// can expand itself causing the click to land on a wrong button.
|
// can expand itself causing the click to land on a wrong button.
|
||||||
await waitForServer();
|
await waitForServer();
|
||||||
await driver.findWait('.test-usermenu-org', 1000);
|
await driver.findWait('.test-usermenu-org', 1000);
|
||||||
await driver.sleep(250); // There's still some jitter (scroll-bar? other user accounts?)
|
await driver.sleep(250); // There's still some jitter (scroll-bar? other user accounts?)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openUserProfile() {
|
||||||
|
await openAccountMenu();
|
||||||
await driver.findContent('.grist-floating-menu li', 'Profile Settings').click();
|
await driver.findContent('.grist-floating-menu li', 'Profile Settings').click();
|
||||||
await driver.findWait('.test-login-method', 5000);
|
await driver.findWait('.test-login-method', 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function openDocumentSettings() {
|
||||||
|
await openAccountMenu();
|
||||||
|
await driver.findContent('.grist-floating-menu li', 'Document Settings').click();
|
||||||
|
await driver.findWait('.test-modal-title', 5000);
|
||||||
|
}
|
||||||
|
|
||||||
} // end of namespace gristUtils
|
} // end of namespace gristUtils
|
||||||
|
|
||||||
stackWrapOwnMethods(gristUtils);
|
stackWrapOwnMethods(gristUtils);
|
||||||
|
Loading…
Reference in New Issue
Block a user