Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
edf45be
wip
pascalbaljet Oct 1, 2025
3fd3af9
wip
pascalbaljet Oct 1, 2025
fabb789
wip
pascalbaljet Oct 1, 2025
e71367c
wip
pascalbaljet Oct 2, 2025
eba97a1
test
pascalbaljet Oct 2, 2025
41ce2a6
Update form-component.spec.ts
pascalbaljet Oct 2, 2025
0a1d80e
wip
pascalbaljet Oct 2, 2025
b3608ac
Update package.json
pascalbaljet Oct 2, 2025
dc31fa0
Update form.ts
pascalbaljet Oct 3, 2025
6bf015a
refactor
pascalbaljet Oct 3, 2025
3ef1f6d
wip
pascalbaljet Oct 3, 2025
131dfe7
test
pascalbaljet Oct 3, 2025
fb3f9e6
tests
pascalbaljet Oct 3, 2025
d205af1
wip
pascalbaljet Oct 3, 2025
e73b45d
split
pascalbaljet Oct 3, 2025
3e43529
valid method
pascalbaljet Oct 3, 2025
2c23e1d
props
pascalbaljet Oct 3, 2025
c2e9fd4
files
pascalbaljet Oct 3, 2025
a54f9a5
Update precognition.ts
pascalbaljet Oct 3, 2025
85a1398
react
pascalbaljet Oct 3, 2025
17f4bbc
svelte
pascalbaljet Oct 3, 2025
b73535c
refactor
pascalbaljet Oct 3, 2025
cea03db
transform + callbacks
pascalbaljet Oct 3, 2025
0da0760
types
pascalbaljet Oct 3, 2025
4c735d3
Update types.ts
pascalbaljet Oct 3, 2025
3e75998
refactor
pascalbaljet Oct 3, 2025
4a59c16
Revert playground
pascalbaljet Oct 3, 2025
0733ab0
Update Form.svelte
pascalbaljet Oct 3, 2025
ec8b445
Used transformed data for validation
pascalbaljet Oct 7, 2025
361c22b
wip
pascalbaljet Oct 7, 2025
d56cce6
React + Svelte + refactor
pascalbaljet Oct 7, 2025
9387e8c
Merge branch 'master' into form-precognition
pascalbaljet Oct 7, 2025
e16c2ab
Update PrecognitionReset.vue
pascalbaljet Oct 7, 2025
25774a3
Force simple errors payload
pascalbaljet Oct 7, 2025
f502fdd
improve test
pascalbaljet Oct 7, 2025
0587acb
`touched()` method
pascalbaljet Oct 8, 2025
cd16aba
Prop for errors in array format
pascalbaljet Oct 8, 2025
8c34a38
Update Form.svelte
pascalbaljet Oct 8, 2025
4601332
Update server.js
pascalbaljet Oct 9, 2025
c734518
Improve cancel test
pascalbaljet Oct 9, 2025
5d40fa5
wip
pascalbaljet Oct 9, 2025
161bc5b
manual cancel
pascalbaljet Oct 9, 2025
5ed51bb
Remove redundant test
pascalbaljet Oct 9, 2025
d105ede
Unify tests
pascalbaljet Oct 9, 2025
8343809
Unify tests
pascalbaljet Oct 9, 2025
eec5986
Unify tests
pascalbaljet Oct 9, 2025
a98ecb4
Refine test suite
pascalbaljet Oct 14, 2025
b68d8e1
Refine tests
pascalbaljet Oct 14, 2025
68d7ed3
Pass validation timeout
pascalbaljet Oct 14, 2025
cb24e3e
Cleanup
pascalbaljet Oct 14, 2025
27aff2b
Vue playground
pascalbaljet Oct 14, 2025
94d83b5
React playground
pascalbaljet Oct 14, 2025
29f49eb
Svelte 4 playground
pascalbaljet Oct 14, 2025
ddd58e2
Svelte 5 playground
pascalbaljet Oct 14, 2025
3298c42
Make Prettier happier
pascalbaljet Oct 14, 2025
be8d679
Renamed `onBeforeValidation` to `onBefore`
pascalbaljet Oct 15, 2025
ae1d2ce
Improve playground
pascalbaljet Oct 15, 2025
192f981
Build upon `laravel-precognition` library
pascalbaljet Oct 30, 2025
8425e8d
React
pascalbaljet Oct 30, 2025
622f237
Svelte
pascalbaljet Oct 30, 2025
975c168
Merge branch 'master' into form-precognition
pascalbaljet Oct 30, 2025
56bc441
fixes
pascalbaljet Oct 30, 2025
701cf43
Fix code style
pascalbaljet Oct 30, 2025
adb1a47
Revert custom implementations
pascalbaljet Oct 30, 2025
3eaca3b
Merge branch 'form-precognition' of https://github.com/inertiajs/iner…
pascalbaljet Oct 30, 2025
2d2307a
Remove dep
pascalbaljet Oct 30, 2025
7a334ea
Revert "Remove dep"
pascalbaljet Oct 30, 2025
1691cc7
align
pascalbaljet Oct 30, 2025
66aa717
Update form.ts
pascalbaljet Oct 30, 2025
1040f92
fix watchers
pascalbaljet Oct 30, 2025
63a2ad9
wip
pascalbaljet Oct 31, 2025
8323d99
Update types.ts
pascalbaljet Oct 31, 2025
cdad30f
fix type
pascalbaljet Oct 31, 2025
7c8a30f
improve tests
pascalbaljet Oct 31, 2025
4f2d66d
cleanup
pascalbaljet Oct 31, 2025
b3edfa2
playgrounds
pascalbaljet Oct 31, 2025
5ad07b7
playgrounds
pascalbaljet Oct 31, 2025
a3146c9
move import
pascalbaljet Oct 31, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@
},
"dependencies": {
"@types/lodash-es": "^4.17.12",
"axios": "^1.12.2",
"axios": "^1.13.1",
"laravel-precognition": "^0.7.3",
"lodash-es": "^4.17.21",
"qs": "^6.14.0"
},
Expand Down
8 changes: 5 additions & 3 deletions packages/core/src/resetFormFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,10 @@ export function resetFormFields(formElement: HTMLFormElement, defaults: FormData
return
}

const resetEntireForm = !fieldNames || fieldNames.length === 0

// If no specific fields provided, reset the entire form
if (!fieldNames || fieldNames.length === 0) {
if (resetEntireForm) {
// Get all field names from both defaults and form elements (including disabled ones)
const formData = new FormData(formElement)
const formElementNames = Array.from(formElement.elements)
Expand All @@ -173,7 +175,7 @@ export function resetFormFields(formElement: HTMLFormElement, defaults: FormData

let hasChanged = false

fieldNames.forEach((fieldName) => {
fieldNames!.forEach((fieldName) => {
const elements = formElement.elements.namedItem(fieldName)

if (elements) {
Expand All @@ -184,7 +186,7 @@ export function resetFormFields(formElement: HTMLFormElement, defaults: FormData
})

// Dispatch reset event if any field changed (matching native form.reset() behavior)
if (hasChanged) {
if (hasChanged && resetEntireForm) {
formElement.dispatchEvent(new Event('reset', { bubbles: true }))
}
}
11 changes: 11 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AxiosProgressEvent, AxiosResponse } from 'axios'
import { NamedInputEvent, ValidationConfig, Validator } from 'laravel-precognition'
import { Response } from './response'

declare module 'axios' {
Expand Down Expand Up @@ -568,6 +569,9 @@ export type FormComponentProps = Partial<
resetOnSuccess?: boolean | string[]
resetOnError?: boolean | string[]
setDefaultsOnSuccess?: boolean
validateFiles?: boolean
validateTimeout?: number
simpleValidationErrors?: boolean
}

export type FormComponentMethods = {
Expand All @@ -580,6 +584,11 @@ export type FormComponentMethods = {
defaults: () => void
getData: () => Record<string, FormDataConvertible>
getFormData: () => FormData
valid: (field: string) => boolean
invalid: (field: string) => boolean
validate(field?: string | NamedInputEvent | ValidationConfig, config?: ValidationConfig): void
touch: (...fields: string[]) => void
touched(field?: string): boolean
}

export type FormComponentonSubmitCompleteArguments = Pick<FormComponentMethods, 'reset' | 'defaults'>
Expand All @@ -592,6 +601,8 @@ export type FormComponentState = {
wasSuccessful: boolean
recentlySuccessful: boolean
isDirty: boolean
validator: Validator
validating: boolean
}

export type FormComponentSlotProps = FormComponentMethods & FormComponentState
Expand Down
3 changes: 2 additions & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
"devDependencies": {
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"axios": "^1.12.2",
"axios": "^1.13.1",
"es-check": "^9.4.4",
"esbuild": "^0.25.11",
"esbuild-node-externals": "^1.18.0",
Expand All @@ -67,6 +67,7 @@
"dependencies": {
"@inertiajs/core": "workspace:*",
"@types/lodash-es": "^4.17.12",
"laravel-precognition": "^0.7.3",
"lodash-es": "^4.17.21"
}
}
142 changes: 131 additions & 11 deletions packages/react/src/Form.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
Errors,
FormComponentProps,
FormComponentRef,
FormComponentSlotProps,
Expand All @@ -10,7 +11,15 @@ import {
resetFormFields,
VisitOptions,
} from '@inertiajs/core'
import { isEqual } from 'lodash-es'
import {
createValidator,
NamedInputEvent,
resolveName,
toSimpleValidationErrors,
ValidationConfig,
Validator,
} from 'laravel-precognition'
import { get, isEqual } from 'lodash-es'
import React, {
createElement,
FormEvent,
Expand Down Expand Up @@ -64,6 +73,9 @@ const Form = forwardRef<FormComponentRef, ComponentProps>(
resetOnSuccess = false,
setDefaultsOnSuccess = false,
invalidateCacheTags = [],
validateFiles = false,
validateTimeout = 1500,
simpleValidationErrors = true,
children,
...props
},
Expand All @@ -79,36 +91,128 @@ const Form = forwardRef<FormComponentRef, ComponentProps>(
const [isDirty, setIsDirty] = useState(false)
const defaultData = useRef<FormData>(new FormData())

const [validating, setValidating] = useState(false)
const [valid, setValid] = useState<string[]>([])
const [touched, setTouched] = useState<string[]>([])

const [validator, setValidator] = useState<Validator>()

const getFormData = (): FormData => new FormData(formElement.current)

// Convert the FormData to an object because we can't compare two FormData
// instances directly (which is needed for isDirty), mergeDataIntoQueryString()
// expects an object, and submitting a FormData instance directly causes problems with nested objects.
const getData = (): Record<string, FormDataConvertible> => formDataToObject(getFormData())

const getUrlAndData = (): [string, Record<string, FormDataConvertible>] => {
return mergeDataIntoQueryString(
resolvedMethod,
isUrlMethodPair(action) ? action.url : action,
getData(),
queryStringArrayFormat,
)
}

const updateDirtyState = (event: Event) =>
deferStateUpdate(() =>
setIsDirty(event.type === 'reset' ? false : !isEqual(getData(), formDataToObject(defaultData.current))),
)

const clearErrors = (...names: string[]) => {
form.clearErrors(...names)

if (names.length === 0) {
validator!.setErrors({})
} else {
names.forEach(validator!.forgetError)
}

return form
}

const getTransformedData = (): Record<string, FormDataConvertible> => {
const [_url, data] = getUrlAndData()
return transform(data)
}

useEffect(() => {
defaultData.current = getFormData()

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

formEvents.forEach((e) => formElement.current!.addEventListener(e, updateDirtyState))

return () => formEvents.forEach((e) => formElement.current?.removeEventListener(e, updateDirtyState))
// Initialize validator
const newValidator = createValidator(
(client) =>
client[resolvedMethod](getUrlAndData()[0], getTransformedData(), {
headers,
}),
getTransformedData(),
)
.on('validatingChanged', () => {
setValidating(newValidator.validating())
})
.on('validatedChanged', () => {
setValid(newValidator.valid())
})
.on('touchedChanged', () => {
setTouched(newValidator.touched())
})
.on('errorsChanged', () => {
form.clearErrors()

const errors = simpleValidationErrors
? toSimpleValidationErrors(newValidator.errors())
: newValidator.errors()

form.setError(errors as Errors)

setValid(newValidator.valid())
})

newValidator.setTimeout(validateTimeout)

if (validateFiles) {
newValidator.validateFiles()
}

setValidator(newValidator)

return () => {
formEvents.forEach((e) => formElement.current?.removeEventListener(e, updateDirtyState))
}
}, [])

const validate = (field?: string | NamedInputEvent | ValidationConfig, config?: ValidationConfig) => {
if (typeof field === 'object' && !('target' in field)) {
config = field
field = undefined
}

if (typeof field === 'undefined') {
validator!.validate(config)
} else {
field = resolveName(field)

validator!.validate(field, get(getTransformedData(), field), config)
}
}

useEffect(() => {
validator?.setTimeout(validateTimeout)
}, [validateTimeout, validator])

const reset = (...fields: string[]) => {
if (formElement.current) {
resetFormFields(formElement.current, defaultData.current, fields)
}

validator!.reset(...fields)
}

const resetAndClearErrors = (...fields: string[]) => {
form.clearErrors(...fields)
clearErrors(...fields)
reset(...fields)
}

Expand All @@ -125,12 +229,7 @@ const Form = forwardRef<FormComponentRef, ComponentProps>(
}

const submit = () => {
const [url, _data] = mergeDataIntoQueryString(
resolvedMethod,
isUrlMethodPair(action) ? action.url : action,
getData(),
queryStringArrayFormat,
)
const [url, _data] = getUrlAndData()

const submitOptions: FormSubmitOptions = {
headers,
Expand Down Expand Up @@ -171,6 +270,18 @@ const Form = forwardRef<FormComponentRef, ComponentProps>(
setIsDirty(false)
}

const touch = (...fields: string[]) => {
validator!.touch(fields)
}

const isTouched = (field?: string): boolean => {
if (typeof field === 'string') {
return touched.includes(field)
}

return touched.length > 0
}

const exposed = () => ({
errors: form.errors,
hasErrors: form.hasErrors,
Expand All @@ -179,17 +290,26 @@ const Form = forwardRef<FormComponentRef, ComponentProps>(
wasSuccessful: form.wasSuccessful,
recentlySuccessful: form.recentlySuccessful,
isDirty,
clearErrors: form.clearErrors,
clearErrors,
resetAndClearErrors,
setError: form.setError,
reset,
submit,
defaults,
getData,
getFormData,

// Precognition
validator: validator!,
validating,
valid: (field: string) => valid.includes(field),
invalid: (field: string) => form.errors[field] !== undefined,
validate,
touch,
touched: isTouched,
})

useImperativeHandle(ref, exposed, [form, isDirty, submit])
useImperativeHandle(ref, exposed, [form, isDirty, submit, validating, valid, touched, touch, validator])

return createElement(
'form',
Expand Down
29 changes: 29 additions & 0 deletions packages/react/test-app/Pages/FormComponent/Precognition.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Form } from '@inertiajs/react'

export default () => {
return (
<div>
<h1>Form Precognition</h1>

<Form action="/form-component/precognition" method="post" validateTimeout={100}>
{({ invalid, errors, validate, valid, validating }) => (
<>
<div>
<input name="name" placeholder="Name" onBlur={() => validate('name')} />
{invalid('name') && <p>{errors.name}</p>}
{valid('name') && <p>Name is valid!</p>}
</div>

<div>
<input name="email" placeholder="Email" onBlur={() => validate('email')} />
{invalid('email') && <p>{errors.email}</p>}
{valid('email') && <p>Email is valid!</p>}
</div>

{validating && <p>Validating...</p>}
</>
)}
</Form>
</div>
)
}
Loading