Skip to content
Open
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
83 changes: 71 additions & 12 deletions frontend/__tests__/components/ui/form/InputField.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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(() => <InputField field={() => field} type="number" />);

Comment thread
fehmer marked this conversation as resolved.
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(() => <InputField field={() => field} />);
Expand Down Expand Up @@ -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(() => (
<InputField
field={() => 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(() => (
<InputField field={() => 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(() => <InputField field={() => field} type="number" />);

expect(screen.getByRole("spinbutton").getAttribute("value")).toBeNull();
});
});
12 changes: 6 additions & 6 deletions frontend/src/ts/components/modals/CustomGeneratorModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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++) {
Expand Down
16 changes: 6 additions & 10 deletions frontend/src/ts/components/modals/CustomWordAmountModal.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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();

Expand All @@ -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 });
}}
>
<form
Expand All @@ -54,12 +55,7 @@ export function CustomWordAmountModal(): JSXElement {
<form.Field
name="words"
validators={{
onChange: ({ value }) => {
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) => (
<InputField field={field} type="number" placeholder="word amount" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function SearchableAutoSetting<T extends ConfigKey>(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]);
Expand All @@ -56,17 +56,9 @@ export function SearchableAutoSetting<T extends ConfigKey>(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();
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand All @@ -44,15 +44,7 @@ export function MaxLineWidth(): JSXElement {
<form.Field
name="maxLineWidth"
validators={{
onChange: ({ value }) => {
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();
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
//
Expand Down Expand Up @@ -50,15 +50,7 @@ export function MinAcc(): JSXElement {
<form.Field
name="minAccCustom"
validators={{
onChange: ({ value }) => {
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();
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
//
Expand Down Expand Up @@ -50,15 +50,7 @@ export function MinBurst(): JSXElement {
<form.Field
name="minBurstCustomSpeed"
validators={{
onChange: ({ value }) => {
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();
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
//
Expand Down Expand Up @@ -50,13 +50,7 @@ export function MinSpeed(): JSXElement {
<form.Field
name="minWpmCustomSpeed"
validators={{
onChange: ({ value }) => {
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();
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
//
Expand Down Expand Up @@ -57,15 +57,7 @@ export function PaceCaret(): JSXElement {
<form.Field
name="paceCaretCustomSpeed"
validators={{
onChange: ({ value }) => {
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();
},
Expand Down
31 changes: 28 additions & 3 deletions frontend/src/ts/components/ui/form/InputField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
);
Comment thread
fehmer marked this conversation as resolved.
props.field().handleChange(value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
Expand Down Expand Up @@ -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<T extends unknown | undefined>(
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;
}
Loading