diff --git a/frontend/src/metabase-types/api/card.ts b/frontend/src/metabase-types/api/card.ts index 1be0f5a6caa3..e7837f8b6d92 100644 --- a/frontend/src/metabase-types/api/card.ts +++ b/frontend/src/metabase-types/api/card.ts @@ -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 diff --git a/frontend/src/metabase-types/api/field.ts b/frontend/src/metabase-types/api/field.ts index 6b7be5e35bdf..7a4c4f78586d 100644 --- a/frontend/src/metabase-types/api/field.ts +++ b/frontend/src/metabase-types/api/field.ts @@ -117,6 +117,7 @@ export interface Field { export interface FieldFormattingSettings { currency?: string; number_separators?: string; + number_grouping?: string; } export interface GetFieldRequest { diff --git a/frontend/src/metabase-types/api/settings.ts b/frontend/src/metabase-types/api/settings.ts index 144685d3c472..40704c0c1d5c 100644 --- a/frontend/src/metabase-types/api/settings.ts +++ b/frontend/src/metabase-types/api/settings.ts @@ -24,6 +24,7 @@ export interface DateFormattingSettings { export interface NumberFormattingSettings { number_separators?: string; + number_grouping?: string; } export interface CurrencyFormattingSettings { diff --git a/frontend/src/metabase/admin/settings/components/widgets/FormattingWidget.tsx b/frontend/src/metabase/admin/settings/components/widgets/FormattingWidget.tsx index 841b75f21ae5..c55390609737 100644 --- a/frontend/src/metabase/admin/settings/components/widgets/FormattingWidget.tsx +++ b/frontend/src/metabase/admin/settings/components/widgets/FormattingWidget.tsx @@ -24,6 +24,7 @@ const DEFAULT_FORMATTING_SETTINGS: FormattingSettings = { }, "type/Number": { number_separators: ".,", + number_grouping: "standard", }, "type/Currency": { currency: "USD", @@ -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"] || {}; @@ -173,6 +176,25 @@ export function FormattingWidget() { }) } /> + + handleChange({ + ...localValue, + "type/Number": { + ...localValue?.["type/Number"], + number_grouping: newValue as string, + }, + }) + } + /> { }, "type/Number": { number_separators: ".", + number_grouping: "standard", }, "type/Temporal": { date_abbreviate: true, diff --git a/frontend/src/metabase/lib/formatting/numbers.tsx b/frontend/src/metabase/lib/formatting/numbers.tsx index 2cc056bedb18..1e694b8dee2b 100644 --- a/frontend/src/metabase/lib/formatting/numbers.tsx +++ b/frontend/src/metabase/lib/formatting/numbers.tsx @@ -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(); +const PRECISION_NUMBER_FORMATTERS = new Map(); + +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; @@ -29,6 +59,7 @@ export type FormatNumberOptions = { minimumSignificantDigits?: number; negativeInParentheses?: boolean; number_separators?: string; + number_grouping?: string; number_style?: string; scale?: number; type?: string; @@ -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); } } } @@ -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: @@ -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") { @@ -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 diff --git a/frontend/src/metabase/lib/formatting/numbers.unit.spec.ts b/frontend/src/metabase/lib/formatting/numbers.unit.spec.ts index 2492f8e0ccdd..2591d1df3784 100644 --- a/frontend/src/metabase/lib/formatting/numbers.unit.spec.ts +++ b/frontend/src/metabase/lib/formatting/numbers.unit.spec.ts @@ -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; diff --git a/frontend/src/metabase/lib/formatting/types.ts b/frontend/src/metabase/lib/formatting/types.ts index a2ea4b698bc3..32599ffef4fc 100644 --- a/frontend/src/metabase/lib/formatting/types.ts +++ b/frontend/src/metabase/lib/formatting/types.ts @@ -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; diff --git a/frontend/src/metabase/visualizations/echarts/cartesian/model/axis.ts b/frontend/src/metabase/visualizations/echarts/cartesian/model/axis.ts index 626a7725546b..63bc07e0dd3f 100644 --- a/frontend/src/metabase/visualizations/echarts/cartesian/model/axis.ts +++ b/frontend/src/metabase/visualizations/echarts/cartesian/model/axis.ts @@ -72,6 +72,7 @@ const KEYS_TO_COMPARE = new Set([ "currency", "currency_style", "number_separators", + "number_grouping", "decimals", "scale", "prefix", diff --git a/frontend/src/metabase/visualizations/echarts/pie/format.ts b/frontend/src/metabase/visualizations/echarts/pie/format.ts index b3df6c71cad2..4dcbd454d1d7 100644 --- a/frontend/src/metabase/visualizations/echarts/pie/format.ts +++ b/frontend/src/metabase/visualizations/echarts/pie/format.ts @@ -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, }), diff --git a/frontend/src/metabase/visualizations/lib/settings.js b/frontend/src/metabase/visualizations/lib/settings.js index b4d7d7beb17f..84bf8acf0340 100644 --- a/frontend/src/metabase/visualizations/lib/settings.js +++ b/frontend/src/metabase/visualizations/lib/settings.js @@ -281,6 +281,7 @@ const KEYS_TO_COMPARE = new Set([ "currency", "currency_style", "number_separators", + "number_grouping", "decimals", "scale", "prefix", diff --git a/frontend/src/metabase/visualizations/lib/settings/column.js b/frontend/src/metabase/visualizations/lib/settings/column.js index cc523198fbb7..6dffaf9e377d 100644 --- a/frontend/src/metabase/visualizations/lib/settings/column.js +++ b/frontend/src/metabase/visualizations/lib/settings/column.js @@ -23,6 +23,7 @@ import { getDefaultCurrency, getDefaultCurrencyInHeader, getDefaultCurrencyStyle, + getDefaultNumberGrouping, getDefaultNumberSeparators, getDefaultNumberStyle, } from "metabase/visualizations/shared/settings/column"; @@ -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`; diff --git a/frontend/src/metabase/visualizations/shared/settings/column.ts b/frontend/src/metabase/visualizations/shared/settings/column.ts index a9a72be0c8e2..474672c4cb69 100644 --- a/frontend/src/metabase/visualizations/shared/settings/column.ts +++ b/frontend/src/metabase/visualizations/shared/settings/column.ts @@ -59,3 +59,7 @@ export function getDefaultCurrencyInHeader() { export function getDefaultNumberSeparators() { return ".,"; } + +export function getDefaultNumberGrouping() { + return "standard"; +} diff --git a/frontend/src/metabase/visualizations/visualizations/RowChart/utils/format.ts b/frontend/src/metabase/visualizations/visualizations/RowChart/utils/format.ts index 167a372a2761..6ea45bc2a32a 100644 --- a/frontend/src/metabase/visualizations/visualizations/RowChart/utils/format.ts +++ b/frontend/src/metabase/visualizations/visualizations/RowChart/utils/format.ts @@ -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, diff --git a/frontend/test/metabase/lib/formatting.unit.spec.js b/frontend/test/metabase/lib/formatting.unit.spec.js index 225e35651a74..bee9f88b39c6 100644 --- a/frontend/test/metabase/lib/formatting.unit.spec.js +++ b/frontend/test/metabase/lib/formatting.unit.spec.js @@ -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"); diff --git a/src/metabase/appearance/settings.clj b/src/metabase/appearance/settings.clj index 4a02c48a8101..efa2c1ec324f 100644 --- a/src/metabase/appearance/settings.clj +++ b/src/metabase/appearance/settings.clj @@ -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") diff --git a/src/metabase/formatter/impl.clj b/src/metabase/formatter/impl.clj index e850397a6b22..4f240c6d1863 100644 --- a/src/metabase/formatter/impl.clj +++ b/src/metabase/formatter/impl.clj @@ -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))) @@ -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! {})] diff --git a/src/metabase/models/visualization_settings.cljc b/src/metabase/models/visualization_settings.cljc index 0d91c9e5a6e4..870256e9732a 100644 --- a/src/metabase/models/visualization_settings.cljc +++ b/src/metabase/models/visualization_settings.cljc @@ -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 diff --git a/src/metabase/query_processor/streaming/xlsx.clj b/src/metabase/query_processor/streaming/xlsx.clj index 4e1e3ca0947b..7b57869288e5 100644 --- a/src/metabase/query_processor/streaming/xlsx.clj +++ b/src/metabase/query_processor/streaming/xlsx.clj @@ -49,6 +49,7 @@ "If any of these settings are present, we should format the column as a number." #{::mb.viz/number-style ::mb.viz/number-separators + ::mb.viz/number-grouping ::mb.viz/currency ::mb.viz/currency-style ::mb.viz/currency-in-header @@ -96,6 +97,7 @@ (not (seq (dissoc format-settings ::mb.viz/number-style ::mb.viz/number-separators + ::mb.viz/number-grouping ::mb.viz/scale ::mb.viz/prefix ::mb.viz/suffix))))) @@ -104,13 +106,23 @@ "Returns format strings for a number column corresponding to the given settings. The first value in the returned list should be used for integers, or numbers that round to integers. The second number should be used for all other values." - [{::mb.viz/keys [prefix suffix number-style number-separators currency-in-header decimals] :as format-settings}] + [{::mb.viz/keys [prefix suffix number-style number-separators number-grouping currency-in-header decimals] :as format-settings}] (let [format-strings - (let [base-string (if (= number-separators ".") - ;; Omit thousands separator if omitted in the format settings. Otherwise ignore - ;; number separator settings, since custom separators are not supported in XLSX. - "###0" - "#,##0") + (let [number-separators (or number-separators ".,") + grouping (second number-separators) + base-with-grouping (cond + (= number-separators ".") + ;; Omit thousands separator if omitted in the format settings. Otherwise ignore + ;; number separator settings, since custom separators are not supported in XLSX. + "###0" + + (= number-grouping "south_asian") + "#,##,##0" + + :else "#,##0") + base-string (if grouping + base-with-grouping + (str/replace base-with-grouping #"," "")) decimals (or decimals 2) base-strings (if (unformatted-number? format-settings) ;; [int-format, float-format] diff --git a/src/metabase/util/formatting/internal/numbers.clj b/src/metabase/util/formatting/internal/numbers.clj index 5f29d7b2beff..79f5e604c20e 100644 --- a/src/metabase/util/formatting/internal/numbers.clj +++ b/src/metabase/util/formatting/internal/numbers.clj @@ -45,9 +45,17 @@ replaced." #{:BTC}) +(def ^:private south-asian-grouping "south_asian") + (defn- active-locale [options] - (if (:locale options) + (cond + (:locale options) (Locale. (:locale options)) + + (= (:number-grouping options) south-asian-grouping) + (Locale. "en" "IN") + + :else (Locale/getDefault))) (defn- number-formatter-for-options-baseline diff --git a/src/metabase/util/formatting/internal/numbers.cljs b/src/metabase/util/formatting/internal/numbers.cljs index 75a24c477ad1..1e0341a3e283 100644 --- a/src/metabase/util/formatting/internal/numbers.cljs +++ b/src/metabase/util/formatting/internal/numbers.cljs @@ -8,6 +8,12 @@ [metabase.util.formatting.internal.numbers-core :as core])) (def ^:private default-number-separators ".,") +(def ^:private south-asian-grouping "south_asian") + +(defn- locale-for-options [options] + (if (= (:number-grouping options) south-asian-grouping) + "en-IN" + "en")) (defn- adjust-number-separators [text separators] (if (and separators @@ -51,7 +57,7 @@ (let [default-fraction-digits (when (= (:number-style options) "currency") 2)] (js/Intl.NumberFormat. - "en" + (locale-for-options options) (clj->js (u/remove-nils {:style (when-not (= (:number-style options) "scientific") (:number-style options "decimal")) diff --git a/test/metabase/appearance/settings_test.clj b/test/metabase/appearance/settings_test.clj index eb578b1f4608..446e83afd654 100644 --- a/test/metabase/appearance/settings_test.clj +++ b/test/metabase/appearance/settings_test.clj @@ -184,9 +184,21 @@ (let [violating-separators ".'"] (is (thrown-with-msg? Exception #"Invalid number separators." + (appearance.settings/custom-formatting! + #:type{:Temporal {:date_style "MMMM D, YYYY" + :time_style "h:mm A" + :date_abbreviate false} + :Number {:number_separators violating-separators} + :Currency {:currency "USD" :currency_style "symbol"}})))))) + +(deftest custom-formatting-number-grouping-test + (testing "Only valid values can be set as number grouping" + (let [violating-grouping "broken"] + (is (thrown-with-msg? + Exception #"Invalid number grouping." (appearance.settings/custom-formatting! #:type{:Temporal {:date_style "MMMM D, YYYY" :time_style "h:mm A" :date_abbreviate false} - :Number {:number_separators violating-separators} + :Number {:number_grouping violating-grouping} :Currency {:currency "USD" :currency_style "symbol"}})))))) diff --git a/test/metabase/formatter/impl_test.clj b/test/metabase/formatter/impl_test.clj index b7eda5c86fef..09ed3e292b92 100644 --- a/test/metabase/formatter/impl_test.clj +++ b/test/metabase/formatter/impl_test.clj @@ -51,7 +51,12 @@ (is (= "12,345.54" (fmt-part {::mb.viz/decimals 2}))) (is (= "12,345.5432000" (fmt-part {::mb.viz/decimals 7}))) (is (= "12,346" (fmt-part {::mb.viz/decimals 0}))) - (is (= "2" (fmt-fn 2 nil))))))) + (is (= "2" (fmt-fn 2 nil))) + (is (= "1,23,45,678.9" (fmt-fn 12345678.94 {::mb.viz/number-grouping "south_asian" + ::mb.viz/decimals 1}))) + (is (= "1.23.45.678,9" (fmt-fn 12345678.94 {::mb.viz/number-grouping "south_asian" + ::mb.viz/decimals 1 + ::mb.viz/number-separators ",."}))))))) (deftest scientific-notation-formatting-test (doseq [{:keys [name fmt-fn]} formatters] diff --git a/test/metabase/query_processor/streaming/xlsx_test.clj b/test/metabase/query_processor/streaming/xlsx_test.clj index 48b3fae506ce..2d4e3d26ccc0 100644 --- a/test/metabase/query_processor/streaming/xlsx_test.clj +++ b/test/metabase/query_processor/streaming/xlsx_test.clj @@ -103,6 +103,13 @@ (is (= ["#,##0" "#,##0.##"] (format-string {::mb.viz/number-separators ".,"}))) (is (= ["#,##0" "#,##0.##"] (format-string {::mb.viz/number-separators ".’"})))))))) +(deftest format-settings->format-string-test-2b5 + (mt/with-temporary-setting-values [custom-formatting {}] + (testing "General number formatting" + (testing "South Asian grouping" + (is (= ["#,##,##0" "#,##,##0.##"] + (format-string {::mb.viz/number-grouping "south_asian"}))))))) + (deftest format-settings->format-string-test-2c (mt/with-temporary-setting-values [custom-formatting {}] (testing "General number formatting"