Fix decimal input handling in DynamicJsonForm number fields

This commit is contained in:
MumuTW 2026-03-06 05:45:13 +00:00
commit fdbf896a04
2 changed files with 88 additions and 3 deletions

View file

@ -125,6 +125,9 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
const [rawJsonValue, setRawJsonValue] = useState<string>(
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
);
const [numericInputDrafts, setNumericInputDrafts] = useState<
Record<string, string>
>({});
// Use a ref to manage debouncing timeouts to avoid parsing JSON
// on every keystroke which would be inefficient and error-prone
@ -134,6 +137,37 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
return !!jsonError;
};
const getPathKey = (path: string[]) =>
path.length === 0 ? "$root" : path.join(".");
const getNumericDisplayValue = (
path: string[],
currentValue: JsonValue,
): string => {
const pathKey = getPathKey(path);
if (Object.prototype.hasOwnProperty.call(numericInputDrafts, pathKey)) {
return numericInputDrafts[pathKey];
}
return typeof currentValue === "number" ? currentValue.toString() : "";
};
const updateNumericDraft = (path: string[], draftValue: string) => {
const pathKey = getPathKey(path);
setNumericInputDrafts((prev) => ({ ...prev, [pathKey]: draftValue }));
};
const clearNumericDraft = (path: string[]) => {
const pathKey = getPathKey(path);
setNumericInputDrafts((prev) => {
if (!Object.prototype.hasOwnProperty.call(prev, pathKey)) {
return prev;
}
const next = { ...prev };
delete next[pathKey];
return next;
});
};
// Debounce JSON parsing and parent updates to handle typing gracefully
const debouncedUpdateParent = useCallback(
(jsonString: string) => {
@ -429,9 +463,10 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
return (
<Input
type="number"
value={(currentValue as number)?.toString() ?? ""}
value={getNumericDisplayValue(path, currentValue)}
onChange={(e) => {
const val = e.target.value;
updateNumericDraft(path, val);
if (!val && !isRequired) {
handleFieldChange(path, undefined);
} else {
@ -441,6 +476,19 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
}
}
}}
onBlur={(e) => {
const val = e.target.value;
if (!val) {
clearNumericDraft(path);
return;
}
const num = Number(val);
if (!isNaN(num)) {
handleFieldChange(path, num);
}
clearNumericDraft(path);
}}
placeholder={propSchema.description}
required={isRequired}
min={propSchema.minimum}
@ -453,9 +501,10 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
<Input
type="number"
step="1"
value={(currentValue as number)?.toString() ?? ""}
value={getNumericDisplayValue(path, currentValue)}
onChange={(e) => {
const val = e.target.value;
updateNumericDraft(path, val);
if (!val && !isRequired) {
handleFieldChange(path, undefined);
} else {
@ -465,6 +514,19 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
}
}
}}
onBlur={(e) => {
const val = e.target.value;
if (!val) {
clearNumericDraft(path);
return;
}
const num = Number(val);
if (!isNaN(num) && Number.isInteger(num)) {
handleFieldChange(path, num);
}
clearNumericDraft(path);
}}
placeholder={propSchema.description}
required={isRequired}
min={propSchema.minimum}

View file

@ -1,7 +1,7 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, expect, jest } from "@jest/globals";
import { useRef } from "react";
import { useRef, useState } from "react";
import DynamicJsonForm, { DynamicJsonFormRef } from "../DynamicJsonForm";
import type { JsonSchemaType } from "@/utils/jsonUtils";
@ -402,6 +402,29 @@ describe("DynamicJsonForm Number Fields", () => {
expect(onChange).toHaveBeenCalledWith(98.6);
});
it("should preserve decimal zero while typing", () => {
const schema: JsonSchemaType = {
type: "number",
description: "Coordinate",
};
const WrappedForm = () => {
const [value, setValue] = useState<number>(0);
return (
<DynamicJsonForm schema={schema} value={value} onChange={setValue} />
);
};
render(<WrappedForm />);
const input = screen.getByRole("spinbutton") as HTMLInputElement;
fireEvent.change(input, { target: { value: "-74.0" } });
expect(input.value).toBe("-74.0");
fireEvent.change(input, { target: { value: "-74.01" } });
expect(input.value).toBe("-74.01");
});
});
});