Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ function wrapComponentWithSettings(WrappedComponent) {
"dateTimeFormat",
"integerFormat",
"floatFormat",
"thousandsSeparator",
"decimalSeparator",
"nullValue",
"booleanValues",
"tableCellMaxJSONSize",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from "react";
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
import Form from "antd/lib/form";
import Input from "antd/lib/input";
import Select from "antd/lib/select";
import Skeleton from "antd/lib/skeleton";
import DynamicComponent from "@/components/DynamicComponent";
Expand Down Expand Up @@ -41,6 +42,36 @@ export default function FormatSettings(props) {
</Select>
)}
</Form.Item>
<Form.Item
label="Thousands Separator"
help="Character inserted at thousands positions in numbers (e.g. a space for 1 234 567).">
{loading ? (
<Skeleton.Input style={{ width: 300 }} active />
) : (
<Input
style={{ width: 300 }}
maxLength={1}
value={values.thousands_separator}
onChange={e => onChange({ thousands_separator: e.target.value })}
data-test="ThousandsSeparatorInput"
/>
Comment on lines +51 to +57
)}
</Form.Item>
<Form.Item
label="Decimal Separator"
help="Character used for the decimal point in numbers (e.g. a comma for 1234,56).">
{loading ? (
<Skeleton.Input style={{ width: 300 }} active />
) : (
<Input
style={{ width: 300 }}
maxLength={1}
value={values.decimal_separator}
onChange={e => onChange({ decimal_separator: e.target.value })}
data-test="DecimalSeparatorInput"
/>
)}
</Form.Item>
</DynamicComponent>
);
}
Expand Down
22 changes: 22 additions & 0 deletions client/cypress/integration/settings/organization_settings_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,26 @@ describe("Settings", () => {
.should("exist");
});
});

it("can set a custom thousands separator", () => {
cy.intercept("POST", "/api/settings/organization").as("saveSettings");
cy.getByTestId("ThousandsSeparatorInput").clear().type(" ");
cy.getByTestId("OrganizationSettingsSaveButton").click();
cy.wait("@saveSettings");

// the setting round-trips through the backend
cy.reload();
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
cy.getByTestId("ThousandsSeparatorInput").should("have.value", " ");
Comment on lines +49 to +54

cy.createQuery({
name: "test thousands separator",
query: "SELECT 1234567 AS n",
}).then(({ id: queryId }) => {
cy.visit(`/queries/${queryId}`);
cy.findByText("Refresh Now").click();

// the integer is grouped with the configured space separator
cy.getByTestId("TableVisualization").should("contain", "1 234 567");
});
});
});
2 changes: 2 additions & 0 deletions redash/handlers/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,8 @@ def number_format_config():
return {
"integerFormat": current_org.get_setting("integer_format"),
"floatFormat": current_org.get_setting("float_format"),
"thousandsSeparator": current_org.get_setting("thousands_separator"),
"decimalSeparator": current_org.get_setting("decimal_separator"),
}


Expand Down
7 changes: 7 additions & 0 deletions redash/settings/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@
TIME_FORMAT = os.environ.get("REDASH_TIME_FORMAT", "HH:mm")
INTEGER_FORMAT = os.environ.get("REDASH_INTEGER_FORMAT", "0,0")
FLOAT_FORMAT = os.environ.get("REDASH_FLOAT_FORMAT", "0,0.00")
# Thousands/decimal separators applied to numeral.js at frontend init. The `,` and `.`
# in numeral format strings are locale markers, not literals, so these control the actual
# characters rendered. Defaults preserve the previous en-locale behavior.
THOUSANDS_SEPARATOR = os.environ.get("REDASH_THOUSANDS_SEPARATOR", ",")
DECIMAL_SEPARATOR = os.environ.get("REDASH_DECIMAL_SEPARATOR", ".")
NULL_VALUE = os.environ.get("REDASH_NULL_VALUE", "null")
MULTI_BYTE_SEARCH_ENABLED = parse_boolean(os.environ.get("MULTI_BYTE_SEARCH_ENABLED", "false"))

Expand Down Expand Up @@ -60,6 +65,8 @@
"time_format": TIME_FORMAT,
"integer_format": INTEGER_FORMAT,
"float_format": FLOAT_FORMAT,
"thousands_separator": THOUSANDS_SEPARATOR,
"decimal_separator": DECIMAL_SEPARATOR,
"null_value": NULL_VALUE,
"multi_byte_search_enabled": MULTI_BYTE_SEARCH_ENABLED,
"auth_jwt_login_enabled": JWT_LOGIN_ENABLED,
Expand Down
21 changes: 21 additions & 0 deletions tests/handlers/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,27 @@ def test_post(self):
self.assertEqual(rv.json["settings"]["auth_password_login_enabled"], True)
self.assertEqual(updated_org.settings["settings"]["auth_password_login_enabled"], True)

def test_updates_number_separators(self):
admin = self.factory.create_admin()
rv = self.make_request(
"post",
"/api/settings/organization",
data={"thousands_separator": " ", "decimal_separator": ","},
user=admin,
)
self.assertEqual(rv.json["settings"]["thousands_separator"], " ")
self.assertEqual(rv.json["settings"]["decimal_separator"], ",")

updated_org = Organization.get_by_slug(self.factory.org.slug)
self.assertEqual(updated_org.get_setting("thousands_separator"), " ")
self.assertEqual(updated_org.get_setting("decimal_separator"), ",")

def test_get_returns_default_number_separators(self):
admin = self.factory.create_admin()
rv = self.make_request("get", "/api/settings/organization", user=admin)
self.assertEqual(rv.json["settings"]["thousands_separator"], ",")
self.assertEqual(rv.json["settings"]["decimal_separator"], ".")

def test_updates_google_apps_domains(self):
admin = self.factory.create_admin()
domains = ["example.com"]
Expand Down
40 changes: 40 additions & 0 deletions viz-lib/src/lib/value-format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { createNumberFormatter } from "./value-format";
import { updateVisualizationsSettings } from "@/visualizations/visualizationsSettings";

// numeral keeps a single global locale, so reset the defaults around every test — before,
// so the suite is order-independent even if an earlier suite mutated the delimiters, and
// after, so it doesn't leak overrides into unrelated specs.
beforeEach(() => {
updateVisualizationsSettings({ thousandsSeparator: ",", decimalSeparator: "." });
});

afterEach(() => {
updateVisualizationsSettings({ thousandsSeparator: ",", decimalSeparator: "." });
});

describe("createNumberFormatter", () => {
test("uses the default en delimiters", () => {
expect(createNumberFormatter("0,0")(1234567)).toBe("1,234,567");
expect(createNumberFormatter("0,0.00")(1234567.89)).toBe("1,234,567.89");
});

test("applies a custom thousands separator (the reported space case)", () => {
updateVisualizationsSettings({ thousandsSeparator: " " });
expect(createNumberFormatter("0,0")(1234567)).toBe("1 234 567");
});

test("applies continental-European separators together", () => {
updateVisualizationsSettings({ thousandsSeparator: " ", decimalSeparator: "," });
expect(createNumberFormatter("0,0.00")(1234567.89)).toBe("1 234 567,89");
});

test("applies one separator independently while the other stays default", () => {
updateVisualizationsSettings({ thousandsSeparator: " " });
expect(createNumberFormatter("0,0.00")(1234567.5)).toBe("1 234 567.50");
});

test("falls back to default separators for non-string overrides", () => {
updateVisualizationsSettings({ thousandsSeparator: undefined, decimalSeparator: undefined });
expect(createNumberFormatter("0,0.00")(1234.5)).toBe("1,234.50");
});
});
19 changes: 18 additions & 1 deletion viz-lib/src/visualizations/visualizationsSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from "react";
import { extend } from "lodash";
import { extend, isString } from "lodash";
import numeral from "numeral";
import Tooltip from "antd/lib/tooltip";

type HelpTriggerProps = {
Expand Down Expand Up @@ -35,13 +36,18 @@ function Link(props: any) {
return <a {...props} />;
}

const DEFAULT_THOUSANDS_SEPARATOR = ",";
const DEFAULT_DECIMAL_SEPARATOR = ".";

export const visualizationsSettings = {
HelpTriggerComponent: HelpTrigger,
LinkComponent: Link,
dateFormat: "DD/MM/YYYY",
dateTimeFormat: "DD/MM/YYYY HH:mm",
integerFormat: "0,0",
floatFormat: "0,0.00",
thousandsSeparator: DEFAULT_THOUSANDS_SEPARATOR,
decimalSeparator: DEFAULT_DECIMAL_SEPARATOR,
nullValue: "null",
booleanValues: ["false", "true"],
tableCellMaxJSONSize: 50000,
Expand All @@ -52,4 +58,15 @@ export const visualizationsSettings = {

export function updateVisualizationsSettings(options: any) {
extend(visualizationsSettings, options);

// `,` and `.` in numeral format strings are locale markers, not literal characters —
// the rendered separators come from the active locale's delimiters. Override them so
// settings like a space thousands separator (e.g. "1 234 567") are reachable. Each
// separator is applied independently and falls back to its en default when not a string,
// so a non-string value never leaves numeral stuck on a stale override.
const { thousandsSeparator, decimalSeparator } = visualizationsSettings;
extend(numeral.localeData().delimiters, {
thousands: isString(thousandsSeparator) ? thousandsSeparator : DEFAULT_THOUSANDS_SEPARATOR,
decimal: isString(decimalSeparator) ? decimalSeparator : DEFAULT_DECIMAL_SEPARATOR,
});
}
Comment on lines 59 to 72