Skip to content

Commit 1c862b7

Browse files
authored
impr: native support for number in InputField (@fehmer) (#8197)
1 parent fc737e3 commit 1c862b7

10 files changed

Lines changed: 126 additions & 92 deletions

File tree

frontend/__tests__/components/ui/form/InputField.spec.tsx

Lines changed: 71 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,32 @@ import { describe, it, expect, vi } from "vitest";
44

55
import { InputField } from "../../../../src/ts/components/ui/form/InputField";
66

7-
function makeField(name: string, value = ""): AnyFieldApi {
7+
function makeField(
8+
name: string,
9+
value?: string | number | boolean,
10+
): AnyFieldApi {
11+
let current = value;
12+
const meta = {
13+
isValidating: false,
14+
isTouched: false,
15+
isValid: true,
16+
isDefaultValue: true,
17+
errors: [] as string[],
18+
};
819
return {
920
name,
10-
state: {
11-
value,
12-
meta: {
13-
isValidating: false,
14-
isTouched: false,
15-
isValid: true,
16-
isDefaultValue: true,
17-
errors: [],
18-
},
21+
get state() {
22+
return {
23+
value: current,
24+
meta,
25+
};
1926
},
20-
options: {},
27+
options: { default: value },
2128
handleBlur: vi.fn(),
22-
handleChange: vi.fn(),
29+
handleChange: vi.fn((v: unknown) => {
30+
current = v as typeof value;
31+
}),
32+
setValue: vi.fn(),
2333
getMeta: () => ({ hasWarning: false, warnings: [] }),
2434
} as unknown as AnyFieldApi;
2535
}
@@ -61,6 +71,16 @@ describe("InputField", () => {
6171
expect(field.handleChange).toHaveBeenCalledWith("test");
6272
});
6373

74+
it("calls handleChange on input for number", async () => {
75+
const field = makeField("name", 2.5);
76+
render(() => <InputField field={() => field} type="number" />);
77+
78+
fireEvent.input(screen.getByRole("spinbutton"), {
79+
target: { value: "1.25" },
80+
});
81+
expect(field.handleChange).toHaveBeenCalledWith(1.25);
82+
});
83+
6484
it("calls handleBlur on blur", async () => {
6585
const field = makeField("name");
6686
render(() => <InputField field={() => field} />);
@@ -100,4 +120,43 @@ describe("InputField", () => {
100120

101121
expect(container.querySelector(".fa-circle-notch")).not.toBeInTheDocument();
102122
});
123+
124+
it("resets to default value on blur when empty for type number", async () => {
125+
const field = makeField("age", 25);
126+
field.form = { options: { defaultValues: { age: 25 } } } as any;
127+
render(() => (
128+
<InputField
129+
field={() => field}
130+
type="number"
131+
resetToDefaultIfEmptyOnBlur
132+
/>
133+
));
134+
135+
fireEvent.input(screen.getByRole("spinbutton"), {
136+
target: { value: "" },
137+
});
138+
fireEvent.blur(screen.getByRole("spinbutton"));
139+
expect(field.setValue).toHaveBeenCalledWith(25);
140+
});
141+
142+
it("resets to default value on blur when empty for type string", async () => {
143+
const field = makeField("name", "Alice");
144+
field.form = { options: { defaultValues: { name: "Alice" } } } as any;
145+
render(() => (
146+
<InputField field={() => field} resetToDefaultIfEmptyOnBlur />
147+
));
148+
149+
fireEvent.input(screen.getByRole("textbox"), {
150+
target: { value: "" },
151+
});
152+
fireEvent.blur(screen.getByRole("textbox"));
153+
expect(field.setValue).toHaveBeenCalledWith("Alice");
154+
});
155+
156+
it("renders NaN as empty string for type number", async () => {
157+
const field = makeField("value", NaN);
158+
render(() => <InputField field={() => field} type="number" />);
159+
160+
expect(screen.getByRole("spinbutton").getAttribute("value")).toBeNull();
161+
});
103162
});

frontend/src/ts/components/modals/CustomGeneratorModal.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,9 @@ export function CustomGeneratorModal(props: {
104104
const form = createForm(() => ({
105105
defaultValues: {
106106
characterSet: "",
107-
minLength: "2",
108-
maxLength: "5",
109-
wordCount: "100",
107+
minLength: 2,
108+
maxLength: 5,
109+
wordCount: 100,
110110
},
111111
onSubmit: ({ value }) => {
112112
const input = value.characterSet.trim();
@@ -116,9 +116,9 @@ export function CustomGeneratorModal(props: {
116116
}
117117

118118
const characters = input.split(/\s+/);
119-
const minLength = parseInt(value.minLength) || 2;
120-
const maxLength = parseInt(value.maxLength) || 5;
121-
const wordCount = parseInt(value.wordCount) || 100;
119+
const minLength = value.minLength ?? 2;
120+
const maxLength = value.maxLength ?? 5;
121+
const wordCount = value.wordCount ?? 100;
122122
const generatedWords: string[] = [];
123123

124124
for (let i = 0; i < wordCount; i++) {

frontend/src/ts/components/modals/CustomWordAmountModal.tsx

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createForm } from "@tanstack/solid-form";
22
import { JSXElement } from "solid-js";
3+
import { z } from "zod";
34

45
import { setConfig } from "../../config/setters";
56
import { getConfig } from "../../config/store";
@@ -9,15 +10,15 @@ import { showNoticeNotification } from "../../states/notifications";
910
import { AnimatedModal } from "../common/AnimatedModal";
1011
import { InputField } from "../ui/form/InputField";
1112
import { SubmitButton } from "../ui/form/SubmitButton";
13+
import { fromSchema } from "../ui/form/utils";
1214

1315
export function CustomWordAmountModal(): JSXElement {
1416
const form = createForm(() => ({
1517
defaultValues: {
16-
words: getConfig.words.toString(),
18+
words: getConfig.words,
1719
},
1820
onSubmit: ({ value }) => {
19-
const val = parseInt(value.words, 10);
20-
21+
const val = value.words;
2122
setConfig("words", val);
2223
restartTestEvent.dispatch();
2324

@@ -40,7 +41,7 @@ export function CustomWordAmountModal(): JSXElement {
4041
title="Custom word amount"
4142
focusFirstInput="focusAndSelect"
4243
beforeShow={() => {
43-
form.reset({ words: getConfig.words.toString() });
44+
form.reset({ words: getConfig.words });
4445
}}
4546
>
4647
<form
@@ -54,12 +55,7 @@ export function CustomWordAmountModal(): JSXElement {
5455
<form.Field
5556
name="words"
5657
validators={{
57-
onChange: ({ value }) => {
58-
const val = parseInt(value, 10);
59-
if (isNaN(val) || !isFinite(val)) return "Must be a number";
60-
if (val < 0) return "Must be non-negative";
61-
return undefined;
62-
},
58+
onChange: fromSchema(z.number().nonnegative().safe()),
6359
}}
6460
children={(field) => (
6561
<InputField field={field} type="number" placeholder="word amount" />

frontend/src/ts/components/pages/settings/SearchableAutoSetting.tsx

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export function SearchableAutoSetting<T extends ConfigKey>(props: {
3131
[props.key]: getConfig[props.key],
3232
},
3333
onSubmit: ({ value }) => {
34-
const val = parseFloat(String(value[props.key]));
34+
const val = value[props.key];
3535
if (val === getConfig[props.key]) return;
3636
savedIndicator.flash();
3737
setConfig(props.key, val as Config[T]);
@@ -56,17 +56,9 @@ export function SearchableAutoSetting<T extends ConfigKey>(props: {
5656
//@ts-expect-error -- i think because props.key is a key of config, which is a zod schema, the typechecker gives up (too complex to infer or something)
5757
name={props.key}
5858
validators={{
59-
onChange: ({ value }) => {
60-
const val = parseFloat(String(value));
61-
if (isNaN(val)) {
62-
return "Must be a number";
63-
}
64-
return fromSchema(
65-
ConfigSchema.shape[props.key] as z.ZodNumber,
66-
)({
67-
value: val,
68-
});
69-
},
59+
onChange: fromSchema(
60+
ConfigSchema.shape[props.key] as z.ZodNumber,
61+
),
7062
onBlur: () => {
7163
void form.handleSubmit();
7264
},

frontend/src/ts/components/pages/settings/custom-setting/MaxLineWidth.tsx

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { configMetadata } from "../../../../config/metadata";
66
import { setConfig } from "../../../../config/setters";
77
import { getConfig } from "../../../../config/store";
88
import { useSavedIndicator } from "../../../../hooks/useSavedIndicator";
9-
// import { showSuccessNotification } from "../../../../states/notifications";
109
import { InputField } from "../../../ui/form/InputField";
1110
import { fromSchema } from "../../../ui/form/utils";
1211
import { SearchableSetting } from "../SearchableSetting";
@@ -19,7 +18,8 @@ export function MaxLineWidth(): JSXElement {
1918
maxLineWidth: getConfig.maxLineWidth,
2019
},
2120
onSubmit: ({ value }) => {
22-
const val = parseFloat(String(value.maxLineWidth));
21+
const val = value.maxLineWidth;
22+
2323
if (val === getConfig.maxLineWidth) return;
2424
flash();
2525
setConfig("maxLineWidth", val);
@@ -44,15 +44,7 @@ export function MaxLineWidth(): JSXElement {
4444
<form.Field
4545
name="maxLineWidth"
4646
validators={{
47-
onChange: ({ value }) => {
48-
const val = parseFloat(String(value));
49-
if (isNaN(val)) {
50-
return "Must be a number";
51-
}
52-
return fromSchema(MaxLineWidthSchema)({
53-
value: val,
54-
});
55-
},
47+
onChange: fromSchema(MaxLineWidthSchema),
5648
onBlur: () => {
5749
void form.handleSubmit();
5850
},

frontend/src/ts/components/pages/settings/custom-setting/MinAcc.tsx

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export function MinAcc(): JSXElement {
2020
minAccCustom: getConfig.minAccCustom,
2121
},
2222
onSubmit: ({ value }) => {
23-
const val = parseFloat(String(value.minAccCustom));
23+
const val = value.minAccCustom;
2424
if (val === getConfig.minAccCustom) return;
2525
if (getConfig.minAcc === "custom") {
2626
//
@@ -50,15 +50,7 @@ export function MinAcc(): JSXElement {
5050
<form.Field
5151
name="minAccCustom"
5252
validators={{
53-
onChange: ({ value }) => {
54-
const val = parseFloat(String(value));
55-
if (isNaN(val)) {
56-
return "Must be a number";
57-
}
58-
return fromSchema(MinimumAccuracyCustomSchema)({
59-
value: val,
60-
});
61-
},
53+
onChange: fromSchema(MinimumAccuracyCustomSchema),
6254
onBlur: () => {
6355
void form.handleSubmit();
6456
},

frontend/src/ts/components/pages/settings/custom-setting/MinBurst.tsx

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export function MinBurst(): JSXElement {
2020
minBurstCustomSpeed: getConfig.minBurstCustomSpeed,
2121
},
2222
onSubmit: ({ value }) => {
23-
const val = parseFloat(String(value.minBurstCustomSpeed));
23+
const val = value.minBurstCustomSpeed;
2424
if (val === getConfig.minBurstCustomSpeed) return;
2525
if (getConfig.minBurst !== "off") {
2626
//
@@ -50,15 +50,7 @@ export function MinBurst(): JSXElement {
5050
<form.Field
5151
name="minBurstCustomSpeed"
5252
validators={{
53-
onChange: ({ value }) => {
54-
const val = parseFloat(String(value));
55-
if (isNaN(val)) {
56-
return "Must be a number";
57-
}
58-
return fromSchema(MinimumBurstCustomSpeedSchema)({
59-
value: val,
60-
});
61-
},
53+
onChange: fromSchema(MinimumBurstCustomSpeedSchema),
6254
onBlur: () => {
6355
void form.handleSubmit();
6456
},

frontend/src/ts/components/pages/settings/custom-setting/MinSpeed.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export function MinSpeed(): JSXElement {
2020
minWpmCustomSpeed: getConfig.minWpmCustomSpeed,
2121
},
2222
onSubmit: ({ value }) => {
23-
const val = parseFloat(String(value.minWpmCustomSpeed));
23+
const val = value.minWpmCustomSpeed;
2424
if (val === getConfig.minWpmCustomSpeed) return;
2525
if (getConfig.minWpm === "custom") {
2626
//
@@ -50,13 +50,7 @@ export function MinSpeed(): JSXElement {
5050
<form.Field
5151
name="minWpmCustomSpeed"
5252
validators={{
53-
onChange: ({ value }) => {
54-
const val = parseFloat(String(value));
55-
if (isNaN(val)) {
56-
return "Must be a number";
57-
}
58-
return fromSchema(MinWpmCustomSpeedSchema)({ value: val });
59-
},
53+
onChange: fromSchema(MinWpmCustomSpeedSchema),
6054
onBlur: () => {
6155
void form.handleSubmit();
6256
},

frontend/src/ts/components/pages/settings/custom-setting/PaceCaret.tsx

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export function PaceCaret(): JSXElement {
2626
paceCaretCustomSpeed: getConfig.paceCaretCustomSpeed,
2727
},
2828
onSubmit: ({ value }) => {
29-
const val = parseFloat(String(value.paceCaretCustomSpeed));
29+
const val = value.paceCaretCustomSpeed;
3030
if (val === getConfig.paceCaretCustomSpeed) return;
3131
if (getConfig.paceCaret !== "off") {
3232
//
@@ -57,15 +57,7 @@ export function PaceCaret(): JSXElement {
5757
<form.Field
5858
name="paceCaretCustomSpeed"
5959
validators={{
60-
onChange: ({ value }) => {
61-
const val = parseFloat(String(value));
62-
if (isNaN(val)) {
63-
return "Must be a number";
64-
}
65-
return fromSchema(PaceCaretCustomSpeedSchema)({
66-
value: val,
67-
});
68-
},
60+
onChange: fromSchema(PaceCaretCustomSpeedSchema),
6961
onBlur: () => {
7062
void form.handleSubmit();
7163
},

frontend/src/ts/components/ui/form/InputField.tsx

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,12 @@ export function InputField(props: {
6969
placeholder={props.placeholder ?? ""}
7070
autocomplete={props.autocomplete}
7171
name={props.field().name as string}
72-
value={(props.field().state.value as string) ?? ""}
72+
value={convertValueToString(props.field().state.value)}
7373
onBlur={() => {
7474
if (
7575
props.resetToDefaultIfEmptyOnBlur &&
76-
props.field().state.value === ""
76+
(props.field().state.value === undefined ||
77+
props.field().state.value === "")
7778
) {
7879
props.field().setValue(
7980
// oxlint-disable-next-line typescript/no-unsafe-member-access
@@ -84,7 +85,11 @@ export function InputField(props: {
8485
props.field().handleBlur();
8586
}}
8687
onInput={(e) => {
87-
props.field().handleChange(e.target.value);
88+
const value: unknown = convertStringToValue(
89+
props.field().state.value,
90+
e.target.value,
91+
);
92+
props.field().handleChange(value);
8893
}}
8994
onKeyDown={(e) => {
9095
if (e.key === "Enter") {
@@ -151,3 +156,23 @@ function getDateOptions(
151156
max: applyFormat((schema as ZodDate).maxDate),
152157
};
153158
}
159+
160+
function convertValueToString(input: unknown | undefined): string {
161+
if (input === undefined || input === null) return "";
162+
if (typeof input === "number") {
163+
if (isFinite(input)) return input.toString();
164+
else return "";
165+
}
166+
return input as string;
167+
}
168+
169+
function convertStringToValue<T extends unknown | undefined>(
170+
defaultValue: T,
171+
newValue: string,
172+
): T | undefined {
173+
if (defaultValue === undefined || defaultValue === null) return newValue as T;
174+
if (newValue === "") return undefined;
175+
if (typeof defaultValue === "number") return Number.parseFloat(newValue) as T;
176+
177+
return newValue as T;
178+
}

0 commit comments

Comments
 (0)