fix(ui): reject garbled number input instead of silently clearing limits

Switch the default team/user settings number fields from type=number to
type=text with inputMode, so values the browser flags as badInput (e.g.
12e) reach zod validation and show an error instead of being read as
empty and saved as null. Also add noValidate to the user settings form
so zod messages render there too.
This commit is contained in:
ryan-crabbe-berri 2026-08-12 14:48:39 -07:00
parent 12dff6b319
commit ed86414ab5
4 changed files with 41 additions and 12 deletions

View file

@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
@ -139,13 +139,28 @@ describe("DefaultTeamSettingsForm", () => {
const { updateSettings } = renderForm();
await enterEditMode(user);
fireEvent.change(await screen.findByLabelText("TPM Limit"), { target: { value: "12.5" } });
await user.clear(await screen.findByLabelText("TPM Limit"));
await user.type(screen.getByLabelText("TPM Limit"), "12.5");
await user.click(await saveButton());
expect(await screen.findByText("Must be a non-negative whole number")).toBeInTheDocument();
expect(updateSettings).not.toHaveBeenCalled();
});
it("keeps number-ish junk visible and rejects it instead of silently clearing the limit", async () => {
const user = userEvent.setup();
const { updateSettings } = renderForm();
await enterEditMode(user);
await user.clear(await screen.findByLabelText("TPM Limit"));
await user.type(screen.getByLabelText("TPM Limit"), "12e");
await user.click(await saveButton());
expect(screen.getByLabelText("TPM Limit")).toHaveValue("12e");
expect(await screen.findByText("Must be a non-negative whole number")).toBeInTheDocument();
expect(updateSettings).not.toHaveBeenCalled();
});
it("sends the models selection through unchanged, sentinel values included", async () => {
const user = userEvent.setup();
const { updateSettings } = renderForm();
@ -223,7 +238,7 @@ describe("DefaultTeamSettingsForm", () => {
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith("Set `'STORE_MODEL_IN_DB='True'` in your env."),
);
expect(await saveButton()).toBeEnabled();
expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue(250);
expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue("250");
});
it("discards edits and returns to the read-only view when Cancel is pressed", async () => {
@ -240,7 +255,7 @@ describe("DefaultTeamSettingsForm", () => {
expect(updateSettings).not.toHaveBeenCalled();
await enterEditMode(user);
expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue(100);
expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue("100");
expect(await saveButton()).toBeDisabled();
});
});

View file

@ -175,7 +175,7 @@ const SettingsForm = ({ initialValues, updateSettings, onCancel, onSaved }: Sett
label="Max Budget (USD)"
description="Default maximum budget for new teams"
>
{({ ref, ...field }) => <Input {...field} ref={ref} type="number" step={0.01} min={0} />}
{({ ref, ...field }) => <Input {...field} ref={ref} type="text" inputMode="decimal" />}
</FormField>
<FormField
@ -210,7 +210,7 @@ const SettingsForm = ({ initialValues, updateSettings, onCancel, onSaved }: Sett
label="TPM Limit"
description="Default tokens-per-minute limit for new teams"
>
{({ ref, ...field }) => <Input {...field} ref={ref} type="number" step={1} min={0} />}
{({ ref, ...field }) => <Input {...field} ref={ref} type="text" inputMode="numeric" />}
</FormField>
<FormField
@ -219,7 +219,7 @@ const SettingsForm = ({ initialValues, updateSettings, onCancel, onSaved }: Sett
label="RPM Limit"
description="Default requests-per-minute limit for new teams"
>
{({ ref, ...field }) => <Input {...field} ref={ref} type="number" step={1} min={0} />}
{({ ref, ...field }) => <Input {...field} ref={ref} type="text" inputMode="numeric" />}
</FormField>
<FormField

View file

@ -161,6 +161,20 @@ describe("DefaultUserSettingsForm", () => {
expect(updateSettings).toHaveBeenCalledWith({ ...SAVED_BODY, max_budget: null });
});
it("keeps number-ish junk visible and rejects it instead of silently clearing the budget", async () => {
const user = userEvent.setup();
const { updateSettings } = renderForm();
await enterEditMode(user);
await user.clear(await screen.findByLabelText("Max Budget (USD)"));
await user.type(screen.getByLabelText("Max Budget (USD)"), "12e");
await user.click(await saveButton());
expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue("12e");
expect(await screen.findByText("Must be a non-negative number")).toBeInTheDocument();
expect(updateSettings).not.toHaveBeenCalled();
});
it("sends the models selection through unchanged, sentinel values included", async () => {
const user = userEvent.setup();
const { updateSettings } = renderForm();
@ -275,7 +289,7 @@ describe("DefaultUserSettingsForm", () => {
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith("Team(s) not found: team-alhpa."),
);
expect(await saveButton()).toBeEnabled();
expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue(250);
expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue("250");
});
it("discards edits and returns to the read-only view when Cancel is pressed", async () => {
@ -293,7 +307,7 @@ describe("DefaultUserSettingsForm", () => {
expect(updateSettings).not.toHaveBeenCalled();
await enterEditMode(user);
expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue(100);
expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue("100");
expect(await saveButton()).toBeDisabled();
});
});

View file

@ -124,7 +124,7 @@ const TeamsField = ({ control }: { control: SettingsControl }) => {
<FormField control={control} name={`teams.${index}.max_budget_in_team`} label="Max Budget in Team (USD)">
{({ ref, ...budgetField }) => (
<Input {...budgetField} ref={ref} type="number" step={0.01} min={0} placeholder="Optional" />
<Input {...budgetField} ref={ref} type="text" inputMode="decimal" placeholder="Optional" />
)}
</FormField>
@ -244,7 +244,7 @@ const SettingsForm = ({ initialValues, roleOptions, updateSettings, onCancel, on
const onSubmit = form.handleSubmit((values) => mutation.mutate(values));
return (
<form onSubmit={onSubmit}>
<form onSubmit={onSubmit} noValidate>
<FieldGroup>
<FormField
control={form.control}
@ -281,7 +281,7 @@ const SettingsForm = ({ initialValues, roleOptions, updateSettings, onCancel, on
label="Max Budget (USD)"
description="Default maximum budget for new users"
>
{({ ref, ...field }) => <Input {...field} ref={ref} type="number" step={0.01} min={0} />}
{({ ref, ...field }) => <Input {...field} ref={ref} type="text" inputMode="decimal" />}
</FormField>
<FormField