fix(grid): parse ambiguous comma numbers by locale
This commit is contained in:
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);
|
||||||
|
|||||||
Reference in new issue
Block a user