fix(ui): let the internal user and org forms save sub-cent budgets

The Default User Settings form on Internal Users, the org settings form and
the org create dialog all rendered their money fields as
`<input type="number" step={0.01}>` inside a form that never opted out of
native constraint validation. Any value with more than two decimals, such as
a 0.001 max budget, failed the browser's step check, so Chrome vetoed the
submit before react-hook-form ran. No request went out, no field error was
shown, and the read view kept displaying the old value; it looked like the
budget silently refused to stick.

Money fields now use `step="any"`, and the three react-hook-form forms carry
`noValidate` so zod stays the only validator and a DOM-level constraint can
never swallow a submit again.
This commit is contained in:
ryan-crabbe-berri 2026-07-30 18:44:45 -07:00
parent 8ccbc3e735
commit 9dbf530d6e
6 changed files with 75 additions and 7 deletions

View file

@ -149,6 +149,34 @@ describe("DefaultUserSettingsForm", () => {
expect(updateSettings).toHaveBeenCalledWith({ ...SAVED_BODY, max_budget: 250 });
});
it("saves a sub-cent budget the browser would veto under a 0.01 step", async () => {
const user = userEvent.setup();
const { updateSettings } = renderForm();
await enterEditMode(user);
const budget: HTMLInputElement = await screen.findByLabelText("Max Budget (USD)");
await user.clear(budget);
await user.type(budget, "0.001");
const teamBudget: HTMLInputElement = screen.getByLabelText("Max Budget in Team (USD)");
await user.clear(teamBudget);
await user.type(teamBudget, "0.002");
// jsdom never blocks the submit itself, so assert the constraint the real browser
// enforces before handleSubmit ever runs
expect(budget.checkValidity()).toBe(true);
expect(teamBudget.checkValidity()).toBe(true);
await user.click(await saveButton());
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
expect(updateSettings).toHaveBeenCalledWith({
...SAVED_BODY,
max_budget: 0.001,
teams: [{ team_id: "team-alpha", max_budget_in_team: 0.002, user_role: "user" }],
});
});
it("clears an emptied budget with null", async () => {
const user = userEvent.setup();
const { updateSettings } = renderForm();

View file

@ -133,7 +133,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="number" step="any" min={0} placeholder="Optional" />
)}
</FormField>
@ -249,7 +249,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}
@ -286,7 +286,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="number" step="any" min={0} />}
</FormField>
<FormField

View file

@ -84,6 +84,28 @@ describe("OrgCreateDialog", () => {
await waitFor(() => expect(screen.queryByLabelText("Organization Name")).not.toBeInTheDocument());
});
it("creates with a sub-cent max budget the browser would veto under a 0.01 step", async () => {
const user = userEvent.setup();
const { createOrganization } = renderDialog();
await user.type(screen.getByLabelText("Organization Name"), "new-org");
const budget: HTMLInputElement = screen.getByLabelText("Max Budget (USD)");
await user.type(budget, "0.001");
// jsdom never blocks the submit itself, so assert the constraint the real browser
// enforces before handleSubmit ever runs
expect(budget.checkValidity()).toBe(true);
await user.click(screen.getByRole("button", { name: "Create Organization" }));
await waitFor(() => expect(createOrganization).toHaveBeenCalledTimes(1));
expect(createOrganization.mock.calls[0][0]).toStrictEqual({
organization_alias: "new-org",
models: [],
max_budget: 0.001,
});
});
it("maps selectors and limits into the create body", async () => {
const user = userEvent.setup();
const { createOrganization } = renderDialog();

View file

@ -79,7 +79,7 @@ export const OrgCreateDialog = ({
<DialogTitle>Create Organization</DialogTitle>
</DialogHeader>
<form onSubmit={onSubmit}>
<form onSubmit={onSubmit} noValidate>
<FieldGroup>
<FormField control={form.control} name="organization_alias" label="Organization Name">
{({ ref, ...field }) => <Input {...field} ref={ref} />}
@ -97,7 +97,7 @@ export const OrgCreateDialog = ({
</FormField>
<FormField control={form.control} name="max_budget" label="Max Budget (USD)">
{({ ref, ...field }) => <Input {...field} ref={ref} type="number" step={0.01} min={0} />}
{({ ref, ...field }) => <Input {...field} ref={ref} type="number" step="any" min={0} />}
</FormField>
<FormField control={form.control} name="budget_duration" label="Reset Budget">

View file

@ -113,6 +113,24 @@ describe("OrgSettingsForm", () => {
expect(patchOrganization).toHaveBeenCalledWith("org-1", { organization_alias: "acme-2" });
});
it("saves a sub-cent max budget the browser would veto under a 0.01 step", async () => {
const user = userEvent.setup();
const { patchOrganization } = renderForm();
const budget: HTMLInputElement = screen.getByLabelText("Max Budget (USD)");
await user.clear(budget);
await user.type(budget, "0.001");
// jsdom never blocks the submit itself, so assert the constraint the real browser
// enforces before handleSubmit ever runs
expect(budget.checkValidity()).toBe(true);
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => expect(patchOrganization).toHaveBeenCalledTimes(1));
expect(patchOrganization).toHaveBeenCalledWith("org-1", { max_budget: 0.001 });
});
it("sends null when a limit is cleared", async () => {
const user = userEvent.setup();
const { patchOrganization } = renderForm();

View file

@ -78,7 +78,7 @@ export const OrgSettingsForm = ({
});
return (
<form onSubmit={onSubmit}>
<form onSubmit={onSubmit} noValidate>
<FieldGroup>
<FormField control={form.control} name="organization_alias" label="Organization Name">
{({ ref, ...field }) => <Input {...field} ref={ref} />}
@ -96,7 +96,7 @@ export const OrgSettingsForm = ({
</FormField>
<FormField control={form.control} name="max_budget" label="Max Budget (USD)">
{({ ref, ...field }) => <Input {...field} ref={ref} type="number" step={0.01} min={0} />}
{({ ref, ...field }) => <Input {...field} ref={ref} type="number" step="any" min={0} />}
</FormField>
<FormField control={form.control} name="budget_duration" label="Reset Budget">