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;
+2 -1
View File
@@ -1,4 +1,5 @@
import { makeErrorCell, isErrorCell } from "./error";
import { valueToString } from "./number";
import { MAX_EVAL_DEPTH } from "./types";
import type { FormulaAST, OpCode } from "./ast";
import type { Value, EvalContext } from "./types";
@@ -102,7 +103,7 @@ function evalOp(
switch (op as Exclude<OpCode, "neg" | "not">) {
case "+":
if (typeof a === "string" || typeof b === "string") return (a == null ? "" : String(a)) + (b == null ? "" : String(b));
if (typeof a === "string" || typeof b === "string") return valueToString(a) + valueToString(b);
if (a == null || b == null) return null;
return Number(a) + Number(b);
case "-": return a == null || b == null ? null : Number(a) - Number(b);
@@ -1,4 +1,5 @@
import { register } from "./registry";
import { valueToString } from "../number";
register({
name: "toNumber", arity: { min: 1, max: 1 }, paramTypes: "any", returnType: "number",
@@ -11,6 +12,6 @@ register({
});
register({
name: "toString", arity: { min: 1, max: 1 }, paramTypes: "any", returnType: "string",
eval: ([v]) => v == null ? "" : String(v),
eval: ([v]) => valueToString(v),
doc: "Converts the value to a string.", category: "coercion",
});
@@ -1,6 +1,7 @@
import { register } from "./registry";
import { valueToString } from "../number";
const s = (v: unknown): string => v == null ? "" : String(v);
const s = (v: unknown): string => valueToString(v);
register({
name: "concat", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "string",
@@ -11,3 +11,4 @@ export * from "./format";
export { registry, register } from "./functions/registry";
export type { FormulaFn } from "./functions/registry";
export * from "./graph";
export * from "./number";
@@ -12,3 +12,4 @@ export { registry, register } from "./functions/index";
export type { FormulaFn } from "./functions/index";
export * from "./graph";
export * from "./eval";
export * from "./number";
+10
View File
@@ -0,0 +1,10 @@
export function snapNumber(n: number): number {
if (!Number.isFinite(n)) return n;
return Number(n.toPrecision(15));
}
export function valueToString(v: unknown): string {
if (v == null) return "";
if (typeof v === "number") return String(snapNumber(v));
return String(v);
}