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
221 changes: 221 additions & 0 deletions packages/graph-explorer/src/components/NumberInput.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen } from "@testing-library/react";
import { useDeferredValue, useState } from "react";

import { NumberInput } from "./NumberInput";

function Harness({
initialValue,
onChange = () => {},
...props
}: {
initialValue: number | undefined;
onChange?: (value: number | undefined) => void;
} & Omit<React.ComponentProps<typeof NumberInput>, "value" | "onValueChange">) {
const [value, setValue] = useState(initialValue);
return (
<NumberInput
aria-label="width"
value={value}
onValueChange={next => {
setValue(next);
onChange(next);
}}
{...props}
/>
);
}

function widthInput() {
return screen.getByLabelText<HTMLInputElement>("width");
}

// The browser reports the raw editing buffer (e.g. "1.") through the change
// event. These tests fire those buffers directly, which is exactly what
// user typing produces in a real browser.
describe("NumberInput", () => {
it("should render the numeric value", () => {
render(<Harness initialValue={1.4} />);
expect(widthInput()).toHaveValue(1.4);
});

it("should render empty when the value is undefined", () => {
render(<Harness initialValue={undefined} />);
expect(widthInput().value).toBe("");
});

it("should preserve a trailing decimal point while editing", () => {
// Regression for aws/graph-explorer#1906: deleting the 4 from "1.4"
// collapsed "1." back to "1", destroying the decimal point.
const onChange = vi.fn();
render(<Harness initialValue={1.4} onChange={onChange} />);

fireEvent.change(widthInput(), { target: { value: "1." } });

expect(widthInput().value).toBe("1.");
expect(onChange).toHaveBeenCalledWith(1);
});

it("should allow retyping a decimal digit after deleting one", () => {
const onChange = vi.fn();
render(<Harness initialValue={1.4} onChange={onChange} />);

fireEvent.change(widthInput(), { target: { value: "1." } });
fireEvent.change(widthInput(), { target: { value: "1.2" } });

expect(widthInput().value).toBe("1.2");
expect(onChange).toHaveBeenLastCalledWith(1.2);
});

it("should clear with a single deletion and report undefined", () => {
const onChange = vi.fn();
render(<Harness initialValue={5} onChange={onChange} />);

fireEvent.change(widthInput(), { target: { value: "" } });

expect(widthInput().value).toBe("");
expect(onChange).toHaveBeenCalledWith(undefined);
});

it("should reformat to the canonical value on blur", () => {
render(<Harness initialValue={1.4} />);

fireEvent.change(widthInput(), { target: { value: "1." } });
fireEvent.blur(widthInput());

expect(widthInput()).toHaveValue(1);
});

it("should reflect external value changes when not editing", () => {
function ExternalChange() {
const [value, setValue] = useState<number | undefined>(1);
return (
<>
<NumberInput
aria-label="width"
value={value}
onValueChange={setValue}
/>
<button onClick={() => setValue(7)}>reset</button>
</>
);
}
render(<ExternalChange />);

fireEvent.click(screen.getByRole("button", { name: "reset" }));

expect(widthInput()).toHaveValue(7);
});

it("should reset after editing when value is deferred", () => {
function DeferredParent() {
const [raw, setRaw] = useState<number | undefined>(0);
const deferred = useDeferredValue(raw);
return (
<>
<NumberInput
aria-label="width"
value={deferred}
onValueChange={setRaw}
/>
<button onClick={() => setRaw(0)}>reset</button>
</>
);
}
render(<DeferredParent />);

fireEvent.change(widthInput(), { target: { value: "3.5" } });
expect(widthInput().value).toBe("3.5");

// Clicking a button in a real browser blurs the input first
fireEvent.blur(widthInput());
fireEvent.click(screen.getByRole("button", { name: "reset" }));
expect(widthInput()).toHaveValue(0);
});

it("should reset after clearing when value is deferred", () => {
function DeferredParent() {
const [raw, setRaw] = useState<number | undefined>(2);
const deferred = useDeferredValue(raw);
return (
<>
<NumberInput
aria-label="width"
value={deferred}
onValueChange={setRaw}
/>
<button onClick={() => setRaw(2)}>reset</button>
</>
);
}
render(<DeferredParent />);

fireEvent.change(widthInput(), { target: { value: "5" } });
fireEvent.change(widthInput(), { target: { value: "" } });

fireEvent.blur(widthInput());
fireEvent.click(screen.getByRole("button", { name: "reset" }));
expect(widthInput()).toHaveValue(2);
});

it("should reset after an edit that did not change the parsed value", () => {
function Parent() {
const [value, setValue] = useState<number | undefined>(1.4);
return (
<>
<NumberInput
aria-label="width"
value={value}
onValueChange={setValue}
/>
<button onClick={() => setValue(0)}>reset</button>
</>
);
}
render(<Parent />);

// Trailing zero: "1.40" still parses to 1.4, so value prop doesn't change
fireEvent.change(widthInput(), { target: { value: "1.40" } });
expect(widthInput().value).toBe("1.40");

fireEvent.blur(widthInput());
fireEvent.click(screen.getByRole("button", { name: "reset" }));
expect(widthInput()).toHaveValue(0);
});

it("should accept an external value change to a non-default value", () => {
function Parent() {
const [value, setValue] = useState<number | undefined>(1);
return (
<>
<NumberInput
aria-label="width"
value={value}
onValueChange={setValue}
/>
<button onClick={() => setValue(9.5)}>set-external</button>
</>
);
}
render(<Parent />);

fireEvent.change(widthInput(), { target: { value: "3" } });
expect(widthInput().value).toBe("3");

fireEvent.blur(widthInput());
fireEvent.click(screen.getByRole("button", { name: "set-external" }));
expect(widthInput().value).toBe("9.5");
});

it("should preserve buffer across rapid edits", () => {
const onChange = vi.fn();
render(<Harness initialValue={1} onChange={onChange} />);

fireEvent.change(widthInput(), { target: { value: "1." } });
fireEvent.change(widthInput(), { target: { value: "1.5" } });
fireEvent.change(widthInput(), { target: { value: "1.55" } });

expect(widthInput().value).toBe("1.55");
expect(onChange).toHaveBeenLastCalledWith(1.55);
});
});
49 changes: 49 additions & 0 deletions packages/graph-explorer/src/components/NumberInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useState } from "react";

import { parseNumberSafely } from "@/utils/parseNumberSafely";

import { Input, type InputProps } from "./Input";

export interface NumberInputProps extends Omit<
InputProps,
"type" | "value" | "onChange" | "onBlur"
> {
value: number | undefined;
onValueChange: (value: number | undefined) => void;
}

/**
* A numeric input that keeps the raw editing buffer (e.g. "1." or "") on
* screen while reporting parsed numbers upward. Binding a controlled input
* directly to a number destroys intermediate states — `Number("1.")` is `1`,
* so the decimal point vanishes as soon as it is typed or exposed by
* deleting a digit.
*
* While the user is editing, the draft string owns the display. Outside of an
* edit the display derives directly from the value prop, so external changes
* (e.g. Reset to Default, which blurs the input by taking focus) always show.
*/
export function NumberInput({
value,
onValueChange,
...props
}: NumberInputProps) {
const [draft, setDraft] = useState<string | null>(null);

return (
<Input
type="number"
value={draft ?? formatValue(value)}
onChange={e => {
setDraft(e.target.value);
onValueChange(parseNumberSafely(e.target.value));
}}
onBlur={() => setDraft(null)}
{...props}
/>
);
}

function formatValue(value: number | undefined) {
return value === undefined ? "" : String(value);
}
1 change: 1 addition & 0 deletions packages/graph-explorer/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export * from "./numberFormat";
export * from "./icons";

export * from "./Input";
export * from "./NumberInput";
export * from "./LabelledSetting";
export { default as InputField } from "./InputField";
export * from "./InputField";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
FieldLabel,
FieldLegend,
FieldSet,
Input,
NumberInput,
Select,
SelectContent,
SelectItem,
Expand Down Expand Up @@ -36,7 +36,7 @@ import {
useEdgeStyling,
} from "@/core/StateProvider/graphStyles";
import useTranslations from "@/hooks/useTranslations";
import { parseNumberSafely, RESERVED_TYPES_PROPERTY } from "@/utils";
import { RESERVED_TYPES_PROPERTY } from "@/utils";

import { ARROW_STYLE_OPTIONS } from "./arrowsStyling";
import { LINE_STYLE_OPTIONS } from "./lineStyling";
Expand Down Expand Up @@ -141,18 +141,13 @@ function Content({ edgeType }: { edgeType: EdgeType }) {
</Field>
<Field>
<FieldLabel>Background Opacity</FieldLabel>
<Input
type="number"
<NumberInput
min={0}
max={1}
step={0.1}
value={edgeStyle.labelBackgroundOpacity}
onChange={e =>
setEdgeStyle({
labelBackgroundOpacity: parseNumberSafely(
e.target.value,
),
})
onValueChange={labelBackgroundOpacity =>
setEdgeStyle({ labelBackgroundOpacity })
}
/>
</Field>
Expand All @@ -169,14 +164,12 @@ function Content({ edgeType }: { edgeType: EdgeType }) {
</Field>
<Field>
<FieldLabel>Border Width</FieldLabel>
<Input
type="number"
<NumberInput
min={0}
step={0.5}
value={edgeStyle.labelBorderWidth}
onChange={e =>
setEdgeStyle({
labelBorderWidth: parseNumberSafely(e.target.value),
})
onValueChange={labelBorderWidth =>
setEdgeStyle({ labelBorderWidth })
}
/>
</Field>
Expand Down Expand Up @@ -218,14 +211,12 @@ function Content({ edgeType }: { edgeType: EdgeType }) {

<Field>
<FieldLabel>Line Thickness</FieldLabel>
<Input
type="number"
<NumberInput
min={1}
step={0.5}
value={edgeStyle.lineThickness}
onChange={e =>
setEdgeStyle({
lineThickness: parseNumberSafely(e.target.value),
})
onValueChange={lineThickness =>
setEdgeStyle({ lineThickness })
}
/>
</Field>
Expand Down
Loading
Loading