diff --git a/frontend/__tests__/components/ui/form/InputField.spec.tsx b/frontend/__tests__/components/ui/form/InputField.spec.tsx index 8b079abdcf3e..aaa221edf9d1 100644 --- a/frontend/__tests__/components/ui/form/InputField.spec.tsx +++ b/frontend/__tests__/components/ui/form/InputField.spec.tsx @@ -4,22 +4,32 @@ import { describe, it, expect, vi } from "vitest"; import { InputField } from "../../../../src/ts/components/ui/form/InputField"; -function makeField(name: string, value = ""): AnyFieldApi { +function makeField( + name: string, + value?: string | number | boolean, +): AnyFieldApi { + let current = value; + const meta = { + isValidating: false, + isTouched: false, + isValid: true, + isDefaultValue: true, + errors: [] as string[], + }; return { name, - state: { - value, - meta: { - isValidating: false, - isTouched: false, - isValid: true, - isDefaultValue: true, - errors: [], - }, + get state() { + return { + value: current, + meta, + }; }, - options: {}, + options: { default: value }, handleBlur: vi.fn(), - handleChange: vi.fn(), + handleChange: vi.fn((v: unknown) => { + current = v as typeof value; + }), + setValue: vi.fn(), getMeta: () => ({ hasWarning: false, warnings: [] }), } as unknown as AnyFieldApi; } @@ -61,6 +71,16 @@ describe("InputField", () => { expect(field.handleChange).toHaveBeenCalledWith("test"); }); + it("calls handleChange on input for number", async () => { + const field = makeField("name", 2.5); + render(() => field} type="number" />); + + fireEvent.input(screen.getByRole("spinbutton"), { + target: { value: "1.25" }, + }); + expect(field.handleChange).toHaveBeenCalledWith(1.25); + }); + it("calls handleBlur on blur", async () => { const field = makeField("name"); render(() => field} />); @@ -100,4 +120,43 @@ describe("InputField", () => { expect(container.querySelector(".fa-circle-notch")).not.toBeInTheDocument(); }); + + it("resets to default value on blur when empty for type number", async () => { + const field = makeField("age", 25); + field.form = { options: { defaultValues: { age: 25 } } } as any; + render(() => ( + field} + type="number" + resetToDefaultIfEmptyOnBlur + /> + )); + + fireEvent.input(screen.getByRole("spinbutton"), { + target: { value: "" }, + }); + fireEvent.blur(screen.getByRole("spinbutton")); + expect(field.setValue).toHaveBeenCalledWith(25); + }); + + it("resets to default value on blur when empty for type string", async () => { + const field = makeField("name", "Alice"); + field.form = { options: { defaultValues: { name: "Alice" } } } as any; + render(() => ( + field} resetToDefaultIfEmptyOnBlur /> + )); + + fireEvent.input(screen.getByRole("textbox"), { + target: { value: "" }, + }); + fireEvent.blur(screen.getByRole("textbox")); + expect(field.setValue).toHaveBeenCalledWith("Alice"); + }); + + it("renders NaN as empty string for type number", async () => { + const field = makeField("value", NaN); + render(() => field} type="number" />); + + expect(screen.getByRole("spinbutton").getAttribute("value")).toBeNull(); + }); }); diff --git a/frontend/src/ts/components/modals/CustomGeneratorModal.tsx b/frontend/src/ts/components/modals/CustomGeneratorModal.tsx index 4d7631f8fb13..5c94924b5976 100644 --- a/frontend/src/ts/components/modals/CustomGeneratorModal.tsx +++ b/frontend/src/ts/components/modals/CustomGeneratorModal.tsx @@ -104,9 +104,9 @@ export function CustomGeneratorModal(props: { const form = createForm(() => ({ defaultValues: { characterSet: "", - minLength: "2", - maxLength: "5", - wordCount: "100", + minLength: 2, + maxLength: 5, + wordCount: 100, }, onSubmit: ({ value }) => { const input = value.characterSet.trim(); @@ -116,9 +116,9 @@ export function CustomGeneratorModal(props: { } const characters = input.split(/\s+/); - const minLength = parseInt(value.minLength) || 2; - const maxLength = parseInt(value.maxLength) || 5; - const wordCount = parseInt(value.wordCount) || 100; + const minLength = value.minLength ?? 2; + const maxLength = value.maxLength ?? 5; + const wordCount = value.wordCount ?? 100; const generatedWords: string[] = []; for (let i = 0; i < wordCount; i++) { diff --git a/frontend/src/ts/components/modals/CustomWordAmountModal.tsx b/frontend/src/ts/components/modals/CustomWordAmountModal.tsx index 57e6ced0a9f2..766b037dbd08 100644 --- a/frontend/src/ts/components/modals/CustomWordAmountModal.tsx +++ b/frontend/src/ts/components/modals/CustomWordAmountModal.tsx @@ -1,5 +1,6 @@ import { createForm } from "@tanstack/solid-form"; import { JSXElement } from "solid-js"; +import { z } from "zod"; import { setConfig } from "../../config/setters"; import { getConfig } from "../../config/store"; @@ -9,15 +10,15 @@ import { showNoticeNotification } from "../../states/notifications"; import { AnimatedModal } from "../common/AnimatedModal"; import { InputField } from "../ui/form/InputField"; import { SubmitButton } from "../ui/form/SubmitButton"; +import { fromSchema } from "../ui/form/utils"; export function CustomWordAmountModal(): JSXElement { const form = createForm(() => ({ defaultValues: { - words: getConfig.words.toString(), + words: getConfig.words, }, onSubmit: ({ value }) => { - const val = parseInt(value.words, 10); - + const val = value.words; setConfig("words", val); restartTestEvent.dispatch(); @@ -40,7 +41,7 @@ export function CustomWordAmountModal(): JSXElement { title="Custom word amount" focusFirstInput="focusAndSelect" beforeShow={() => { - form.reset({ words: getConfig.words.toString() }); + form.reset({ words: getConfig.words }); }} >
{ - const val = parseInt(value, 10); - if (isNaN(val) || !isFinite(val)) return "Must be a number"; - if (val < 0) return "Must be non-negative"; - return undefined; - }, + onChange: fromSchema(z.number().nonnegative().safe()), }} children={(field) => ( diff --git a/frontend/src/ts/components/pages/settings/SearchableAutoSetting.tsx b/frontend/src/ts/components/pages/settings/SearchableAutoSetting.tsx index 13a2e115e08e..bfff9ac024e5 100644 --- a/frontend/src/ts/components/pages/settings/SearchableAutoSetting.tsx +++ b/frontend/src/ts/components/pages/settings/SearchableAutoSetting.tsx @@ -31,7 +31,7 @@ export function SearchableAutoSetting(props: { [props.key]: getConfig[props.key], }, onSubmit: ({ value }) => { - const val = parseFloat(String(value[props.key])); + const val = value[props.key]; if (val === getConfig[props.key]) return; savedIndicator.flash(); setConfig(props.key, val as Config[T]); @@ -56,17 +56,9 @@ export function SearchableAutoSetting(props: { //@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) name={props.key} validators={{ - onChange: ({ value }) => { - const val = parseFloat(String(value)); - if (isNaN(val)) { - return "Must be a number"; - } - return fromSchema( - ConfigSchema.shape[props.key] as z.ZodNumber, - )({ - value: val, - }); - }, + onChange: fromSchema( + ConfigSchema.shape[props.key] as z.ZodNumber, + ), onBlur: () => { void form.handleSubmit(); }, diff --git a/frontend/src/ts/components/pages/settings/custom-setting/MaxLineWidth.tsx b/frontend/src/ts/components/pages/settings/custom-setting/MaxLineWidth.tsx index 74d60ad3b301..c00eaca0992b 100644 --- a/frontend/src/ts/components/pages/settings/custom-setting/MaxLineWidth.tsx +++ b/frontend/src/ts/components/pages/settings/custom-setting/MaxLineWidth.tsx @@ -6,7 +6,6 @@ import { configMetadata } from "../../../../config/metadata"; import { setConfig } from "../../../../config/setters"; import { getConfig } from "../../../../config/store"; import { useSavedIndicator } from "../../../../hooks/useSavedIndicator"; -// import { showSuccessNotification } from "../../../../states/notifications"; import { InputField } from "../../../ui/form/InputField"; import { fromSchema } from "../../../ui/form/utils"; import { SearchableSetting } from "../SearchableSetting"; @@ -19,7 +18,8 @@ export function MaxLineWidth(): JSXElement { maxLineWidth: getConfig.maxLineWidth, }, onSubmit: ({ value }) => { - const val = parseFloat(String(value.maxLineWidth)); + const val = value.maxLineWidth; + if (val === getConfig.maxLineWidth) return; flash(); setConfig("maxLineWidth", val); @@ -44,15 +44,7 @@ export function MaxLineWidth(): JSXElement { { - const val = parseFloat(String(value)); - if (isNaN(val)) { - return "Must be a number"; - } - return fromSchema(MaxLineWidthSchema)({ - value: val, - }); - }, + onChange: fromSchema(MaxLineWidthSchema), onBlur: () => { void form.handleSubmit(); }, diff --git a/frontend/src/ts/components/pages/settings/custom-setting/MinAcc.tsx b/frontend/src/ts/components/pages/settings/custom-setting/MinAcc.tsx index 47204d95869d..beedc431d894 100644 --- a/frontend/src/ts/components/pages/settings/custom-setting/MinAcc.tsx +++ b/frontend/src/ts/components/pages/settings/custom-setting/MinAcc.tsx @@ -20,7 +20,7 @@ export function MinAcc(): JSXElement { minAccCustom: getConfig.minAccCustom, }, onSubmit: ({ value }) => { - const val = parseFloat(String(value.minAccCustom)); + const val = value.minAccCustom; if (val === getConfig.minAccCustom) return; if (getConfig.minAcc === "custom") { // @@ -50,15 +50,7 @@ export function MinAcc(): JSXElement { { - const val = parseFloat(String(value)); - if (isNaN(val)) { - return "Must be a number"; - } - return fromSchema(MinimumAccuracyCustomSchema)({ - value: val, - }); - }, + onChange: fromSchema(MinimumAccuracyCustomSchema), onBlur: () => { void form.handleSubmit(); }, diff --git a/frontend/src/ts/components/pages/settings/custom-setting/MinBurst.tsx b/frontend/src/ts/components/pages/settings/custom-setting/MinBurst.tsx index 45293467adda..768c99d94ce9 100644 --- a/frontend/src/ts/components/pages/settings/custom-setting/MinBurst.tsx +++ b/frontend/src/ts/components/pages/settings/custom-setting/MinBurst.tsx @@ -20,7 +20,7 @@ export function MinBurst(): JSXElement { minBurstCustomSpeed: getConfig.minBurstCustomSpeed, }, onSubmit: ({ value }) => { - const val = parseFloat(String(value.minBurstCustomSpeed)); + const val = value.minBurstCustomSpeed; if (val === getConfig.minBurstCustomSpeed) return; if (getConfig.minBurst !== "off") { // @@ -50,15 +50,7 @@ export function MinBurst(): JSXElement { { - const val = parseFloat(String(value)); - if (isNaN(val)) { - return "Must be a number"; - } - return fromSchema(MinimumBurstCustomSpeedSchema)({ - value: val, - }); - }, + onChange: fromSchema(MinimumBurstCustomSpeedSchema), onBlur: () => { void form.handleSubmit(); }, diff --git a/frontend/src/ts/components/pages/settings/custom-setting/MinSpeed.tsx b/frontend/src/ts/components/pages/settings/custom-setting/MinSpeed.tsx index fc4a1a8d754c..122510c63080 100644 --- a/frontend/src/ts/components/pages/settings/custom-setting/MinSpeed.tsx +++ b/frontend/src/ts/components/pages/settings/custom-setting/MinSpeed.tsx @@ -20,7 +20,7 @@ export function MinSpeed(): JSXElement { minWpmCustomSpeed: getConfig.minWpmCustomSpeed, }, onSubmit: ({ value }) => { - const val = parseFloat(String(value.minWpmCustomSpeed)); + const val = value.minWpmCustomSpeed; if (val === getConfig.minWpmCustomSpeed) return; if (getConfig.minWpm === "custom") { // @@ -50,13 +50,7 @@ export function MinSpeed(): JSXElement { { - const val = parseFloat(String(value)); - if (isNaN(val)) { - return "Must be a number"; - } - return fromSchema(MinWpmCustomSpeedSchema)({ value: val }); - }, + onChange: fromSchema(MinWpmCustomSpeedSchema), onBlur: () => { void form.handleSubmit(); }, diff --git a/frontend/src/ts/components/pages/settings/custom-setting/PaceCaret.tsx b/frontend/src/ts/components/pages/settings/custom-setting/PaceCaret.tsx index 8e20275d3d91..124af98666bc 100644 --- a/frontend/src/ts/components/pages/settings/custom-setting/PaceCaret.tsx +++ b/frontend/src/ts/components/pages/settings/custom-setting/PaceCaret.tsx @@ -26,7 +26,7 @@ export function PaceCaret(): JSXElement { paceCaretCustomSpeed: getConfig.paceCaretCustomSpeed, }, onSubmit: ({ value }) => { - const val = parseFloat(String(value.paceCaretCustomSpeed)); + const val = value.paceCaretCustomSpeed; if (val === getConfig.paceCaretCustomSpeed) return; if (getConfig.paceCaret !== "off") { // @@ -57,15 +57,7 @@ export function PaceCaret(): JSXElement { { - const val = parseFloat(String(value)); - if (isNaN(val)) { - return "Must be a number"; - } - return fromSchema(PaceCaretCustomSpeedSchema)({ - value: val, - }); - }, + onChange: fromSchema(PaceCaretCustomSpeedSchema), onBlur: () => { void form.handleSubmit(); }, diff --git a/frontend/src/ts/components/ui/form/InputField.tsx b/frontend/src/ts/components/ui/form/InputField.tsx index cb72a3f5268f..ad080d184cab 100644 --- a/frontend/src/ts/components/ui/form/InputField.tsx +++ b/frontend/src/ts/components/ui/form/InputField.tsx @@ -69,11 +69,12 @@ export function InputField(props: { placeholder={props.placeholder ?? ""} autocomplete={props.autocomplete} name={props.field().name as string} - value={(props.field().state.value as string) ?? ""} + value={convertValueToString(props.field().state.value)} onBlur={() => { if ( props.resetToDefaultIfEmptyOnBlur && - props.field().state.value === "" + (props.field().state.value === undefined || + props.field().state.value === "") ) { props.field().setValue( // oxlint-disable-next-line typescript/no-unsafe-member-access @@ -84,7 +85,11 @@ export function InputField(props: { props.field().handleBlur(); }} onInput={(e) => { - props.field().handleChange(e.target.value); + const value: unknown = convertStringToValue( + props.field().state.value, + e.target.value, + ); + props.field().handleChange(value); }} onKeyDown={(e) => { if (e.key === "Enter") { @@ -151,3 +156,23 @@ function getDateOptions( max: applyFormat((schema as ZodDate).maxDate), }; } + +function convertValueToString(input: unknown | undefined): string { + if (input === undefined || input === null) return ""; + if (typeof input === "number") { + if (isFinite(input)) return input.toString(); + else return ""; + } + return input as string; +} + +function convertStringToValue( + defaultValue: T, + newValue: string, +): T | undefined { + if (defaultValue === undefined || defaultValue === null) return newValue as T; + if (newValue === "") return undefined; + if (typeof defaultValue === "number") return Number.parseFloat(newValue) as T; + + return newValue as T; +}