Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 64 additions & 2 deletions client/src/components/DynamicJsonForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) => {
Expand Down Expand Up @@ -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 {
Expand All @@ -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}
Expand All @@ -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 {
Expand All @@ -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}
Expand Down
25 changes: 24 additions & 1 deletion client/src/components/__tests__/DynamicJsonForm.test.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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");
});
});
});

Expand Down