mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Simple localization support and currency selector.
Summary: - Grist document has a associated "locale" setting that affects how currency is formatted. - Currency selector for number format. Test Plan: not done Reviewers: dsagal Reviewed By: dsagal Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D2977
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
4
app/common/DocumentSettings.ts
Normal file
4
app/common/DocumentSettings.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface DocumentSettings {
|
||||
locale: string;
|
||||
currency?: string;
|
||||
}
|
||||
53
app/common/Locales.ts
Normal file
53
app/common/Locales.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import LocaleCurrency = require('locale-currency/map');
|
||||
import {nativeCompare} from 'app/common/gutil';
|
||||
|
||||
const localeCodes = [
|
||||
"es-AR", "hy-AM", "en-AU", "az-AZ", "be-BY", "quz-BO", "pt-BR",
|
||||
"bg-BG", "en-CA", "arn-CL", "es-CO", "hr-HR", "cs-CZ", "da-DK",
|
||||
"es-EC", "ar-EG", "fi-FI", "fr-FR", "ka-GE", "de-DE", "el-GR", "en-HK",
|
||||
"hu-HU", "hi-IN", "id-ID", "ga-IE", "ar-IL", "it-IT", "ja-JP", "kk-KZ",
|
||||
"lv-LV", "lt-LT", "es-MX", "mn-MN", "my-MM", "nl-NL", "nb-NO",
|
||||
"es-PY", "ceb-PH", "pl-PL", "pt-PT", "ro-RO", "ru-RU", "sr-RS",
|
||||
"sk-SK", "sl-SI", "ko-KR", "es-ES", "sv-SE", "de-CH", "zh-TW", "th-TH",
|
||||
"tr-TR", "uk-UA", "en-GB", "en-US", "es-UY", "es-VE", "vi-VN"
|
||||
];
|
||||
|
||||
export interface Locale {
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export let locales: Readonly<Locale[]>;
|
||||
|
||||
// Intl.DisplayNames is only supported on recent browsers, so proceed with caution.
|
||||
try {
|
||||
const localeDisplay = new Intl.DisplayNames('en', {type: 'region'});
|
||||
locales = localeCodes.map(code => {
|
||||
return { name: localeDisplay.of(new Intl.Locale(code).region), code };
|
||||
});
|
||||
} catch {
|
||||
// Fall back to using the locale code as the display name.
|
||||
locales = localeCodes.map(code => ({ name: code, code }));
|
||||
}
|
||||
|
||||
export interface Currency {
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export let currencies: Readonly<Currency[]>;
|
||||
|
||||
// Intl.DisplayNames is only supported on recent browsers, so proceed with caution.
|
||||
try {
|
||||
const currencyDisplay = new Intl.DisplayNames('en', {type: 'currency'});
|
||||
currencies = [...new Set(Object.values(LocaleCurrency))].map(code => {
|
||||
return { name: currencyDisplay.of(code), code };
|
||||
});
|
||||
} catch {
|
||||
// Fall back to using the currency code as the display name.
|
||||
currencies = [...new Set(Object.values(LocaleCurrency))].map(code => {
|
||||
return { name: code, code };
|
||||
});
|
||||
}
|
||||
|
||||
currencies = [...currencies].sort((a, b) => nativeCompare(a.code, b.code));
|
||||
@@ -19,26 +19,25 @@
|
||||
*/
|
||||
|
||||
import {clamp} from 'app/common/gutil';
|
||||
import * 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);
|
||||
}
|
||||
|
||||
18
app/common/declarations.d.ts
vendored
18
app/common/declarations.d.ts
vendored
@@ -3,3 +3,21 @@ declare module "app/common/MemBuffer" {
|
||||
type MemBuffer = any;
|
||||
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": {
|
||||
|
||||
Reference in New Issue
Block a user