mirror of
https://github.com/docmost/docmost.git
synced 2026-07-12 11:14:48 +10:00
fix number precision
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: aed638e895...ea79487479
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user