From a6e08883e013b777edc2521b2d99bc8f75518caa Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Thu, 26 Aug 2021 09:35:11 -0700 Subject: [PATCH] (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 --- app/client/components/CopySelection.js | 5 +- app/client/components/GristDoc.ts | 2 +- app/client/components/SearchBar.ts | 2 +- app/client/components/TypeConversion.ts | 2 +- app/client/models/DocModel.ts | 4 + app/client/models/SearchModel.ts | 2 +- app/client/models/entities/DocInfoRec.ts | 5 ++ app/client/models/entities/ViewFieldRec.ts | 11 ++- app/client/ui/DocumentSettings.ts | 93 ++++++++++++++++++---- app/client/widgets/AbstractWidget.js | 5 +- app/client/widgets/CurrencyPicker.ts | 49 ++++++++++++ app/client/widgets/DateEditor.js | 14 ++++ app/client/widgets/NewAbstractWidget.ts | 2 +- app/client/widgets/NumericTextBox.ts | 38 +++++++-- app/client/widgets/Spinner.ts | 7 +- app/common/BrowserSettings.ts | 2 + app/common/DocumentSettings.ts | 4 + app/common/Locales.ts | 53 ++++++++++++ app/common/NumberFormat.ts | 25 +++--- app/common/ValueFormatter.ts | 48 +++++++---- app/common/declarations.d.ts | 18 +++++ app/common/schema.ts | 2 + app/server/lib/ActiveDoc.ts | 6 +- app/server/lib/Client.ts | 7 +- app/server/lib/Comm.js | 3 +- app/server/lib/DocApi.ts | 2 + app/server/lib/DocSession.ts | 1 + app/server/lib/ExcelFormatter.ts | 10 +-- app/server/lib/Export.ts | 15 +++- app/server/lib/ExportCSV.ts | 5 +- app/server/lib/ServerLocale.ts | 16 ++++ app/server/tsconfig.json | 2 +- sandbox/grist/migrations.py | 7 ++ sandbox/grist/schema.py | 5 +- sandbox/grist/useractions.py | 5 +- test/nbrowser/gristUtils.ts | 12 ++- 36 files changed, 405 insertions(+), 84 deletions(-) create mode 100644 app/client/widgets/CurrencyPicker.ts create mode 100644 app/common/DocumentSettings.ts create mode 100644 app/common/Locales.ts create mode 100644 app/server/lib/ServerLocale.ts diff --git a/app/client/components/CopySelection.js b/app/client/components/CopySelection.js index d873baed..3f9ae291 100644 --- a/app/client/components/CopySelection.js +++ b/app/client/components/CopySelection.js @@ -19,7 +19,10 @@ function CopySelection(tableData, rowIds, fields, options) { this.colStyle = options.colStyle; this.columns = fields.map((f, i) => { let formatter = ValueFormatter.createFormatter( - f.displayColModel().type(), f.widgetOptionsJson()); + f.displayColModel().type(), + f.widgetOptionsJson(), + f.documentSettings() + ); let _fmtGetter = tableData.getRowPropFunc(this.displayColIds[i]); let _rawGetter = tableData.getRowPropFunc(this.colIds[i]); diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index cb3bc3d3..aada14ec 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -158,7 +158,7 @@ export class GristDoc extends DisposableWithEvents { app.topAppModel.getUntrustedContentOrigin(), this.docComm, app.clientScope); // 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; diff --git a/app/client/components/SearchBar.ts b/app/client/components/SearchBar.ts index b5319f10..3ab00587 100644 --- a/app/client/components/SearchBar.ts +++ b/app/client/components/SearchBar.ts @@ -267,7 +267,7 @@ class Searcher { this._fieldStepper.array = section.viewFields().peek(); this._fieldFormatters = this._fieldStepper.array.map( - f => createFormatter(f.displayColModel().type(), f.widgetOptionsJson())); + f => createFormatter(f.displayColModel().type(), f.widgetOptionsJson(), f.documentSettings())); return tableModel; } diff --git a/app/client/components/TypeConversion.ts b/app/client/components/TypeConversion.ts index d48bee5b..b2df94b2 100644 --- a/app/client/components/TypeConversion.ts +++ b/app/client/components/TypeConversion.ts @@ -35,7 +35,7 @@ export function addColTypeSuffix(type: string, column: ColumnRec, docModel: DocM return `${type}:${refTableId}`; } case "DateTime": - return 'DateTime:' + docModel.docInfo.getRowModel(1).timezone(); + return 'DateTime:' + docModel.docInfoRow.timezone(); default: return type; } diff --git a/app/client/models/DocModel.ts b/app/client/models/DocModel.ts index bf32b233..1caa2c8e 100644 --- a/app/client/models/DocModel.ts +++ b/app/client/models/DocModel.ts @@ -111,6 +111,8 @@ export class DocModel { public pages: MTM = this._metaTableModel("_grist_Pages", createPageRec); public rules: MTM = this._metaTableModel("_grist_ACLRules", createACLRuleRec); + public docInfoRow: DocInfoRec; + public allTables: KoArray; public allTableIds: KoArray; @@ -135,6 +137,8 @@ export class DocModel { model.loadData(); } + this.docInfoRow = this.docInfo.getRowModel(1); + // An observable array of user-visible tables, sorted by tableId, excluding summary tables. // This is a publicly exposed member. this.allTables = createUserTablesArray(this.tables); diff --git a/app/client/models/SearchModel.ts b/app/client/models/SearchModel.ts index 6407ab5d..88ae690a 100644 --- a/app/client/models/SearchModel.ts +++ b/app/client/models/SearchModel.ts @@ -217,7 +217,7 @@ class FinderImpl implements IFinder { this._fieldStepper.array = section.viewFields().peek(); this._fieldFormatters = this._fieldStepper.array.map( - f => createFormatter(f.displayColModel().type(), f.widgetOptionsJson())); + f => createFormatter(f.displayColModel().type(), f.widgetOptionsJson(), f.documentSettings())); return tableModel; } diff --git a/app/client/models/entities/DocInfoRec.ts b/app/client/models/entities/DocInfoRec.ts index 3900654a..e38369ed 100644 --- a/app/client/models/entities/DocInfoRec.ts +++ b/app/client/models/entities/DocInfoRec.ts @@ -1,13 +1,18 @@ 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'; // The document-wide metadata. It's all contained in a single record with id=1. export interface DocInfoRec extends IRowModel<"_grist_DocInfo"> { + documentSettingsJson: modelUtil.SaveableObjObservable defaultViewId: ko.Computed; newDefaultViewId: ko.Computed; } export function createDocInfoRec(this: DocInfoRec, docModel: DocModel): void { + this.documentSettingsJson = jsonObservable(this.documentSettings); this.defaultViewId = this.autoDispose(ko.pureComputed(() => { const tab = docModel.allTabs.at(0); return tab ? tab.viewRef() : 0; diff --git a/app/client/models/entities/ViewFieldRec.ts b/app/client/models/entities/ViewFieldRec.ts index e75cd08d..43388406 100644 --- a/app/client/models/entities/ViewFieldRec.ts +++ b/app/client/models/entities/ViewFieldRec.ts @@ -1,6 +1,7 @@ import {ColumnRec, DocModel, IRowModel, refRecord, ViewSectionRec} from 'app/client/models/DocModel'; import * as modelUtil from 'app/client/models/modelUtil'; import * as UserType from 'app/client/widgets/UserType'; +import {DocumentSettings} from 'app/common/DocumentSettings'; import {BaseFormatter, createFormatter} from 'app/common/ValueFormatter'; import {Computed, fromKo} from 'grainjs'; 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, // which takes into account the default options for column's type. - widgetOptionsJson: modelUtil.SaveableObjObservable; // Whether lines should wrap in a cell. @@ -73,6 +73,8 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field"> { textColor: modelUtil.KoSaveableObservable; fillColor: modelUtil.KoSaveableObservable; + documentSettings: ko.PureComputed; + // Helper which adds/removes/updates field's displayCol to match the formula. saveDisplayFormula(formula: string): Promise|undefined; @@ -167,8 +169,8 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void this.createVisibleColFormatter = function() { const vcol = this.visibleColModel(); return (vcol.getRowId() !== 0) ? - createFormatter(vcol.type(), vcol.widgetOptionsJson()) : - createFormatter(this.column().type(), this.widgetOptionsJson()); + createFormatter(vcol.type(), vcol.widgetOptionsJson(), this.documentSettings()) : + createFormatter(this.column().type(), this.widgetOptionsJson(), this.documentSettings()); }; // 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, // which takes into account the default options for this column's type. - this.widgetOptionsJson = modelUtil.jsonObservable(this._widgetOptionsStr, (opts: any) => UserType.mergeOptions(opts || {}, this.column().pureType())); @@ -211,4 +212,6 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void read: () => fillColorProp(), write: (setter, val) => setter(fillColorProp, val.toUpperCase() === '#FFFFFF' ? '' : val), }); + + this.documentSettings = ko.pureComputed(() => docModel.docInfoRow.documentSettingsJson()); } diff --git a/app/client/ui/DocumentSettings.ts b/app/client/ui/DocumentSettings.ts index f175dec2..6f8fc37f 100644 --- a/app/client/ui/DocumentSettings.ts +++ b/app/client/ui/DocumentSettings.ts @@ -2,15 +2,21 @@ * This module export a component for editing some document settings consisting of the timezone, * (new settings to be added here ...). */ -import { dom, styled } from 'grainjs'; -import { Computed, Observable } from 'grainjs'; +import {dom, IDisposableOwner, styled} from 'grainjs'; +import {Computed, Observable} from 'grainjs'; -import { loadMomentTimezone } from 'app/client/lib/imports'; -import { DocInfoRec } from 'app/client/models/DocModel'; -import { DocPageModel } from 'app/client/models/DocPageModel'; -import { vars } from 'app/client/ui2018/cssVars'; -import { saveModal } from 'app/client/ui2018/modals'; -import { buildTZAutocomplete } from 'app/client/widgets/TZAutocomplete'; + +import {ACSelectItem, buildACSelect} from "app/client/lib/ACSelect"; +import {ACIndexImpl} from "app/client/lib/ACIndex"; +import {loadMomentTimezone} from 'app/client/lib/imports'; +import {DocInfoRec} from 'app/client/models/DocModel'; +import {DocPageModel} from 'app/client/models/DocPageModel'; +import {testId, vars} from 'app/client/ui2018/cssVars'; +import {saveModal} from 'app/client/ui2018/modals'; +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. @@ -18,23 +24,82 @@ import { buildTZAutocomplete } from 'app/client/widgets/TZAutocomplete'; export async function showDocSettingsModal(docInfo: DocInfoRec, docPageModel: DocPageModel): Promise { const moment = await loadMomentTimezone(); 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 { title: 'Document Settings', body: [ cssDataRow("This document's ID (for API use):"), cssDataRow(dom('tt', docPageModel.currentDocId.get())), 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.timezone.saveOnly(timezone.get()), - // If timezone hasn't changed, there is nothing to save, so disable the Save button. - saveDisabled: Computed.create(owner, (use) => use(timezone) === docInfo.timezone.peek()), + saveFunc: () => docInfo.updateColValues({ + timezone: timezoneObs.get(), + documentSettings: JSON.stringify({ + ...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 +) { + 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(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. const cssDataRow = styled('div', ` margin: 16px 0px; diff --git a/app/client/widgets/AbstractWidget.js b/app/client/widgets/AbstractWidget.js index d68e8556..8e928dc7 100644 --- a/app/client/widgets/AbstractWidget.js +++ b/app/client/widgets/AbstractWidget.js @@ -17,9 +17,8 @@ function AbstractWidget(field, opts = {}) { this.options = field.widgetOptionsJson; const {defaultTextColor = '#000000'} = opts; - this.valueFormatter = this.autoDispose(ko.computed(() => { - return ValueFormatter.createFormatter(field.displayColModel().type(), this.options()); - })); + this.valueFormatter = this.autoDispose(ko.computed(() => + ValueFormatter.createFormatter(field.displayColModel().type(), this.options(), field.documentSettings()))); this.textColor = Computed.create(this, (use) => use(this.field.textColor) || defaultTextColor) .onWrite((val) => this.field.textColor(val === defaultTextColor ? undefined : val)); diff --git a/app/client/widgets/CurrencyPicker.ts b/app/client/widgets/CurrencyPicker.ts new file mode 100644 index 00000000..06a66501 --- /dev/null +++ b/app/client/widgets/CurrencyPicker.ts @@ -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, + 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(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") + ); +} diff --git a/app/client/widgets/DateEditor.js b/app/client/widgets/DateEditor.js index b9604946..bc33dcdc 100644 --- a/app/client/widgets/DateEditor.js +++ b/app/client/widgets/DateEditor.js @@ -30,7 +30,9 @@ Object.defineProperty(Datepicker.prototype, 'isInput', { function DateEditor(options) { // A string that is always `UTC` in the DateEditor, eases DateTimeEditor inheritance. this.timezone = options.timezone || 'UTC'; + 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. this.safeFormat = DateEditor.parseMomentToSafe(this.dateFormat); @@ -68,6 +70,10 @@ function DateEditor(options) { todayHighlight: true, todayBtn: 'linked', 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. + // + language : this.getLanguage(), // Convert the stripped format string to one suitable for the datepicker. format: DateEditor.parseSafeToCalendar(this.safeFormat) }); @@ -168,5 +174,13 @@ DateEditor.parseSafeToCalendar = function(sFormat) { 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; diff --git a/app/client/widgets/NewAbstractWidget.ts b/app/client/widgets/NewAbstractWidget.ts index 135a83a5..85950f5c 100644 --- a/app/client/widgets/NewAbstractWidget.ts +++ b/app/client/widgets/NewAbstractWidget.ts @@ -47,7 +47,7 @@ export abstract class NewAbstractWidget extends Disposable { // Note that its easier to create a knockout computed from the several knockout observables, // but then we turn it into a grainjs observable. const formatter = this.autoDispose(ko.computed(() => - createFormatter(field.displayColModel().type(), this.options()))); + createFormatter(field.displayColModel().type(), this.options(), field.documentSettings()))); this.valueFormatter = fromKo(formatter); } diff --git a/app/client/widgets/NumericTextBox.ts b/app/client/widgets/NumericTextBox.ts index 3599131f..698c70a8 100644 --- a/app/client/widgets/NumericTextBox.ts +++ b/app/client/widgets/NumericTextBox.ts @@ -11,13 +11,15 @@ import {NTextBox} from 'app/client/widgets/NTextBox'; import {clamp} from 'app/common/gutil'; import {buildNumberFormat, NumberFormatOptions, NumMode, NumSign} from 'app/common/NumberFormat'; 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> = [ {value: 'currency', label: '$'}, {value: 'decimal', label: ','}, - {value: 'percent', label: '%'}, - {value: 'scientific', label: 'Exp'} + {value: 'percent', label: '%'}, + {value: 'scientific', label: 'Exp'} ]; const signOptions: Array> = [ @@ -37,17 +39,25 @@ export class NumericTextBox extends NTextBox { const holder = new MultiHolder(); // Resolved options, to show default min/max decimals, which change depending on numMode. - const resolved = Computed.create(holder, (use) => - buildNumberFormat({numMode: use(this.options).numMode}).resolvedOptions()); + const resolved = Computed.create(holder, (use) => { + 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. 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 currency = Computed.create(holder, options, (use, opts) => opts.currency); const minDecimals = Computed.create(holder, options, (use, opts) => numberOrDefault(opts.decimals, '')); const maxDecimals = Computed.create(holder, options, (use, opts) => numberOrDefault(opts.maxDecimals, '')); const defaultMin = Computed.create(holder, resolved, (use, res) => res.minimumFractionDigits); 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 // 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. const setMode = (val: NumMode) => setSave('numMode', val !== numMode.get() ? val : undefined); const setSign = (val: NumSign) => setSave('numSign', val !== numSign.get() ? val : undefined); + const setCurrency = (val: string|undefined) => setSave('currency', val); return [ super.buildConfigDom(), @@ -75,6 +86,16 @@ export class NumericTextBox extends NTextBox { makeButtonSelect(numMode, modeOptions, setMode, {}, cssModeSelect.cls(''), testId('numeric-mode')), 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'), cssRow( decimals('min', minDecimals, defaultMin, setMinDecimals, testId('numeric-min-decimals')), @@ -84,12 +105,13 @@ export class NumericTextBox extends NTextBox { } } -function numberOrDefault(value: unknown, def: T): number|T { + +function numberOrDefault(value: unknown, def: T): number | T { return typeof value !== 'undefined' ? Number(value) : def; } // 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 { // Reset the numSign to default when toggling mode to percent or scientific. if (prop === 'numMode' && (!value || value === 'scientific' || value === 'percent')) { return {numSign: undefined}; @@ -99,7 +121,7 @@ function updateOptions(prop: keyof NumberFormatOptions, value: unknown): NumberF function decimals( label: string, - value: Observable, + value: Observable, defaultValue: Observable, setFunc: (val?: number) => void, ...args: DomElementArg[] ) { diff --git a/app/client/widgets/Spinner.ts b/app/client/widgets/Spinner.ts index 42b7ddbc..321f0d6e 100644 --- a/app/client/widgets/Spinner.ts +++ b/app/client/widgets/Spinner.ts @@ -14,8 +14,11 @@ export class Spinner extends NumericTextBox { constructor(field: ViewFieldRec) { super(field); - const resolved = this.autoDispose(ko.computed(() => - buildNumberFormat({numMode: this.options().numMode}).resolvedOptions())); + const resolved = this.autoDispose(ko.computed(() => { + const {numMode} = this.options(); + const docSettings = this.field.documentSettings(); + return buildNumberFormat({numMode}, docSettings).resolvedOptions(); + })); this._stepSize = this.autoDispose(ko.computed(() => { const extraScaling = (this.options().numMode === 'percent') ? 2 : 0; return Math.pow(10, -(this.options().decimals || resolved().minimumFractionDigits) - extraScaling); diff --git a/app/common/BrowserSettings.ts b/app/common/BrowserSettings.ts index cb56edf0..6593b172 100644 --- a/app/common/BrowserSettings.ts +++ b/app/common/BrowserSettings.ts @@ -4,4 +4,6 @@ export interface BrowserSettings { // The browser's timezone, must be one of `momet.tz.names()`. timezone?: string; + // The browser's locale, should be read from Accept-Language header. + locale?: string; } diff --git a/app/common/DocumentSettings.ts b/app/common/DocumentSettings.ts new file mode 100644 index 00000000..3a50fce2 --- /dev/null +++ b/app/common/DocumentSettings.ts @@ -0,0 +1,4 @@ +export interface DocumentSettings { + locale: string; + currency?: string; +} diff --git a/app/common/Locales.ts b/app/common/Locales.ts new file mode 100644 index 00000000..1f9797d3 --- /dev/null +++ b/app/common/Locales.ts @@ -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; + +// 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; + +// 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)); diff --git a/app/common/NumberFormat.ts b/app/common/NumberFormat.ts index daea6161..f9750e08 100644 --- a/app/common/NumberFormat.ts +++ b/app/common/NumberFormat.ts @@ -19,26 +19,25 @@ */ 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. export type NumMode = 'currency' | 'decimal' | 'percent' | 'scientific'; export type NumSign = 'parens'; -// TODO: In the future, locale should be a value associated with the document or the user. -const defaultLocale = 'en-US'; - -// TODO: The currency to use for currency formatting could be made configurable. -const defaultCurrency = 'USD'; - -export interface NumberFormatOptions { +export interface NumberFormatOptions extends FormatOptions { numMode?: NumMode; numSign?: NumSign; decimals?: number; // aka minimum fraction digits maxDecimals?: number; + currency?: string; } -export function buildNumberFormat(options: NumberFormatOptions): Intl.NumberFormat { - const nfOptions: Intl.NumberFormatOptions = parseNumMode(options.numMode); +export function buildNumberFormat(options: NumberFormatOptions, docSettings: DocumentSettings): Intl.NumberFormat { + 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' // 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 // implied by numMode. - const tmp = new Intl.NumberFormat(defaultLocale, nfOptions).resolvedOptions(); + const tmp = new Intl.NumberFormat(docSettings.locale, nfOptions).resolvedOptions(); if (options.maxDecimals !== undefined) { // 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); } - 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) { - case 'currency': return {style: 'currency', currency: defaultCurrency}; + case 'currency': return {style: 'currency', currency, currencyDisplay: 'narrowSymbol' }; case 'decimal': return {useGrouping: true}; case 'percent': return {style: 'percent'}; // TODO 'notation' option (and therefore numMode 'scientific') works on recent Firefox and diff --git a/app/common/ValueFormatter.ts b/app/common/ValueFormatter.ts index 24598c6d..3fd51f90 100644 --- a/app/common/ValueFormatter.ts +++ b/app/common/ValueFormatter.ts @@ -7,9 +7,14 @@ import {buildNumberFormat, NumberFormatOptions} from 'app/common/NumberFormat'; import {decodeObject, GristDateTime} from 'app/plugin/objtypes'; import isPlainObject = require('lodash/isPlainObject'); import * as moment from 'moment-timezone'; +import {DocumentSettings} from 'app/common/DocumentSettings'; 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). */ @@ -46,7 +51,7 @@ export type IsRightTypeFunc = (value: CellValue) => boolean; export class BaseFormatter { 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)) || gristTypes.isRightType('Any')!; } @@ -78,9 +83,9 @@ export class NumericFormatter extends BaseFormatter { private _numFormat: Intl.NumberFormat; private _formatter: (val: number) => string; - constructor(type: string, options: NumberFormatOptions) { - super(type, options); - this._numFormat = buildNumberFormat(options); + constructor(type: string, options: NumberFormatOptions, docSettings: DocumentSettings) { + super(type, options, docSettings); + this._numFormat = buildNumberFormat(options, docSettings); this._formatter = (options.numSign === 'parens') ? this._formatParens : this._formatPlain; } @@ -101,18 +106,22 @@ export class NumericFormatter extends BaseFormatter { } class IntFormatter extends NumericFormatter { - constructor(type: string, opts: object) { - super(type, {decimals: 0, ...opts}); + constructor(type: string, opts: FormatOptions, docSettings: DocumentSettings) { + super(type, {decimals: 0, ...opts}, docSettings); } } +interface DateFormatOptions { + dateFormat?: string; +} + class DateFormatter extends BaseFormatter { private _dateTimeFormat: string; private _timezone: string; - constructor(type: string, opts: {dateFormat?: string}, timezone: string = 'UTC') { - super(type, opts); - this._dateTimeFormat = opts.dateFormat || 'YYYY-MM-DD'; + constructor(type: string, widgetOpts: DateFormatOptions, docSettings: DocumentSettings, timezone: string = 'UTC') { + super(type, widgetOpts, docSettings); + this._dateTimeFormat = widgetOpts.dateFormat || 'YYYY-MM-DD'; this._timezone = timezone; } @@ -123,12 +132,16 @@ class DateFormatter extends BaseFormatter { } } +interface DateTimeFormatOptions extends DateFormatOptions { + timeFormat?: string; +} + 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 timeFormat = opts.timeFormat === undefined ? 'h:mma' : opts.timeFormat; - const dateFormat = (opts.dateFormat || 'YYYY-MM-DD') + " " + timeFormat; - super(type, {dateFormat}, timezone); + const timeFormat = widgetOpts.timeFormat === undefined ? 'h:mma' : widgetOpts.timeFormat; + const dateFormat = (widgetOpts.dateFormat || 'YYYY-MM-DD') + " " + timeFormat; + 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 - * properly convert a value passed to it into the right format for that column. + * Takes column type, widget options and document settings, and returns a constructor + * 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; - return new ctor(type, opts); + return new ctor(type, widgetOpts, docSettings); } diff --git a/app/common/declarations.d.ts b/app/common/declarations.d.ts index c0d0611b..ee2bdda2 100644 --- a/app/common/declarations.d.ts +++ b/app/common/declarations.d.ts @@ -3,3 +3,21 @@ declare module "app/common/MemBuffer" { type MemBuffer = any; export = MemBuffer; } + +declare module "locale-currency/map" { + const Map: Record; + type Map = Record; + 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); + } +} diff --git a/app/common/schema.ts b/app/common/schema.ts index 77d03af6..aff7a8d9 100644 --- a/app/common/schema.ts +++ b/app/common/schema.ts @@ -9,6 +9,7 @@ export const schema = { basketId : "Text", schemaVersion : "Int", timezone : "Text", + documentSettings : "Text", }, "_grist_Tables": { @@ -182,6 +183,7 @@ export interface SchemaTypes { basketId: string; schemaVersion: number; timezone: string; + documentSettings: string; }; "_grist_Tables": { diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index 25d91511..dd409f2d 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -85,6 +85,7 @@ bluebird.promisifyAll(tmp); const MAX_RECENT_ACTIONS = 100; 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. // 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.docStorage.createFile(); 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 // 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(() => this.docStorage.applyStoredActions(getEnvContent(initBundle.stored))); diff --git a/app/server/lib/Client.ts b/app/server/lib/Client.ts index 48d7f1df..6886ebc3 100644 --- a/app/server/lib/Client.ts +++ b/app/server/lib/Client.ts @@ -84,7 +84,8 @@ export class Client { constructor( private _comm: any, private _methods: any, - private _host: string + private _host: string, + private _locale?: string, ) { this.clientId = generateClientId(); } @@ -102,6 +103,10 @@ export class Client { return this._host; } + public get locale(): string|undefined { + return this._locale; + } + public setConnection(websocket: any, reqHost: string, browserSettings: BrowserSettings) { this._websocket = websocket; // Set this._loginState, used by CognitoClient to construct login/logout URLs. diff --git a/app/server/lib/Comm.js b/app/server/lib/Comm.js index 7485a9fa..b841ad9d 100644 --- a/app/server/lib/Comm.js +++ b/app/server/lib/Comm.js @@ -49,6 +49,7 @@ var gutil = require('app/common/gutil'); const {parseFirstUrlPart} = require('app/common/gristUrls'); const version = require('app/common/version'); const {Client} = require('./Client'); +const {localeFromRequest} = require('app/server/lib/ServerLocale'); // Bluebird promisification, to be able to use e.g. websocket.sendAsync method. Promise.promisifyAll(ws.prototype); @@ -216,7 +217,7 @@ Comm.prototype._onWebSocketConnection = async function(websocket, req) { } client.setConnection(websocket, req.headers.host, browserSettings); } 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.setConnection(websocket, req.headers.host, browserSettings); this._clients[client.clientId] = client; diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index 0f93ad8d..1425af3f 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -32,6 +32,7 @@ import { exportToDrive } from "app/server/lib/GoogleExport"; import { googleAuthTokenMiddleware } from "app/server/lib/GoogleAuth"; import * as _ from "lodash"; 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 // 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'); } const browserSettings: BrowserSettings = {}; if (parameters.timezone) { browserSettings.timezone = parameters.timezone; } + browserSettings.locale = localeFromRequest(req); if (uploadId !== undefined) { const result = await this._docManager.importDocToWorkspace(userId, uploadId, null, browserSettings); diff --git a/app/server/lib/DocSession.ts b/app/server/lib/DocSession.ts index c50110d8..704f7596 100644 --- a/app/server/lib/DocSession.ts +++ b/app/server/lib/DocSession.ts @@ -24,6 +24,7 @@ export interface OptDocSession { export function makeOptDocSession(client: Client|null, browserSettings?: BrowserSettings): OptDocSession { if (client && !browserSettings) { browserSettings = client.browserSettings; } + if (client && browserSettings && !browserSettings.locale) { browserSettings.locale = client.locale; } return {client, browserSettings}; } diff --git a/app/server/lib/ExcelFormatter.ts b/app/server/lib/ExcelFormatter.ts index 4ac47755..0918e279 100644 --- a/app/server/lib/ExcelFormatter.ts +++ b/app/server/lib/ExcelFormatter.ts @@ -3,7 +3,7 @@ import {GristType} from 'app/common/gristTypes'; import * as gutil from 'app/common/gutil'; import * as gristTypes from 'app/common/gristTypes'; 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 {Style} from 'exceljs'; import * as moment from 'moment-timezone'; @@ -17,9 +17,9 @@ interface WidgetOptions extends NumberFormatOptions { } class BaseFormatter { 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)) || gristTypes.isRightType('Any')!; this.widgetOptions = opts; @@ -164,11 +164,11 @@ const formatters: Partial> = { }; /** - * 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. * 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; return new ctor(type, opts); } diff --git a/app/server/lib/Export.ts b/app/server/lib/Export.ts index d89c0644..0818437b 100644 --- a/app/server/lib/Export.ts +++ b/app/server/lib/Export.ts @@ -7,6 +7,7 @@ import {buildRowFilter} from 'app/common/RowFilterFunc'; import {SchemaTypes} from 'app/common/schema'; import {SortFunc} from 'app/common/SortFunc'; import {TableData} from 'app/common/TableData'; +import {DocumentSettings} from 'app/common/DocumentSettings'; import {ActiveDoc} from 'app/server/lib/ActiveDoc'; import {RequestWithLogin} from 'app/server/lib/Authorizer'; import {docSessionFromRequest} from 'app/server/lib/DocSession'; @@ -60,6 +61,10 @@ export interface ExportData { * Columns information (primary used for formatting). */ columns: ExportColumn[]; + /** + * Document settings + */ + docSettings: DocumentSettings; } /** @@ -194,12 +199,15 @@ export async function exportTable( tableName = view.name; } + const docInfo = safeRecord(safeTable(docData, '_grist_DocInfo'), 1) as DocInfo; + const docSettings = gutil.safeJsonParse(docInfo.documentSettings, {}); return { tableName, docName: activeDoc.docName, rowIds, access, - columns + columns, + docSettings }; } @@ -277,11 +285,15 @@ export async function exportSection( // filter rows numbers rowIds = rowIds.filter(rowFilter); + const docInfo = safeRecord(safeTable(docData, '_grist_DocInfo'), 1) as DocInfo; + const docSettings = gutil.safeJsonParse(docInfo.documentSettings, {}); + return { tableName: table.tableId, docName: activeDoc.docName, rowIds, access, + docSettings, columns: viewColumns }; } @@ -295,6 +307,7 @@ type GristTables = RowModel<'_grist_Tables'> type GristViewsSectionField = RowModel<'_grist_Views_section_field'> type GristTablesColumn = RowModel<'_grist_Tables_column'> type GristView = RowModel<'_grist_Views'> +type DocInfo = RowModel<'_grist_DocInfo'> // Type for filters passed from the client export interface Filter { colRef: number, filter: string } diff --git a/app/server/lib/ExportCSV.ts b/app/server/lib/ExportCSV.ts index 289006ed..e659b3e4 100644 --- a/app/server/lib/ExportCSV.ts +++ b/app/server/lib/ExportCSV.ts @@ -33,11 +33,12 @@ export async function makeCSV( function convertToCsv({ rowIds, access, - columns: viewColumns + columns: viewColumns, + docSettings }: ExportData) { // 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. const csvMatrix = [viewColumns.map(col => col.label)]; // populate all the rows with values as strings diff --git a/app/server/lib/ServerLocale.ts b/app/server/lib/ServerLocale.ts new file mode 100644 index 00000000..833ba94e --- /dev/null +++ b/app/server/lib/ServerLocale.ts @@ -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; +} diff --git a/app/server/tsconfig.json b/app/server/tsconfig.json index 989b4975..e73dc524 100644 --- a/app/server/tsconfig.json +++ b/app/server/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../buildtools/tsconfig-base.json", "references": [ - { "path": "../common" } + { "path": "../common" }, ], "include": [ "**/*", diff --git a/sandbox/grist/migrations.py b/sandbox/grist/migrations.py index fc26ef51..3b54ec0f 100644 --- a/sandbox/grist/migrations.py +++ b/sandbox/grist/migrations.py @@ -788,3 +788,10 @@ def migration22(tdset): add_column('_grist_Tables_column', 'recalcWhen', 'Int'), 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"}'}) + ]) diff --git a/sandbox/grist/schema.py b/sandbox/grist/schema.py index bcaeb189..9e19974c 100644 --- a/sandbox/grist/schema.py +++ b/sandbox/grist/schema.py @@ -15,7 +15,7 @@ import six import actions -SCHEMA_VERSION = 22 +SCHEMA_VERSION = 23 def make_column(col_id, col_type, formula='', isFormula=False): return { @@ -40,6 +40,9 @@ def schema_create_actions(): # Document timezone. 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. diff --git a/sandbox/grist/useractions.py b/sandbox/grist/useractions.py index a313bb93..d06c4957 100644 --- a/sandbox/grist/useractions.py +++ b/sandbox/grist/useractions.py @@ -193,13 +193,14 @@ class UserActions(object): #---------------------------------------- @useraction - def InitNewDoc(self, timezone): + def InitNewDoc(self, timezone, locale): creation_actions = schema.schema_create_actions() self._engine.out_actions.stored.extend(creation_actions) self._engine.out_actions.direct += [True] * len(creation_actions) self._do_doc_action(actions.AddRecord("_grist_DocInfo", 1, {'schemaVersion': schema.SCHEMA_VERSION, - 'timezone': timezone})) + 'timezone': timezone, + 'documentSettings': json.dumps({'locale': locale})})) # Set up initial ACL data. # NOTE The special records below are not actually used. They were intended for obsolete ACL diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index 4b9aa8c4..59ee6c8f 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -1621,17 +1621,27 @@ export function addSamplesForSuite() { }); } -export async function openUserProfile() { +async function openAccountMenu() { await driver.findWait('.test-dm-account', 1000).click(); // Since the AccountWidget loads orgs and the user data asynchronously, the menu // can expand itself causing the click to land on a wrong button. await waitForServer(); await driver.findWait('.test-usermenu-org', 1000); 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.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 stackWrapOwnMethods(gristUtils);