Skip to content

Commit 4f4a21c

Browse files
Merge pull request #2181 from joaopalopes24/nested-paths-in-form
[2.x]: Support for nested paths in forms
2 parents 10c605f + e0160d7 commit 4f4a21c

File tree

10 files changed

+559
-78
lines changed

10 files changed

+559
-78
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"test:svelte": "cd tests && PACKAGE=svelte npx playwright test",
1212
"test:svelte:watch": "cd packages/svelte/test-app && npm run watch",
1313
"test:vue": "cd tests && PACKAGE=vue3 npx playwright test",
14-
"test:vue:watch": "cd packages/vue3/test-app && npm run watch"
14+
"test:vue:watch": "cd packages/vue3/test-app && npm run watch",
15+
"build:all": "cd packages/core && npm run build && cd ../react && npm run build && cd ../vue3 && npm run build && cd ../svelte && npm run build"
1516
},
1617
"dependencies": {
1718
"prettier": "^3.2.5",

packages/core/src/types.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,26 @@ export type FormDataConvertible =
2121
| null
2222
| undefined
2323

24+
export type FormDataKeys<T extends Record<any, any>> = T extends T
25+
? keyof T extends infer Key extends Extract<keyof T, string>
26+
? Key extends Key
27+
? T[Key] extends Record<any, any>
28+
? `${Key}.${FormDataKeys<T[Key]>}` | Key
29+
: Key
30+
: never
31+
: never
32+
: never
33+
34+
export type FormDataValues<T extends Record<any, any>, K extends FormDataKeys<T>> = K extends `${infer P}.${infer Rest}`
35+
? P extends keyof T
36+
? Rest extends FormDataKeys<T[P]>
37+
? FormDataValues<T[P], Rest>
38+
: never
39+
: never
40+
: K extends keyof T
41+
? T[K]
42+
: never
43+
2444
export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete'
2545

2646
export type RequestPayload = Record<string, FormDataConvertible> | FormData

packages/react/src/useForm.ts

Lines changed: 40 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,41 @@
1-
import { FormDataConvertible, Method, Progress, router, VisitOptions } from '@inertiajs/core'
2-
import { isEqual, cloneDeep } from 'es-toolkit'
1+
import {
2+
FormDataConvertible,
3+
FormDataKeys,
4+
FormDataValues,
5+
Method,
6+
Progress,
7+
router,
8+
VisitOptions,
9+
} from '@inertiajs/core'
10+
import { cloneDeep, isEqual } from 'es-toolkit'
11+
import { get, has, set } from 'es-toolkit/compat'
312
import { useCallback, useEffect, useRef, useState } from 'react'
413
import useRemember from './useRemember'
514

6-
type setDataByObject<TForm> = (data: TForm) => void
7-
type setDataByMethod<TForm> = (data: (previousData: TForm) => TForm) => void
8-
type setDataByKeyValuePair<TForm> = <K extends keyof TForm>(key: K, value: TForm[K]) => void
15+
type SetDataByObject<TForm> = (data: TForm) => void
16+
type SetDataByMethod<TForm> = (data: (previousData: TForm) => TForm) => void
17+
type SetDataByKeyValuePair<TForm> = <K extends FormDataKeys<TForm>>(key: K, value: FormDataValues<TForm, K>) => void
918
type FormDataType = Record<string, FormDataConvertible>
1019
type FormOptions = Omit<VisitOptions, 'data'>
1120

1221
export interface InertiaFormProps<TForm extends FormDataType> {
1322
data: TForm
1423
isDirty: boolean
15-
errors: Partial<Record<keyof TForm, string>>
24+
errors: Partial<Record<FormDataKeys<TForm>, string>>
1625
hasErrors: boolean
1726
processing: boolean
1827
progress: Progress | null
1928
wasSuccessful: boolean
2029
recentlySuccessful: boolean
21-
setData: setDataByObject<TForm> & setDataByMethod<TForm> & setDataByKeyValuePair<TForm>
30+
setData: SetDataByObject<TForm> & SetDataByMethod<TForm> & SetDataByKeyValuePair<TForm>
2231
transform: (callback: (data: TForm) => object) => void
2332
setDefaults(): void
24-
setDefaults(field: keyof TForm, value: FormDataConvertible): void
33+
setDefaults(field: FormDataKeys<TForm>, value: FormDataConvertible): void
2534
setDefaults(fields: Partial<TForm>): void
26-
reset: (...fields: (keyof TForm)[]) => void
27-
clearErrors: (...fields: (keyof TForm)[]) => void
28-
setError(field: keyof TForm, value: string): void
29-
setError(errors: Record<keyof TForm, string>): void
35+
reset: (...fields: FormDataKeys<TForm>[]) => void
36+
clearErrors: (...fields: FormDataKeys<TForm>[]) => void
37+
setError(field: FormDataKeys<TForm>, value: string): void
38+
setError(errors: Record<FormDataKeys<TForm>, string>): void
3039
submit: (...args: [Method, string, FormOptions?] | [{ url: string; method: Method }, FormOptions?]) => void
3140
get: (url: string, options?: FormOptions) => void
3241
patch: (url: string, options?: FormOptions) => void
@@ -53,8 +62,8 @@ export default function useForm<TForm extends FormDataType>(
5362
const recentlySuccessfulTimeoutId = useRef(null)
5463
const [data, setData] = rememberKey ? useRemember(defaults, `${rememberKey}:data`) : useState(defaults)
5564
const [errors, setErrors] = rememberKey
56-
? useRemember({} as Partial<Record<keyof TForm, string>>, `${rememberKey}:errors`)
57-
: useState({} as Partial<Record<keyof TForm, string>>)
65+
? useRemember({} as Partial<Record<FormDataKeys<TForm>, string>>, `${rememberKey}:errors`)
66+
: useState({} as Partial<Record<FormDataKeys<TForm>, string>>)
5867
const [hasErrors, setHasErrors] = useState(false)
5968
const [processing, setProcessing] = useState(false)
6069
const [progress, setProgress] = useState(null)
@@ -175,9 +184,9 @@ export default function useForm<TForm extends FormDataType>(
175184
)
176185

177186
const setDataFunction = useCallback(
178-
(keyOrData: keyof TForm | Function | TForm, maybeValue?: TForm[keyof TForm]) => {
187+
(keyOrData: FormDataKeys<TForm> | Function | TForm, maybeValue?: any) => {
179188
if (typeof keyOrData === 'string') {
180-
setData((data) => ({ ...data, [keyOrData]: maybeValue }))
189+
setData((data) => set(cloneDeep(data), keyOrData, maybeValue))
181190
} else if (typeof keyOrData === 'function') {
182191
setData((data) => keyOrData(data))
183192
} else {
@@ -188,14 +197,15 @@ export default function useForm<TForm extends FormDataType>(
188197
)
189198

190199
const setDefaultsFunction = useCallback(
191-
(fieldOrFields?: keyof TForm | Partial<TForm>, maybeValue?: FormDataConvertible) => {
200+
(fieldOrFields?: FormDataKeys<TForm> | Partial<TForm>, maybeValue?: FormDataConvertible) => {
192201
if (typeof fieldOrFields === 'undefined') {
193202
setDefaults(() => data)
194203
} else {
195-
setDefaults((defaults) => ({
196-
...defaults,
197-
...(typeof fieldOrFields === 'string' ? { [fieldOrFields]: maybeValue } : (fieldOrFields as TForm)),
198-
}))
204+
setDefaults((defaults) => {
205+
return typeof fieldOrFields === 'string'
206+
? set(cloneDeep(defaults), fieldOrFields, maybeValue)
207+
: Object.assign(cloneDeep(defaults), fieldOrFields)
208+
})
199209
}
200210
},
201211
[data, setDefaults],
@@ -207,14 +217,13 @@ export default function useForm<TForm extends FormDataType>(
207217
setData(defaults)
208218
} else {
209219
setData((data) =>
210-
(Object.keys(defaults) as Array<keyof TForm>)
211-
.filter((key) => fields.includes(key))
220+
(fields as Array<FormDataKeys<TForm>>)
221+
.filter((key) => has(defaults, key))
212222
.reduce(
213223
(carry, key) => {
214-
carry[key] = defaults[key]
215-
return carry
224+
return set(carry, key, get(defaults, key))
216225
},
217-
{ ...data },
226+
{ ...data } as TForm,
218227
),
219228
)
220229
}
@@ -223,13 +232,13 @@ export default function useForm<TForm extends FormDataType>(
223232
)
224233

225234
const setError = useCallback(
226-
(fieldOrFields: keyof TForm | Record<keyof TForm, string>, maybeValue?: string) => {
235+
(fieldOrFields: FormDataKeys<TForm> | Record<FormDataKeys<TForm>, string>, maybeValue?: string) => {
227236
setErrors((errors) => {
228237
const newErrors = {
229238
...errors,
230239
...(typeof fieldOrFields === 'string'
231240
? { [fieldOrFields]: maybeValue }
232-
: (fieldOrFields as Record<keyof TForm, string>)),
241+
: (fieldOrFields as Record<FormDataKeys<TForm>, string>)),
233242
}
234243
setHasErrors(Object.keys(newErrors).length > 0)
235244
return newErrors
@@ -241,7 +250,7 @@ export default function useForm<TForm extends FormDataType>(
241250
const clearErrors = useCallback(
242251
(...fields) => {
243252
setErrors((errors) => {
244-
const newErrors = (Object.keys(errors) as Array<keyof TForm>).reduce(
253+
const newErrors = (Object.keys(errors) as Array<FormDataKeys<TForm>>).reduce(
245254
(carry, field) => ({
246255
...carry,
247256
...(fields.length > 0 && !fields.includes(field) ? { [field]: errors[field] } : {}),
@@ -258,7 +267,7 @@ export default function useForm<TForm extends FormDataType>(
258267
const createSubmitMethod = (method) => (url, options) => {
259268
submit(method, url, options)
260269
}
261-
const get = useCallback(createSubmitMethod('get'), [submit])
270+
const getMethod = useCallback(createSubmitMethod('get'), [submit])
262271
const post = useCallback(createSubmitMethod('post'), [submit])
263272
const put = useCallback(createSubmitMethod('put'), [submit])
264273
const patch = useCallback(createSubmitMethod('patch'), [submit])
@@ -290,7 +299,7 @@ export default function useForm<TForm extends FormDataType>(
290299
setError,
291300
clearErrors,
292301
submit,
293-
get,
302+
get: getMethod,
294303
post,
295304
put,
296305
patch,
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { useForm } from '@inertiajs/react'
2+
3+
export default (props) => {
4+
const form = useForm({
5+
name: 'foo',
6+
address: {
7+
street: '123 Main St',
8+
city: 'New York',
9+
},
10+
organization: {
11+
name: 'Inertia',
12+
repo: {
13+
name: 'inertiajs/inertia',
14+
tags: ['v0.1', 'v0.2'],
15+
},
16+
},
17+
checked: ['foo', 'bar'],
18+
})
19+
20+
const submit = () => {
21+
form.submit('post', '/dump/post')
22+
}
23+
24+
return (
25+
<div>
26+
<label>
27+
Full Name
28+
<input
29+
type="text"
30+
id="name"
31+
name="name"
32+
onChange={(e) => form.setData('name', e.target.value)}
33+
value={form.data.name}
34+
/>
35+
</label>
36+
<label>
37+
Street
38+
<input
39+
type="text"
40+
id="street"
41+
name="address.street"
42+
onChange={(e) => form.setData('address.street', e.target.value)}
43+
value={form.data.address.street}
44+
/>
45+
</label>
46+
<label>
47+
City
48+
<input
49+
type="text"
50+
id="city"
51+
name="address.city"
52+
onChange={(e) => form.setData('address.city', e.target.value)}
53+
value={form.data.address.city}
54+
/>
55+
</label>
56+
<label>
57+
Foo
58+
<input
59+
type="checkbox"
60+
id="foo"
61+
name="checked[]"
62+
value="foo"
63+
onChange={(e) =>
64+
form.setData(
65+
'checked',
66+
e.target.checked
67+
? [...form.data.checked, e.target.value]
68+
: form.data.checked.filter((item) => item !== e.target.value),
69+
)
70+
}
71+
checked={form.data.checked.includes('foo')}
72+
/>
73+
</label>
74+
<label>
75+
Bar
76+
<input
77+
type="checkbox"
78+
id="bar"
79+
name="checked[]"
80+
value="bar"
81+
onChange={(e) =>
82+
form.setData(
83+
'checked',
84+
e.target.checked
85+
? [...form.data.checked, e.target.value]
86+
: form.data.checked.filter((item) => item !== e.target.value),
87+
)
88+
}
89+
checked={form.data.checked.includes('bar')}
90+
/>
91+
</label>
92+
<label>
93+
Baz
94+
<input
95+
type="checkbox"
96+
id="baz"
97+
name="checked[]"
98+
value="baz"
99+
onChange={(e) =>
100+
form.setData(
101+
'checked',
102+
e.target.checked
103+
? [...form.data.checked, e.target.value]
104+
: form.data.checked.filter((item) => item !== e.target.value),
105+
)
106+
}
107+
checked={form.data.checked.includes('baz')}
108+
/>
109+
</label>
110+
<label>
111+
Organization Name
112+
<input
113+
type="text"
114+
id="organization-name"
115+
name="organization.name"
116+
onChange={(e) => form.setData('organization.name', e.target.value)}
117+
value={form.data.organization.name}
118+
/>
119+
</label>
120+
<label>
121+
Repository Name
122+
<input
123+
type="text"
124+
id="repo-name"
125+
name="organization.repo.name"
126+
onChange={(e) => form.setData('organization.repo.name', e.target.value)}
127+
value={form.data.organization.repo.name}
128+
/>
129+
</label>
130+
Repository Tags
131+
<label>
132+
v0.1
133+
<input
134+
type="checkbox"
135+
id="tag-0"
136+
name="organization.repo.tags[]"
137+
value="v0.1"
138+
onChange={(e) =>
139+
form.setData(
140+
'organization.repo.tags',
141+
e.target.checked
142+
? [...form.data.organization.repo.tags, e.target.value]
143+
: form.data.organization.repo.tags.filter((item) => item !== e.target.value),
144+
)
145+
}
146+
checked={form.data.organization.repo.tags.includes('v0.1')}
147+
/>
148+
</label>
149+
<label>
150+
v0.2
151+
<input
152+
type="checkbox"
153+
id="tag-1"
154+
name="organization.repo.tags[]"
155+
value="v0.2"
156+
onChange={(e) =>
157+
form.setData(
158+
'organization.repo.tags',
159+
e.target.checked
160+
? [...form.data.organization.repo.tags, e.target.value]
161+
: form.data.organization.repo.tags.filter((item) => item !== e.target.value),
162+
)
163+
}
164+
checked={form.data.organization.repo.tags.includes('v0.2')}
165+
/>
166+
</label>
167+
<label>
168+
v0.3
169+
<input
170+
type="checkbox"
171+
id="tag-2"
172+
name="organization.repo.tags[]"
173+
value="v0.3"
174+
onChange={(e) =>
175+
form.setData(
176+
'organization.repo.tags',
177+
e.target.checked
178+
? [...form.data.organization.repo.tags, e.target.value]
179+
: form.data.organization.repo.tags.filter((item) => item !== e.target.value),
180+
)
181+
}
182+
checked={form.data.organization.repo.tags.includes('v0.3')}
183+
/>
184+
</label>
185+
<button onClick={submit} className="submit">
186+
Submit form
187+
</button>
188+
</div>
189+
)
190+
}

0 commit comments

Comments
 (0)