Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 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
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
10 changes: 3 additions & 7 deletions packages/core/src/debounce.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
export default function debounce<F extends (...params: any[]) => ReturnType<F>>(fn: F, delay: number): F {
let timeoutID: NodeJS.Timeout
return function (...args: unknown[]) {
clearTimeout(timeoutID)
timeoutID = setTimeout(() => fn.apply(this, args), delay)
} as F
}
import { debounce } from 'lodash-es'

export default debounce
44 changes: 41 additions & 3 deletions packages/core/src/files.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,49 @@
import { FormDataConvertible, RequestPayload } from './types'

export function isFile(value: unknown): boolean {
return (
(typeof File !== 'undefined' && value instanceof File) ||
value instanceof Blob ||
(typeof FileList !== 'undefined' && value instanceof FileList && value.length > 0)
)
}

export function hasFiles(data: RequestPayload | FormDataConvertible): boolean {
return (
data instanceof File ||
data instanceof Blob ||
(data instanceof FileList && data.length > 0) ||
isFile(data) ||
(data instanceof FormData && Array.from(data.values()).some((value) => hasFiles(value))) ||
(typeof data === 'object' && data !== null && Object.values(data).some((value) => hasFiles(value)))
)
}

export function forgetFiles(data: Record<string, unknown>): Record<string, unknown> {
const newData = { ...data }

Object.keys(newData).forEach((name) => {
const value = newData[name]

if (value === null) {
return
}

if (isFile(value)) {
delete newData[name]

return
}

if (Array.isArray(value)) {
newData[name] = Object.values(forgetFiles({ ...value }))

return
}

if (typeof value === 'object') {
newData[name] = forgetFiles(newData[name] as Record<string, unknown>)

return
}
})

return newData
}
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Router } from './router'

export { getScrollableParent } from './domUtils'
export { hasFiles } from './files'
export { objectToFormData } from './formData'
export { formDataToObject } from './formObject'
export { default as createHeadManager } from './head'
export { default as useInfiniteScroll } from './infiniteScroll'
export { shouldIntercept, shouldNavigate } from './navigationEvents'
export { default as usePrecognition } from './precognition'
export { hide as hideProgress, progress, reveal as revealProgress, default as setupProgress } from './progress'
export { resetFormFields } from './resetFormFields'
export * from './types'
Expand Down
151 changes: 151 additions & 0 deletions packages/core/src/precognition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { default as axios, AxiosRequestConfig } from 'axios'
import { get, isEqual } from 'lodash-es'
import debounce from './debounce'
import { forgetFiles, hasFiles } from './files'
import { objectToFormData } from './formData'
import { Errors, FormComponentValidateOptions, RequestData, Visit } from './types'

interface UsePrecognitionOptions {
timeout: number
onStart: () => void
onFinish: () => void
}

type PrecognitionValidateOptions = Pick<Visit<RequestData>, 'method' | 'data' | 'only' | 'errorBag' | 'headers'> &
Pick<FormComponentValidateOptions, 'onBefore' | 'onException' | 'onFinish'> & {
url: string
onPrecognitionSuccess: () => void
onValidationError: (errors: Errors) => void
simpleValidationErrors?: boolean
}

interface PrecognitionValidator {
setOldData: (data: RequestData) => void
validateFiles: (value: boolean) => void
validate: (options: PrecognitionValidateOptions) => void
setTimeout: (value: number) => void
cancelAll: () => void
}

export default function usePrecognition(precognitionOptions: UsePrecognitionOptions): PrecognitionValidator {
let debounceTimeoutDuration = precognitionOptions.timeout
let validateFiles: boolean = false

let oldData: RequestData = {}
let oldTouched: string[] = []
const abortControllers: Record<string, AbortController> = {}

const cancelAll = () => {
Object.entries(abortControllers).forEach(([key, controller]) => {
controller.abort()
delete abortControllers[key]
})
}

const setTimeout = (value: number) => {
if (value !== debounceTimeoutDuration) {
cancelAll()
debounceTimeoutDuration = value
validateFunction = createValidateFunction()
}
}

const createFingerprint = (options: PrecognitionValidateOptions) => `${options.method}:${options.url}`

const toSimpleValidationErrors = (errors: Errors): Errors => {
return Object.keys(errors).reduce(
(carry, key) => ({
...carry,
[key]: Array.isArray(errors[key]) ? errors[key][0] : errors[key],
}),
{},
)
}

const createValidateFunction = () =>
debounce(
(options: PrecognitionValidateOptions) => {
const changed = options.only.filter((field) => !isEqual(get(options.data, field), get(oldData, field)))
const data = validateFiles ? options.data : (forgetFiles(options.data) as RequestData)

if (options.only.length > 0 && changed.length === 0) {
return
}

const beforeValidatonResult = options.onBefore?.(
{ data: options.data, touched: options.only },
{ data: oldData, touched: oldTouched },
)

if (beforeValidatonResult === false) {
return
}

const fingerprint = createFingerprint(options)

if (abortControllers[fingerprint]) {
abortControllers[fingerprint].abort()
delete abortControllers[fingerprint]
}

abortControllers[fingerprint] = new AbortController()

precognitionOptions.onStart()

const submitOptions: AxiosRequestConfig = {
method: options.method,
url: options.url,
data: hasFiles(data) ? objectToFormData(data) : { ...data },
signal: abortControllers[fingerprint].signal,
headers: {
...(options.headers || {}),
'X-Requested-With': 'XMLHttpRequest',
Precognition: true,
...(options.only.length ? { 'Precognition-Validate-Only': options.only.join(',') } : {}),
},
}

axios(submitOptions)
.then((response) => {
if (response.status === 204 && response.headers['precognition-success'] === 'true') {
options.onPrecognitionSuccess()
}
})
.catch((error) => {
if (error.response?.status === 422) {
const errors = error.response.data?.errors || {}
const scopedErrors = (options.errorBag ? errors[options.errorBag] || {} : errors) as Errors
const formattedErrors =
options.simpleValidationErrors === false ? scopedErrors : toSimpleValidationErrors(scopedErrors)
return options.onValidationError(formattedErrors)
}

if (options.onException) {
options.onException(error)
return
}

throw error
})
.finally(() => {
oldData = { ...data }
oldTouched = [...options.only]
delete abortControllers[fingerprint]
options.onFinish?.()
precognitionOptions.onFinish()
})
},
debounceTimeoutDuration,
{ leading: true, trailing: true },
)

let validateFunction = createValidateFunction()

return {
setOldData: (data) => (oldData = { ...data }),
validateFiles: (value) => (validateFiles = value),
setTimeout,
validate: (options) => validateFunction(options),
cancelAll,
}
}
30 changes: 29 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ export type FormDataError<T> = Partial<Record<FormDataKeys<T>, ErrorValue>>

export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete'

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

export interface PageProps {
[key: string]: unknown
Expand Down Expand Up @@ -476,6 +477,23 @@ export type FormComponentProps = Partial<
resetOnSuccess?: boolean | string[]
resetOnError?: boolean | string[]
setDefaultsOnSuccess?: boolean
validateFiles?: boolean
validateTimeout?: number
simpleValidationErrors?: boolean
}

type RevalidatePayload = {
data: RequestData
touched: string[]
}

export type FormComponentValidateOptions = {
only?: string | string[]
onBefore?: (newRequest: RevalidatePayload, oldRequest: RevalidatePayload) => boolean | undefined
onSuccess?: () => void
onError?: (errors: Errors) => void
onFinish?: () => void
onException?: (error: Error) => void
}

export type FormComponentMethods = {
Expand All @@ -486,6 +504,15 @@ export type FormComponentMethods = {
reset: (...fields: string[]) => void
submit: () => void
defaults: () => void
valid: (field: string) => boolean
invalid: (field: string) => boolean
validate: (
only?: string | string[] | FormComponentValidateOptions,
maybeOptions?: FormComponentValidateOptions,
) => void
touch: (field: string | string[]) => void
touched(field?: string): boolean
cancelValidation: () => void
}

export type FormComponentonSubmitCompleteArguments = Pick<FormComponentMethods, 'reset' | 'defaults'>
Expand All @@ -498,6 +525,7 @@ export type FormComponentState = {
wasSuccessful: boolean
recentlySuccessful: boolean
isDirty: boolean
validating: boolean
}

export type FormComponentSlotProps = FormComponentMethods & FormComponentState
Expand Down
Loading