From fdbf896a04aec63b7c422309bda86f8c5663aeae Mon Sep 17 00:00:00 2001 From: MumuTW Date: Fri, 6 Mar 2026 05:45:13 +0000 Subject: [PATCH] Fix decimal input handling in DynamicJsonForm number fields --- client/src/components/DynamicJsonForm.tsx | 66 ++++++++++++++++++- .../__tests__/DynamicJsonForm.test.tsx | 25 ++++++- 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index ecd150f22..90249ab1c 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -125,6 +125,9 @@ const DynamicJsonForm = forwardRef( const [rawJsonValue, setRawJsonValue] = useState( JSON.stringify(value ?? generateDefaultValue(schema), null, 2), ); + const [numericInputDrafts, setNumericInputDrafts] = useState< + Record + >({}); // 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( 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( return ( { const val = e.target.value; + updateNumericDraft(path, val); if (!val && !isRequired) { handleFieldChange(path, undefined); } else { @@ -441,6 +476,19 @@ const DynamicJsonForm = forwardRef( } } }} + 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( { const val = e.target.value; + updateNumericDraft(path, val); if (!val && !isRequired) { handleFieldChange(path, undefined); } else { @@ -465,6 +514,19 @@ const DynamicJsonForm = forwardRef( } } }} + 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} diff --git a/client/src/components/__tests__/DynamicJsonForm.test.tsx b/client/src/components/__tests__/DynamicJsonForm.test.tsx index a9f17d55b..a273f75ae 100644 --- a/client/src/components/__tests__/DynamicJsonForm.test.tsx +++ b/client/src/components/__tests__/DynamicJsonForm.test.tsx @@ -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(0); + return ( + + ); + }; + + render(); + 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"); + }); }); });