diff --git a/packages/vue-inertia/src/form.ts b/packages/vue-inertia/src/form.ts new file mode 100644 index 0000000..1925a22 --- /dev/null +++ b/packages/vue-inertia/src/form.ts @@ -0,0 +1,104 @@ +import { defineComponent, h, PropType, ref } from 'vue' +import { Form as InertiaForm } from '@inertiajs/vue3' +import type { FormComponentProps, FormComponentSlotProps, FormDataConvertible } from '@inertiajs/core' +import { formDataToObject, Method } from '@inertiajs/core' +import type { ValidationConfig } from 'laravel-precognition' +import { createValidator, toSimpleValidationErrors } from 'laravel-precognition' + +// Thin wrapper over Inertia's
that wires Precognition live validation +export const Form = defineComponent({ + name: 'Form', + props: { + // Pass-through Inertia Form props (loosely typed to avoid duplication) + action: { type: [String, Object] as PropType, default: '' }, + method: { type: String as PropType, default: 'get' }, + + // Live validation extras + precognitive: { type: [Boolean, Object] as PropType, default: true }, + validateOn: { type: [String, Array] as PropType<'input' | 'change' | 'blur' | Array<'input' | 'change' | 'blur'>>, default: 'change' }, + validationTimeout: { type: Number, default: undefined }, + }, + setup(props, { slots, attrs }) { + const formRef = ref(null) + let validator: ReturnType | null = null + + const getAction = () => (typeof props.action === 'object' ? props.action.url : (props.action as string)) + const getMethod = () => ((typeof props.action === 'object' ? props.action.method : props.method).toLowerCase() as Method) + + const ensureValidator = (formEl: HTMLFormElement) => { + if (validator) return validator + const initial = formDataToObject(new FormData(formEl)) as Record + validator = createValidator((client) => { + const current = formDataToObject(new FormData(formEl)) as Record + return client[getMethod()](getAction(), current, { precognitive: true }) + }, initial) + .on('errorsChanged', () => { + const simple = toSimpleValidationErrors(validator!.errors()) as Record + try { + formRef.value?.clearErrors() + formRef.value?.setError(simple) + } catch {} + }) + if (typeof props.validationTimeout === 'number') { + validator.setTimeout(props.validationTimeout) + } + return validator + } + + const shouldValidateField = (target: EventTarget | null) => { + const el = target as HTMLElement | null + if (!el) return false + return ( + props.precognitive === true || + typeof props.precognitive === 'object' || + el.hasAttribute?.('precognitive') || + el.hasAttribute?.('data-precognitive') || + el.getAttribute?.('data-precognitive') === 'true' + ) + } + + const onMaybeValidate = (e: Event) => { + const evType = e.type as 'input' | 'change' | 'blur' + const types = Array.isArray(props.validateOn) ? props.validateOn : [props.validateOn] + if (!types.includes(evType)) return + + const formEl = e.currentTarget as HTMLFormElement | null + if (!formEl || !shouldValidateField(e.target)) return + + const v = ensureValidator(formEl) + const baseConfig = (typeof props.precognitive === 'object' ? props.precognitive : {}) as ValidationConfig + + const target = e.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | null + const name = target ? target.name : undefined + + try { + if (name) { + const data = formDataToObject(new FormData(formEl)) as Record + v.validate(name, data[name], { ...baseConfig, precognitive: true }) + } else { + v.validate({ ...baseConfig, precognitive: true }) + } + } catch {} + } + + return () => + h( + InertiaForm as any, + { + ...attrs, + ref: formRef, + action: getAction(), + method: getMethod(), + onInput: onMaybeValidate, + onChange: onMaybeValidate, + onBlur: onMaybeValidate, + }, + { + default: slots.default + ? (slotProps: FormComponentSlotProps) => + slots.default?.({ ...slotProps, validating: validator?.validating() ?? false }) + : undefined, + }, + ) + }, +}) diff --git a/packages/vue-inertia/src/index.ts b/packages/vue-inertia/src/index.ts index 300dbd8..61b6935 100644 --- a/packages/vue-inertia/src/index.ts +++ b/packages/vue-inertia/src/index.ts @@ -1,13 +1,15 @@ import { NamedInputEvent, RequestMethod, SimpleValidationErrors, toSimpleValidationErrors, ValidationConfig, ValidationErrors, resolveUrl, resolveMethod } from 'laravel-precognition' import { useForm as usePrecognitiveForm, client } from 'laravel-precognition-vue' import { useForm as useInertiaForm } from '@inertiajs/vue3' -import { VisitOptions } from '@inertiajs/core' +import type { VisitOptions, FormDataKeys, FormDataType, FormDataErrors, ErrorValue } from '@inertiajs/core' import { watchEffect } from 'vue' -import { Form, FormDataConvertible } from './types' +import type { Form, FormDataConvertible } from './types' -export { client, Form } +export { client } +export type { Form } +export { Form as PrecognitionForm } from "./form"; -export const useForm = >(method: RequestMethod | (() => RequestMethod), url: string | (() => string), inputs: Data | (() => Data), config: ValidationConfig = {}): Form => { +export const useForm = >(method: RequestMethod | (() => RequestMethod), url: string | (() => string), inputs: Data | (() => Data), config: ValidationConfig = {}): Form => { /** * The Inertia form. */ @@ -66,7 +68,7 @@ export const useForm = >(method const form: Form = Object.assign(inertiaForm, { validating: precognitiveForm.validating, touched: precognitiveForm.touched, - touch(name: Array | string | NamedInputEvent) { + touch(name: Array> | FormDataKeys | NamedInputEvent) { precognitiveForm.touch(name) return form @@ -81,34 +83,63 @@ export const useForm = >(method return form }, - clearErrors(...names: string[]) { + clearErrors>(...names: K[]) { inertiaClearErrors(...names) if (names.length === 0) { precognitiveForm.setErrors({}) } else { - names.forEach(precognitiveForm.forgetError) + names.forEach((n) => + precognitiveForm.forgetError( + String(n) as unknown as keyof Data, + ), + ) } return form }, - reset(...names: string[]) { + reset>(...names: K[]) { inertiaReset(...names) - precognitiveForm.reset(...names) + if (names.length === 0) { + precognitiveForm.reset(); + } else { + const str = names.map((n) => String(n)); + precognitiveForm.reset( + ...(str as unknown as Array), + ); + } + + return form; }, setErrors(errors: SimpleValidationErrors | ValidationErrors) { - // @ts-expect-error - precognitiveForm.setErrors(errors) + const anyErr = errors as unknown as Record + const firstVal = anyErr ? Object.values(anyErr)[0] : undefined + const simple: SimpleValidationErrors = + typeof firstVal === "string" + ? (errors as SimpleValidationErrors) + : toSimpleValidationErrors(errors as ValidationErrors) + + precognitiveForm.setErrors( + simple as unknown as Partial< + Record + >, + ) return form }, - forgetError(name: string | NamedInputEvent) { - precognitiveForm.forgetError(name) + forgetError(name: FormDataKeys | NamedInputEvent) { + if (typeof name === "object" && "target" in name) { + precognitiveForm.forgetError(name) + } else { + precognitiveForm.forgetError( + String(name) as unknown as keyof Data, + ) + } return form }, - setError(key: (keyof Data) | Record, value?: string) { + setError(key: FormDataKeys | FormDataErrors, value?: ErrorValue) { let errors: SimpleValidationErrors if (typeof key !== 'object') { @@ -135,7 +166,7 @@ export const useForm = >(method return form }, - validate(name?: string | NamedInputEvent | ValidationConfig, config?: ValidationConfig) { + validate(name?: FormDataKeys | NamedInputEvent | ValidationConfig, config?: ValidationConfig) { precognitiveForm.setData(transformer(inertiaForm.data())) if (typeof name === 'object' && !('target' in name)) { @@ -150,8 +181,13 @@ export const useForm = >(method if (typeof name === 'undefined') { precognitiveForm.validate(config) - } else { + } else if (typeof name === "object" && "target" in name) { precognitiveForm.validate(name, config) + } else { + precognitiveForm.validate( + String(name) as unknown as keyof Data, + config, + ) } return form diff --git a/packages/vue-inertia/src/types.ts b/packages/vue-inertia/src/types.ts index 04e8b46..2c9ba1a 100644 --- a/packages/vue-inertia/src/types.ts +++ b/packages/vue-inertia/src/types.ts @@ -1,21 +1,22 @@ import { NamedInputEvent, RequestMethod, SimpleValidationErrors, ValidationConfig, ValidationErrors } from 'laravel-precognition' import { Form as PrecognitiveForm } from 'laravel-precognition-vue/dist/types' import { InertiaForm } from '@inertiajs/vue3' -import { VisitOptions } from '@inertiajs/core' +import { VisitOptions, FormDataErrors, FormDataKeys } from '@inertiajs/core' -type RedefinedProperties = 'setErrors' | 'touch' | 'forgetError' | 'setValidationTimeout' | 'submit' | 'reset' | 'validateFiles' | 'setData' | 'validate' +type RedefinedProperties = 'setErrors' | 'touch' | 'forgetError' | 'setValidationTimeout' | 'submit' | 'reset' | 'validateFiles' | 'setData' | 'validate' | 'errors' export type Form> = Omit, RedefinedProperties> & InertiaForm & { + errors: FormDataErrors, setErrors(errors: SimpleValidationErrors | ValidationErrors): Data & Form, - touch(name: Array | string | NamedInputEvent): Data & Form, - forgetError(string: keyof Data | NamedInputEvent): Data & Form, + touch(name: Array> | FormDataKeys | NamedInputEvent): Data & Form, + forgetError(string: FormDataKeys | NamedInputEvent): Data & Form, setValidationTimeout(duration: number): Data & Form, submit(config?: Partial): void, submit(method: RequestMethod, url: string, options?: Partial): void, - reset(...keys: (keyof Partial)[]): Data & Form, + reset(...keys: FormDataKeys[]): Data & Form, validateFiles(): Data & Form, setData(data: Record): Data & Form, - validate(name?: (keyof Data | NamedInputEvent) | ValidationConfig, config?: ValidationConfig): Data & Form, + validate(name?: (FormDataKeys | NamedInputEvent) | ValidationConfig, config?: ValidationConfig): Data & Form, } // This type has been duplicated from @inertiajs/core to