diff --git a/packages/core/src/resetFormFields.ts b/packages/core/src/resetFormFields.ts index abaecc1a6..f4948506d 100644 --- a/packages/core/src/resetFormFields.ts +++ b/packages/core/src/resetFormFields.ts @@ -161,8 +161,10 @@ export function resetFormFields(formElement: HTMLFormElement, defaults: FormData return } + const resetEntireForm = !fieldNames || fieldNames.length === 0 + // If no specific fields provided, reset the entire form - if (!fieldNames || fieldNames.length === 0) { + if (resetEntireForm) { // Get all field names from both defaults and form elements (including disabled ones) const formData = new FormData(formElement) const formElementNames = Array.from(formElement.elements) @@ -173,7 +175,7 @@ export function resetFormFields(formElement: HTMLFormElement, defaults: FormData let hasChanged = false - fieldNames.forEach((fieldName) => { + fieldNames!.forEach((fieldName) => { const elements = formElement.elements.namedItem(fieldName) if (elements) { @@ -184,7 +186,7 @@ export function resetFormFields(formElement: HTMLFormElement, defaults: FormData }) // Dispatch reset event if any field changed (matching native form.reset() behavior) - if (hasChanged) { + if (hasChanged && resetEntireForm) { formElement.dispatchEvent(new Event('reset', { bubbles: true })) } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 8410727b9..505e7d6bc 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,4 +1,5 @@ import { AxiosProgressEvent, AxiosResponse } from 'axios' +import { NamedInputEvent, ValidationConfig, Validator } from 'laravel-precognition' import { Response } from './response' declare module 'axios' { @@ -635,6 +636,9 @@ export type FormComponentProps = Partial< resetOnSuccess?: boolean | string[] resetOnError?: boolean | string[] setDefaultsOnSuccess?: boolean + validateFiles?: boolean + validationTimeout?: number + withAllErrors?: boolean } export type FormComponentMethods = { @@ -647,6 +651,12 @@ export type FormComponentMethods = { defaults: () => void getData: () => Record getFormData: () => FormData + valid: (field: string) => boolean + invalid: (field: string) => boolean + validate(field?: string | NamedInputEvent | ValidationConfig, config?: ValidationConfig): void + touch: (...fields: string[]) => void + touched(field?: string): boolean + validator: () => Validator } export type FormComponentonSubmitCompleteArguments = Pick @@ -659,6 +669,7 @@ export type FormComponentState = { wasSuccessful: boolean recentlySuccessful: boolean isDirty: boolean + validating: boolean } export type FormComponentSlotProps = FormComponentMethods & FormComponentState diff --git a/packages/core/src/useFormUtils.ts b/packages/core/src/useFormUtils.ts index a78df54ed..5cd821a86 100644 --- a/packages/core/src/useFormUtils.ts +++ b/packages/core/src/useFormUtils.ts @@ -1,3 +1,4 @@ +import { NamedInputEvent, ValidationConfig } from 'laravel-precognition' import { FormDataType, Method, @@ -113,4 +114,34 @@ export class UseFormUtils { // Use Precognition endpoint with optional options... return { ...precognitionEndpoint!(), options: (args[0] as UseFormSubmitOptions) ?? {} } } + + /** + * Merges headers into the Precognition validate() arguments. + */ + public static mergeHeadersForValidation( + field?: string | NamedInputEvent | ValidationConfig, + config?: ValidationConfig, + headers?: Record, + ): [string | NamedInputEvent | ValidationConfig | undefined, ValidationConfig | undefined] { + const merge = (config: ValidationConfig): ValidationConfig => { + config.headers = { + ...(headers ?? {}), + ...(config.headers ?? {}), + } + + return config + } + + if (field && typeof field === 'object' && !('target' in field)) { + field = merge(field) + } else if (config && typeof config === 'object') { + config = merge(config) + } else if (typeof field === 'string') { + config = merge(config ?? {}) + } else { + field = merge(field ?? {}) + } + + return [field, config] + } } diff --git a/packages/react/src/Form.ts b/packages/react/src/Form.ts index 1de194c80..abcadcffa 100644 --- a/packages/react/src/Form.ts +++ b/packages/react/src/Form.ts @@ -8,8 +8,10 @@ import { mergeDataIntoQueryString, Method, resetFormFields, + UseFormUtils, VisitOptions, } from '@inertiajs/core' +import { NamedInputEvent, ValidationConfig } from 'laravel-precognition' import { isEqual } from 'lodash-es' import React, { createElement, @@ -64,12 +66,36 @@ const Form = forwardRef( resetOnSuccess = false, setDefaultsOnSuccess = false, invalidateCacheTags = [], + validateFiles = false, + validationTimeout = 1500, + withAllErrors = false, children, ...props }, ref, ) => { + const getTransformedData = (): Record => { + const [_url, data] = getUrlAndData() + return transform(data) + } + const form = useForm>({}) + .withPrecognition( + () => resolvedMethod, + () => getUrlAndData()[0], + ) + .setValidationTimeout(validationTimeout) + + if (validateFiles) { + form.validateFiles() + } + + if (withAllErrors) { + form.withAllErrors() + } + + form.transform(getTransformedData) + const formElement = useRef(undefined) const resolvedMethod = useMemo(() => { @@ -86,29 +112,62 @@ 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))), ) + const clearErrors = (...names: string[]) => { + form.clearErrors(...names) + + return form + } + useEffect(() => { defaultData.current = getFormData() + form.setDefaults(getData()) + const formEvents: Array = ['input', 'change', 'reset'] formEvents.forEach((e) => formElement.current!.addEventListener(e, updateDirtyState)) - return () => formEvents.forEach((e) => formElement.current?.removeEventListener(e, updateDirtyState)) + return () => { + formEvents.forEach((e) => formElement.current?.removeEventListener(e, updateDirtyState)) + } }, []) + useEffect(() => { + form.setValidationTimeout(validationTimeout) + }, [validationTimeout]) + + useEffect(() => { + if (validateFiles) { + form.validateFiles() + } else { + form.withoutFileValidation() + } + }, [validateFiles]) + const reset = (...fields: string[]) => { if (formElement.current) { resetFormFields(formElement.current, defaultData.current, fields) } + + form.reset(...fields) } const resetAndClearErrors = (...fields: string[]) => { - form.clearErrors(...fields) + clearErrors(...fields) reset(...fields) } @@ -125,12 +184,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, @@ -163,7 +217,6 @@ const Form = forwardRef( ...options, } - form.transform(() => transform(_data)) form.submit(resolvedMethod, url, submitOptions) } @@ -180,7 +233,7 @@ const Form = forwardRef( wasSuccessful: form.wasSuccessful, recentlySuccessful: form.recentlySuccessful, isDirty, - clearErrors: form.clearErrors, + clearErrors, resetAndClearErrors, setError: form.setError, reset, @@ -188,6 +241,16 @@ const Form = forwardRef( defaults, getData, getFormData, + + // Precognition + validator: () => form.validator(), + validating: form.validating, + valid: form.valid, + invalid: form.invalid, + validate: (field?: string | NamedInputEvent | ValidationConfig, config?: ValidationConfig) => + form.validate(...UseFormUtils.mergeHeadersForValidation(field, config, headers)), + touch: form.touch, + touched: form.touched, }) useImperativeHandle(ref, exposed, [form, isDirty, submit]) diff --git a/packages/react/src/useForm.ts b/packages/react/src/useForm.ts index acd0b676d..730c2f4af 100644 --- a/packages/react/src/useForm.ts +++ b/packages/react/src/useForm.ts @@ -296,8 +296,10 @@ export default function useForm>( const setDefaultsFunction = useCallback( (fieldOrFields?: FormDataKeys | Partial, maybeValue?: unknown) => { setDefaultsCalledInOnSuccess.current = true + let newDefaults = {} as TForm if (typeof fieldOrFields === 'undefined') { + newDefaults = { ...dataRef.current } setDefaults(dataRef.current) // If setData was called right before setDefaults, data was not // updated in that render yet, so we set a flag to update @@ -305,11 +307,16 @@ export default function useForm>( setDataAsDefaults(true) } else { setDefaults((defaults) => { - return typeof fieldOrFields === 'string' - ? set(cloneDeep(defaults), fieldOrFields, maybeValue) - : Object.assign(cloneDeep(defaults), fieldOrFields) + newDefaults = + typeof fieldOrFields === 'string' + ? set(cloneDeep(defaults), fieldOrFields, maybeValue) + : Object.assign(cloneDeep(defaults), fieldOrFields) + + return newDefaults as TForm }) } + + validatorRef.current?.defaults(newDefaults) }, [setDefaults], ) @@ -496,7 +503,7 @@ export default function useForm>( const currentData = dataRef.current const transformedData = transform.current(currentData) as Record return client[method](url, transformedData) - }, defaults) + }, cloneDeep(defaults)) validatorRef.current = validator diff --git a/packages/react/test-app/Pages/FormComponent/Precognition/BeforeValidation.tsx b/packages/react/test-app/Pages/FormComponent/Precognition/BeforeValidation.tsx new file mode 100644 index 000000000..e7258c4be --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/Precognition/BeforeValidation.tsx @@ -0,0 +1,55 @@ +import { Form } from '@inertiajs/react' +import { isEqual } from 'lodash-es' + +export default function PrecognitionBefore() { + const handleBeforeValidation = ( + newRequest: { data: Record | null; touched: string[] }, + oldRequest: { data: Record | null; 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', { + onBeforeValidation: handleBeforeValidation, + }) + } + /> + {invalid('name') &&

{errors.name}

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

{errors.email}

} +
+ + {validating &&

Validating...

} + + + + )} +
+
+ ) +} diff --git a/packages/react/test-app/Pages/FormComponent/Precognition/Callbacks.tsx b/packages/react/test-app/Pages/FormComponent/Precognition/Callbacks.tsx new file mode 100644 index 000000000..c9e32d56a --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/Precognition/Callbacks.tsx @@ -0,0 +1,52 @@ +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) + + return ( +
+

Form Precognition Callbacks

+ +

Callbacks Test

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

Validating...

} + {successCalled &&

onPrecognitionSuccess called!

} + {errorCalled &&

onValidationError called!

} + {finishCalled &&

onFinish called!

} + + + + )} +
+
+ ) +} diff --git a/packages/react/test-app/Pages/FormComponent/Precognition/Cancel.tsx b/packages/react/test-app/Pages/FormComponent/Precognition/Cancel.tsx new file mode 100644 index 000000000..40bd45516 --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/Precognition/Cancel.tsx @@ -0,0 +1,25 @@ +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...

} + + + + )} +
+
+ ) +} diff --git a/packages/react/test-app/Pages/FormComponent/Precognition/Default.tsx b/packages/react/test-app/Pages/FormComponent/Precognition/Default.tsx new file mode 100644 index 000000000..705719db5 --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/Precognition/Default.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/Precognition/Files.tsx b/packages/react/test-app/Pages/FormComponent/Precognition/Files.tsx new file mode 100644 index 000000000..895f398ef --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/Precognition/Files.tsx @@ -0,0 +1,40 @@ +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 }) => ( + <> +
+ 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/Precognition/Headers.tsx b/packages/react/test-app/Pages/FormComponent/Precognition/Headers.tsx new file mode 100644 index 000000000..9a698f2f5 --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/Precognition/Headers.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/Precognition/Methods.tsx b/packages/react/test-app/Pages/FormComponent/Precognition/Methods.tsx new file mode 100644 index 000000000..d354fece1 --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/Precognition/Methods.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/Precognition/Transform.tsx b/packages/react/test-app/Pages/FormComponent/Precognition/Transform.tsx new file mode 100644 index 000000000..14df3b05f --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/Precognition/Transform.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/react/test-app/Pages/FormComponent/Precognition/WithAllErrors.tsx b/packages/react/test-app/Pages/FormComponent/Precognition/WithAllErrors.tsx new file mode 100644 index 000000000..718a77e8f --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/Precognition/WithAllErrors.tsx @@ -0,0 +1,53 @@ +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/Precognition/WithoutAllErrors.tsx b/packages/react/test-app/Pages/FormComponent/Precognition/WithoutAllErrors.tsx new file mode 100644 index 000000000..1553499bf --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/Precognition/WithoutAllErrors.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/Ref.tsx b/packages/react/test-app/Pages/FormComponent/Ref.tsx index 4723b4f95..3cd5f73b8 100644 --- a/packages/react/test-app/Pages/FormComponent/Ref.tsx +++ b/packages/react/test-app/Pages/FormComponent/Ref.tsx @@ -29,6 +29,14 @@ export default function Ref() { formRef.current?.defaults() } + const callPrecognitionMethods = () => { + const validator = formRef.current?.validator() + + if (validator && !formRef.current?.touched('company') && !formRef.current?.valid('company')) { + formRef.current?.validate({ only: ['company'] }) + } + } + return (

Form Ref Test

@@ -63,6 +71,7 @@ export default function Ref() { +
) diff --git a/packages/svelte/src/components/Form.svelte b/packages/svelte/src/components/Form.svelte index 0206a8630..ae82bacc4 100644 --- a/packages/svelte/src/components/Form.svelte +++ b/packages/svelte/src/components/Form.svelte @@ -9,7 +9,9 @@ type FormDataConvertible, type VisitOptions, isUrlMethodPair, + UseFormUtils, } from '@inertiajs/core' + import { type NamedInputEvent, type ValidationConfig, type Validator } from 'laravel-precognition' import { isEqual } from 'lodash-es' import { onMount } from 'svelte' import useForm from '../useForm' @@ -38,10 +40,34 @@ 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 validationTimeout: FormComponentProps['validationTimeout'] = 1500 + export let withAllErrors: FormComponentProps['withAllErrors'] = false type FormSubmitOptions = Omit - const form = useForm({}) + function getTransformedData(): Record { + const [_url, data] = getUrlAndData() + return transform!(data) + } + + const form = useForm>({}) + .withPrecognition( + () => _method, + () => getUrlAndData()[0], + ) + .setValidationTimeout(validationTimeout!) + + if (validateFiles) { + form.validateFiles() + } + + if (withAllErrors) { + form.withAllErrors() + } + + form.transform(getTransformedData) + let formElement: HTMLFormElement let isDirty = false let defaultData: FormData = new FormData() @@ -60,12 +86,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) { @@ -137,26 +167,21 @@ export function reset(...fields: string[]) { resetFormFields(formElement, defaultData, fields) + + form.reset(...fields) } export function clearErrors(...fields: string[]) { - // @ts-expect-error $form.clearErrors(...fields) } export function resetAndClearErrors(...fields: string[]) { - // @ts-expect-error - $form.clearErrors(...fields) + clearErrors(...fields) reset(...fields) } - export function setError(field: string | object, value?: string) { - if (typeof field === 'string') { - // @ts-expect-error - $form.setError(field, value) - } else { - $form.setError(field) - } + export function setError(fieldOrFields: string | Record, maybeValue?: string) { + $form.setError((typeof fieldOrFields === 'string' ? { [fieldOrFields]: maybeValue } : fieldOrFields) as Errors) } export function defaults() { @@ -164,16 +189,54 @@ isDirty = false } + export function validate(field?: string | NamedInputEvent | ValidationConfig, config?: ValidationConfig) { + return form.validate(...UseFormUtils.mergeHeadersForValidation(field, config, headers!)) + } + + export function valid(field: string) { + return form.valid(field) + } + + export function invalid(field: string) { + return form.invalid(field) + } + + export function touch(field: string | NamedInputEvent | string[], ...fields: string[]) { + return form.touch(field, ...fields) + } + + export function touched(field?: string) { + return form.touched(field) + } + + export function validator(): Validator { + return form.validator() + } + onMount(() => { defaultData = getFormData() + form.defaults(getData()) + const formEvents = ['input', 'change', 'reset'] + formEvents.forEach((e) => formElement.addEventListener(e, updateDirtyState)) return () => { formEvents.forEach((e) => formElement?.removeEventListener(e, updateDirtyState)) } }) + + $: { + form.setValidationTimeout(validationTimeout!) + + if (validateFiles) { + form.validateFiles() + } else { + form.withoutFileValidation() + } + } + $: slotErrors = $form.errors as Errors @@ -199,7 +262,15 @@ {isDirty} {submit} {defaults} + {reset} {getData} {getFormData} + {validator} + {validate} + {touch} + validating={$form.validating} + valid={$form.valid} + invalid={$form.invalid} + touched={$form.touched} /> diff --git a/packages/svelte/src/useForm.ts b/packages/svelte/src/useForm.ts index b3eaf3d0c..c65656b25 100644 --- a/packages/svelte/src/useForm.ts +++ b/packages/svelte/src/useForm.ts @@ -152,7 +152,7 @@ export default function useForm>( const form = formWithPrecognition() const transformedData = transform(form.data()) as Record return client[method](url, transformedData) - }, defaults) + }, cloneDeep(defaults)) validatorRef = validator @@ -292,6 +292,8 @@ export default function useForm>( : Object.assign(cloneDeep(defaults), fieldOrFields) } + validatorRef?.defaults(defaults) + return this }, reset(...fields: Array>) { diff --git a/packages/svelte/test-app/Pages/FormComponent/Precognition/BeforeValidation.svelte b/packages/svelte/test-app/Pages/FormComponent/Precognition/BeforeValidation.svelte new file mode 100644 index 000000000..b311c6aaa --- /dev/null +++ b/packages/svelte/test-app/Pages/FormComponent/Precognition/BeforeValidation.svelte @@ -0,0 +1,63 @@ + + +
+

Precognition - onBefore

+ +
+
+ + + validate('name', { + onBeforeValidation: 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/Precognition/Callbacks.svelte b/packages/svelte/test-app/Pages/FormComponent/Precognition/Callbacks.svelte new file mode 100644 index 000000000..be399d623 --- /dev/null +++ b/packages/svelte/test-app/Pages/FormComponent/Precognition/Callbacks.svelte @@ -0,0 +1,53 @@ + + +
+

Form Precognition Callbacks

+ +

Callbacks Test

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

Validating...

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

onPrecognitionSuccess called!

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

onValidationError called!

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

onFinish called!

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

Precognition - Cancel Tests

+ +

Auto Cancel Test

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

+ {errors.name} +

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

Validating...

+ {/if} + + +
+
diff --git a/packages/svelte/test-app/Pages/FormComponent/Precognition/Default.svelte b/packages/svelte/test-app/Pages/FormComponent/Precognition/Default.svelte new file mode 100644 index 000000000..0835f2382 --- /dev/null +++ b/packages/svelte/test-app/Pages/FormComponent/Precognition/Default.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/Precognition/Files.svelte b/packages/svelte/test-app/Pages/FormComponent/Precognition/Files.svelte new file mode 100644 index 000000000..6b7116f52 --- /dev/null +++ b/packages/svelte/test-app/Pages/FormComponent/Precognition/Files.svelte @@ -0,0 +1,51 @@ + + +
+

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/Precognition/Headers.svelte b/packages/svelte/test-app/Pages/FormComponent/Precognition/Headers.svelte new file mode 100644 index 000000000..6b3a00240 --- /dev/null +++ b/packages/svelte/test-app/Pages/FormComponent/Precognition/Headers.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/Precognition/Methods.svelte b/packages/svelte/test-app/Pages/FormComponent/Precognition/Methods.svelte new file mode 100644 index 000000000..b99084087 --- /dev/null +++ b/packages/svelte/test-app/Pages/FormComponent/Precognition/Methods.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/Precognition/Transform.svelte b/packages/svelte/test-app/Pages/FormComponent/Precognition/Transform.svelte new file mode 100644 index 000000000..027305c95 --- /dev/null +++ b/packages/svelte/test-app/Pages/FormComponent/Precognition/Transform.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/svelte/test-app/Pages/FormComponent/Precognition/WithAllErrors.svelte b/packages/svelte/test-app/Pages/FormComponent/Precognition/WithAllErrors.svelte new file mode 100644 index 000000000..481aed263 --- /dev/null +++ b/packages/svelte/test-app/Pages/FormComponent/Precognition/WithAllErrors.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 (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 (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/Precognition/WithoutAllErrors.svelte b/packages/svelte/test-app/Pages/FormComponent/Precognition/WithoutAllErrors.svelte new file mode 100644 index 000000000..036f87996 --- /dev/null +++ b/packages/svelte/test-app/Pages/FormComponent/Precognition/WithoutAllErrors.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/Ref.svelte b/packages/svelte/test-app/Pages/FormComponent/Ref.svelte index 301946794..4669b5649 100644 --- a/packages/svelte/test-app/Pages/FormComponent/Ref.svelte +++ b/packages/svelte/test-app/Pages/FormComponent/Ref.svelte @@ -28,6 +28,14 @@ function setCurrentAsDefaults() { formRef?.defaults() } + + function callPrecognitionMethods() { + const validator = formRef?.validator() + + if (validator && !formRef?.touched('company') && !formRef?.valid('company')) { + formRef?.validate({ only: ['company'] }) + } + }
@@ -63,5 +71,6 @@ +
diff --git a/packages/vue3/src/form.ts b/packages/vue3/src/form.ts index e5dafb6f6..72a309892 100644 --- a/packages/vue3/src/form.ts +++ b/packages/vue3/src/form.ts @@ -9,10 +9,12 @@ import { mergeDataIntoQueryString, Method, resetFormFields, + UseFormUtils, VisitOptions, } from '@inertiajs/core' +import { NamedInputEvent, ValidationConfig } from 'laravel-precognition' 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 @@ -113,9 +115,42 @@ const Form = defineComponent({ type: [String, Array] as PropType, default: () => [], }, + validateFiles: { + type: Boolean as PropType, + default: false, + }, + validationTimeout: { + type: Number as PropType, + default: 1500, + }, + withAllErrors: { + type: Boolean as PropType, + default: false, + }, }, setup(props, { slots, attrs, expose }) { + const getTransformedData = (): Record => { + const [_url, data] = getUrlAndData() + + return props.transform(data) + } + const form = useForm>({}) + .withPrecognition( + () => method.value, + () => getUrlAndData()[0], + ) + .transform(getTransformedData) + .setValidationTimeout(props.validationTimeout) + + if (props.validateFiles) { + form.validateFiles() + } + + if (props.withAllErrors) { + form.withAllErrors() + } + const formElement = ref() const method = computed(() => isUrlMethodPair(props.action) ? props.action.method : (props.method.toLowerCase() as Method), @@ -137,9 +172,22 @@ const Form = defineComponent({ onMounted(() => { defaultData.value = getFormData() + + form.defaults(getData()) + formEvents.forEach((e) => formElement.value.addEventListener(e, onFormUpdate)) }) + watch( + () => props.validateFiles, + (value) => (value ? form.validateFiles() : form.withoutFileValidation()), + ) + + watch( + () => props.validationTimeout, + (value) => form.setValidationTimeout(value), + ) + onBeforeUnmount(() => formEvents.forEach((e) => formElement.value?.removeEventListener(e, onFormUpdate))) const getFormData = (): FormData => new FormData(formElement.value) @@ -149,14 +197,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 @@ -197,16 +247,24 @@ 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 reset = (...fields: string[]) => { resetFormFields(formElement.value, defaultData.value, fields) + + form.reset(...fields) } - const resetAndClearErrors = (...fields: string[]) => { + const clearErrors = (...fields: string[]) => { form.clearErrors(...fields) + } + + const resetAndClearErrors = (...fields: string[]) => { + clearErrors(...fields) reset(...fields) } @@ -234,7 +292,10 @@ const Form = defineComponent({ get recentlySuccessful() { return form.recentlySuccessful }, - clearErrors: (...fields: string[]) => form.clearErrors(...fields), + get validating() { + return form.validating + }, + clearErrors, resetAndClearErrors, setError: (fieldOrFields: string | Record, maybeValue?: string) => form.setError((typeof fieldOrFields === 'string' ? { [fieldOrFields]: maybeValue } : fieldOrFields) as Errors), @@ -246,6 +307,15 @@ const Form = defineComponent({ defaults, getData, getFormData, + + // Precognition + touch: form.touch, + valid: form.valid, + invalid: form.invalid, + touched: form.touched, + validate: (field?: string | NamedInputEvent | ValidationConfig, config?: ValidationConfig) => + form.validate(...UseFormUtils.mergeHeadersForValidation(field, config, props.headers)), + validator: () => form.validator(), } expose(exposed) diff --git a/packages/vue3/src/useForm.ts b/packages/vue3/src/useForm.ts index 2001a0ec2..f89a4c6c5 100644 --- a/packages/vue3/src/useForm.ts +++ b/packages/vue3/src/useForm.ts @@ -154,7 +154,7 @@ export default function useForm>( const transformedData = transform(this.data()) as Record return client[method](url, transformedData) - }, defaults) + }, cloneDeep(defaults)) validatorRef = validator @@ -265,6 +265,8 @@ export default function useForm>( : Object.assign({}, cloneDeep(defaults), fieldOrFields) } + validatorRef?.defaults(defaults) + return this }, reset(...fields: FormDataKeys[]) { diff --git a/packages/vue3/test-app/Pages/FormComponent/Precognition/BeforeValidation.vue b/packages/vue3/test-app/Pages/FormComponent/Precognition/BeforeValidation.vue new file mode 100644 index 000000000..9ffcb6a81 --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/Precognition/BeforeValidation.vue @@ -0,0 +1,56 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/Precognition/Callbacks.vue b/packages/vue3/test-app/Pages/FormComponent/Precognition/Callbacks.vue new file mode 100644 index 000000000..43870df42 --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/Precognition/Callbacks.vue @@ -0,0 +1,52 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/Precognition/Cancel.vue b/packages/vue3/test-app/Pages/FormComponent/Precognition/Cancel.vue new file mode 100644 index 000000000..966625cb4 --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/Precognition/Cancel.vue @@ -0,0 +1,28 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/Precognition/Default.vue b/packages/vue3/test-app/Pages/FormComponent/Precognition/Default.vue new file mode 100644 index 000000000..16cc4fe3d --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/Precognition/Default.vue @@ -0,0 +1,34 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/Precognition/Files.vue b/packages/vue3/test-app/Pages/FormComponent/Precognition/Files.vue new file mode 100644 index 000000000..901e127cf --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/Precognition/Files.vue @@ -0,0 +1,44 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/Precognition/Headers.vue b/packages/vue3/test-app/Pages/FormComponent/Precognition/Headers.vue new file mode 100644 index 000000000..40a6ddf91 --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/Precognition/Headers.vue @@ -0,0 +1,28 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/Precognition/Methods.vue b/packages/vue3/test-app/Pages/FormComponent/Precognition/Methods.vue new file mode 100644 index 000000000..60656c5a5 --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/Precognition/Methods.vue @@ -0,0 +1,55 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/Precognition/Transform.vue b/packages/vue3/test-app/Pages/FormComponent/Precognition/Transform.vue new file mode 100644 index 000000000..654101b74 --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/Precognition/Transform.vue @@ -0,0 +1,26 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/Precognition/WithAllErrors.vue b/packages/vue3/test-app/Pages/FormComponent/Precognition/WithAllErrors.vue new file mode 100644 index 000000000..6933888bf --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/Precognition/WithAllErrors.vue @@ -0,0 +1,45 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/Precognition/WithoutAllErrors.vue b/packages/vue3/test-app/Pages/FormComponent/Precognition/WithoutAllErrors.vue new file mode 100644 index 000000000..4de969e49 --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/Precognition/WithoutAllErrors.vue @@ -0,0 +1,34 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/Ref.vue b/packages/vue3/test-app/Pages/FormComponent/Ref.vue index 8fbdbb469..0e8848d6e 100644 --- a/packages/vue3/test-app/Pages/FormComponent/Ref.vue +++ b/packages/vue3/test-app/Pages/FormComponent/Ref.vue @@ -28,6 +28,14 @@ const setTestError = () => { const setCurrentAsDefaults = () => { formRef.value?.defaults() } + +const callPrecognitionMethods = () => { + const validator = formRef.value?.validator() + + if (validator && !formRef.value?.touched('company') && !formRef.value?.valid('company')) { + formRef.value?.validate({ only: ['company'] }) + } +} 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 cfc75a5cc..d14c87599 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 Comp + {'
'} + + + 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..5863ceb9b --- /dev/null +++ b/playgrounds/react/resources/js/Pages/FormComponentPrecognition.tsx @@ -0,0 +1,271 @@ +import { FormComponentMethods } from '@inertiajs/core' +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, + }) + + const validateWithCallbacks = (validate: FormComponentMethods['validate']) => { + setCallbacks({ + success: false, + error: false, + finish: false, + }) + + validate({ + onPrecognitionSuccess: () => setCallbacks((prev) => ({ ...prev, success: true })), + onValidationError: () => setCallbacks((prev) => ({ ...prev, error: true })), + onFinish: () => setCallbacks((prev) => ({ ...prev, finish: true })), + onBeforeValidation: (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 [validationTimeout, 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 &&

onPrecognitionSuccess called!

} + {callbacks.error &&

onValidationError called!

} + {callbacks.finish &&

onFinish called!

} +
+ )} + +
+ +
+ + )} +
+
+
+ + ) +} + +FormComponentPrecognition.layout = (page) => + +export default FormComponentPrecognition diff --git a/playgrounds/react/routes/web.php b/playgrounds/react/routes/web.php index f35381cf0..49c191ad4 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 7bf6fec58..a930284aa 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 Comp + 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..66bc13795 --- /dev/null +++ b/playgrounds/svelte4/resources/js/Pages/FormComponentPrecognition.svelte @@ -0,0 +1,279 @@ + + + + + + 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} +

onPrecognitionSuccess called!

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

onValidationError called!

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

onFinish called!

+ {/if} +
+ {/if} + +
+ +
+
+
+
diff --git a/playgrounds/svelte4/routes/web.php b/playgrounds/svelte4/routes/web.php index 83db38bdf..2310dcfb6 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 66f136f3d..3b919a0ee 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 Comp + useForm + {'
'} + Precognition Photo Grid Photo Row 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..295172320 --- /dev/null +++ b/playgrounds/svelte5/resources/js/Pages/FormComponentPrecognition.svelte @@ -0,0 +1,258 @@ + + + + + + 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} +

onPrecognitionSuccess called!

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

onValidationError called!

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

onFinish called!

+ {/if} +
+ {/if} + +
+ +
+ {/snippet} +
+
+
diff --git a/playgrounds/svelte5/routes/web.php b/playgrounds/svelte5/routes/web.php index 0c19c9068..b5c2c88ec 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 a9bd98049..8c08c066e 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 Comp + 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..4866f221b --- /dev/null +++ b/playgrounds/vue3/resources/js/Pages/FormComponentPrecognition.vue @@ -0,0 +1,227 @@ + + + + + diff --git a/playgrounds/vue3/routes/web.php b/playgrounds/vue3/routes/web.php index 5c750bf54..a9e60ee90 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/form-component.spec.ts b/tests/form-component.spec.ts index 9da05eae9..5933873d3 100644 --- a/tests/form-component.spec.ts +++ b/tests/form-component.spec.ts @@ -954,6 +954,22 @@ test.describe('Form Component', () => { expect(await page.inputValue('input[name="name"]')).toBe('New Name') expect(await page.inputValue('input[name="email"]')).toBe('new@example.com') }) + + test('the precognition methods are available via ref', async ({ page }) => { + await page.goto('/form-component/ref') + requests.listen(page) + + await page.click('button:has-text("Call Precognition Methods")') + + await page.waitForTimeout(500) // Wait for request to be made + + await expect(requests.requests).toHaveLength(1) + + const request = requests.requests[0] + + expect(request.method()).toBe('POST') + expect(request.headers()['precognition']).toBe('true') + }) }) test.describe('Uppercase Methods', () => { diff --git a/tests/precognition.spec.ts b/tests/precognition.spec.ts index 618721115..90ccce900 100644 --- a/tests/precognition.spec.ts +++ b/tests/precognition.spec.ts @@ -1,7 +1,7 @@ import test, { expect } from '@playwright/test' import { requests, shouldBeDumpPage } from './support' -const integrations = ['form-helper'] +const integrations = ['form-component', 'form-helper'] integrations.forEach((integration) => { test.describe('Precognition', () => { @@ -170,7 +170,7 @@ integrations.forEach((integration) => { await expect(page.getByText('The name contains invalid characters.')).not.toBeVisible() }) - test(prefix + 'shows all errors when simpleValidationErrors is false', async ({ page }) => { + test(prefix + 'shows all errors using array errors', async ({ page }) => { await page.goto('/' + integration + '/precognition/with-all-errors') await page.fill('input[name="name"]', 'ab') diff --git a/tests/support.ts b/tests/support.ts index baba83c2b..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,