diff --git a/packages/core/src/debounce.ts b/packages/core/src/debounce.ts index 1755318a4..dc0c90e1f 100644 --- a/packages/core/src/debounce.ts +++ b/packages/core/src/debounce.ts @@ -1,7 +1,3 @@ -export default function debounce ReturnType>(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 diff --git a/packages/core/src/files.ts b/packages/core/src/files.ts index 4d927f7b2..34d271831 100644 --- a/packages/core/src/files.ts +++ b/packages/core/src/files.ts @@ -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): Record { + 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) + + return + } + }) + + return newData +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ce12a6e40..5ac463f59 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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' diff --git a/packages/core/src/precognition.ts b/packages/core/src/precognition.ts new file mode 100644 index 000000000..c719fe068 --- /dev/null +++ b/packages/core/src/precognition.ts @@ -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, 'method' | 'data' | 'only' | 'errorBag' | 'headers'> & + Pick & { + 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 = {} + + 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, + } +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 9e780c52b..14cd54da1 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -99,7 +99,8 @@ export type FormDataError = Partial, ErrorValue>> export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete' -export type RequestPayload = Record | FormData +export type RequestData = Record +export type RequestPayload = RequestData | FormData export interface PageProps { [key: string]: unknown @@ -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 = { @@ -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 @@ -498,6 +525,7 @@ export type FormComponentState = { wasSuccessful: boolean recentlySuccessful: boolean isDirty: boolean + validating: boolean } export type FormComponentSlotProps = FormComponentMethods & FormComponentState diff --git a/packages/react/src/Form.ts b/packages/react/src/Form.ts index fb4a117b7..d1b1e2522 100644 --- a/packages/react/src/Form.ts +++ b/packages/react/src/Form.ts @@ -2,12 +2,14 @@ import { FormComponentProps, FormComponentRef, FormComponentSlotProps, + FormComponentValidateOptions, FormDataConvertible, formDataToObject, isUrlMethodPair, mergeDataIntoQueryString, Method, resetFormFields, + usePrecognition, VisitOptions, } from '@inertiajs/core' import { isEqual } from 'lodash-es' @@ -64,6 +66,9 @@ const Form = forwardRef( resetOnSuccess = false, setDefaultsOnSuccess = false, invalidateCacheTags = [], + validateFiles = false, + validateTimeout = 1500, + simpleValidationErrors = true, children, ...props }, @@ -79,6 +84,20 @@ const Form = forwardRef( const [isDirty, setIsDirty] = useState(false) const defaultData = useRef(new FormData()) + const [validating, setValidating] = useState(false) + const [validated, setValidated] = useState([]) + const [touched, setTouched] = useState([]) + + const validator = useMemo( + () => + usePrecognition({ + timeout: validateTimeout, + onStart: () => setValidating(true), + onFinish: () => setValidating(false), + }), + [], + ) + const getFormData = (): FormData => new FormData(formElement.current) // Convert the FormData to an object because we can't compare two FormData @@ -86,6 +105,15 @@ const Form = forwardRef( // expects an object, and submitting a FormData instance directly causes problems with nested objects. const getData = (): Record => formDataToObject(getFormData()) + const getUrlAndData = (): [string, Record] => { + 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))), @@ -101,8 +129,35 @@ const Form = forwardRef( return () => formEvents.forEach((e) => formElement.current?.removeEventListener(e, updateDirtyState)) }, []) + useEffect(() => { + validator.validateFiles(validateFiles) + }, [validateFiles, validator]) + + useEffect(() => { + validator.setTimeout(validateTimeout) + }, [validateTimeout, validator]) + + useEffect(() => { + updateDataOnValidator() + }, []) + + const updateDataOnValidator = () => { + try { + // This might fail if the component is already unmounted but this function + // is called after navigating away after a form submission. + validator.setOldData(transform(getData())) + } catch {} + } + const reset = (...fields: string[]) => { resetFormFields(formElement.current, defaultData.current, fields) + updateDataOnValidator() + + if (fields.length === 0) { + setTouched([]) + } else { + setTouched((prev) => prev.filter((field) => !fields.includes(field))) + } } const resetAndClearErrors = (...fields: string[]) => { @@ -123,12 +178,7 @@ const Form = forwardRef( } const submit = () => { - const [url, _data] = mergeDataIntoQueryString( - resolvedMethod, - isUrlMethodPair(action) ? action.url : action, - getData(), - queryStringArrayFormat, - ) + const [url, _data] = getUrlAndData() const submitOptions: FormSubmitOptions = { headers, @@ -167,6 +217,78 @@ const Form = forwardRef( const defaults = () => { defaultData.current = getFormData() setIsDirty(false) + updateDataOnValidator() + } + + const validate = ( + only?: string | string[] | FormComponentValidateOptions, + maybeOptions?: FormComponentValidateOptions, + ) => { + let fields: string[] + let options: FormComponentValidateOptions = {} + + if (typeof only === 'object' && !Array.isArray(only)) { + // Called as validate({ only: [...], onSuccess, onError, onFinish }) + const onlyFields = only.only + fields = onlyFields === undefined ? touched : Array.isArray(onlyFields) ? onlyFields : [onlyFields] + options = only + } else { + // Called as validate('field') or validate(['field1', 'field2']) or validate('field', {options}) + fields = only === undefined ? touched : Array.isArray(only) ? only : [only] + options = maybeOptions || {} + } + + // We're not using the data object from this method as it might be empty + // on GET requests, and we still want to pass a data object to the + // validator so it knows the current state of the form. + const [url] = getUrlAndData() + + validator.validate({ + url, + method: resolvedMethod, + data: transform(getData()), + only: fields, + errorBag, + headers, + simpleValidationErrors, + onBefore: options.onBefore, + onPrecognitionSuccess: () => { + setValidated((prev) => [...prev, ...fields]) + form.clearErrors(...fields) + options.onSuccess?.() + }, + onValidationError: (errors) => { + setValidated((prev) => [...prev, ...fields]) + + const validFields = fields.filter((field) => errors[field] === undefined) + + if (validFields.length) { + form.clearErrors(...validFields) + } + + form.setError({ ...form.errors, ...errors }) + options.onError?.(errors) + }, + onException: options.onException, + onFinish: () => { + options.onFinish?.() + }, + }) + } + + const touch = (field: string | string[]) => { + const fields = Array.isArray(field) ? field : [field] + + // Use Set to avoid duplicates + setTouched((prev) => [...new Set([...prev, ...fields])]) + } + + const isTouched = (field?: string): boolean => { + if (typeof field === 'string') { + return touched.includes(field) + } + + return touched.length > 0 } const exposed = () => ({ @@ -183,9 +305,31 @@ const Form = forwardRef( reset, submit, defaults, + + // Precognition + validating, + valid: (field: string) => validated.includes(field) && form.errors[field] === undefined, + invalid: (field: string) => form.errors[field] !== undefined, + validate, + touch, + touched: isTouched, + cancelValidation: () => { + validator.cancelAll() + setValidating(false) + }, }) - useImperativeHandle(ref, exposed, [form, isDirty, submit]) + useImperativeHandle(ref, exposed, [ + form, + isDirty, + submit, + validating, + validated, + touched, + validate, + touch, + validator, + ]) return createElement( 'form', diff --git a/packages/react/test-app/Pages/FormComponent/Precognition.tsx b/packages/react/test-app/Pages/FormComponent/Precognition.tsx new file mode 100644 index 000000000..2ff56745b --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/Precognition.tsx @@ -0,0 +1,29 @@ +import { Form } from '@inertiajs/react' + +export default () => { + return ( +
+

Form Precognition

+ +
+ {({ invalid, errors, validate, valid, validating }) => ( + <> +
+ validate('name')} /> + {invalid('name') &&

{errors.name}

} + {valid('name') &&

Name is valid!

} +
+ +
+ validate('email')} /> + {invalid('email') &&

{errors.email}

} + {valid('email') &&

Email is valid!

} +
+ + {validating &&

Validating...

} + + )} +
+
+ ) +} diff --git a/packages/react/test-app/Pages/FormComponent/PrecognitionAllErrors.tsx b/packages/react/test-app/Pages/FormComponent/PrecognitionAllErrors.tsx new file mode 100644 index 000000000..4b63a3df7 --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/PrecognitionAllErrors.tsx @@ -0,0 +1,58 @@ +import { Form } from '@inertiajs/react' + +export default () => { + return ( +
+

Form Precognition - All Errors

+ +
+ {({ invalid, errors, validate, valid, validating }) => ( + <> +
+ validate('name')} /> + {invalid('name') && ( +
+ {Array.isArray(errors.name) ? ( + errors.name.map((error, index) => ( +

+ {error} +

+ )) + ) : ( +

{errors.name}

+ )} +
+ )} + {valid('name') &&

Name is valid!

} +
+ +
+ validate('email')} /> + {invalid('email') && ( +
+ {Array.isArray(errors.email) ? ( + errors.email.map((error, index) => ( +

+ {error} +

+ )) + ) : ( +

{errors.email}

+ )} +
+ )} + {valid('email') &&

Email is valid!

} +
+ + {validating &&

Validating...

} + + )} +
+
+ ) +} diff --git a/packages/react/test-app/Pages/FormComponent/PrecognitionArrayErrors.tsx b/packages/react/test-app/Pages/FormComponent/PrecognitionArrayErrors.tsx new file mode 100644 index 000000000..a1a4ffe79 --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/PrecognitionArrayErrors.tsx @@ -0,0 +1,29 @@ +import { Form } from '@inertiajs/react' + +export default () => { + return ( +
+

Form Precognition - Array Errors

+ +
+ {({ invalid, errors, validate, valid, validating }) => ( + <> +
+ validate('name')} /> + {invalid('name') &&

{errors.name}

} + {valid('name') &&

Name is valid!

} +
+ +
+ validate('email')} /> + {invalid('email') &&

{errors.email}

} + {valid('email') &&

Email is valid!

} +
+ + {validating &&

Validating...

} + + )} +
+
+ ) +} diff --git a/packages/react/test-app/Pages/FormComponent/PrecognitionBeforeValidation.tsx b/packages/react/test-app/Pages/FormComponent/PrecognitionBeforeValidation.tsx new file mode 100644 index 000000000..bbb2bd98d --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/PrecognitionBeforeValidation.tsx @@ -0,0 +1,55 @@ +import { Form } from '@inertiajs/react' +import { isEqual } from 'lodash-es' + +export default function PrecognitionBefore() { + const handleBeforeValidation = ( + newRequest: { data: Record; touched: string[] }, + oldRequest: { data: Record; touched: string[] }, + ) => { + const payloadIsCorrect = + isEqual(newRequest, { data: { name: 'block' }, touched: ['name'] }) && + isEqual(oldRequest, { data: {}, touched: [] }) + + if (payloadIsCorrect && newRequest.data.name === 'block') { + return false + } + + return true + } + + return ( +
+

Precognition - onBefore

+ +
+ {({ errors, invalid, validate, validating }) => ( + <> +
+ + { + validate('name', { + onBefore: handleBeforeValidation, + }) + }} + /> + {invalid('name') &&

{errors.name}

} +
+ +
+ + validate('email')} /> + {invalid('email') &&

{errors.email}

} +
+ + {validating &&

Validating...

} + + + + )} +
+
+ ) +} diff --git a/packages/react/test-app/Pages/FormComponent/PrecognitionCallbacks.tsx b/packages/react/test-app/Pages/FormComponent/PrecognitionCallbacks.tsx new file mode 100644 index 000000000..b2e5e583a --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/PrecognitionCallbacks.tsx @@ -0,0 +1,101 @@ +import { Form } from '@inertiajs/react' +import { useState } from 'react' + +export default () => { + const [successCalled, setSuccessCalled] = useState(false) + const [errorCalled, setErrorCalled] = useState(false) + const [finishCalled, setFinishCalled] = useState(false) + const [exceptionCaught, setExceptionCaught] = useState(false) + const [exceptionMessage, setExceptionMessage] = useState('') + + const handleException = (error: Error) => { + setExceptionCaught(true) + setExceptionMessage(error.message || 'Unknown error') + } + + return ( +
+

Form Precognition Callbacks & Exceptions

+ +

Callbacks Test

+
+ {({ validate, validating, touch }) => ( + <> +
+ touch('name')} /> +
+ + {validating &&

Validating...

} + {successCalled &&

onSuccess called!

} + {errorCalled &&

onError called!

} + {finishCalled &&

onFinish called!

} + + + + + + )} +
+ +
+ +

Exception Test

+
+ {({ validate, validating }) => ( + <> +
+ +
+ + {validating &&

Validating...

} + {exceptionCaught &&

Exception caught: {exceptionMessage}

} + + {/* This will trigger a validation request to a non-existent endpoint */} + + + + + )} +
+
+ ) +} diff --git a/packages/react/test-app/Pages/FormComponent/PrecognitionCancel.tsx b/packages/react/test-app/Pages/FormComponent/PrecognitionCancel.tsx new file mode 100644 index 000000000..1ea7a108a --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/PrecognitionCancel.tsx @@ -0,0 +1,45 @@ +import { Form } from '@inertiajs/react' + +export default () => { + return ( +
+

Precognition - Cancel Tests

+ +

Auto Cancel Test

+
+ {({ invalid, errors, validate, validating }) => ( + <> +
+ validate('name')} /> + {invalid('name') &&

{errors.name}

} +
+ + {validating &&

Validating...

} + + + + )} +
+ +
+ +

Manual Cancel Test

+
+ {({ validate, cancelValidation, validating }) => ( + <> +
+ validate('name')} /> +
+ + {validating &&

Validating...

} + + + + + )} +
+
+ ) +} diff --git a/packages/react/test-app/Pages/FormComponent/PrecognitionDefaults.tsx b/packages/react/test-app/Pages/FormComponent/PrecognitionDefaults.tsx new file mode 100644 index 000000000..0333b43cb --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/PrecognitionDefaults.tsx @@ -0,0 +1,37 @@ +import { Form } from '@inertiajs/react' +import { useRef } from 'react' + +export default function PrecognitionDefaults() { + const formRef = useRef(null) + + const handleSetDefaults = () => { + formRef.current?.defaults() + } + + return ( +
+

Precognition - Defaults Updates Validator

+ +
+ {({ invalid, errors, validate, validating }) => ( + <> +
+ + {invalid('name') &&

{errors.name}

} +
+ + {validating &&

Validating...

} + + + + + + )} +
+
+ ) +} diff --git a/packages/react/test-app/Pages/FormComponent/PrecognitionFiles.tsx b/packages/react/test-app/Pages/FormComponent/PrecognitionFiles.tsx new file mode 100644 index 000000000..2265275c8 --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/PrecognitionFiles.tsx @@ -0,0 +1,45 @@ +import { Form } from '@inertiajs/react' +import { useState } from 'react' + +export default () => { + const [validateFilesEnabled, setValidateFilesEnabled] = useState(false) + + return ( +
+

Form Precognition Files

+ +
+ {({ invalid, errors, validate, valid, validating, touch }) => ( + <> +
+ validate('name')} /> + {invalid('name') &&

{errors.name}

} + {valid('name') &&

Name is valid!

} +
+ +
+ + {invalid('avatar') &&

{errors.avatar}

} + {valid('avatar') &&

Avatar is valid!

} +
+ + {validating &&

Validating...

} + + + + + + )} +
+
+ ) +} diff --git a/packages/react/test-app/Pages/FormComponent/PrecognitionHeaders.tsx b/packages/react/test-app/Pages/FormComponent/PrecognitionHeaders.tsx new file mode 100644 index 000000000..edbd99d9f --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/PrecognitionHeaders.tsx @@ -0,0 +1,29 @@ +import { Form } from '@inertiajs/react' + +export default function PrecognitionHeaders() { + return ( +
+

Precognition - Custom Headers

+ +
+ {({ invalid, errors, validate, validating }) => ( + <> +
+ validate('name')} /> + {invalid('name') &&

{errors.name}

} +
+ + {validating &&

Validating...

} + + + + )} +
+
+ ) +} diff --git a/packages/react/test-app/Pages/FormComponent/PrecognitionMethods.tsx b/packages/react/test-app/Pages/FormComponent/PrecognitionMethods.tsx new file mode 100644 index 000000000..3fb5fdca3 --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/PrecognitionMethods.tsx @@ -0,0 +1,62 @@ +import { Form } from '@inertiajs/react' + +export default () => { + return ( +
+

Form Precognition - Touch, Reset & Validate

+ +
+ {({ invalid, errors, validate, touch, touched, validating, reset }) => ( + <> +
+ touch('name')} /> + {invalid('name') &&

{errors.name}

} +
+ +
+ touch('email')} /> + {invalid('email') &&

{errors.email}

} +
+ + {validating &&

Validating...

} + +

{touched('name') ? 'Name is touched' : 'Name is not touched'}

+

{touched('email') ? 'Email is touched' : 'Email is not touched'}

+

{touched() ? 'Form has touched fields' : 'Form has no touched fields'}

+ + + + + + + + + + + )} +
+
+ ) +} diff --git a/packages/react/test-app/Pages/FormComponent/PrecognitionTransform.tsx b/packages/react/test-app/Pages/FormComponent/PrecognitionTransform.tsx new file mode 100644 index 000000000..ced7a4552 --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/PrecognitionTransform.tsx @@ -0,0 +1,28 @@ +import { Form } from '@inertiajs/react' + +export default () => { + return ( +
+

Form Precognition Transform

+ +
({ name: String(data.name || '').repeat(2) })} + > + {({ invalid, errors, validate, valid, validating }) => ( + <> +
+ validate('name')} /> + {invalid('name') &&

{errors.name}

} + {valid('name') &&

Name is valid!

} +
+ + {validating &&

Validating...

} + + )} +
+
+ ) +} diff --git a/packages/svelte/src/components/Form.svelte b/packages/svelte/src/components/Form.svelte index 194e4cf1e..bf0702d01 100644 --- a/packages/svelte/src/components/Form.svelte +++ b/packages/svelte/src/components/Form.svelte @@ -6,8 +6,10 @@ type Errors, type FormComponentProps, type FormDataConvertible, + type FormComponentValidateOptions, type VisitOptions, isUrlMethodPair, + usePrecognition, } from '@inertiajs/core' import { isEqual } from 'lodash-es' import { onMount } from 'svelte' @@ -37,6 +39,9 @@ export let resetOnError: FormComponentProps['resetOnError'] = false export let resetOnSuccess: FormComponentProps['resetOnSuccess'] = false export let setDefaultsOnSuccess: FormComponentProps['setDefaultsOnSuccess'] = false + export let validateFiles: FormComponentProps['validateFiles'] = false + export let validateTimeout: FormComponentProps['validateTimeout'] = 1500 + export let simpleValidationErrors: FormComponentProps['simpleValidationErrors'] = true type FormSubmitOptions = Omit @@ -45,6 +50,20 @@ let isDirty = false let defaultData: FormData = new FormData() + let validating = false + let validated: string[] = [] + let touchedFields: string[] = [] + + const validator = usePrecognition({ + timeout: validateTimeout, + onStart: () => { + validating = true + }, + onFinish: () => { + validating = false + }, + }) + $: _method = isUrlMethodPair(action) ? action.method : (method.toLowerCase() as FormComponentProps['method']) $: _action = isUrlMethodPair(action) ? action.url : action @@ -59,12 +78,16 @@ return formDataToObject(getFormData()) } + function getUrlAndData(): [string, Record] { + return mergeDataIntoQueryString(_method, _action, getData(), queryStringArrayFormat) + } + function updateDirtyState(event: Event) { isDirty = event.type === 'reset' ? false : !isEqual(getData(), formDataToObject(defaultData)) } export function submit() { - const [url, _data] = mergeDataIntoQueryString(_method, _action, getData(), queryStringArrayFormat) + const [url, _data] = getUrlAndData() const maybeReset = (resetOption: boolean | string[] | undefined) => { if (!resetOption) { @@ -133,8 +156,23 @@ } } + function updateDataOnValidator() { + try { + // This might fail if the component is already unmounted but this function + // is called after navigating away after a form submission. + validator.setOldData(transform(getData())) + } catch {} + } + export function reset(...fields: string[]) { resetFormFields(formElement, defaultData, fields) + updateDataOnValidator() + + if (fields.length === 0) { + touchedFields = [] + } else { + touchedFields = touchedFields.filter((field) => !fields.includes(field)) + } } export function clearErrors(...fields: string[]) { @@ -160,6 +198,93 @@ export function defaults() { defaultData = getFormData() isDirty = false + updateDataOnValidator() + } + + export function validate( + only?: string | string[] | FormComponentValidateOptions, + maybeOptions?: FormComponentValidateOptions, + ) { + let fields: string[] + let options: FormComponentValidateOptions = {} + + if (typeof only === 'object' && !Array.isArray(only)) { + // Called as validate({ only: [...], onSuccess, onError, onFinish }) + const onlyFields = only.only + fields = onlyFields === undefined ? touchedFields : Array.isArray(onlyFields) ? onlyFields : [onlyFields] + options = only + } else { + // Called as validate('field') or validate(['field1', 'field2']) or validate('field', {options}) + fields = only === undefined ? touchedFields : Array.isArray(only) ? only : [only] + options = maybeOptions || {} + } + + // We're not using the data object from this method as it might be empty + // on GET requests, and we still want to pass a data object to the + // validator so it knows the current state of the form. + const [url] = getUrlAndData() + + validator.validate({ + url, + method: _method, + data: transform(getData()), + only: fields, + errorBag, + headers, + simpleValidationErrors, + onBefore: options.onBefore, + onPrecognitionSuccess: () => { + validated = [...validated, ...fields] + clearErrors(...fields) + options.onSuccess?.() + }, + onValidationError: (errors) => { + validated = [...validated, ...fields] + + const validFields = fields.filter((field) => errors[field] === undefined) + + if (validFields.length) { + clearErrors(...validFields) + } + + // Merge current errors with new errors + const mergedErrors = { ...$form.errors, ...errors } + setError(mergedErrors) + options.onError?.(errors) + }, + onException: options.onException, + onFinish: () => { + options.onFinish?.() + }, + }) + } + + export function touch(field: string | string[]) { + const fields = Array.isArray(field) ? field : [field] + + // Use Set to avoid duplicates + touchedFields = [...new Set([...touchedFields, ...fields])] + } + + export function touched(field?: string): boolean { + if (typeof field === 'string') { + return touchedFields.includes(field) + } + + return touchedFields.length > 0 + } + + export function valid(field: string): boolean { + return validated.includes(field) && $form.errors[field] === undefined + } + + export function invalid(field: string): boolean { + return $form.errors[field] !== undefined + } + + export function cancelValidation() { + validator.cancelAll() + validating = false } onMount(() => { @@ -168,11 +293,25 @@ const formEvents = ['input', 'change', 'reset'] formEvents.forEach((e) => formElement.addEventListener(e, updateDirtyState)) + updateDataOnValidator() + validator.validateFiles(validateFiles) + validator.setTimeout(validateTimeout) + return () => { formEvents.forEach((e) => formElement?.removeEventListener(e, updateDirtyState)) } }) + + $: validator.validateFiles(validateFiles) + $: validator.setTimeout(validateTimeout) + $: slotErrors = $form.errors as Errors + + // Create reactive slot props that update when state changes + $: validMethod = (field: string) => validated.includes(field) && slotErrors[field] === undefined + $: invalidMethod = (field: string) => slotErrors[field] !== undefined + $: touchedMethod = (field?: string) => + typeof field === 'string' ? touchedFields.includes(field) : touchedFields.length > 0
diff --git a/packages/svelte/test-app/Pages/FormComponent/Precognition.svelte b/packages/svelte/test-app/Pages/FormComponent/Precognition.svelte new file mode 100644 index 000000000..42a41c237 --- /dev/null +++ b/packages/svelte/test-app/Pages/FormComponent/Precognition.svelte @@ -0,0 +1,42 @@ + + +
+

Form Precognition

+ +
+
+ validate('name')} /> + {#if invalid('name')} +

{errors.name}

+ {/if} + {#if valid('name')} +

Name is valid!

+ {/if} +
+ +
+ validate('email')} /> + {#if invalid('email')} +

{errors.email}

+ {/if} + {#if valid('email')} +

Email is valid!

+ {/if} +
+ + {#if validating} +

Validating...

+ {/if} +
+
diff --git a/packages/svelte/test-app/Pages/FormComponent/PrecognitionAllErrors.svelte b/packages/svelte/test-app/Pages/FormComponent/PrecognitionAllErrors.svelte new file mode 100644 index 000000000..cf4c16f2b --- /dev/null +++ b/packages/svelte/test-app/Pages/FormComponent/PrecognitionAllErrors.svelte @@ -0,0 +1,59 @@ + + +
+

Form Precognition - All Errors

+ +
+
+ validate('name')} /> + {#if invalid('name')} +
+ {#if Array.isArray(errors.name)} + {#each errors.name as error, index} +

{error}

+ {/each} + {:else} +

{errors.name}

+ {/if} +
+ {/if} + {#if valid('name')} +

Name is valid!

+ {/if} +
+ +
+ validate('email')} /> + {#if invalid('email')} +
+ {#if Array.isArray(errors.email)} + {#each errors.email as error, index} +

{error}

+ {/each} + {:else} +

{errors.email}

+ {/if} +
+ {/if} + {#if valid('email')} +

Email is valid!

+ {/if} +
+ + {#if validating} +

Validating...

+ {/if} +
+
diff --git a/packages/svelte/test-app/Pages/FormComponent/PrecognitionArrayErrors.svelte b/packages/svelte/test-app/Pages/FormComponent/PrecognitionArrayErrors.svelte new file mode 100644 index 000000000..252e533ca --- /dev/null +++ b/packages/svelte/test-app/Pages/FormComponent/PrecognitionArrayErrors.svelte @@ -0,0 +1,42 @@ + + +
+

Form Precognition - Array Errors

+ +
+
+ validate('name')} /> + {#if invalid('name')} +

{errors.name}

+ {/if} + {#if valid('name')} +

Name is valid!

+ {/if} +
+ +
+ validate('email')} /> + {#if invalid('email')} +

{errors.email}

+ {/if} + {#if valid('email')} +

Email is valid!

+ {/if} +
+ + {#if validating} +

Validating...

+ {/if} +
+
diff --git a/packages/svelte/test-app/Pages/FormComponent/PrecognitionBeforeValidation.svelte b/packages/svelte/test-app/Pages/FormComponent/PrecognitionBeforeValidation.svelte new file mode 100644 index 000000000..337b9dae8 --- /dev/null +++ b/packages/svelte/test-app/Pages/FormComponent/PrecognitionBeforeValidation.svelte @@ -0,0 +1,63 @@ + + +
+

Precognition - onBefore

+ +
+
+ + + validate('name', { + onBefore: handleBeforeValidation, + })} + /> + {#if invalid('name')} +

{errors.name}

+ {/if} +
+ +
+ + validate('email')} /> + {#if invalid('email')} +

{errors.email}

+ {/if} +
+ + {#if validating} +

Validating...

+ {/if} + + +
+
diff --git a/packages/svelte/test-app/Pages/FormComponent/PrecognitionCallbacks.svelte b/packages/svelte/test-app/Pages/FormComponent/PrecognitionCallbacks.svelte new file mode 100644 index 000000000..5941c4782 --- /dev/null +++ b/packages/svelte/test-app/Pages/FormComponent/PrecognitionCallbacks.svelte @@ -0,0 +1,101 @@ + + +
+

Form Precognition Callbacks & Exceptions

+ +

Callbacks Test

+
+
+ touch('name')} /> +
+ + {#if validating} +

Validating...

+ {/if} + {#if successCalled} +

onSuccess called!

+ {/if} + {#if errorCalled} +

onError called!

+ {/if} + {#if finishCalled} +

onFinish called!

+ {/if} + + + + +
+ +
+ +

Exception Test

+
+
+ +
+ + {#if validating} +

Validating...

+ {/if} + {#if exceptionCaught} +

Exception caught: {exceptionMessage}

+ {/if} + + + + + +
+
diff --git a/packages/svelte/test-app/Pages/FormComponent/PrecognitionCancel.svelte b/packages/svelte/test-app/Pages/FormComponent/PrecognitionCancel.svelte new file mode 100644 index 000000000..4cf3e2ce6 --- /dev/null +++ b/packages/svelte/test-app/Pages/FormComponent/PrecognitionCancel.svelte @@ -0,0 +1,56 @@ + + +
+

Precognition - Cancel Tests

+ +

Auto Cancel Test

+
+
+ validate('name')} /> + {#if invalid('name')} +

+ {errors.name} +

+ {/if} +
+ + {#if validating} +

Validating...

+ {/if} + + +
+ +
+ +

Manual Cancel Test

+
+
+ validate('name')} /> +
+ + {#if validating} +

Validating...

+ {/if} + + + +
+
diff --git a/packages/svelte/test-app/Pages/FormComponent/PrecognitionDefaults.svelte b/packages/svelte/test-app/Pages/FormComponent/PrecognitionDefaults.svelte new file mode 100644 index 000000000..31475a453 --- /dev/null +++ b/packages/svelte/test-app/Pages/FormComponent/PrecognitionDefaults.svelte @@ -0,0 +1,41 @@ + + +
+

Precognition - Defaults Updates Validator

+ +
+
+ + {#if invalid('name')} +

+ {errors.name} +

+ {/if} +
+ + {#if validating} +

Validating...

+ {/if} + + + + +
+
diff --git a/packages/svelte/test-app/Pages/FormComponent/PrecognitionFiles.svelte b/packages/svelte/test-app/Pages/FormComponent/PrecognitionFiles.svelte new file mode 100644 index 000000000..7c9e8bfa7 --- /dev/null +++ b/packages/svelte/test-app/Pages/FormComponent/PrecognitionFiles.svelte @@ -0,0 +1,52 @@ + + +
+

Form Precognition Files

+ +
+
+ validate('name')} /> + {#if invalid('name')} +

{errors.name}

+ {/if} + {#if valid('name')} +

Name is valid!

+ {/if} +
+ +
+ + {#if invalid('avatar')} +

{errors.avatar}

+ {/if} + {#if valid('avatar')} +

Avatar is valid!

+ {/if} +
+ + {#if validating} +

Validating...

+ {/if} + + + + +
+
diff --git a/packages/svelte/test-app/Pages/FormComponent/PrecognitionHeaders.svelte b/packages/svelte/test-app/Pages/FormComponent/PrecognitionHeaders.svelte new file mode 100644 index 000000000..ada432b86 --- /dev/null +++ b/packages/svelte/test-app/Pages/FormComponent/PrecognitionHeaders.svelte @@ -0,0 +1,33 @@ + + +
+

Precognition - Custom Headers

+ +
+
+ validate('name')} /> + {#if invalid('name')} +

+ {errors.name} +

+ {/if} +
+ + {#if validating} +

Validating...

+ {/if} + + +
+
diff --git a/packages/svelte/test-app/Pages/FormComponent/PrecognitionMethods.svelte b/packages/svelte/test-app/Pages/FormComponent/PrecognitionMethods.svelte new file mode 100644 index 000000000..199a121e8 --- /dev/null +++ b/packages/svelte/test-app/Pages/FormComponent/PrecognitionMethods.svelte @@ -0,0 +1,59 @@ + + +
+

Form Precognition - Touch, Reset & Validate

+ +
+
+ touch('name')} /> + {#if invalid('name')} +

{errors.name}

+ {/if} +
+ +
+ touch('email')} /> + {#if invalid('email')} +

{errors.email}

+ {/if} +
+ + {#if validating} +

Validating...

+ {/if} + +

{touched('name') ? 'Name is touched' : 'Name is not touched'}

+

{touched('email') ? 'Email is touched' : 'Email is not touched'}

+

{touched() ? 'Form has touched fields' : 'Form has no touched fields'}

+ + + + + + + + + +
+
diff --git a/packages/svelte/test-app/Pages/FormComponent/PrecognitionTransform.svelte b/packages/svelte/test-app/Pages/FormComponent/PrecognitionTransform.svelte new file mode 100644 index 000000000..a4f2f0785 --- /dev/null +++ b/packages/svelte/test-app/Pages/FormComponent/PrecognitionTransform.svelte @@ -0,0 +1,33 @@ + + +
+

Form Precognition Transform

+ +
({ name: String(data.name || '').repeat(2) })} + let:invalid + let:errors + let:validate + let:valid + let:validating + > +
+ validate('name')} /> + {#if invalid('name')} +

{errors.name}

+ {/if} + {#if valid('name')} +

Name is valid!

+ {/if} +
+ + {#if validating} +

Validating...

+ {/if} +
+
diff --git a/packages/vue3/src/form.ts b/packages/vue3/src/form.ts index 84944d5c9..b5bc68f45 100644 --- a/packages/vue3/src/form.ts +++ b/packages/vue3/src/form.ts @@ -2,16 +2,18 @@ import { FormComponentProps, FormComponentRef, FormComponentSlotProps, + FormComponentValidateOptions, FormDataConvertible, formDataToObject, isUrlMethodPair, mergeDataIntoQueryString, Method, resetFormFields, + usePrecognition, VisitOptions, } from '@inertiajs/core' import { isEqual } from 'lodash-es' -import { computed, defineComponent, h, onBeforeUnmount, onMounted, PropType, ref, SlotsType } from 'vue' +import { computed, defineComponent, h, onBeforeUnmount, onMounted, PropType, ref, SlotsType, watch } from 'vue' import useForm from './useForm' type FormSubmitOptions = Omit @@ -112,6 +114,18 @@ const Form = defineComponent({ type: [String, Array] as PropType, default: () => [], }, + validateFiles: { + type: Boolean as PropType, + default: false, + }, + validateTimeout: { + type: Number as PropType, + default: 1500, + }, + simpleValidationErrors: { + type: Boolean as PropType, + default: true, + }, }, setup(props, { slots, attrs, expose }) { const form = useForm>({}) @@ -134,11 +148,34 @@ const Form = defineComponent({ const formEvents: Array = ['input', 'change', 'reset'] + const validating = ref(false) + const validated = ref([]) + const touched = ref([]) + + const validator = usePrecognition({ + timeout: props.validateTimeout, + onStart: () => (validating.value = true), + onFinish: () => (validating.value = false), + }) + onMounted(() => { defaultData.value = getFormData() formEvents.forEach((e) => formElement.value.addEventListener(e, onFormUpdate)) + updateDataOnValidator() }) + watch( + () => props.validateFiles, + (value) => validator.validateFiles(value), + { immediate: true }, + ) + + watch( + () => props.validateTimeout, + (value) => validator.setTimeout(value), + { immediate: true }, + ) + onBeforeUnmount(() => formEvents.forEach((e) => formElement.value?.removeEventListener(e, onFormUpdate))) const getFormData = (): FormData => new FormData(formElement.value) @@ -148,14 +185,16 @@ const Form = defineComponent({ // expects an object, and submitting a FormData instance directly causes problems with nested objects. const getData = (): Record => formDataToObject(getFormData()) - const submit = () => { - const [action, data] = mergeDataIntoQueryString( + const getUrlAndData = (): [string, Record] => { + return mergeDataIntoQueryString( method.value, isUrlMethodPair(props.action) ? props.action.url : props.action, getData(), props.queryStringArrayFormat, ) + } + const submit = () => { const maybeReset = (resetOption: boolean | string[]) => { if (!resetOption) { return @@ -195,12 +234,29 @@ const Form = defineComponent({ ...props.options, } + const [url, data] = getUrlAndData() + // We need transform because we can't override the default data with different keys (by design) - form.transform(() => props.transform(data)).submit(method.value, action, submitOptions) + form.transform(() => props.transform(data)).submit(method.value, url, submitOptions) + } + + const updateDataOnValidator = () => { + try { + // This might fail if the component is already unmounted but this function + // is called after navigating away after a form submission. + validator.setOldData(props.transform(getData())) + } catch {} } const reset = (...fields: string[]) => { resetFormFields(formElement.value, defaultData.value, fields) + updateDataOnValidator() + + if (fields.length === 0) { + touched.value = [] + } else { + touched.value = touched.value.filter((field) => !fields.includes(field)) + } } const resetAndClearErrors = (...fields: string[]) => { @@ -211,6 +267,78 @@ const Form = defineComponent({ const defaults = () => { defaultData.value = getFormData() isDirty.value = false + updateDataOnValidator() + } + + const validate = ( + only?: string | string[] | FormComponentValidateOptions, + maybeOptions?: FormComponentValidateOptions, + ) => { + let fields: string[] + let options: FormComponentValidateOptions = {} + + if (typeof only === 'object' && !Array.isArray(only)) { + // Called as validate({ only: [...], onSuccess, onError, onFinish }) + const onlyFields = only.only + fields = onlyFields === undefined ? touched.value : Array.isArray(onlyFields) ? onlyFields : [onlyFields] + options = only + } else { + // Called as validate('field') or validate(['field1', 'field2']) or validate('field', {options}) + fields = only === undefined ? touched.value : Array.isArray(only) ? only : [only] + options = maybeOptions || {} + } + + // We're not using the data object from this method as it might be empty + // on GET requests, and we still want to pass a data object to the + // validator so it knows the current state of the form. + const [url] = getUrlAndData() + + validator.validate({ + url, + method: method.value, + data: props.transform(getData()), + only: fields, + errorBag: props.errorBag, + headers: props.headers, + simpleValidationErrors: props.simpleValidationErrors, + onBefore: options.onBefore, + onPrecognitionSuccess: () => { + validated.value = [...validated.value, ...fields] + form.clearErrors(...fields) + options.onSuccess?.() + }, + onValidationError: (errors) => { + validated.value = [...validated.value, ...fields] + + const validFields = fields.filter((field) => errors[field] === undefined) + + if (validFields.length) { + form.clearErrors(...validFields) + } + + form.setError({ ...form.errors, ...errors }) + options.onError?.(errors) + }, + onException: options.onException, + onFinish: () => { + options.onFinish?.() + }, + }) + } + + const touch = (field: string | string[]) => { + const fields = Array.isArray(field) ? field : [field] + + // Use Set to avoid duplicates + touched.value = [...new Set([...touched.value, ...fields])] + } + + const isTouched = (field?: string): boolean => { + if (typeof field === 'string') { + return touched.value.includes(field) + } + + return touched.value.length > 0 } const exposed = { @@ -232,6 +360,9 @@ const Form = defineComponent({ get recentlySuccessful() { return form.recentlySuccessful }, + get validating() { + return validating.value + }, clearErrors: (...fields: string[]) => form.clearErrors(...fields), resetAndClearErrors, setError: (fieldOrFields: string | Record, maybeValue?: string) => @@ -242,6 +373,17 @@ const Form = defineComponent({ reset, submit, defaults, + + // Precognition + valid: (field: string) => validated.value.includes(field) && form.errors[field] === undefined, + invalid: (field: string) => form.errors[field] !== undefined, + validate, + touch, + touched: isTouched, + cancelValidation: () => { + validator.cancelAll() + validating.value = false + }, } expose(exposed) diff --git a/packages/vue3/test-app/Pages/FormComponent/Precognition.vue b/packages/vue3/test-app/Pages/FormComponent/Precognition.vue new file mode 100644 index 000000000..ce7ae4213 --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/Precognition.vue @@ -0,0 +1,34 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/PrecognitionAllErrors.vue b/packages/vue3/test-app/Pages/FormComponent/PrecognitionAllErrors.vue new file mode 100644 index 000000000..9e4519de0 --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/PrecognitionAllErrors.vue @@ -0,0 +1,45 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/PrecognitionArrayErrors.vue b/packages/vue3/test-app/Pages/FormComponent/PrecognitionArrayErrors.vue new file mode 100644 index 000000000..559d650a2 --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/PrecognitionArrayErrors.vue @@ -0,0 +1,34 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/PrecognitionBeforeValidation.vue b/packages/vue3/test-app/Pages/FormComponent/PrecognitionBeforeValidation.vue new file mode 100644 index 000000000..e55e96a1b --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/PrecognitionBeforeValidation.vue @@ -0,0 +1,56 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/PrecognitionCallbacks.vue b/packages/vue3/test-app/Pages/FormComponent/PrecognitionCallbacks.vue new file mode 100644 index 000000000..33fe56981 --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/PrecognitionCallbacks.vue @@ -0,0 +1,103 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/PrecognitionCancel.vue b/packages/vue3/test-app/Pages/FormComponent/PrecognitionCancel.vue new file mode 100644 index 000000000..d724d8666 --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/PrecognitionCancel.vue @@ -0,0 +1,47 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/PrecognitionDefaults.vue b/packages/vue3/test-app/Pages/FormComponent/PrecognitionDefaults.vue new file mode 100644 index 000000000..4341388a4 --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/PrecognitionDefaults.vue @@ -0,0 +1,37 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/PrecognitionFiles.vue b/packages/vue3/test-app/Pages/FormComponent/PrecognitionFiles.vue new file mode 100644 index 000000000..cb2a7e14b --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/PrecognitionFiles.vue @@ -0,0 +1,44 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/PrecognitionHeaders.vue b/packages/vue3/test-app/Pages/FormComponent/PrecognitionHeaders.vue new file mode 100644 index 000000000..b006055e7 --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/PrecognitionHeaders.vue @@ -0,0 +1,28 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/PrecognitionMethods.vue b/packages/vue3/test-app/Pages/FormComponent/PrecognitionMethods.vue new file mode 100644 index 000000000..af27c4991 --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/PrecognitionMethods.vue @@ -0,0 +1,55 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/PrecognitionTransform.vue b/packages/vue3/test-app/Pages/FormComponent/PrecognitionTransform.vue new file mode 100644 index 000000000..03a97bc96 --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/PrecognitionTransform.vue @@ -0,0 +1,26 @@ + + + diff --git a/playgrounds/react/app/Http/Requests/PrecognitionFormRequest.php b/playgrounds/react/app/Http/Requests/PrecognitionFormRequest.php new file mode 100644 index 000000000..3fc8a6916 --- /dev/null +++ b/playgrounds/react/app/Http/Requests/PrecognitionFormRequest.php @@ -0,0 +1,32 @@ +|string> + */ + public function rules(): array + { + sleep(1); + + return [ + 'name' => ['required', 'string', 'min:3', 'max:255'], + 'email' => ['required', 'email', 'max:255'], + 'avatar' => ['nullable', 'file', 'image'], + ]; + } +} diff --git a/playgrounds/react/resources/js/Components/Layout.tsx b/playgrounds/react/resources/js/Components/Layout.tsx index 69d7cb70b..b1a0abc6d 100644 --- a/playgrounds/react/resources/js/Components/Layout.tsx +++ b/playgrounds/react/resources/js/Components/Layout.tsx @@ -17,10 +17,13 @@ export default function Layout({ children, padding = true }: { children: React.R Article - Form + useForm - Form Component + {'
'} + + + Precognition Async diff --git a/playgrounds/react/resources/js/Pages/FormComponentPrecognition.tsx b/playgrounds/react/resources/js/Pages/FormComponentPrecognition.tsx new file mode 100644 index 000000000..d0388b83a --- /dev/null +++ b/playgrounds/react/resources/js/Pages/FormComponentPrecognition.tsx @@ -0,0 +1,277 @@ +import { Form, Head } from '@inertiajs/react' +import { useState } from 'react' +import Layout from '../Components/Layout' + +const FormComponentPrecognition = () => { + const [callbacks, setCallbacks] = useState({ + success: false, + error: false, + finish: false, + exception: false, + exceptionMessage: '', + }) + + const validateWithCallbacks = (validate) => { + setCallbacks({ + success: false, + error: false, + finish: false, + exception: false, + exceptionMessage: '', + }) + + validate('name', { + onSuccess: () => setCallbacks((prev) => ({ ...prev, success: true })), + onError: () => setCallbacks((prev) => ({ ...prev, error: true })), + onFinish: () => setCallbacks((prev) => ({ ...prev, finish: true })), + onBefore: (newReq, oldReq) => { + // Prevent validation if name is 'block' + if (newReq.data.name === 'block') { + alert('Validation blocked by onBefore!') + return false + } + }, + }) + } + + const [validateFiles, setValidateFiles] = useState(false) + const [validateTimeout, setValidateTimeout] = useState(1500) + + return ( + <> + +

Form Precognition

+ + {/* Live Validation & File Uploads */} +
+
+

Live Validation & File Uploads

+ + {/* Configuration Toggle */} +
+ +
+ + +
+
+ + + {({ errors, invalid, valid, validate, validating }) => ( + <> +

Validating: {validating ? ' Yes...' : ' No'}

+ +
+ + validate('name')} + className={`mt-1 w-full appearance-none rounded border px-2 py-1 shadow-sm ${ + invalid('name') ? 'border-red-500' : valid('name') ? 'border-green-500' : '' + }`} + /> + {invalid('name') &&

{errors.name}

} + {valid('name') &&

Valid!

} +
+ +
+ + validate('email')} + className={`mt-1 w-full appearance-none rounded border px-2 py-1 shadow-sm ${ + invalid('email') ? 'border-red-500' : valid('email') ? 'border-green-500' : '' + }`} + /> + {invalid('email') &&

{errors.email}

} + {valid('email') &&

Valid!

} +
+ +
+ + validate('avatar')} + className={`mt-1 w-full appearance-none rounded border px-2 py-1 shadow-sm ${ + invalid('avatar') ? 'border-red-500' : valid('avatar') ? 'border-green-500' : '' + }`} + /> + {invalid('avatar') &&

{errors.avatar}

} + {valid('avatar') &&

Valid!

} +

+ Files are validated during precognitive requests when validateFiles is enabled +

+
+ + )} + +
+ + {/* Touch & Reset Methods */} +
+

Touch & Reset Methods

+ +
+ {({ errors, invalid, validate, touch, touched, reset, validating }) => ( + <> +
+ + touch('name')} + className="mt-1 w-full appearance-none rounded border px-2 py-1 shadow-sm" + /> + {invalid('name') &&

{errors.name}

} +

Touched: {String(touched('name'))}

+
+ +
+ + touch('email')} + className="mt-1 w-full appearance-none rounded border px-2 py-1 shadow-sm" + /> + {invalid('email') &&

{errors.email}

} +

Touched: {String(touched('email'))}

+
+ + {validating &&

Validating...

} + +
+ + + +
+ +
+ Status: +
    +
  • Any field touched: {String(touched())}
  • +
  • Name touched: {String(touched('name'))}
  • +
  • Email touched: {String(touched('email'))}
  • +
+
+ + )} +
+
+ + {/* Validation Callbacks */} +
+

Validation Callbacks

+ +
+ {({ errors, invalid, validate, touch, validating }) => ( + <> +
+ + touch('name')} + className="mt-1 w-full appearance-none rounded border px-2 py-1 shadow-sm" + /> + {invalid('name') &&

{errors.name}

} +
+ + {validating &&

Validating...

} + + {(callbacks.success || callbacks.error || callbacks.finish) && ( +
+ {callbacks.success &&

onSuccess called!

} + {callbacks.error &&

onError called!

} + {callbacks.finish &&

onFinish called!

} + {callbacks.exception && ( +

onException: {callbacks.exceptionMessage}

+ )} +
+ )} + +
+ +
+ + )} +
+
+
+ + ) +} + +FormComponentPrecognition.layout = (page) => + +export default FormComponentPrecognition diff --git a/playgrounds/react/routes/web.php b/playgrounds/react/routes/web.php index 61aa69c20..36db775ce 100644 --- a/playgrounds/react/routes/web.php +++ b/playgrounds/react/routes/web.php @@ -1,6 +1,9 @@ validated(); + + // dd($data); + + return back(); +})->middleware([HandlePrecognitiveRequests::class]); + Route::post('/user', function () { return inertia('User', [ 'user' => request()->validate([ diff --git a/playgrounds/svelte4/app/Http/Requests/PrecognitionFormRequest.php b/playgrounds/svelte4/app/Http/Requests/PrecognitionFormRequest.php new file mode 100644 index 000000000..3fc8a6916 --- /dev/null +++ b/playgrounds/svelte4/app/Http/Requests/PrecognitionFormRequest.php @@ -0,0 +1,32 @@ +|string> + */ + public function rules(): array + { + sleep(1); + + return [ + 'name' => ['required', 'string', 'min:3', 'max:255'], + 'email' => ['required', 'email', 'max:255'], + 'avatar' => ['nullable', 'file', 'image'], + ]; + } +} diff --git a/playgrounds/svelte4/resources/js/Components/Layout.svelte b/playgrounds/svelte4/resources/js/Components/Layout.svelte index e9e358dbf..02a2cd9b9 100644 --- a/playgrounds/svelte4/resources/js/Components/Layout.svelte +++ b/playgrounds/svelte4/resources/js/Components/Layout.svelte @@ -12,8 +12,9 @@ Home Users Article - Form - Form Component + useForm + {'
'} + Precognition External Async diff --git a/playgrounds/svelte4/resources/js/Pages/FormComponentPrecognition.svelte b/playgrounds/svelte4/resources/js/Pages/FormComponentPrecognition.svelte new file mode 100644 index 000000000..44b70730c --- /dev/null +++ b/playgrounds/svelte4/resources/js/Pages/FormComponentPrecognition.svelte @@ -0,0 +1,284 @@ + + + + + + Precognition - {appName} + + +

Form Precognition

+ + +
+
+

Live Validation & File Uploads

+ + +
+ +
+ + +
+
+ + +

Validating: {validating ? 'Yes...' : 'No'}

+ +
+ + validate('name')} + class="mt-1 w-full appearance-none rounded border px-2 py-1 shadow-sm" + class:border-red-500={invalid('name')} + class:border-green-500={valid('name')} + /> + {#if invalid('name')} +

{errors.name}

+ {/if} + {#if valid('name')} +

Valid!

+ {/if} +
+ +
+ + validate('email')} + class="mt-1 w-full appearance-none rounded border px-2 py-1 shadow-sm" + class:border-red-500={invalid('email')} + class:border-green-500={valid('email')} + /> + {#if invalid('email')} +

{errors.email}

+ {/if} + {#if valid('email')} +

Valid!

+ {/if} +
+ +
+ + validate('avatar')} + class="mt-1 w-full appearance-none rounded border px-2 py-1 shadow-sm" + class:border-red-500={invalid('avatar')} + class:border-green-500={valid('avatar')} + /> + {#if invalid('avatar')} +

{errors.avatar}

+ {/if} + {#if valid('avatar')} +

Valid!

+ {/if} +

+ Files are validated during precognitive requests when validateFiles is enabled +

+
+ +
+ + +
+

Touch & Reset Methods

+ +
+
+ + touch('name')} + class="mt-1 w-full appearance-none rounded border px-2 py-1 shadow-sm" + /> + {#if invalid('name')} +

{errors.name}

+ {/if} +

Touched: {touched('name')}

+
+ +
+ + touch('email')} + class="mt-1 w-full appearance-none rounded border px-2 py-1 shadow-sm" + /> + {#if invalid('email')} +

{errors.email}

+ {/if} +

Touched: {touched('email')}

+
+ + {#if validating} +

Validating...

+ {/if} + +
+ + + +
+ +
+ Status: +
    +
  • Any field touched: {touched()}
  • +
  • Name touched: {touched('name')}
  • +
  • Email touched: {touched('email')}
  • +
+
+
+
+ + +
+

Validation Callbacks

+ +
+
+ + touch('name')} + class="mt-1 w-full appearance-none rounded border px-2 py-1 shadow-sm" + /> + {#if invalid('name')} +

{errors.name}

+ {/if} +
+ + {#if validating} +

Validating...

+ {/if} + + {#if callbacks.success || callbacks.error || callbacks.finish} +
+ {#if callbacks.success} +

onSuccess called!

+ {/if} + {#if callbacks.error} +

onError called!

+ {/if} + {#if callbacks.finish} +

onFinish called!

+ {/if} + {#if callbacks.exception} +

onException: {callbacks.exceptionMessage}

+ {/if} +
+ {/if} + +
+ +
+
+
+
diff --git a/playgrounds/svelte4/routes/web.php b/playgrounds/svelte4/routes/web.php index b8029f3ec..0dec64e82 100644 --- a/playgrounds/svelte4/routes/web.php +++ b/playgrounds/svelte4/routes/web.php @@ -1,5 +1,7 @@ validated(); + + // dd($data); + + return back(); +})->middleware([HandlePrecognitiveRequests::class]); + Route::get('/photo-grid/{horizontal?}', function ($horizontal = null) { if (request()->header('X-Inertia-Partial-Component')) { // Simulate latency for partial reloads diff --git a/playgrounds/svelte5/app/Http/Requests/PrecognitionFormRequest.php b/playgrounds/svelte5/app/Http/Requests/PrecognitionFormRequest.php new file mode 100644 index 000000000..3fc8a6916 --- /dev/null +++ b/playgrounds/svelte5/app/Http/Requests/PrecognitionFormRequest.php @@ -0,0 +1,32 @@ +|string> + */ + public function rules(): array + { + sleep(1); + + return [ + 'name' => ['required', 'string', 'min:3', 'max:255'], + 'email' => ['required', 'email', 'max:255'], + 'avatar' => ['nullable', 'file', 'image'], + ]; + } +} diff --git a/playgrounds/svelte5/resources/js/Components/Layout.svelte b/playgrounds/svelte5/resources/js/Components/Layout.svelte index f5ae9ec6d..a66a99102 100644 --- a/playgrounds/svelte5/resources/js/Components/Layout.svelte +++ b/playgrounds/svelte5/resources/js/Components/Layout.svelte @@ -9,8 +9,9 @@ Home Users Article - Form - Form Component + useForm + {'
'} + Precognition Photo Grid Photo Row Data Table diff --git a/playgrounds/svelte5/resources/js/Pages/FormComponentPrecognition.svelte b/playgrounds/svelte5/resources/js/Pages/FormComponentPrecognition.svelte new file mode 100644 index 000000000..30b2ade76 --- /dev/null +++ b/playgrounds/svelte5/resources/js/Pages/FormComponentPrecognition.svelte @@ -0,0 +1,263 @@ + + + + + + Precognition - {appName} + + +

Form Precognition

+ + +
+
+

Live Validation & File Uploads

+ + +
+ +
+ + +
+
+ + + {#snippet children({ errors, invalid, valid, validate, validating })} +

Validating: {validating ? 'Yes...' : 'No'}

+ +
+ + validate('name')} + class="mt-1 w-full appearance-none rounded border px-2 py-1 shadow-sm" + class:border-red-500={invalid('name')} + class:border-green-500={valid('name')} + /> + {#if invalid('name')} +

{errors.name}

+ {/if} + {#if valid('name')} +

Valid!

+ {/if} +
+ +
+ + validate('email')} + class="mt-1 w-full appearance-none rounded border px-2 py-1 shadow-sm" + class:border-red-500={invalid('email')} + class:border-green-500={valid('email')} + /> + {#if invalid('email')} +

{errors.email}

+ {/if} + {#if valid('email')} +

Valid!

+ {/if} +
+ +
+ + validate('avatar')} + class="mt-1 w-full appearance-none rounded border px-2 py-1 shadow-sm" + class:border-red-500={invalid('avatar')} + class:border-green-500={valid('avatar')} + /> + {#if invalid('avatar')} +

{errors.avatar}

+ {/if} + {#if valid('avatar')} +

Valid!

+ {/if} +

+ Files are validated during precognitive requests when validateFiles is enabled +

+
+ {/snippet} + +
+ + +
+

Touch & Reset Methods

+ +
+ {#snippet children({ errors, invalid, validate, touch, touched, reset, validating })} +
+ + touch('name')} + class="mt-1 w-full appearance-none rounded border px-2 py-1 shadow-sm" + /> + {#if invalid('name')} +

{errors.name}

+ {/if} +

Touched: {touched('name')}

+
+ +
+ + touch('email')} + class="mt-1 w-full appearance-none rounded border px-2 py-1 shadow-sm" + /> + {#if invalid('email')} +

{errors.email}

+ {/if} +

Touched: {touched('email')}

+
+ + {#if validating} +

Validating...

+ {/if} + +
+ + + +
+ +
+ Status: +
    +
  • Any field touched: {touched()}
  • +
  • Name touched: {touched('name')}
  • +
  • Email touched: {touched('email')}
  • +
+
+ {/snippet} +
+
+ + +
+

Validation Callbacks

+ +
+ {#snippet children({ errors, invalid, validate, touch, validating })} +
+ + touch('name')} + class="mt-1 w-full appearance-none rounded border px-2 py-1 shadow-sm" + /> + {#if invalid('name')} +

{errors.name}

+ {/if} +
+ + {#if validating} +

Validating...

+ {/if} + + {#if callbacks.success || callbacks.error || callbacks.finish} +
+ {#if callbacks.success} +

onSuccess called!

+ {/if} + {#if callbacks.error} +

onError called!

+ {/if} + {#if callbacks.finish} +

onFinish called!

+ {/if} + {#if callbacks.exception} +

onException: {callbacks.exceptionMessage}

+ {/if} +
+ {/if} + +
+ +
+ {/snippet} +
+
+
diff --git a/playgrounds/svelte5/routes/web.php b/playgrounds/svelte5/routes/web.php index 29df038d2..28c3938ca 100644 --- a/playgrounds/svelte5/routes/web.php +++ b/playgrounds/svelte5/routes/web.php @@ -1,5 +1,7 @@ validated(); + + // dd($data); + + return back(); +})->middleware([HandlePrecognitiveRequests::class]); + Route::get('/photo-grid/{horizontal?}', function ($horizontal = null) { if (request()->header('X-Inertia-Partial-Component')) { // Simulate latency for partial reloads diff --git a/playgrounds/vue3/app/Http/Requests/PrecognitionFormRequest.php b/playgrounds/vue3/app/Http/Requests/PrecognitionFormRequest.php new file mode 100644 index 000000000..3fc8a6916 --- /dev/null +++ b/playgrounds/vue3/app/Http/Requests/PrecognitionFormRequest.php @@ -0,0 +1,32 @@ +|string> + */ + public function rules(): array + { + sleep(1); + + return [ + 'name' => ['required', 'string', 'min:3', 'max:255'], + 'email' => ['required', 'email', 'max:255'], + 'avatar' => ['nullable', 'file', 'image'], + ]; + } +} diff --git a/playgrounds/vue3/resources/js/Components/Layout.vue b/playgrounds/vue3/resources/js/Components/Layout.vue index 88c5bea06..8da94e29a 100644 --- a/playgrounds/vue3/resources/js/Components/Layout.vue +++ b/playgrounds/vue3/resources/js/Components/Layout.vue @@ -22,8 +22,9 @@ const appName = computed(() => page.props.appName) Home Users Article - Form - Form Component + useForm + {{ '<' + 'Form' + '>' }} + Precognition Logout External Async diff --git a/playgrounds/vue3/resources/js/Pages/FormComponentPrecognition.vue b/playgrounds/vue3/resources/js/Pages/FormComponentPrecognition.vue new file mode 100644 index 000000000..384fc0b71 --- /dev/null +++ b/playgrounds/vue3/resources/js/Pages/FormComponentPrecognition.vue @@ -0,0 +1,233 @@ + + + + + diff --git a/playgrounds/vue3/routes/web.php b/playgrounds/vue3/routes/web.php index efa24a69e..11af53004 100644 --- a/playgrounds/vue3/routes/web.php +++ b/playgrounds/vue3/routes/web.php @@ -1,6 +1,8 @@ validated(); + + // dd($data); + + return back(); +})->middleware([HandlePrecognitiveRequests::class]); + Route::post('/user', function () { return inertia('User', [ 'user' => request()->validate([ diff --git a/tests/app/server.js b/tests/app/server.js index 49672ca2c..1041482a4 100644 --- a/tests/app/server.js +++ b/tests/app/server.js @@ -12,6 +12,7 @@ app.use(bodyParser.json({ extended: true })) const upload = multer() const adapters = ['react', 'svelte', 'vue3'] +const runsInCI = !!process.env.CI if (!adapters.includes(inertia.package)) { throw new Error(`Invalid adapter package "${inertia.package}". Expected one of: ${adapters.join(', ')}.`) @@ -906,6 +907,168 @@ app.get('/form-component/invalidate-tags/:propType', (req, res) => }), ) +// + +app.post('/form-component/precognition', (req, res) => { + setTimeout( + () => { + const only = req.headers['precognition-validate-only'] ? req.headers['precognition-validate-only'].split(',') : [] + const name = req.body['name'] + const email = req.body['email'] + const errors = {} + + if (!name) { + errors.name = 'The name field is required.' + } + + if (name && name.length < 3) { + errors.name = 'The name must be at least 3 characters.' + } + + if (!email) { + errors.email = 'The email field is required.' + } + + if (email && !/\S+@\S+\.\S+/.test(email)) { + errors.email = 'The email must be a valid email address.' + } + + if (only.length) { + Object.keys(errors).forEach((key) => { + if (!only.includes(key)) { + delete errors[key] + } + }) + } + + res.header('Precognition', 'true') + res.header('Vary', 'Precognition') + + if (Object.keys(errors).length) { + return res.status(422).json({ errors }) + } + + return res.status(204).header('Precognition-Success', 'true').send() + }, + !!req.query['slow'] ? 2000 : 500, + ) +}) + +app.post('/form-component/precognition-array-errors', (req, res) => { + setTimeout(() => { + const only = req.headers['precognition-validate-only'] ? req.headers['precognition-validate-only'].split(',') : [] + const name = req.body['name'] + const email = req.body['email'] + const errors = {} + + if (!name) { + errors.name = ['The name field is required.'] + } + + if (name && name.length < 3) { + errors.name = ['The name must be at least 3 characters.', 'The name contains invalid characters.'] + } + + if (!email) { + errors.email = ['The email field is required.'] + } + + if (email && !/\S+@\S+\.\S+/.test(email)) { + errors.email = ['The email must be a valid email address.', 'The email format is incorrect.'] + } + + if (only.length) { + Object.keys(errors).forEach((key) => { + if (!only.includes(key)) { + delete errors[key] + } + }) + } + + res.header('Precognition', 'true') + res.header('Vary', 'Precognition') + + if (Object.keys(errors).length) { + return res.status(422).json({ errors }) + } + + return res.status(204).header('Precognition-Success', 'true').send() + }, 500) +}) + +app.post('/form-component/precognition-files', upload.any(), (req, res) => { + setTimeout(() => { + console.log(req, req) + const only = req.headers['precognition-validate-only'] ? req.headers['precognition-validate-only'].split(',') : [] + const name = req.body['name'] + const hasAvatar = req.files && req.files.avatar + const errors = {} + + if (!name) { + errors.name = 'The name field is required.' + } + + if (name && name.length < 3) { + errors.name = 'The name must be at least 3 characters.' + } + + if (!hasAvatar) { + errors.avatar = 'The avatar field is required.' + } + + if (only.length) { + Object.keys(errors).forEach((key) => { + if (!only.includes(key)) { + delete errors[key] + } + }) + } + + res.header('Precognition', 'true') + res.header('Vary', 'Precognition') + + if (Object.keys(errors).length) { + return res.status(422).json({ errors }) + } + + return res.status(204).header('Precognition-Success', 'true').send() + }, 500) +}) + +app.post('/form-component/precognition-headers', (req, res) => { + setTimeout(() => { + const customHeader = req.headers['x-custom-header'] + const name = req.body['name'] + const errors = {} + + // Show error when custom header IS present (to prove it was sent) + if (customHeader === 'custom-value') { + errors.name = 'Custom header received: custom-value' + } else if (!name) { + errors.name = 'The name field is required.' + } else if (name.length < 3) { + errors.name = 'The name must be at least 3 characters.' + } + + res.header('Precognition', 'true') + res.header('Vary', 'Precognition') + + if (Object.keys(errors).length) { + return res.status(422).json({ errors }) + } + + return res.status(204).header('Precognition-Success', 'true').send() + }, 500) +}) + +app.post('/form-component/precognition-exception', (req, res) => { + setTimeout(() => { + res.status(500).json({ message: 'Internal server error' }) + }, 500) +}) + +// + function renderInfiniteScroll(req, res, component, total = 40, orderByDesc = false, perPage = 15) { const page = req.query.page ? parseInt(req.query.page) : 1 const partialReload = !!req.headers['x-inertia-partial-data'] diff --git a/tests/form-component.spec.ts b/tests/form-component.spec.ts index a1aecddb8..9d258e21d 100644 --- a/tests/form-component.spec.ts +++ b/tests/form-component.spec.ts @@ -1431,6 +1431,592 @@ test.describe('Form Component', () => { }) }) + test.describe('Precognition', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/form-component/precognition') + }) + + test('shows validation error when field is invalid', async ({ page }) => { + await page.fill('input[name="name"]', 'ab') + await page.locator('input[name="name"]').blur() + + await expect(page.getByText('The name must be at least 3 characters.')).toBeVisible() + }) + + test('clears validation error when field becomes valid', async ({ page }) => { + await page.fill('input[name="name"]', 'ab') + await page.locator('input[name="name"]').blur() + + await expect(page.getByText('The name must be at least 3 characters.')).toBeVisible() + + await page.fill('input[name="name"]', 'John Doe') + await page.locator('input[name="name"]').blur() + + await expect(page.getByText('The name must be at least 3 characters.')).not.toBeVisible() + }) + + test('validates only the specified field', async ({ page }) => { + await page.fill('input[name="name"]', 'ab') + await page.locator('input[name="name"]').blur() + + await expect(page.getByText('The name must be at least 3 characters.')).toBeVisible() + await expect(page.getByText('The email field is required.')).not.toBeVisible() + }) + + test('validates multiple fields independently', async ({ page }) => { + await page.fill('input[name="name"]', 'ab') + await page.locator('input[name="name"]').blur() + + await expect(page.getByText('The name must be at least 3 characters.')).toBeVisible() + await expect(page.getByText('The email must be a valid email address.')).not.toBeVisible() + + await page.fill('input[name="email"]', 'x') + await page.locator('input[name="email"]').blur() + + await expect(page.getByText('The name must be at least 3 characters.')).toBeVisible() + await expect(page.getByText('The email must be a valid email address.')).toBeVisible() + }) + + test('does not clear unrelated field errors', async ({ page }) => { + await page.fill('input[name="name"]', 'ab') + await page.locator('input[name="name"]').blur() + + await expect(page.getByText('The name must be at least 3 characters.')).toBeVisible() + + await page.fill('input[name="email"]', 'x') + await page.locator('input[name="email"]').blur() + + await expect(page.getByText('The email must be a valid email address.')).toBeVisible() + + await page.fill('input[name="email"]', 'test@example.com') + await page.locator('input[name="email"]').blur() + + await expect(page.getByText('The email must be a valid email address.')).not.toBeVisible() + await expect(page.getByText('The name must be at least 3 characters.')).toBeVisible() + }) + + test('field is valid when validated and no errors exist', async ({ page }) => { + await expect(page.getByText('Name is valid!')).not.toBeVisible() + + await page.fill('input[name="name"]', 'John Doe') + await page.locator('input[name="name"]').blur() + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + await expect(page.getByText('Name is valid!')).toBeVisible() + }) + + test('field is not valid before validation', async ({ page }) => { + await expect(page.getByText('Name is valid!')).not.toBeVisible() + + await page.fill('input[name="name"]', 'John Doe') + await page.locator('input[name="name"]').blur() + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + await expect(page.getByText('Name is valid!')).toBeVisible() + await expect(page.getByText('The name must be at least 3 characters.')).not.toBeVisible() + }) + + test('field is not valid after failed validation', async ({ page }) => { + await page.fill('input[name="name"]', 'ab') + await page.locator('input[name="name"]').blur() + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + await expect(page.getByText('The name must be at least 3 characters.')).toBeVisible() + await expect(page.getByText('Name is valid!')).not.toBeVisible() + }) + + test('valid field persists after successful validation', async ({ page }) => { + await page.fill('input[name="name"]', 'John Doe') + await page.locator('input[name="name"]').blur() + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + await expect(page.getByText('Name is valid!')).toBeVisible() + + await page.fill('input[name="name"]', 'Jane Doe') + await page.locator('input[name="name"]').blur() + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + await expect(page.getByText('Name is valid!')).toBeVisible() + }) + + test('valid field becomes invalid when field is revalidated with errors', async ({ page }) => { + await page.fill('input[name="name"]', 'John Doe') + await page.locator('input[name="name"]').blur() + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + await expect(page.getByText('Name is valid!')).toBeVisible() + + await page.fill('input[name="name"]', 'ab') + await page.locator('input[name="name"]').blur() + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + await expect(page.getByText('The name must be at least 3 characters.')).toBeVisible() + await expect(page.getByText('Name is valid!')).not.toBeVisible() + }) + + test('shows only first error when server returns errors as array', async ({ page }) => { + await page.goto('/form-component/precognition-array-errors') + + await page.fill('input[name="name"]', 'ab') + await page.locator('input[name="name"]').blur() + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + // Should show only the first error from the array, not the second + await expect(page.getByText('The name must be at least 3 characters.')).toBeVisible() + await expect(page.getByText('The name contains invalid characters.')).not.toBeVisible() + }) + + test('shows all errors when simpleValidationErrors is false', async ({ page }) => { + await page.goto('/form-component/precognition-all-errors') + + await page.fill('input[name="name"]', 'ab') + await page.locator('input[name="name"]').blur() + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + // Should show all errors from the array + await expect(page.locator('#name-error-0')).toHaveText('The name must be at least 3 characters.') + await expect(page.locator('#name-error-1')).toHaveText('The name contains invalid characters.') + }) + + test('validates all touched fields when calling validate() without arguments', async ({ page }) => { + await page.goto('/form-component/precognition-methods') + + await page.fill('input[name="name"]', 'ab') + await page.locator('input[name="name"]').blur() + + await page.fill('input[name="email"]', 'x') + await page.locator('input[name="email"]').blur() + + await expect(page.getByText('Validating...')).not.toBeVisible() + await expect(page.getByText('The name must be at least 3 characters.')).not.toBeVisible() + await expect(page.getByText('The email must be a valid email address.')).not.toBeVisible() + + await page.getByRole('button', { name: 'Validate All Touched' }).click() + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + await expect(page.getByText('The name must be at least 3 characters.')).toBeVisible() + await expect(page.getByText('The email must be a valid email address.')).toBeVisible() + }) + + test('reset all fields clears all touched fields', async ({ page }) => { + await page.goto('/form-component/precognition-methods') + + await page.fill('input[name="name"]', 'ab') + await page.locator('input[name="name"]').blur() + + await page.fill('input[name="email"]', 'x') + await page.locator('input[name="email"]').blur() + + await page.getByRole('button', { name: 'Reset All' }).click() + + await expect(page.locator('input[name="name"]')).toHaveValue('') + await expect(page.locator('input[name="email"]')).toHaveValue('') + + await page.fill('input[name="name"]', 'ab') + await page.locator('input[name="name"]').blur() + + await page.getByRole('button', { name: 'Validate All Touched' }).click() + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + await expect(page.getByText('The name must be at least 3 characters.')).toBeVisible() + await expect(page.getByText('The email field is required.')).not.toBeVisible() + }) + + test('reset specific fields removes only those fields from touched', async ({ page }) => { + await page.goto('/form-component/precognition-methods') + + await page.fill('input[name="name"]', 'ab') + await page.locator('input[name="name"]').blur() + + await page.fill('input[name="email"]', 'x') + await page.locator('input[name="email"]').blur() + + await expect(page.locator('input[name="name"]')).toHaveValue('ab') + await expect(page.locator('input[name="email"]')).toHaveValue('x') + + await page.getByRole('button', { name: 'Reset Name', exact: true }).click() + + await expect(page.locator('input[name="name"]')).toHaveValue('') + await expect(page.locator('input[name="email"]')).toHaveValue('x') + + await page.fill('input[name="email"]', 'y') + + await page.getByRole('button', { name: 'Validate All Touched' }).click() + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + await expect(page.getByText('The name field is required.')).not.toBeVisible() + await expect(page.getByText('The email must be a valid email address.')).toBeVisible() + }) + + test('touch with array marks multiple fields as touched', async ({ page }) => { + await page.goto('/form-component/precognition-methods') + + await page.fill('input[name="name"]', 'ab') + await page.fill('input[name="email"]', 'x') + + await page.getByRole('button', { name: 'Touch Name and Email' }).click() + await page.getByRole('button', { name: 'Validate All Touched' }).click() + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + await expect(page.getByText('The name must be at least 3 characters.')).toBeVisible() + await expect(page.getByText('The email must be a valid email address.')).toBeVisible() + }) + + test('touch deduplicates fields when called multiple times', async ({ page }) => { + await page.goto('/form-component/precognition-methods') + + await page.fill('input[name="name"]', 'ab') + + await page.getByRole('button', { name: 'Touch Name Twice' }).click() + await page.getByRole('button', { name: 'Validate All Touched' }).click() + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + await expect(page.getByText('The name must be at least 3 characters.')).toBeVisible() + await expect(page.getByText('The email must be a valid email address.')).not.toBeVisible() + }) + + test('touched() returns false when no fields are touched', async ({ page }) => { + await page.goto('/form-component/precognition-methods') + + await expect(page.locator('#any-touched')).toHaveText('Form has no touched fields') + await expect(page.locator('#name-touched')).toHaveText('Name is not touched') + await expect(page.locator('#email-touched')).toHaveText('Email is not touched') + }) + + test('touched(field) returns true when specific field is touched', async ({ page }) => { + await page.goto('/form-component/precognition-methods') + + await page.locator('input[name="name"]').focus() + await page.locator('input[name="name"]').blur() + + await expect(page.locator('#name-touched')).toHaveText('Name is touched') + await expect(page.locator('#email-touched')).toHaveText('Email is not touched') + await expect(page.locator('#any-touched')).toHaveText('Form has touched fields') + }) + + test('touched() returns true when any field is touched', async ({ page }) => { + await page.goto('/form-component/precognition-methods') + + await page.locator('input[name="email"]').focus() + await page.locator('input[name="email"]').blur() + + await expect(page.locator('#any-touched')).toHaveText('Form has touched fields') + await expect(page.locator('#email-touched')).toHaveText('Email is touched') + await expect(page.locator('#name-touched')).toHaveText('Name is not touched') + }) + + test('touched() updates when multiple fields are touched', async ({ page }) => { + await page.goto('/form-component/precognition-methods') + + await page.locator('input[name="name"]').focus() + await page.locator('input[name="name"]').blur() + await page.locator('input[name="email"]').focus() + await page.locator('input[name="email"]').blur() + + await expect(page.locator('#name-touched')).toHaveText('Name is touched') + await expect(page.locator('#email-touched')).toHaveText('Email is touched') + await expect(page.locator('#any-touched')).toHaveText('Form has touched fields') + }) + + test('validate with specific field works independently of touched state', async ({ page }) => { + await page.goto('/form-component/precognition-methods') + + await page.fill('input[name="name"]', 'ab') + await page.fill('input[name="email"]', 'x') + + await page.getByRole('button', { name: 'Validate Name', exact: true }).click() + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + await expect(page.getByText('The name must be at least 3 characters.')).toBeVisible() + await expect(page.getByText('The email must be a valid email address.')).not.toBeVisible() + }) + + test('validate with array of fields validates multiple fields', async ({ page }) => { + await page.goto('/form-component/precognition-methods') + + await page.fill('input[name="name"]', 'ab') + await page.fill('input[name="email"]', 'x') + + await page.getByRole('button', { name: 'Validate Name and Email' }).click() + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + await expect(page.getByText('The name must be at least 3 characters.')).toBeVisible() + await expect(page.getByText('The email must be a valid email address.')).toBeVisible() + }) + + test('reset with array removes multiple fields from touched', async ({ page }) => { + await page.goto('/form-component/precognition-methods') + + await page.fill('input[name="name"]', 'ab') + await page.locator('input[name="name"]').blur() + + await page.fill('input[name="email"]', 'x') + await page.locator('input[name="email"]').blur() + + await page.getByRole('button', { name: 'Validate All Touched' }).click() + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + await expect(page.getByText('The name must be at least 3 characters.')).toBeVisible() + await expect(page.getByText('The email must be a valid email address.')).toBeVisible() + + await page.getByRole('button', { name: 'Reset Name and Email' }).click() + + await page.fill('input[name="name"]', 'abc') + await page.fill('input[name="email"]', 'test@example.com') + + await page.getByRole('button', { name: 'Validate All Touched' }).click() + + await page.waitForTimeout(500) + + await expect(page.getByText('The name must be at least 3 characters.')).not.toBeVisible() + await expect(page.getByText('The email must be a valid email address.')).not.toBeVisible() + }) + + test('touching one field and validating another does not validate the touched field', async ({ page }) => { + await page.goto('/form-component/precognition-methods') + + await page.fill('input[name="name"]', 'ab') + await page.locator('input[name="name"]').blur() + + await page.fill('input[name="email"]', 'x') + + await page.getByRole('button', { name: 'Validate Name', exact: true }).click() + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + await expect(page.getByText('The name must be at least 3 characters.')).toBeVisible() + await expect(page.getByText('The email must be a valid email address.')).not.toBeVisible() + }) + + test('does not submit files by default', async ({ page }) => { + await page.goto('/form-component/precognition-files') + + await page.setInputFiles('#avatar', { + name: 'avatar.jpg', + mimeType: 'image/jpeg', + buffer: Buffer.from('fake image data'), + }) + + await page.getByRole('button', { name: 'Validate Both' }).click() + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + await expect(page.getByText('The name field is required.')).toBeVisible() + await expect(page.getByText('The avatar field is required.')).toBeVisible() + }) + + test('validates files when validate-files prop is true', async ({ page }) => { + await page.goto('/form-component/precognition-files') + await page.getByRole('button', { name: /Toggle Validate Files/ }).click() + await expect(page.getByText('Toggle Validate Files (enabled)')).toBeVisible() + + await page.fill('input[name="name"]', 'ab') + await page.setInputFiles('#avatar', { + name: 'avatar.jpg', + mimeType: 'image/jpeg', + buffer: Buffer.from('fake image data'), + }) + + await page.getByRole('button', { name: 'Validate Both' }).click() + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + await expect(page.getByText('The name must be at least 3 characters.')).toBeVisible() + await expect(page.getByText('The avatar field is required.')).not.toBeVisible() + }) + + test('transforms data for validation requests', async ({ page }) => { + await page.goto('/form-component/precognition-transform') + + await page.fill('input[name="name"]', 'a') + await page.locator('input[name="name"]').blur() + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + await expect(page.getByText('The name must be at least 3 characters.')).toBeVisible() + + await page.fill('input[name="name"]', 'aa') + await page.locator('input[name="name"]').blur() + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + await expect(page.getByText('The name must be at least 3 characters.')).not.toBeVisible() + await expect(page.getByText('Name is valid!')).toBeVisible() + }) + + test('calls onSuccess and onFinish callbacks when validation succeeds', async ({ page }) => { + await page.goto('/form-component/precognition-callbacks') + + await page.fill('input[name="name"]', 'John Doe') + await page.click('button:has-text("Validate with onSuccess")') + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + await expect(page.getByText('onSuccess called!')).toBeVisible() + await expect(page.getByText('onError called!')).not.toBeVisible() + await expect(page.getByText('onFinish called!')).toBeVisible() + }) + + test('calls onError and onFinish callbacks when validation fails', async ({ page }) => { + await page.goto('/form-component/precognition-callbacks') + + await page.fill('input[name="name"]', 'ab') + await page.click('button:has-text("Validate with onError")') + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + await expect(page.getByText('onSuccess called!')).not.toBeVisible() + await expect(page.getByText('onError called!')).toBeVisible() + await expect(page.getByText('onFinish called!')).toBeVisible() + }) + + test('onBefore can block validation', async ({ page }) => { + await page.goto('/form-component/precognition-before-validation') + + await page.fill('input[name="name"]', 'block') + await page.locator('input[name="name"]').blur() + + for (let i = 0; i < 5; i++) { + await expect(page.getByText('Validating...')).not.toBeVisible() + await page.waitForTimeout(50) + } + }) + + test('onException handles non-422 errors during validation', async ({ page }) => { + await page.goto('/form-component/precognition-callbacks') + + await page.fill('#name-input', 'John') + + // Trigger validation that will return 500 error + await page.click('button:has-text("Validate with Exception Handler")') + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + // Exception should be caught and displayed + await expect(page.getByText(/Exception caught:/)).toBeVisible() + }) + + test('sends custom headers with validation requests', async ({ page }) => { + await page.goto('/form-component/precognition-headers') + + // Fill in a valid name to trigger validation + await page.fill('input[name="name"]', 'John Doe') + await page.locator('input[name="name"]').blur() + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + // Should show error confirming custom header was received + await expect(page.getByText('Custom header received: custom-value')).toBeVisible() + }) + + test('automatically cancels previous validation when new validation starts', async ({ page }) => { + await page.goto('/form-component/precognition-cancel') + + requests.listenForFailed(page) + requests.listenForResponses(page) + + await page.fill('#auto-cancel-name-input', 'ab') + await page.locator('#auto-cancel-name-input').blur() + await expect(page.getByText('Validating...')).toBeVisible() + + // Immediately change value and trigger new validation - should cancel the first one + await page.fill('#auto-cancel-name-input', 'xy') + await page.locator('#auto-cancel-name-input').blur() + await expect(page.getByText('Validating...')).not.toBeVisible() + await expect(page.getByText('The name must be at least 3 characters.')).toBeVisible() + + // One cancelled, one 422 response + expect(requests.failed).toHaveLength(1) + expect(requests.responses).toHaveLength(1) + + const cancelledRequestError = await requests.failed[0].failure()?.errorText + expect(cancelledRequestError).toBe('net::ERR_ABORTED') + }) + + test('cancelValidation() cancels in-flight validation and resets validating state', async ({ page }) => { + await page.goto('/form-component/precognition-cancel') + + requests.listenForFailed(page) + + await page.fill('#manual-cancel-name-input', 'ab') + await page.locator('#manual-cancel-name-input').blur() + await expect(page.getByText('Validating...')).toBeVisible() + + await page.getByText('Cancel Validation').click() + await expect(page.getByText('Validating...')).not.toBeVisible() + await page.waitForTimeout(100) + expect(requests.failed).toHaveLength(1) + + const cancelledRequestError = await requests.failed[0].failure()?.errorText + expect(cancelledRequestError).toBe('net::ERR_ABORTED') + }) + + test('defaults() updates validator data as well', async ({ page }) => { + await page.goto('/form-component/precognition-defaults') + + await page.fill('#name-input', 'John') + await page.locator('#name-input').blur() + await page.click('button:has-text("Validate Name")') + + await expect(page.getByText('Validating...')).toBeVisible() + await expect(page.getByText('Validating...')).not.toBeVisible() + + // Click again, should not validate again because data hasn't changed + await expect(page.getByText('Validating...')).not.toBeVisible() + await page.click('button:has-text("Validate Name")') + await expect(page.getByText('Validating...')).not.toBeVisible() + + // Now change default to a different value, should not validate because value matches new default + await page.fill('#name-input', 'Johnny') + await page.click('button:has-text("Set Defaults")') + await page.click('button:has-text("Validate Name")') + await expect(page.getByText('Validating...')).not.toBeVisible() + }) + }) + test.describe('React', () => { test.skip(process.env.PACKAGE !== 'react', 'Skipping React-specific tests') diff --git a/tests/support.ts b/tests/support.ts index bae7d4abb..a3d2e0009 100644 --- a/tests/support.ts +++ b/tests/support.ts @@ -1,4 +1,4 @@ -import { expect, Page, Request } from '@playwright/test' +import { expect, Page, Request, Response } from '@playwright/test' export const clickAndWaitForResponse = async ( page: Page, @@ -43,6 +43,8 @@ export const consoleMessages = { export const requests = { requests: [] as Request[], finished: [] as Request[], + failed: [] as Request[], + responses: [] as Response[], listen(page: Page) { this.requests = [] @@ -53,6 +55,16 @@ export const requests = { this.finished = [] page.on('requestfinished', (request) => this.finished.push(request)) }, + + listenForFailed(page: Page) { + this.failed = [] + page.on('requestfailed', (request) => this.failed.push(request)) + }, + + listenForResponses(page: Page) { + this.responses = [] + page.on('response', (data) => this.responses.push(data)) + }, } export const shouldBeDumpPage = async (page: Page, method: 'get' | 'post' | 'patch' | 'put' | 'delete') => {