(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:
George Gevoian
2021-08-26 09:35:11 -07:00
parent e492dfdb22
commit a6e08883e0
36 changed files with 405 additions and 84 deletions

View File

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

View File

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

53
app/common/Locales.ts Normal file
View 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));

View File

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

View File

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

View File

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

View File

@@ -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": {