(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
pull/12/head
George Gevoian 3 years ago
parent e492dfdb22
commit a6e08883e0

@ -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]);

@ -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;

@ -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;
}

@ -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;
}

@ -111,6 +111,8 @@ export class DocModel {
public pages: MTM<PageRec> = this._metaTableModel("_grist_Pages", createPageRec);
public rules: MTM<ACLRuleRec> = this._metaTableModel("_grist_ACLRules", createACLRuleRec);
public docInfoRow: DocInfoRec;
public allTables: KoArray<TableRec>;
public allTableIds: KoArray<string>;
@ -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);

@ -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;
}

@ -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<DocumentSettings>
defaultViewId: ko.Computed<number>;
newDefaultViewId: ko.Computed<number>;
}
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;

@ -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<any>;
// 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>;
fillColor: modelUtil.KoSaveableObservable<string>;
documentSettings: ko.PureComputed<DocumentSettings>;
// Helper which adds/removes/updates field's displayCol to match the formula.
saveDisplayFormula(formula: string): Promise<void>|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());
}

@ -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<void> {
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<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.
const cssDataRow = styled('div', `
margin: 16px 0px;

@ -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));

@ -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) {
// 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.
// <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.
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;

@ -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);
}

@ -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<ISelectorOption<NumMode>> = [
{value: 'currency', label: '$'},
{value: 'decimal', label: ','},
{value: 'percent', label: '%'},
{value: 'scientific', label: 'Exp'}
{value: 'percent', label: '%'},
{value: 'scientific', label: 'Exp'}
];
const signOptions: Array<ISelectorOption<NumSign>> = [
@ -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<Intl.ResolvedNumberFormatOptions>(holder, (use) =>
buildNumberFormat({numMode: use(this.options).numMode}).resolvedOptions());
const resolved = Computed.create<Intl.ResolvedNumberFormatOptions>(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<T>(value: unknown, def: T): number|T {
function numberOrDefault<T>(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<NumberFormatOptions> {
// 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<number|''>,
value: Observable<number | ''>,
defaultValue: Observable<number>,
setFunc: (val?: number) => void, ...args: DomElementArg[]
) {

@ -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);

@ -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;
}

@ -0,0 +1,4 @@
export interface DocumentSettings {
locale: string;
currency?: string;
}

@ -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 * 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

@ -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);
}

@ -3,3 +3,21 @@ declare module "app/common/MemBuffer" {
type MemBuffer = any;
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",
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": {

@ -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)));

@ -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.

@ -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;

@ -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);

@ -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};
}

@ -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<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.
* 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);
}

@ -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 }

@ -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

@ -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",
"references": [
{ "path": "../common" }
{ "path": "../common" },
],
"include": [
"**/*",

@ -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"}'})
])

@ -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.

@ -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

@ -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);

Loading…
Cancel
Save