(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

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

View File

@@ -0,0 +1,49 @@
import {ACSelectItem, buildACSelect} from "app/client/lib/ACSelect";
import {Computed, IDisposableOwner, Observable} from "grainjs";
import {ACIndexImpl} from "app/client/lib/ACIndex";
import {testId} from 'app/client/ui2018/cssVars';
import {currencies} from 'app/common/Locales';
interface CurrencyPickerOptions {
// The label to use in the select menu for the default option.
defaultCurrencyLabel: string;
}
export function buildCurrencyPicker(
owner: IDisposableOwner,
currency: Observable<string|undefined>,
onSave: (value: string|undefined) => void,
{defaultCurrencyLabel}: CurrencyPickerOptions
) {
const currencyItems: ACSelectItem[] = currencies
.map(item => ({
value: item.code,
label: `${item.code} ${item.name}`,
cleanText: `${item.code} ${item.name}`.trim().toLowerCase(),
}));
// Add default currency label option to the very front.
currencyItems.unshift({
label : defaultCurrencyLabel,
value : defaultCurrencyLabel,
cleanText : defaultCurrencyLabel.toLowerCase(),
});
// Create a computed that will display 'Local currency' as a value and label
// when `currency` is undefined.
const valueObs = Computed.create(owner, (use) => use(currency) || defaultCurrencyLabel);
const acIndex = new ACIndexImpl<ACSelectItem>(currencyItems, 200, true);
return buildACSelect(owner,
{
acIndex, valueObs,
save(_, item: ACSelectItem | undefined) {
// Save only if we have found a match
if (!item) {
throw new Error("Invalid currency");
}
// For default value, return undefined to use default currency for document.
onSave(item.value === defaultCurrencyLabel ? undefined : item.value);
}
},
testId("currency-autocomplete")
);
}

View File

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

View File

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

View File

@@ -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[]
) {

View File

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