Skip to content

Commit 02032e4

Browse files
authored
Introduction of the Form component (#2474)
1 parent 232e1a0 commit 02032e4

57 files changed

Lines changed: 4445 additions & 68 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/core/src/formObject.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { get, set } from 'es-toolkit/compat'
2+
import { FormDataConvertible } from './types'
3+
4+
/**
5+
* Transform dotted notation to bracket notation.
6+
*
7+
* Examples:
8+
* user.name => user[name]
9+
* user.profile.city => user[profile][city]
10+
* user.skills[] => user[skills][]
11+
* users.company[address].street => users[company][address][street]
12+
* config\.app\.name => config.app.name (escaped, literal)
13+
*/
14+
function undotKey(key: string): string {
15+
if (!key.includes('.')) {
16+
return key
17+
}
18+
19+
const transformSegment = (segment: string): string => {
20+
if (segment.startsWith('[') && segment.endsWith(']')) {
21+
return segment // Already in bracket notation - leave untouched
22+
}
23+
24+
// Convert dotted segment to bracket notation: "user.name" → "user[name]"
25+
return segment.split('.').reduce((result, part, index) => (index === 0 ? part : `${result}[${part}]`))
26+
}
27+
28+
return key
29+
.replace(/\\\./g, '__ESCAPED_DOT__') // Temporarily replace escaped dots (\.) to protect them from transformation
30+
.split(/(\[[^\]]*\])/) // Split on bracket notation while preserving the brackets in the result array
31+
.filter(Boolean) // Remove empty strings from the split operation
32+
.map(transformSegment) // Transform each segment: dotted parts become bracketed, existing brackets stay as-is
33+
.join('') // Reassemble all segments back into a single string
34+
.replace(/__ESCAPED_DOT__/g, '.') // Restore the escaped dots as literal dots in the final result
35+
}
36+
37+
/**
38+
* Parse a key into an array of path segments.
39+
*
40+
* Examples:
41+
* - "user[name]" => ["user", "name"]
42+
* - "tags[]" => ["tags", ""]
43+
* - "items[0][name]" => ["items", 0, "name"]
44+
*/
45+
function parseKey(key: string): (string | number | '')[] {
46+
const path: (string | number | '')[] = []
47+
const pattern = /([^\[\]]+)|\[(\d*)\]/g
48+
let match: RegExpExecArray | null
49+
50+
while ((match = pattern.exec(key)) !== null) {
51+
if (match[1] !== undefined) {
52+
path.push(match[1])
53+
} else if (match[2] !== undefined) {
54+
path.push(match[2] === '' ? '' : Number(match[2]))
55+
}
56+
}
57+
58+
return path
59+
}
60+
61+
/**
62+
* Convert a FormData instance into an object structure.
63+
*/
64+
export function formDataToObject(source: FormData): Record<string, FormDataConvertible> {
65+
const form: Record<string, any> = {}
66+
67+
// formData.entries() returns an iterator where the first element is the key and the second element
68+
// is the value. Examples of the keys are "user[name]", "tags[]", "items[0][name]", "user.name", etc.
69+
// We should construct a new (nested) object based on these keys.
70+
for (const [key, value] of source.entries()) {
71+
if (value instanceof File && value.size === 0 && value.name === '') {
72+
// Check if the given value is an empty file. We want to filter
73+
// those out as they prevent us from comparing objects with
74+
// each other, which we do to set the isDirty prop.
75+
continue
76+
}
77+
78+
const path = parseKey(undotKey(key))
79+
80+
// If the key ends with an empty string (''), treat it as an array push (e.g., "tags[]")
81+
if (path[path.length - 1] === '') {
82+
const arrayPath = path.slice(0, -1)
83+
const existing = get(form, arrayPath)
84+
85+
if (Array.isArray(existing)) {
86+
existing.push(value)
87+
} else {
88+
set(form, arrayPath, [value])
89+
}
90+
91+
continue
92+
}
93+
94+
// No brackets: last value wins when multiple fields have the same key
95+
set(form, path, value)
96+
}
97+
98+
return form
99+
}

packages/core/src/index.ts

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

3+
export { objectToFormData } from './formData'
4+
export { formDataToObject } from './formObject'
35
export { default as createHeadManager } from './head'
46
export { hide as hideProgress, reveal as revealProgress, default as setupProgress } from './progress'
57
export { default as shouldIntercept } from './shouldIntercept'

packages/core/src/types.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,36 @@ export type ProgressSettings = {
341341
color: string
342342
}
343343

344+
export type FormComponentOptions = Pick<
345+
VisitOptions,
346+
'preserveScroll' | 'preserveState' | 'preserveUrl' | 'replace' | 'only' | 'except' | 'reset'
347+
>
348+
349+
export type FormComponentProps = Partial<
350+
Pick<Visit, 'method' | 'headers' | 'queryStringArrayFormat' | 'errorBag' | 'showProgress'> &
351+
Omit<VisitCallbacks, 'onPrefetched' | 'onPrefetching'>
352+
> & {
353+
action?: string | { url: string; method: Method }
354+
transform?: (data: Record<string, FormDataConvertible>) => Record<string, FormDataConvertible>
355+
options?: FormComponentOptions
356+
}
357+
358+
export type FormComponentSlotProps = {
359+
errors: Record<string, string>
360+
hasErrors: boolean
361+
processing: boolean
362+
progress: Progress | null
363+
wasSuccessful: boolean
364+
recentlySuccessful: boolean
365+
clearErrors: (...fields: string[]) => void
366+
resetAndClearErrors: (...fields: string[]) => void
367+
setError(field: string, value: string): void
368+
setError(errors: Record<string, string>): void
369+
isDirty: boolean
370+
reset: () => void
371+
submit: () => void
372+
}
373+
344374
declare global {
345375
interface DocumentEventMap {
346376
'inertia:before': GlobalEvent<'before'>

packages/react/src/Form.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import {
2+
FormComponentProps,
3+
FormComponentSlotProps,
4+
FormDataConvertible,
5+
formDataToObject,
6+
mergeDataIntoQueryString,
7+
Method,
8+
VisitOptions,
9+
} from '@inertiajs/core'
10+
import { isEqual } from 'es-toolkit'
11+
import { createElement, FormEvent, ReactNode, useEffect, useMemo, useRef, useState } from 'react'
12+
import useForm from './useForm'
13+
14+
type ComponentProps = (FormComponentProps &
15+
Omit<React.FormHTMLAttributes<HTMLFormElement>, keyof FormComponentProps | 'children'> &
16+
Omit<React.AllHTMLAttributes<HTMLFormElement>, keyof FormComponentProps | 'children'>) & {
17+
children: (props: FormComponentSlotProps) => ReactNode
18+
}
19+
20+
type FormSubmitOptions = Omit<VisitOptions, 'data' | 'onPrefetched' | 'onPrefetching'>
21+
22+
const noop = () => undefined
23+
24+
const Form = ({
25+
action = '',
26+
method = 'get',
27+
headers = {},
28+
queryStringArrayFormat = 'brackets',
29+
errorBag = null,
30+
showProgress = true,
31+
transform = (data) => data,
32+
options = {},
33+
onStart = noop,
34+
onProgress = noop,
35+
onFinish = noop,
36+
onBefore = noop,
37+
onCancel = noop,
38+
onSuccess = noop,
39+
onError = noop,
40+
onCancelToken = noop,
41+
children,
42+
...props
43+
}: ComponentProps) => {
44+
const form = useForm({})
45+
const formElement = useRef<HTMLFormElement>(null)
46+
47+
const resolvedMethod = useMemo(() => {
48+
return typeof action === 'object' ? action.method : (method.toLowerCase() as Method)
49+
}, [action, method])
50+
51+
const [isDirty, setIsDirty] = useState(false)
52+
const defaultValues = useRef<Record<string, FormDataConvertible>>({})
53+
54+
const getData = (): Record<string, FormDataConvertible> => {
55+
// Convert the FormData to an object because we can't compare two FormData
56+
// instances directly (which is needed for isDirty), mergeDataIntoQueryString()
57+
// expects an object, and submitting a FormData instance directly causes problems with nested objects.
58+
return formDataToObject(new FormData(formElement.current))
59+
}
60+
61+
const updateDirtyState = (event: Event) =>
62+
setIsDirty(event.type === 'reset' ? false : !isEqual(getData(), defaultValues.current))
63+
64+
useEffect(() => {
65+
defaultValues.current = getData()
66+
67+
const formEvents: Array<keyof HTMLElementEventMap> = ['input', 'change', 'reset']
68+
69+
formEvents.forEach((e) => formElement.current.addEventListener(e, updateDirtyState))
70+
71+
return () => formEvents.forEach((e) => formElement.current?.removeEventListener(e, updateDirtyState))
72+
}, [])
73+
74+
const submit = () => {
75+
const [url, _data] = mergeDataIntoQueryString(
76+
resolvedMethod,
77+
typeof action === 'object' ? action.url : action,
78+
getData(),
79+
queryStringArrayFormat,
80+
)
81+
82+
const submitOptions: FormSubmitOptions = {
83+
headers,
84+
errorBag,
85+
showProgress,
86+
onCancelToken,
87+
onBefore,
88+
onStart,
89+
onProgress,
90+
onFinish,
91+
onCancel,
92+
onSuccess,
93+
onError,
94+
...options,
95+
}
96+
97+
form.transform(() => transform(_data))
98+
form.submit(resolvedMethod, url, submitOptions)
99+
}
100+
101+
return createElement(
102+
'form',
103+
{
104+
...props,
105+
ref: formElement,
106+
action: typeof action === 'object' ? action.url : action,
107+
method: resolvedMethod,
108+
onSubmit: (event: FormEvent<HTMLFormElement>) => {
109+
event.preventDefault()
110+
submit()
111+
},
112+
},
113+
typeof children === 'function'
114+
? children({
115+
errors: form.errors,
116+
hasErrors: form.hasErrors,
117+
processing: form.processing,
118+
progress: form.progress,
119+
wasSuccessful: form.wasSuccessful,
120+
recentlySuccessful: form.recentlySuccessful,
121+
setError: form.setError,
122+
clearErrors: form.clearErrors,
123+
resetAndClearErrors: form.resetAndClearErrors,
124+
isDirty,
125+
reset: () => formElement.current?.reset(),
126+
submit,
127+
})
128+
: children,
129+
)
130+
}
131+
132+
Form.displayName = 'InertiaForm'
133+
134+
export default Form

packages/react/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { router as Router } from '@inertiajs/core'
33
export const router = Router
44
export { default as createInertiaApp } from './createInertiaApp'
55
export { default as Deferred } from './Deferred'
6+
export { default as Form } from './Form'
67
export { default as Head } from './Head'
78
export { InertiaLinkProps, default as Link } from './Link'
89
export { InertiaFormProps, SetDataByObject, SetDataByMethod, SetDataByKeyValuePair, SetDataAction, default as useForm } from './useForm'

packages/react/test-app/Pages/Dump.jsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,12 @@ export default (props) => {
1010
form: props.form,
1111
files: props.files ? props.files : {},
1212
query: props.query,
13+
url: props.url,
1314
$page: page,
1415
}
1516

1617
useEffect(() => {
17-
window._inertia_request_dump = {
18-
headers: props.headers,
19-
method: props.method,
20-
form: props.form,
21-
files: props.files ? props.files : {},
22-
query: props.query,
23-
$page: page,
24-
}
18+
window._inertia_request_dump = dump
2519
}, [])
2620

2721
return (
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Form } from '@inertiajs/react'
2+
3+
export default function DottedKeys() {
4+
return (
5+
<div>
6+
<h1>Dotted Keys Form Test</h1>
7+
8+
{/* Test basic and nested dotted keys */}
9+
<Form action="/dump/post" method="post">
10+
<h2>Basic Dotted Keys</h2>
11+
<input type="text" name="user.name" placeholder="User Name" />
12+
<input type="text" name="user.profile.city" placeholder="City" />
13+
<input type="text" name="user.skills[]" placeholder="First Skill" />
14+
<input type="text" name="user.skills[]" placeholder="Second Skill" />
15+
<input type="text" name="company.address.street" placeholder="Street" />
16+
<button type="submit">Submit Basic</button>
17+
</Form>
18+
19+
{/* Test escaped dots (literal keys) */}
20+
<Form action="/dump/post" method="post">
21+
<h2>Escaped Dots</h2>
22+
<input type="text" name="config\.app\.name" placeholder="App Name" />
23+
<input type="text" name="settings.theme\.mode" placeholder="Theme Mode" />
24+
<button type="submit">Submit Escaped</button>
25+
</Form>
26+
27+
{/* Test mixed bracket and dotted notation */}
28+
<Form action="/dump/post" method="post">
29+
<h2>Mixed Notation</h2>
30+
<input type="text" name="user[roles][]" defaultValue="admin" />
31+
<input type="text" name="user[roles][]" defaultValue="editor" />
32+
<input type="text" name="settings.ui.theme" placeholder="UI Theme" />
33+
<button type="submit">Submit Mixed</button>
34+
</Form>
35+
36+
</div>
37+
)
38+
}

0 commit comments

Comments
 (0)