From 20541d71849cdbf5d7994f83d10a0b10cb4771d4 Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:41:15 -0600 Subject: [PATCH] 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 --- app/src/components/Download.test.js | 28 ++++++++++++++++++++++-- app/src/hooks/useForm.js | 7 ++++-- app/src/hooks/useForm.test.js | 33 +++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/app/src/components/Download.test.js b/app/src/components/Download.test.js index 00492ff1..1493151e 100644 --- a/app/src/components/Download.test.js +++ b/app/src/components/Download.test.js @@ -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 ; + } + + it('typing into a NumberField updates the form without throwing', async () => { + let latest = null; + renderWithProviders( 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'); + }); +}); diff --git a/app/src/hooks/useForm.js b/app/src/hooks/useForm.js index 092c1f5a..0c03ec7c 100644 --- a/app/src/hooks/useForm.js +++ b/app/src/hooks/useForm.js @@ -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); diff --git a/app/src/hooks/useForm.test.js b/app/src/hooks/useForm.test.js index efe4d67c..443f0411 100644 --- a/app/src/hooks/useForm.test.js +++ b/app/src/hooks/useForm.test.js @@ -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: ''}},