Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/metabase-types/api/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ export type YAxisScale = NumericScale;
export interface ColumnSettings {
column_title?: string;
number_separators?: string;
number_grouping?: string;
currency?: string;

// some options are untyped
Expand Down
1 change: 1 addition & 0 deletions frontend/src/metabase-types/api/field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export interface Field {
export interface FieldFormattingSettings {
currency?: string;
number_separators?: string;
number_grouping?: string;
}

export interface GetFieldRequest {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/metabase-types/api/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface DateFormattingSettings {

export interface NumberFormattingSettings {
number_separators?: string;
number_grouping?: string;
}

export interface CurrencyFormattingSettings {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const DEFAULT_FORMATTING_SETTINGS: FormattingSettings = {
},
"type/Number": {
number_separators: ".,",
number_grouping: "standard",
},
"type/Currency": {
currency: "USD",
Expand Down Expand Up @@ -56,6 +57,8 @@ export function FormattingWidget() {

const { number_separators: numberSeparators } =
localValue?.["type/Number"] || {};
const { number_grouping: numberGrouping } =
localValue?.["type/Number"] || {};

const { currency, currency_style: currencyStyle } =
localValue?.["type/Currency"] || {};
Expand Down Expand Up @@ -173,6 +176,25 @@ export function FormattingWidget() {
})
}
/>
<FormattingInput
id="number_grouping"
label={t`Digit grouping`}
value={numberGrouping}
inputType="select"
options={[
{ label: "100,000.00", value: "standard" },
{ label: "1,00,000.00", value: "south_asian" },
]}
onChange={(newValue) =>
handleChange({
...localValue,
"type/Number": {
...localValue?.["type/Number"],
number_grouping: newValue as string,
},
})
}
/>
</SettingsSection>
<SettingsSection title={t`Currency`}>
<FormattingInput
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ describe("FormattingWidget", () => {
},
"type/Number": {
number_separators: ".",
number_grouping: "standard",
},
"type/Temporal": {
date_abbreviate: true,
Expand Down
66 changes: 51 additions & 15 deletions frontend/src/metabase/lib/formatting/numbers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,46 @@ import { COMPACT_CURRENCY_OPTIONS, getCurrencySymbol } from "./currency";

const DISPLAY_COMPACT_DECIMALS_CUTOFF = 1000;

const FIXED_NUMBER_FORMATTER = new Intl.NumberFormat("en", {
useGrouping: true,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});

const PRECISION_NUMBER_FORMATTER = new Intl.NumberFormat("en", {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
const NUMBER_GROUPING_SOUTH_ASIAN = "south_asian";
const FIXED_NUMBER_FORMATTERS = new Map<string, Intl.NumberFormat>();
const PRECISION_NUMBER_FORMATTERS = new Map<string, Intl.NumberFormat>();

function getLocaleForGrouping(grouping?: string) {
return grouping === NUMBER_GROUPING_SOUTH_ASIAN ? "en-IN" : "en";
}

function getFixedNumberFormatter(grouping?: string) {
const key = grouping === NUMBER_GROUPING_SOUTH_ASIAN ? grouping : "standard";

if (!FIXED_NUMBER_FORMATTERS.has(key)) {
FIXED_NUMBER_FORMATTERS.set(
key,
new Intl.NumberFormat(getLocaleForGrouping(grouping), {
useGrouping: true,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}),
);
}

return FIXED_NUMBER_FORMATTERS.get(key)!;
}

function getPrecisionNumberFormatter(grouping?: string) {
const key = grouping === NUMBER_GROUPING_SOUTH_ASIAN ? grouping : "standard";

if (!PRECISION_NUMBER_FORMATTERS.has(key)) {
PRECISION_NUMBER_FORMATTERS.set(
key,
new Intl.NumberFormat(getLocaleForGrouping(grouping), {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}),
);
}

return PRECISION_NUMBER_FORMATTERS.get(key)!;
}

export type FormatNumberOptions = {
_numberFormatter?: Intl.NumberFormat;
Expand All @@ -29,6 +59,7 @@ export type FormatNumberOptions = {
minimumSignificantDigits?: number;
negativeInParentheses?: boolean;
number_separators?: string;
number_grouping?: string;
number_style?: string;
scale?: number;
type?: string;
Expand Down Expand Up @@ -158,7 +189,7 @@ export function formatNumber(
console.warn("Error formatting number", e);
// fall back to old, less capable formatter
// NOTE: does not handle things like currency, percent
return FIXED_NUMBER_FORMATTER.format(number);
return getFixedNumberFormatter(options.number_grouping).format(number);
}
}
}
Expand All @@ -184,9 +215,10 @@ export function numberFormatterForOptions(options: FormatNumberOptions) {
...getDefaultNumberOptions(options),
...options,
};
// always use "en" locale so we have known number separators we can replace depending on number_separators option
const locale = getLocaleForGrouping(options.number_grouping);
// always use a known locale so we have known number separators we can replace depending on number_separators option
// TODO: if we do that how can we get localized currency names?
return new Intl.NumberFormat("en", {
return new Intl.NumberFormat(locale, {
style: options.number_style as Intl.NumberFormatOptions["style"],
currency: options.currency,
currencyDisplay:
Expand Down Expand Up @@ -253,7 +285,9 @@ function _formatNumberCompact(
let formatted;
if (abs(value) < DISPLAY_COMPACT_DECIMALS_CUTOFF) {
// 0.1 => 0.1
formatted = PRECISION_NUMBER_FORMATTER.format(value);
formatted = getPrecisionNumberFormatter(
options.number_grouping,
).format(value);

// round the number if decimals is set and the result is more compact
if (options.decimals != null && typeof value === "number") {
Expand Down Expand Up @@ -281,7 +315,9 @@ function _formatNumberCompact(

formatted = compactNumber(value, decimals);
} else {
formatted = PRECISION_NUMBER_FORMATTER.format(value);
formatted = getPrecisionNumberFormatter(
options.number_grouping,
).format(value);
}

return options?.number_separators !== DEFAULT_NUMBER_SEPARATORS
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/metabase/lib/formatting/numbers.unit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,19 @@ describe("formatNumber", () => {
).toEqual("1.000015e+0");
});

it("should support south asian digit grouping", () => {
expect(
formatNumber(12345678.9, { number_grouping: "south_asian" }),
).toEqual("1,23,45,678.9");

expect(
formatNumber(12345678.9, {
number_grouping: "south_asian",
number_separators: ",.",
}),
).toEqual("1.23.45.678,9");
});

describe("compact mode with decimals setting", () => {
it("should respect various decimal settings in compact mode (metabase#63145)", () => {
const value = 1234567;
Expand Down
1 change: 1 addition & 0 deletions frontend/src/metabase/lib/formatting/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface OptionsType extends TimeOnlyOptions {
negativeInParentheses?: boolean;
noRange?: boolean;
number_separators?: string;
number_grouping?: string;
number_style?: string;
prefix?: string;
remap?: any;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ const KEYS_TO_COMPARE = new Set([
"currency",
"currency_style",
"number_separators",
"number_grouping",
"decimals",
"scale",
"prefix",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/metabase/visualizations/echarts/pie/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export function getPieChartFormatters(
formatValue(value, {
column: metricColSettings.column,
number_separators: metricColSettings.number_separators as string,
number_grouping: metricColSettings.number_grouping as string,
number_style: "percent",
decimals,
}),
Expand Down
1 change: 1 addition & 0 deletions frontend/src/metabase/visualizations/lib/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ const KEYS_TO_COMPARE = new Set([
"currency",
"currency_style",
"number_separators",
"number_grouping",
"decimals",
"scale",
"prefix",
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/metabase/visualizations/lib/settings/column.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
getDefaultCurrency,
getDefaultCurrencyInHeader,
getDefaultCurrencyStyle,
getDefaultNumberGrouping,
getDefaultNumberSeparators,
getDefaultNumberStyle,
} from "metabase/visualizations/shared/settings/column";
Expand Down Expand Up @@ -338,6 +339,19 @@ export const NUMBER_COLUMN_SETTINGS = {
},
getDefault: getDefaultNumberSeparators,
},
number_grouping: {
get title() {
return t`Digit grouping`;
},
widget: "select",
props: {
options: [
{ name: "100,000.00", value: "standard" },
{ name: "1,00,000.00", value: "south_asian" },
],
},
getDefault: getDefaultNumberGrouping,
},
decimals: {
get title() {
return t`Number of decimal places`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,7 @@ export function getDefaultCurrencyInHeader() {
export function getDefaultNumberSeparators() {
return ".,";
}

export function getDefaultNumberGrouping() {
return "standard";
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ export const getFormatters = (
const percentXTicksFormatter = (percent: NumberLike) => {
const column = metricColumn.column;
const number_separators = settings.column(column)?.number_separators;
const number_grouping = settings.column(column)?.number_grouping;
const options = getFormattingOptionsWithoutScaling({
column,
number_separators,
number_grouping,
jsx: false,
number_style: "percent",
decimals: 2,
Expand Down
13 changes: 13 additions & 0 deletions frontend/test/metabase/lib/formatting.unit.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,19 @@ describe("formatting", () => {
expect(formatNumber(-99999999.9, options)).toEqual("-99.999.999,9");
});

it("should support south asian digit grouping", () => {
expect(
formatNumber(12345678.9, { number_grouping: "south_asian" }),
).toEqual("1,23,45,678.9");

expect(
formatNumber(12345678.9, {
number_grouping: "south_asian",
number_separators: ",.",
}),
).toEqual("1.23.45.678,9");
});

it("should format to 2 significant digits", () => {
expect(formatNumber(1 / 3)).toEqual("0.33");
expect(formatNumber(-1 / 3)).toEqual("-0.33");
Expand Down
12 changes: 11 additions & 1 deletion src/metabase/appearance/settings.clj
Original file line number Diff line number Diff line change
Expand Up @@ -352,13 +352,23 @@ See [fonts](../configuring-metabase/fonts.md).")
", "
".’"})

(def available-number-groupings
"Digit grouping styles supported for number formatting."
#{"standard"
"south_asian"})

(defn- validate-custom-formatting!
[new-value]
(when-some [separators (some-> new-value :type/Number :number_separators)]
(when-not (avaialable-number-separators separators)
(throw (ex-info (tru "Invalid number separators.")
{:separators separators
:available-separators avaialable-number-separators})))))
:available-separators avaialable-number-separators}))))
(when-some [grouping (some-> new-value :type/Number :number_grouping)]
(when-not (available-number-groupings grouping)
(throw (ex-info (tru "Invalid number grouping.")
{:grouping grouping
:available-groupings available-number-groupings})))))

(defsetting custom-formatting
(deferred-tru "Object keyed by type, containing formatting settings")
Expand Down
13 changes: 10 additions & 3 deletions src/metabase/formatter/impl.clj
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@
(::mb.viz/currency column-settings)))))

{::mb.viz/keys [number-separators decimals scale number-style
prefix suffix currency-style currency]} global-settings
prefix suffix currency-style currency number-grouping]} global-settings
currency (when currency?
(keyword (or currency "USD")))
integral? (and (isa? (or effective_type base_type) :type/Integer) (integer? (or scale 1)))
Expand All @@ -151,11 +151,18 @@
[decimal grouping] (or number-separators
(get-in (appearance/custom-formatting) [:type/Number :number_separators])
".,")
number-grouping (or number-grouping
(get-in (appearance/custom-formatting) [:type/Number :number_grouping])
"standard")
symbols (doto (DecimalFormatSymbols.)
(cond-> decimal (.setDecimalSeparator decimal))
(cond-> grouping (.setGroupingSeparator grouping)))
base (cond-> (if (or scientific? relation?) "0" "#,##0")
(not grouping) (str/replace #"," ""))
base (let [base-pattern (cond
(or scientific? relation?) "0"
(= number-grouping "south_asian") "#,##,##0"
:else "#,##0")]
(cond-> base-pattern
(not grouping) (str/replace #"," "")))
;; A small cache of decimal-digits->formatter to avoid creating new ones all the time. This cache is bound by
;; the maximum number of decimal digits we can format which is 20 (constrained by `digits-after-decimal`).
fmtr-cache (volatile! {})]
Expand Down
17 changes: 9 additions & 8 deletions src/metabase/models/visualization_settings.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -304,14 +304,15 @@
:time_enabled ::time-enabled
:time_style ::time-style
:number_style ::number-style
:currency ::currency
:currency_style ::currency-style
:currency_in_header ::currency-in-header
:number_separators ::number-separators
:decimals ::decimals
:scale ::scale
:prefix ::prefix
:suffix ::suffix
:currency ::currency
:currency_style ::currency-style
:currency_in_header ::currency-in-header
:number_separators ::number-separators
:number_grouping ::number-grouping
:decimals ::decimals
:scale ::scale
:prefix ::prefix
:suffix ::suffix
:view_as ::view-as
:link_text ::link-text
:link_url ::link-url
Expand Down
Loading