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/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: "western",
},
"type/Currency": {
currency: "USD",
Expand Down Expand Up @@ -54,7 +55,7 @@ export function FormattingWidget() {
time_style: timeStyle,
} = localValue?.["type/Temporal"] || {};

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

const { currency, currency_style: currencyStyle } =
Expand Down Expand Up @@ -151,6 +152,25 @@ export function FormattingWidget() {
/>
</SettingsSection>
<SettingsSection title={t`Numbers`}>
<FormattingInput
id="number_grouping"
label={t`Grouping style`}
value={numberGrouping}
inputType="radio"
options={[
{ label: "10,000,000 (Western)", value: "western" },
{ label: "1,00,00,000 (Indian)", value: "indian" },
]}
onChange={(newValue) =>
handleChange({
...localValue,
"type/Number": {
...localValue?.["type/Number"],
number_grouping: newValue as string,
},
})
}
/>
<FormattingInput
id="number_separators"
label={t`Separator style`}
Expand All @@ -161,7 +181,7 @@ export function FormattingWidget() {
{ label: "100 000,00", value: ", " },
{ label: "100.000,00", value: ",." },
{ label: "100000.00", value: "." },
{ label: "100000.00", value: "." },
{ label: "100'000.00", value: ".'" },
]}
onChange={(newValue) =>
handleChange({
Expand Down
9 changes: 7 additions & 2 deletions frontend/src/metabase/lib/formatting/numbers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,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 @@ -184,9 +185,13 @@ 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
// Use locale based on number_grouping to get correct grouping pattern
// - "en-IN" for Indian numbering (3-2-2: 1,00,00,000)
// - "en" for Western numbering (3-3-3: 10,000,000) - default
// We use "en" as default 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", {
const locale = options.number_grouping === "indian" ? "en-IN" : "en";
return new Intl.NumberFormat(locale, {
style: options.number_style as Intl.NumberFormatOptions["style"],
currency: options.currency,
currencyDisplay:
Expand Down
117 changes: 117 additions & 0 deletions frontend/src/metabase/lib/formatting/numbers.unit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,120 @@ describe("formatNumber with scale (multiply function)", () => {
expect(formatNumber(BigInt(-5), { scale: 2.5 })).toBe("-12.5");
});
});

describe("formatNumber with number_grouping", () => {
describe("Western grouping (3-3-3)", () => {
it("should format numbers with Western grouping (default)", () => {
expect(formatNumber(10000000)).toBe("10,000,000");
expect(formatNumber(1000000)).toBe("1,000,000");
expect(formatNumber(100000)).toBe("100,000");
expect(formatNumber(10000)).toBe("10,000");
expect(formatNumber(1000)).toBe("1,000");
});

it("should format numbers with explicit Western grouping", () => {
expect(formatNumber(10000000, { number_grouping: "western" })).toBe(
"10,000,000",
);
expect(formatNumber(1000000, { number_grouping: "western" })).toBe(
"1,000,000",
);
expect(formatNumber(100000, { number_grouping: "western" })).toBe(
"100,000",
);
});
});

describe("Indian grouping (3-2-2)", () => {
it("should format numbers with Indian grouping", () => {
expect(formatNumber(10000000, { number_grouping: "indian" })).toBe(
"1,00,00,000",
);
expect(formatNumber(1000000, { number_grouping: "indian" })).toBe(
"10,00,000",
);
expect(formatNumber(100000, { number_grouping: "indian" })).toBe(
"1,00,000",
);
expect(formatNumber(10000, { number_grouping: "indian" })).toBe("10,000");
expect(formatNumber(1000, { number_grouping: "indian" })).toBe("1,000");
});

it("should format large numbers with Indian grouping", () => {
expect(formatNumber(100000000, { number_grouping: "indian" })).toBe(
"10,00,00,000",
);
expect(formatNumber(1000000000, { number_grouping: "indian" })).toBe(
"1,00,00,00,000",
);
});

it("should format negative numbers with Indian grouping", () => {
expect(formatNumber(-10000000, { number_grouping: "indian" })).toBe(
"-1,00,00,000",
);
expect(formatNumber(-1000000, { number_grouping: "indian" })).toBe(
"-10,00,000",
);
});

it("should format decimal numbers with Indian grouping", () => {
expect(
formatNumber(10000000.5, {
number_grouping: "indian",
maximumFractionDigits: 2,
}),
).toBe("1,00,00,000.5");
expect(
formatNumber(1234567.89, {
number_grouping: "indian",
maximumFractionDigits: 2,
}),
).toBe("12,34,567.89");
});
});

describe("Indian grouping with different separators", () => {
it("should combine Indian grouping with European separators", () => {
expect(
formatNumber(10000000, {
number_grouping: "indian",
number_separators: ",.",
}),
).toBe("1.00.00.000");
});

it("should combine Indian grouping with space separators", () => {
expect(
formatNumber(10000000, {
number_grouping: "indian",
number_separators: ", ",
}),
).toBe("1 00 00 000");
});
});

describe("Indian grouping with currency", () => {
it("should format INR currency with Indian grouping", () => {
const result = formatNumber(10000000, {
number_style: "currency",
currency: "INR",
currency_style: "symbol",
number_grouping: "indian",
});
// INR symbol should be present with Indian grouping
expect(result).toContain("1,00,00,000");
});

it("should format USD currency with Indian grouping", () => {
const result = formatNumber(10000000, {
number_style: "currency",
currency: "USD",
currency_style: "symbol",
number_grouping: "indian",
});
// USD should work with Indian grouping
expect(result).toContain("1,00,00,000");
});
});
});
16 changes: 14 additions & 2 deletions src/metabase/appearance/settings.clj
Original file line number Diff line number Diff line change
Expand Up @@ -350,15 +350,27 @@ See [fonts](../configuring-metabase/fonts.md).")
".,"
",."
", "
".’"})
".'"})

(def available-number-groupings
"Number grouping styles that are available for formatting numbers.
- western: 3-3-3 grouping (e.g., 10,000,000)
- indian: 3-2-2 grouping (e.g., 1,00,00,000)"
#{"western"
"indian"})

(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
11 changes: 10 additions & 1 deletion src/metabase/util/formatting/internal/numbers.clj
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,17 @@
#{:BTC})

(defn- active-locale [options]
(if (:locale options)
(cond
;; Use Indian locale for Indian number grouping (3-2-2 pattern)
(= (:number-grouping options) "indian")
(Locale. "en" "IN")

;; Use explicit locale if specified
(:locale options)
(Locale. (:locale options))

;; Default to system locale
:else
(Locale/getDefault)))

(defn- number-formatter-for-options-baseline
Expand Down
8 changes: 6 additions & 2 deletions src/metabase/util/formatting/internal/numbers.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,13 @@
;; - "123,00 €" in actual German convention, which is what we would get with a native "de" locale here.
(defn- number-formatter-for-options-baseline [options]
(let [default-fraction-digits (when (= (:number-style options) "currency")
2)]
2)
;; Use locale based on number-grouping to get correct grouping pattern
;; - "en-IN" for Indian numbering (3-2-2: 1,00,00,000)
;; - "en" for Western numbering (3-3-3: 10,000,000) - default
locale (if (= (:number-grouping options) "indian") "en-IN" "en")]
(js/Intl.NumberFormat.
"en"
locale
(clj->js (u/remove-nils
{:style (when-not (= (:number-style options) "scientific")
(:number-style options "decimal"))
Expand Down