fix(grid): parse ambiguous comma numbers by locale

This commit is contained in:
Guoyu Su authored and GitHub committed 2026-08-31 16:54:47 +08:00
1 parent 99c3bd0f42
commit 27f62fe6e2
2 files changed
+75 -18

No files matched your search

@@ -250,15 +250,36 @@ describe("coerceDataGridCellValue", () => {
).toBe("9007199254740993"); ).toBe("9007199254740993");
}); });
it("leaves ambiguous single-group values untouched", () => { it("normalizes ambiguous single-comma values using the runtime number format", () => {
expect( expect(
coerceDataGridCellValue({ coerceDataGridCellValue({
value: "10,000", value: "10,000",
oldValue: 10000, oldValue: "0.0000",
databaseType: "sqlserver", databaseType: "mysql",
columnInfo: { data_type: "int" }, columnInfo: { data_type: "decimal(14,4)" },
numberFormat: { groupSeparator: ",", decimalSeparator: "." },
}), }),
).toBe("10,000"); ).toBe("10000");
expect(
coerceDataGridCellValue({
value: "114,870",
oldValue: "0.0000",
databaseType: "mysql",
columnInfo: { data_type: "decimal(14,4)" },
numberFormat: { groupSeparator: ",", decimalSeparator: "." },
}),
).toBe("114870");
expect(
coerceDataGridCellValue({
value: "114,870",
oldValue: "0.0000",
databaseType: "mysql",
columnInfo: { data_type: "decimal(14,4)" },
numberFormat: { groupSeparator: ".", decimalSeparator: "," },
}),
).toBe("114.870");
expect( expect(
coerceDataGridCellValue({ coerceDataGridCellValue({
@@ -266,8 +287,21 @@ describe("coerceDataGridCellValue", () => {
oldValue: 1000000, oldValue: 1000000,
databaseType: "sqlserver", databaseType: "sqlserver",
columnInfo: { data_type: "float" }, columnInfo: { data_type: "float" },
numberFormat: { groupSeparator: ".", decimalSeparator: "," },
}), }),
).toBe("1,000e3"); ).toBe(1000);
});
it("leaves ambiguous comma values untouched when the runtime format uses neither comma token", () => {
expect(
coerceDataGridCellValue({
value: "10,000",
oldValue: 10000,
databaseType: "sqlserver",
columnInfo: { data_type: "int" },
numberFormat: { groupSeparator: "’", decimalSeparator: "." },
}),
).toBe("10,000");
}); });
it("does not strip commas when the column is not numeric", () => { it("does not strip commas when the column is not numeric", () => {
@@ -9,11 +9,19 @@ export interface CoerceDataGridCellValueOptions {
oldValue: GridCellValue | undefined; oldValue: GridCellValue | undefined;
databaseType: DatabaseType | undefined; databaseType: DatabaseType | undefined;
columnInfo: Pick<ColumnInfo, "data_type"> | undefined; columnInfo: Pick<ColumnInfo, "data_type"> | undefined;
numberFormat?: NumericInputNumberFormat;
preserveEmptyString?: boolean; preserveEmptyString?: boolean;
/** Treat an empty inline bulk edit as SQL NULL before type coercion. */ /** Treat an empty inline bulk edit as SQL NULL before type coercion. */
emptyStringAsNull?: boolean; emptyStringAsNull?: boolean;
} }
export interface NumericInputNumberFormat {
groupSeparator?: string;
decimalSeparator?: string;
}
let cachedRuntimeNumberFormat: NumericInputNumberFormat | undefined;
export function coerceDataGridCellValue(options: CoerceDataGridCellValueOptions): GridCellValue { export function coerceDataGridCellValue(options: CoerceDataGridCellValueOptions): GridCellValue {
const { value, oldValue } = options; const { value, oldValue } = options;
if (value === "" && options.emptyStringAsNull) return null; if (value === "" && options.emptyStringAsNull) return null;
@@ -22,13 +30,13 @@ export function coerceDataGridCellValue(options: CoerceDataGridCellValueOptions)
if (blobValue !== undefined) return blobValue; if (blobValue !== undefined) return blobValue;
const postgresArrayValue = coercePostgresArrayValue(options); const postgresArrayValue = coercePostgresArrayValue(options);
if (postgresArrayValue !== undefined) return postgresArrayValue; if (postgresArrayValue !== undefined) return postgresArrayValue;
// Excel-pasted values often carry thousands separators (10,000.00) that make // Excel-pasted values often carry separators that make Number() return NaN
// Number() return NaN and the literal fail to convert on the server. Strip // and the literal fail to convert on the server. Use the runtime number
// only unambiguous groupings and keep the normalized text for the precision // format to resolve single-comma values, then keep the normalized text for
// checks below, so exact values survive as text. // the precision checks below so exact values survive as text.
const useSampledValueType = normalizeDataType(options.columnInfo?.data_type) === ""; const useSampledValueType = normalizeDataType(options.columnInfo?.data_type) === "";
const numericInput = isNumericColumnType(options.columnInfo?.data_type) || (useSampledValueType && typeof oldValue === "number"); const numericInput = isNumericColumnType(options.columnInfo?.data_type) || (useSampledValueType && typeof oldValue === "number");
const numericText = normalizeGroupedNumberText(value, options.columnInfo, oldValue); const numericText = normalizeGroupedNumberText(value, options.columnInfo, oldValue, options.numberFormat ?? runtimeNumberFormat());
if (isBooleanInputColumn(options) || (useSampledValueType && typeof oldValue === "boolean")) { if (isBooleanInputColumn(options) || (useSampledValueType && typeof oldValue === "boolean")) {
// MySQL exposes TINYINT(1) as an integer in the grid. Keep its numeric // MySQL exposes TINYINT(1) as an integer in the grid. Keep its numeric
// 0/1 edits numeric while still accepting explicit TRUE/FALSE aliases. // 0/1 edits numeric while still accepting explicit TRUE/FALSE aliases.
@@ -155,25 +163,40 @@ function shouldPreserveNumericText(options: CoerceDataGridCellValueOptions, pars
return shouldPreserveNumericTextForType(options.columnInfo?.data_type, text, parsedNumber); return shouldPreserveNumericTextForType(options.columnInfo?.data_type, text, parsedNumber);
} }
function normalizeGroupedNumberText(value: string, columnInfo: Pick<ColumnInfo, "data_type"> | undefined, oldValue: GridCellValue | undefined): string { function normalizeGroupedNumberText(value: string, columnInfo: Pick<ColumnInfo, "data_type"> | undefined, oldValue: GridCellValue | undefined, numberFormat: NumericInputNumberFormat): string {
const useSampledNumberType = normalizeDataType(columnInfo?.data_type) === "" && typeof oldValue === "number"; const useSampledNumberType = normalizeDataType(columnInfo?.data_type) === "" && typeof oldValue === "number";
if (!isNumericColumnType(columnInfo?.data_type) && !useSampledNumberType) return value; if (!isNumericColumnType(columnInfo?.data_type) && !useSampledNumberType) return value;
return stripUnambiguousThousandSeparators(value); return normalizeCommaSeparatedNumberText(value, numberFormat);
} }
function stripUnambiguousThousandSeparators(value: string): string { function normalizeCommaSeparatedNumberText(value: string, numberFormat: NumericInputNumberFormat): string {
const trimmed = value.trim(); const trimmed = value.trim();
const match = trimmed.match(/^([+-]?\d{1,3}(?:,\d{3})+(?:\.\d+)?)([eE][+-]?\d+)?$/); const match = trimmed.match(/^([+-]?\d{1,3}(?:,\d{3})+(?:\.\d+)?)([eE][+-]?\d+)?$/);
if (!match) return value; if (!match) return value;
const mantissa = match[1]; const mantissa = match[1];
const exponent = match[2] ?? ""; const exponent = match[2] ?? "";
// A lone "1,000" (one comma group, no decimal point) is ambiguous: in if (/^[+-]?\d{1,3},\d{3}$/.test(mantissa)) {
// comma-decimal locales it reads as 1.000. Only strip when a decimal point if (numberFormat.groupSeparator === "," && numberFormat.decimalSeparator !== ",") {
// is present (1,234.56) or there are multiple comma groups (1,234,567). return `${mantissa.replace(",", "")}${exponent}`;
if (/^[+-]?\d{1,3},\d{3}$/.test(mantissa)) return value; }
if (numberFormat.decimalSeparator === ",") {
return `${mantissa.replace(",", ".")}${exponent}`;
}
return value;
}
return `${mantissa.replace(/,/g, "")}${exponent}`; return `${mantissa.replace(/,/g, "")}${exponent}`;
} }
function runtimeNumberFormat(): NumericInputNumberFormat {
if (cachedRuntimeNumberFormat) return cachedRuntimeNumberFormat;
const parts = new Intl.NumberFormat().formatToParts(12345.6);
cachedRuntimeNumberFormat = {
groupSeparator: parts.find((part) => part.type === "group")?.value,
decimalSeparator: parts.find((part) => part.type === "decimal")?.value,
};
return cachedRuntimeNumberFormat;
}
function postgresArrayElementDataType(dataType: string | undefined): string { function postgresArrayElementDataType(dataType: string | undefined): string {
const normalized = normalizeDataType(dataType); const normalized = normalizeDataType(dataType);
if (normalized.startsWith("_")) return normalized.slice(1); if (normalized.startsWith("_")) return normalized.slice(1);