Skip to content

Commit 3df837b

Browse files
Reset form fields by name in Form components (#2499)
* Reset form fields by name in Form components * Refactor + tests * wip * wip * wip --------- Co-authored-by: Pascal Baljet <[email protected]>
1 parent 32de529 commit 3df837b

File tree

14 files changed

+1243
-39
lines changed

14 files changed

+1243
-39
lines changed

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Router } from './router'
22

33
export { objectToFormData } from './formData'
44
export { formDataToObject } from './formObject'
5+
export { resetFormFields } from './resetFormFields'
56
export { default as createHeadManager } from './head'
67
export { hide as hideProgress, reveal as revealProgress, default as setupProgress } from './progress'
78
export { default as shouldIntercept } from './shouldIntercept'
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
type FormElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
2+
3+
function isFormElement(element: Element): element is FormElement {
4+
return (
5+
element instanceof HTMLInputElement ||
6+
element instanceof HTMLSelectElement ||
7+
element instanceof HTMLTextAreaElement
8+
)
9+
}
10+
11+
function resetInputElement(input: HTMLInputElement, defaultValues: FormDataEntryValue[]): boolean {
12+
const oldValue = input.value
13+
const oldChecked = input.checked
14+
15+
switch (input.type.toLowerCase()) {
16+
case 'checkbox':
17+
// For checkboxes, check if the input's value is in the array of default values
18+
input.checked = defaultValues.includes(input.value)
19+
break
20+
case 'radio':
21+
// For radios, only use the first default value to avoid multiple radios being checked
22+
input.checked = defaultValues[0] === input.value
23+
break
24+
case 'file':
25+
input.value = ''
26+
break
27+
case 'button':
28+
case 'submit':
29+
case 'reset':
30+
case 'image':
31+
// These input types don't carry form state
32+
break
33+
default:
34+
// text, email, number, date, etc. - use first default value
35+
input.value = defaultValues[0] !== null && defaultValues[0] !== undefined ? String(defaultValues[0]) : ''
36+
}
37+
38+
// Return true if the value actually changed
39+
return input.value !== oldValue || input.checked !== oldChecked
40+
}
41+
42+
function resetSelectElement(select: HTMLSelectElement, defaultValues: FormDataEntryValue[]): boolean {
43+
const oldValue = select.value
44+
const oldSelectedOptions = Array.from(select.selectedOptions).map((opt) => opt.value)
45+
46+
if (select.multiple) {
47+
// For multi-select, select all options that match any of the default values
48+
const defaultStrings = defaultValues.map((value) => String(value))
49+
50+
Array.from(select.options).forEach((option) => {
51+
option.selected = defaultStrings.includes(option.value)
52+
})
53+
} else {
54+
// For single select, use the first default value (or empty string)
55+
select.value = defaultValues[0] !== undefined ? String(defaultValues[0]) : ''
56+
}
57+
58+
// Check if selection actually changed
59+
const newSelectedOptions = Array.from(select.selectedOptions).map((opt) => opt.value)
60+
const hasChanged = select.multiple
61+
? JSON.stringify(oldSelectedOptions.sort()) !== JSON.stringify(newSelectedOptions.sort())
62+
: select.value !== oldValue
63+
64+
return hasChanged
65+
}
66+
67+
function resetFormElement(element: FormElement, defaultValues: FormDataEntryValue[]): boolean {
68+
if (element.disabled) {
69+
// For disabled elements, use their DOM defaultValue since they're not in FormData
70+
if (element instanceof HTMLInputElement) {
71+
const oldValue = element.value
72+
const oldChecked = element.checked
73+
74+
switch (element.type.toLowerCase()) {
75+
case 'checkbox':
76+
case 'radio':
77+
element.checked = element.defaultChecked
78+
return element.checked !== oldChecked
79+
case 'file':
80+
element.value = ''
81+
return oldValue !== ''
82+
case 'button':
83+
case 'submit':
84+
case 'reset':
85+
case 'image':
86+
// These input types don't carry form state
87+
return false
88+
default:
89+
element.value = element.defaultValue
90+
return element.value !== oldValue
91+
}
92+
} else if (element instanceof HTMLSelectElement) {
93+
// Reset select to default selected options
94+
const oldSelectedOptions = Array.from(element.selectedOptions).map((opt) => opt.value)
95+
96+
Array.from(element.options).forEach((option) => {
97+
option.selected = option.defaultSelected
98+
})
99+
100+
const newSelectedOptions = Array.from(element.selectedOptions).map((opt) => opt.value)
101+
return JSON.stringify(oldSelectedOptions.sort()) !== JSON.stringify(newSelectedOptions.sort())
102+
} else if (element instanceof HTMLTextAreaElement) {
103+
const oldValue = element.value
104+
element.value = element.defaultValue
105+
return element.value !== oldValue
106+
}
107+
108+
return false
109+
}
110+
111+
if (element instanceof HTMLInputElement) {
112+
// Pass all default values to handle checkboxes and radios correctly
113+
return resetInputElement(element, defaultValues)
114+
} else if (element instanceof HTMLSelectElement) {
115+
return resetSelectElement(element, defaultValues)
116+
} else if (element instanceof HTMLTextAreaElement) {
117+
const oldValue = element.value
118+
element.value = defaultValues[0] !== undefined ? String(defaultValues[0]) : ''
119+
return element.value !== oldValue
120+
}
121+
122+
return false
123+
}
124+
125+
function resetFieldElements(
126+
elements: Element | RadioNodeList | HTMLCollection,
127+
defaultValues: FormDataEntryValue[],
128+
): boolean {
129+
let hasChanged = false
130+
131+
if (elements instanceof RadioNodeList || elements instanceof HTMLCollection) {
132+
// Handle multiple elements with the same name (e.g., radio buttons, checkboxes, array fields)
133+
Array.from(elements).forEach((node, index) => {
134+
if (node instanceof Element && isFormElement(node)) {
135+
if (node instanceof HTMLInputElement && ['checkbox', 'radio'].includes(node.type.toLowerCase())) {
136+
// For checkboxes and radios, pass all default values for value-based matching
137+
if (resetFormElement(node, defaultValues)) {
138+
hasChanged = true
139+
}
140+
} else {
141+
// For other array elements (like text inputs), use index-based matching
142+
const indexedDefaultValues =
143+
defaultValues[index] !== undefined ? [defaultValues[index]] : [defaultValues[0] ?? null].filter(Boolean)
144+
145+
if (resetFormElement(node, indexedDefaultValues)) {
146+
hasChanged = true
147+
}
148+
}
149+
}
150+
})
151+
} else if (isFormElement(elements)) {
152+
// Handle single element - pass all default values (important for multi-selects)
153+
hasChanged = resetFormElement(elements, defaultValues)
154+
}
155+
156+
return hasChanged
157+
}
158+
159+
export function resetFormFields(formElement: HTMLFormElement, defaults: FormData, fieldNames?: string[]): void {
160+
// If no specific fields provided, reset the entire form
161+
if (!fieldNames || fieldNames.length === 0) {
162+
formElement.reset()
163+
return
164+
}
165+
166+
let hasChanged = false
167+
168+
fieldNames.forEach((fieldName) => {
169+
const elements = formElement.elements.namedItem(fieldName)
170+
171+
if (elements) {
172+
if (resetFieldElements(elements, defaults.getAll(fieldName))) {
173+
hasChanged = true
174+
}
175+
}
176+
})
177+
178+
// Dispatch reset event if any field changed (matching native form.reset() behavior)
179+
if (hasChanged) {
180+
formElement.dispatchEvent(new Event('reset', { bubbles: true }))
181+
}
182+
}

packages/core/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,7 @@ export type FormComponentMethods = {
438438
resetAndClearErrors: (...fields: string[]) => void
439439
setError(field: string, value: string): void
440440
setError(errors: Record<string, string>): void
441-
reset: () => void
441+
reset: (...fields: string[]) => void
442442
submit: () => void
443443
}
444444

packages/react/src/Form.ts

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
formDataToObject,
77
mergeDataIntoQueryString,
88
Method,
9+
resetFormFields,
910
VisitOptions,
1011
} from '@inertiajs/core'
1112
import { isEqual } from 'es-toolkit'
@@ -57,28 +58,28 @@ const Form = forwardRef<FormComponentRef, ComponentProps>(
5758
},
5859
ref,
5960
) => {
60-
const form = useForm({})
61+
const form = useForm<Record<string, any>>({})
6162
const formElement = useRef<HTMLFormElement>(null)
6263

6364
const resolvedMethod = useMemo(() => {
6465
return typeof action === 'object' ? action.method : (method.toLowerCase() as Method)
6566
}, [action, method])
6667

6768
const [isDirty, setIsDirty] = useState(false)
68-
const defaultValues = useRef<Record<string, FormDataConvertible>>({})
69+
const defaults = useRef<FormData>(new FormData())
6970

70-
const getData = (): Record<string, FormDataConvertible> => {
71-
// Convert the FormData to an object because we can't compare two FormData
72-
// instances directly (which is needed for isDirty), mergeDataIntoQueryString()
73-
// expects an object, and submitting a FormData instance directly causes problems with nested objects.
74-
return formDataToObject(new FormData(formElement.current))
75-
}
71+
const getFormData = (): FormData => new FormData(formElement.current)
72+
73+
// Convert the FormData to an object because we can't compare two FormData
74+
// instances directly (which is needed for isDirty), mergeDataIntoQueryString()
75+
// expects an object, and submitting a FormData instance directly causes problems with nested objects.
76+
const getData = (): Record<string, FormDataConvertible> => formDataToObject(getFormData())
7677

7778
const updateDirtyState = (event: Event) =>
78-
setIsDirty(event.type === 'reset' ? false : !isEqual(getData(), defaultValues.current))
79+
setIsDirty(event.type === 'reset' ? false : !isEqual(getData(), formDataToObject(defaults.current)))
7980

8081
useEffect(() => {
81-
defaultValues.current = getData()
82+
defaults.current = getFormData()
8283

8384
const formEvents: Array<keyof HTMLElementEventMap> = ['input', 'change', 'reset']
8485

@@ -124,10 +125,13 @@ const Form = forwardRef<FormComponentRef, ComponentProps>(
124125
wasSuccessful: form.wasSuccessful,
125126
recentlySuccessful: form.recentlySuccessful,
126127
clearErrors: form.clearErrors,
127-
resetAndClearErrors: form.resetAndClearErrors,
128+
resetAndClearErrors: (...fields: string[]) => {
129+
form.clearErrors(...fields)
130+
resetFormFields(formElement.current, defaults.current, fields)
131+
},
128132
setError: form.setError,
129133
isDirty,
130-
reset: () => formElement.current?.reset(),
134+
reset: (...fields) => resetFormFields(formElement.current, defaults.current, fields),
131135
submit,
132136
}),
133137
[form, isDirty, submit],
@@ -156,9 +160,12 @@ const Form = forwardRef<FormComponentRef, ComponentProps>(
156160
recentlySuccessful: form.recentlySuccessful,
157161
setError: form.setError,
158162
clearErrors: form.clearErrors,
159-
resetAndClearErrors: form.resetAndClearErrors,
163+
resetAndClearErrors: (...fields: string[]) => {
164+
form.clearErrors(...fields)
165+
resetFormFields(formElement.current, defaults.current, fields)
166+
},
160167
isDirty,
161-
reset: () => formElement.current?.reset(),
168+
reset: (...fields) => resetFormFields(formElement.current, defaults.current, fields),
162169
submit,
163170
})
164171
: children,

packages/react/test-app/Pages/FormComponent/Ref.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ export default function Ref() {
1313
formRef.current?.reset()
1414
}
1515

16+
const resetNameField = () => {
17+
formRef.current?.reset('name')
18+
}
19+
1620
const clearAllErrors = () => {
1721
formRef.current?.clearErrors()
1822
}
@@ -55,6 +59,9 @@ export default function Ref() {
5559
<button onClick={resetForm}>
5660
Reset Form
5761
</button>
62+
<button onClick={resetNameField}>
63+
Reset Name Field
64+
</button>
5865
<button onClick={clearAllErrors}>
5966
Clear Errors
6067
</button>

0 commit comments

Comments
 (0)