app: number fields no longer throw "e.preventDefault is not a function" (#735)

`useForm`'s input handlers called `e.preventDefault()` unconditionally, but the
Mantine-backed `NumberField` (Sleep between requests, Depth, Maximum Pages)
hands them a bare `{target}` object because NumberInput emits a value, not an
event.  Every keystroke in those fields threw.

`handleInputEvent` now only calls `preventDefault` when the argument actually
has one, and `getInputProps`' wrapper no longer repeats the call.  Audited every
other `preventDefault` call site: all are bound to real DOM/React events or a
guarded no-argument call.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lrnselfreliance 2026-09-26 17:41:15 -06:00 • committed by Roland
parent eead78007d
commit 20541d7184
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
3 changed files with 64 additions and 4 deletions

View file

@ -1,8 +1,9 @@
import React from 'react';
import {render, screen, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {DestinationForm} from './Download';
import {createTestForm} from '../test-utils';
import {DepthInputForm, DestinationForm} from './Download';
import {useForm} from '../hooks/useForm';
import {createTestForm, renderWithProviders} from '../test-utils';
// Mock DirectorySearch component - simplified to avoid useState/useEffect warnings
jest.mock('./Common', () => {
@ -251,3 +252,26 @@ describe('DestinationForm', () => {
});
});
});
describe('NumberField', () => {
// Renders a NumberField against a real useForm: the Mantine NumberInput passes a value (not an
// event) to onChange, which used to reach `e.preventDefault()` and throw while typing.
function DepthHarness({onData}) {
const form = useForm({defaultFormData: {settings: {depth: ''}}, submitter: jest.fn()});
onData(form.formData);
return <DepthInputForm form={form}/>;
}
it('typing into a NumberField updates the form without throwing', async () => {
let latest = null;
renderWithProviders(<DepthHarness onData={(d) => latest = d}/>);
const input = screen.getByRole('textbox', {name: /Depth/});
await userEvent.type(input, '3');
await waitFor(() => {
expect(latest.settings.depth).toBe(3);
});
expect(input).toHaveValue('3');
});
});

View file

@ -188,7 +188,9 @@ export function useForm({
}, 300), [validators]);
const handleInputEvent = (e) => {
if (e) e.preventDefault();
// Mantine inputs (NumberInput, etc.) hand back a value rather than a DOM event, so callers
// wrap it as a bare `{target: {...}}` object. Only a real event has `preventDefault`.
if (e && typeof e.preventDefault === 'function') e.preventDefault();
let {type, value} = e.target;
let {path} = e.target.dataset;
path = path || e.target.name;
@ -286,7 +288,8 @@ export function useForm({
const [customProps, inputAttrs] = getCustomProps({name, validator, path, type, required, afterChange});
const localHandleInputEvent = async (e) => {
if (e) e.preventDefault();
// `handleInputEvent` already handles a real event's preventDefault, and tolerates the
// value-shaped objects Mantine-backed fields pass.
const value = handleInputEvent(e);
if (onChange) {
await onChange(value);

View file

@ -465,6 +465,39 @@ describe('useForm', () => {
expect(result.current.formData.sleep_requests).toBe(0.75);
});
it('handleInputEvent accepts a value-shaped event with no preventDefault (Mantine NumberInput)', () => {
// Mantine's NumberInput hands back the value, so NumberField wraps it in a bare
// {target} object. Typing must not throw "e.preventDefault is not a function".
const {result} = renderHook(() => useForm({
defaultFormData: {settings: {sleep_requests: ''}},
submitter: jest.fn(),
}));
act(() => {
result.current.handleInputEvent({
target: {type: 'number', value: 0.75, dataset: {path: 'settings.sleep_requests'}},
});
});
expect(result.current.formData.settings.sleep_requests).toBe(0.75);
});
it('getInputProps onChange accepts a value-shaped event with no preventDefault', async () => {
const {result} = renderHook(() => useForm({
defaultFormData: {settings: {depth: ''}},
submitter: jest.fn(),
}));
await act(async () => {
const [inputProps] = result.current.getInputProps({name: 'depth', path: 'settings.depth', type: 'number'});
await inputProps.onChange({
target: {type: 'number', value: 3, dataset: {path: 'settings.depth'}},
});
});
expect(result.current.formData.settings.depth).toBe(3);
});
it('handleInputEvent uses data-path attribute when available', () => {
const {result} = renderHook(() => useForm({
defaultFormData: {nested: {field: ''}},