fix number precision

This commit is contained in:
Philipinho
2026-06-15 01:14:17 +01:00
parent 4b5f42cc9b
commit 6187150224
12 changed files with 152 additions and 33 deletions
@@ -3,6 +3,7 @@ import {
NumberTypeOptions,
} from "@/ee/base/types/base.types";
import { formatCurrency } from "@/ee/base/constants/currencies";
import { snapNumber } from "@docmost/base-formula/client";
import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text";
import cellClasses from "@/ee/base/styles/cells.module.css";
@@ -16,38 +17,77 @@ type CellNumberProps = {
onCancel: () => void;
};
const SEPARATOR_CHARS: Record<string, { group: string; decimal: string }> = {
comma_period: { group: ",", decimal: "." },
period_comma: { group: ".", decimal: "," },
space_comma: { group: " ", decimal: "," },
space_period: { group: " ", decimal: "." },
};
function separatorChars(style: string): { group: string; decimal: string } {
if (style === "local") {
const parts = new Intl.NumberFormat().formatToParts(11111.1);
return {
group: parts.find((p) => p.type === "group")?.value ?? ",",
decimal: parts.find((p) => p.type === "decimal")?.value ?? ".",
};
}
return SEPARATOR_CHARS[style] ?? { group: ",", decimal: "." };
}
function formatPlain(
value: number,
precision: number | undefined,
style: string,
): string {
const fixed = precision == null ? String(value) : value.toFixed(precision);
if (style === "none") return fixed;
const { group, decimal } = separatorChars(style);
const neg = fixed[0] === "-";
const abs = neg ? fixed.slice(1) : fixed;
const dot = abs.indexOf(".");
const intPart = dot === -1 ? abs : abs.slice(0, dot);
const fracPart = dot === -1 ? "" : abs.slice(dot + 1);
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, group);
const out = fracPart ? `${grouped}${decimal}${fracPart}` : grouped;
return neg ? `-${out}` : out;
}
export function formatNumber(
val: number | null | undefined,
options: NumberTypeOptions | undefined,
): string {
if (val == null) return "";
const precision = options?.precision ?? 0;
const precision = options?.precision;
const format = options?.format ?? "plain";
const style = options?.separators ?? "none";
const v = precision == null ? snapNumber(val) : val;
switch (format) {
case "separators":
return new Intl.NumberFormat(undefined, {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
}).format(val);
case "currency":
return formatCurrency(val, options?.currencyCode, options?.precision);
return formatCurrency(v, options?.currencyCode, precision);
case "percent":
return `${val.toFixed(precision)}%`;
return `${formatPlain(v, precision, style)}%`;
case "progress":
return `${Math.min(100, Math.max(0, val)).toFixed(0)}%`;
return `${Math.min(100, Math.max(0, v)).toFixed(0)}%`;
default:
return precision > 0 ? val.toFixed(precision) : String(val);
return formatPlain(v, precision, style);
}
}
const toDraft = (value: unknown) =>
typeof value === "number" ? String(value) : "";
const parse = (draft: string) => {
const parsed = draft === "" ? null : Number(draft);
return parsed != null && isNaN(parsed) ? null : parsed;
};
export function sanitizeNumberInput(text: string): string {
return text.replace(/[^0-9.-]/g, "");
}
export function parseNumberDraft(draft: string): number | null {
const cleaned = sanitizeNumberInput(draft);
if (cleaned === "" || cleaned === "-") return null;
const parsed = Number(cleaned);
return isNaN(parsed) ? null : parsed;
}
export function CellNumber({
value,
@@ -58,7 +98,14 @@ export function CellNumber({
}: CellNumberProps) {
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({ value, isEditing, onCommit, onCancel, toDraft, parse });
useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse: parseNumberDraft,
});
if (isEditing) {
return (
@@ -74,6 +121,17 @@ export function CellNumber({
setDraft(v);
}
}}
onPaste={(e) => {
e.preventDefault();
const el = e.currentTarget;
const start = el.selectionStart ?? draft.length;
const end = el.selectionEnd ?? draft.length;
setDraft(
draft.slice(0, start) +
sanitizeNumberInput(e.clipboardData.getData("text")) +
draft.slice(end),
);
}}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
@@ -342,7 +342,6 @@ function NumberOptions({
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
data={[
{ value: "plain", label: t("Number") },
{ value: "separators", label: t("Number with separators") },
{ value: "currency", label: t("Currency") },
{ value: "percent", label: t("Percent") },
{ value: "progress", label: t("Progress") },
@@ -367,13 +366,40 @@ function NumberOptions({
}
/>
)}
<NumberInput
<Select
size="xs"
label={t("Thousands and decimal separators")}
allowDeselect={false}
checkIconPosition="right"
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
data={[
{ value: "none", label: t("None") },
{ value: "local", label: t("Local") },
{ value: "comma_period", label: t("Comma, period") },
{ value: "period_comma", label: t("Period, comma") },
{ value: "space_comma", label: t("Space, comma") },
{ value: "space_period", label: t("Space, period") },
]}
value={options.separators ?? "none"}
onChange={(val) => update({ separators: val ?? "none" })}
/>
<Select
size="xs"
label={t("Decimal places")}
min={0}
max={8}
value={options.precision ?? 0}
onChange={(val) => update({ precision: val })}
allowDeselect={false}
checkIconPosition="right"
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
data={[
{ value: "default", label: t("Default") },
...Array.from({ length: 9 }, (_, i) => ({
value: String(i),
label: String(i),
})),
]}
value={options.precision == null ? "default" : String(options.precision)}
onChange={(val) =>
update({ precision: val == null || val === "default" ? undefined : Number(val) })
}
/>
<NumberInput
size="xs"
@@ -1,17 +1,16 @@
import { useEffect, useRef, useState } from "react";
import { NumberTypeOptions } from "@/ee/base/types/base.types";
import { formatNumber } from "@/ee/base/components/cells/cell-number";
import {
formatNumber,
parseNumberDraft,
sanitizeNumberInput,
} from "@/ee/base/components/cells/cell-number";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
const toDraft = (value: unknown) =>
typeof value === "number" ? String(value) : "";
const parse = (draft: string) => {
const parsed = draft === "" ? null : Number(draft);
return parsed != null && isNaN(parsed) ? null : parsed;
};
export function FieldNumber({ property, value, readOnly, onChange }: FieldProps) {
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
const numValue = typeof value === "number" ? value : null;
@@ -42,7 +41,7 @@ export function FieldNumber({ property, value, readOnly, onChange }: FieldProps)
setDraft(toDraft(value));
return;
}
if (parse(draft) !== numValue) onChange(parse(draft));
if (parseNumberDraft(draft) !== numValue) onChange(parseNumberDraft(draft));
};
return (
@@ -62,6 +61,17 @@ export function FieldNumber({ property, value, readOnly, onChange }: FieldProps)
setDraft(v);
}
}}
onPaste={(e) => {
e.preventDefault();
const el = e.currentTarget;
const start = el.selectionStart ?? draft.length;
const end = el.selectionEnd ?? draft.length;
setDraft(
draft.slice(0, start) +
sanitizeNumberInput(e.clipboardData.getData("text")) +
draft.slice(end),
);
}}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
@@ -74,6 +74,7 @@ export const PROPERTY_TYPE_REGISTRY: Record<
filterInput: "number",
isSystem: false,
hasOptions: true,
defaultTypeOptions: () => ({ separators: "local" }),
},
select: {
type: "select",
+10 -1
View File
@@ -41,8 +41,17 @@ export type SelectTypeOptions = {
defaultValue?: string | string[] | null;
};
export type NumberSeparatorStyle =
| 'none'
| 'local'
| 'comma_period'
| 'period_comma'
| 'space_comma'
| 'space_period';
export type NumberTypeOptions = {
format?: 'plain' | 'separators' | 'currency' | 'percent' | 'progress';
format?: 'plain' | 'currency' | 'percent' | 'progress';
separators?: NumberSeparatorStyle;
precision?: number;
currencyCode?: string;
currencySymbol?: string;