From bc0e0f7b56ab92cd2e0cf34f8e4ebc4f3bfa86ce Mon Sep 17 00:00:00 2001 From: sanasif786ka Date: Tue, 7 Jul 2026 20:54:45 +0530 Subject: [PATCH] fix(tools): allow entering decimal values like 1.0 in parameter fields Number parameter inputs collapsed partial decimals (for example 1.0) to integers while typing because HTML number inputs reject values like 1. and immediately coerced valid floats to integer display values. Use text inputs with decimal input mode for number schema fields, defer committing trailing-zero decimals until blur, and add regression tests (issue #918). --- client/src/components/DynamicJsonForm.tsx | 8 +-- client/src/components/ToolsTab.tsx | 54 ++++++++++++++++--- .../__tests__/DynamicJsonForm.test.tsx | 33 ++++++++++-- .../utils/__tests__/numericInputUtils.test.ts | 19 +++++++ client/src/utils/numericInputUtils.ts | 13 +++++ 5 files changed, 111 insertions(+), 16 deletions(-) create mode 100644 client/src/utils/__tests__/numericInputUtils.test.ts create mode 100644 client/src/utils/numericInputUtils.ts diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index 90249ab1c..27f42ea23 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -11,6 +11,7 @@ import { Input } from "@/components/ui/input"; import JsonEditor from "./JsonEditor"; import { updateValueAtPath } from "@/utils/jsonUtils"; import { generateDefaultValue } from "@/utils/schemaUtils"; +import { shouldDeferNumericCommit } from "@/utils/numericInputUtils"; import type { JsonValue, JsonSchemaType, @@ -462,14 +463,15 @@ const DynamicJsonForm = forwardRef( case "number": return ( { const val = e.target.value; updateNumericDraft(path, val); if (!val && !isRequired) { handleFieldChange(path, undefined); - } else { + } else if (!shouldDeferNumericCommit(val)) { const num = Number(val); if (!isNaN(num)) { handleFieldChange(path, num); @@ -507,7 +509,7 @@ const DynamicJsonForm = forwardRef( updateNumericDraft(path, val); if (!val && !isRequired) { handleFieldChange(path, undefined); - } else { + } else if (!shouldDeferNumericCommit(val)) { const num = Number(val); if (!isNaN(num) && Number.isInteger(num)) { handleFieldChange(path, num); diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 15b85fd67..77345f1ea 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -20,6 +20,7 @@ import { normalizeUnionType, resolveRef, } from "@/utils/schemaUtils"; +import { shouldDeferNumericCommit } from "@/utils/numericInputUtils"; import { CompatibilityCallToolResult, ListToolsResult, @@ -201,6 +202,9 @@ const ToolsTab = ({ serverSupportsTaskRequests: boolean; }) => { const [params, setParams] = useState>({}); + const [numericParamDrafts, setNumericParamDrafts] = useState< + Record + >({}); const [runAsTask, setRunAsTask] = useState(false); const [isToolRunning, setIsToolRunning] = useState(false); const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false); @@ -243,6 +247,7 @@ const ToolsTab = ({ ]; }); setParams(Object.fromEntries(params)); + setNumericParamDrafts({}); const toolTaskSupport = serverSupportsTaskRequests ? getTaskSupport(selectedTool) : "forbidden"; @@ -518,25 +523,33 @@ const ToolsTab = ({ ) : prop.type === "number" || prop.type === "integer" ? ( { const value = e.target.value; + setNumericParamDrafts((prev) => ({ + ...prev, + [key]: value, + })); if (value === "") { - // Field cleared - set to undefined setParams({ ...params, [key]: undefined, }); - } else { - // Field has value - try to convert to number, but store input either way + } else if (!shouldDeferNumericCommit(value)) { const num = Number(value); if (!isNaN(num)) { setParams({ @@ -544,7 +557,6 @@ const ToolsTab = ({ [key]: num, }); } else { - // Store invalid input as string - let server validate setParams({ ...params, [key]: value, @@ -552,6 +564,32 @@ const ToolsTab = ({ } } }} + onBlur={(e) => { + const value = e.target.value; + setNumericParamDrafts((prev) => { + if ( + !Object.prototype.hasOwnProperty.call( + prev, + key, + ) + ) { + return prev; + } + const next = { ...prev }; + delete next[key]; + return next; + }); + if (value === "") { + return; + } + const num = Number(value); + if (!isNaN(num)) { + setParams({ + ...params, + [key]: num, + }); + } + }} className="mt-1" /> ) : ( diff --git a/client/src/components/__tests__/DynamicJsonForm.test.tsx b/client/src/components/__tests__/DynamicJsonForm.test.tsx index a273f75ae..8da940c28 100644 --- a/client/src/components/__tests__/DynamicJsonForm.test.tsx +++ b/client/src/components/__tests__/DynamicJsonForm.test.tsx @@ -384,9 +384,9 @@ describe("DynamicJsonForm Number Fields", () => { , ); - const input = screen.getByRole("spinbutton"); - expect(input).toHaveProperty("min", "0.5"); - expect(input).toHaveProperty("max", "99.9"); + const input = screen.getByRole("textbox"); + expect(input).toHaveProperty("type", "text"); + expect(input).toHaveProperty("inputMode", "decimal"); }); it("should accept decimal values", () => { @@ -397,7 +397,7 @@ describe("DynamicJsonForm Number Fields", () => { }; render(); - const input = screen.getByRole("spinbutton"); + const input = screen.getByRole("textbox"); fireEvent.change(input, { target: { value: "98.6" } }); expect(onChange).toHaveBeenCalledWith(98.6); @@ -417,7 +417,7 @@ describe("DynamicJsonForm Number Fields", () => { }; render(); - const input = screen.getByRole("spinbutton") as HTMLInputElement; + const input = screen.getByRole("textbox") as HTMLInputElement; fireEvent.change(input, { target: { value: "-74.0" } }); expect(input.value).toBe("-74.0"); @@ -425,6 +425,29 @@ describe("DynamicJsonForm Number Fields", () => { fireEvent.change(input, { target: { value: "-74.01" } }); expect(input.value).toBe("-74.01"); }); + + it("should preserve 1.0 while typing digit by digit", () => { + const schema: JsonSchemaType = { + type: "number", + description: "Float parameter", + }; + + const WrappedForm = () => { + const [value, setValue] = useState(0); + return ( + + ); + }; + + render(); + const input = screen.getByRole("textbox") as HTMLInputElement; + + fireEvent.change(input, { target: { value: "1." } }); + expect(input.value).toBe("1."); + + fireEvent.change(input, { target: { value: "1.0" } }); + expect(input.value).toBe("1.0"); + }); }); }); diff --git a/client/src/utils/__tests__/numericInputUtils.test.ts b/client/src/utils/__tests__/numericInputUtils.test.ts new file mode 100644 index 000000000..df74d1779 --- /dev/null +++ b/client/src/utils/__tests__/numericInputUtils.test.ts @@ -0,0 +1,19 @@ +import { shouldDeferNumericCommit } from "../numericInputUtils"; + +describe("shouldDeferNumericCommit", () => { + it("defers partial decimal input", () => { + expect(shouldDeferNumericCommit("1.")).toBe(true); + expect(shouldDeferNumericCommit("-74.")).toBe(true); + }); + + it("defers trailing-zero decimals", () => { + expect(shouldDeferNumericCommit("1.0")).toBe(true); + expect(shouldDeferNumericCommit("-74.0")).toBe(true); + expect(shouldDeferNumericCommit("10.50")).toBe(true); + }); + + it("commits complete non-zero-ending decimals", () => { + expect(shouldDeferNumericCommit("98.6")).toBe(false); + expect(shouldDeferNumericCommit("10.5")).toBe(false); + }); +}); diff --git a/client/src/utils/numericInputUtils.ts b/client/src/utils/numericInputUtils.ts new file mode 100644 index 000000000..324d83823 --- /dev/null +++ b/client/src/utils/numericInputUtils.ts @@ -0,0 +1,13 @@ +/** + * Returns true when a numeric text input should defer committing its parsed + * value. This keeps in-progress decimals (e.g. "1." or "1.0") visible while + * typing instead of collapsing to an integer display value. + */ +export function shouldDeferNumericCommit(value: string): boolean { + if (!value || value.endsWith(".")) { + return true; + } + + // Preserve trailing zeros after the decimal point (e.g. "1.0", "-74.0"). + return /^-?\d+\.\d*0$/.test(value); +}