Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 74 additions & 9 deletions packages/vue3/src/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ import {
} from '@inertiajs/core'
import { isEqual } from 'lodash-es'
import { computed, defineComponent, DefineComponent, h, onBeforeUnmount, onMounted, PropType, ref } from 'vue'
import type { LiveValidationProps, ValidationEvent } from './types'
import useForm from './useForm'
import useValidate from './useValidate'

type InertiaFormComponentProps = FormComponentProps & LiveValidationProps
type InertiaForm = DefineComponent<InertiaFormComponentProps>

type InertiaForm = DefineComponent<FormComponentProps>
type FormSubmitOptions = Omit<VisitOptions, 'data' | 'onPrefetched' | 'onPrefetching'>

const noop = () => undefined
Expand Down Expand Up @@ -109,6 +113,23 @@ const Form: InertiaForm = defineComponent({
type: [String, Array] as PropType<FormComponentProps['invalidateCacheTags']>,
default: () => [],
},
// Precognition integration (optional, only used when enabled)
precognitive: {
type: [Boolean, Object] as PropType<boolean | Record<string, any>>, // ValidationConfig-like
default: false,
},
validateOn: {
type: [String, Array] as PropType<ValidationEvent | ValidationEvent[]>,
default: 'input',
},
validationTimeout: {
type: Number,
default: undefined,
},
provider: {
type: String as PropType<string>,
default: undefined,
},
},
setup(props, { slots, attrs, expose }) {
const form = useForm<Record<string, any>>({})
Expand All @@ -122,6 +143,13 @@ const Form: InertiaForm = defineComponent({

const defaultData = ref(new FormData())

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

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

const onFormUpdate = (event: Event) => {
// If the form is reset, we set isDirty to false as we already know it's back
// to defaults. Also, the fields are updated after the reset event, so the
Expand All @@ -130,20 +158,48 @@ const Form: InertiaForm = defineComponent({
}

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

// ========= Optional live validation via composable =========
const {
validating,
maybeValidate,
validate: runValidate,
reset: resetValidation,
} = useValidate({
getData: () => getData(),
method: () => method.value,
action: () => (typeof props.action === 'object' ? props.action.url : props.action),
props: {
precognitive: props.precognitive as any,
validateOn: props.validateOn as any,
validationTimeout: props.validationTimeout,
provider: props.provider,
},
setErrors: (simple) => {
form.clearErrors()
form.setError(simple as Record<string, string>)
},
})

onMounted(() => {
defaultData.value = getFormData()
formEvents.forEach((e) => formElement.value.addEventListener(e, onFormUpdate))
// Attach validation listeners (includes blur if configured)
;(validationEvents as Array<keyof HTMLElementEventMap>).forEach((e) =>
formElement.value.addEventListener(e, maybeValidate),
)
})

onBeforeUnmount(() => formEvents.forEach((e) => formElement.value?.removeEventListener(e, onFormUpdate)))

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

// Convert the FormData to an object because we can't compare two FormData
// instances directly (which is needed for isDirty), mergeDataIntoQueryString()
// expects an object, and submitting a FormData instance directly causes problems with nested objects.
const getData = (): Record<string, FormDataConvertible> => formDataToObject(getFormData())
onBeforeUnmount(() => {
formEvents.forEach((e) => formElement.value?.removeEventListener(e, onFormUpdate))
;(validationEvents as Array<keyof HTMLElementEventMap>).forEach((e) =>
formElement.value?.removeEventListener(e, maybeValidate),
)
try {
resetValidation()
} catch {}
})

const submit = () => {
const [action, data] = mergeDataIntoQueryString(
Expand Down Expand Up @@ -198,6 +254,9 @@ const Form: InertiaForm = defineComponent({

const reset = (...fields: string[]) => {
resetFormFields(formElement.value, defaultData.value, fields)
try {
resetValidation(...fields)
} catch {}
}

const resetAndClearErrors = (...fields: string[]) => {
Expand Down Expand Up @@ -236,6 +295,12 @@ const Form: InertiaForm = defineComponent({
get isDirty() {
return isDirty.value
},
get validating() {
return validating.value
},
validate: (name?: string) => {
runValidate(name)
},
reset,
submit,
defaults,
Expand Down
1 change: 1 addition & 0 deletions packages/vue3/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export { InertiaForm, InertiaFormProps, default as useForm } from './useForm'
export { default as usePoll } from './usePoll'
export { default as usePrefetch } from './usePrefetch'
export { default as useRemember } from './useRemember'
export { registerLiveValidationProvider } from './validationProviders'
export { default as WhenVisible } from './whenVisible'
10 changes: 10 additions & 0 deletions packages/vue3/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,13 @@ declare module 'vue' {
}
}
}

// Live validation shared types for the Vue adapter
export type ValidationEvent = 'input' | 'change' | 'blur'

export interface LiveValidationProps {
precognitive?: boolean | Record<string, any>
validateOn?: ValidationEvent | ValidationEvent[]
validationTimeout?: number
provider?: string
}
166 changes: 166 additions & 0 deletions packages/vue3/src/useValidate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { computed, ref } from 'vue'
import type { FormDataConvertible, Method } from '@inertiajs/core'
import type { LiveValidationProps, ValidationEvent } from './types'
import { getLiveValidationProvider, detectInstalledProviders } from './validationProviders'

//

export default function useValidate(options: {
getData: () => Record<string, FormDataConvertible>
method: () => Method
action: () => string
props: LiveValidationProps
setErrors: (errors: Record<string, string>) => void
}) {
const { getData, method, action, props, setErrors } = options

const validating = ref(false)
let validator: any = null
let toSimpleValidationErrors: null | ((errors: any) => Record<string, string>) = null
let resolveName: null | ((event: Event) => string) = null
let initPromise: Promise<void> | null = null
let warnedNoProvider = false
// Provider-only path: no local debounce/abort; rely on provider's timeout API

const isGlobalPrecogEnabled = () => typeof props.precognitive === 'object' || props.precognitive === true

const normalizedValidateOn = computed<Array<ValidationEvent>>(() =>
Array.isArray(props.validateOn)
? (props.validateOn as Array<ValidationEvent>)
: [props.validateOn as ValidationEvent],
)

const shouldValidateField = (target: EventTarget | null) => {
if (!target || !(target as HTMLElement)) return false
const el = target as HTMLElement
// Enabled globally or per-field via attribute
return isGlobalPrecogEnabled() || el.hasAttribute?.('precognitive') || el.getAttribute?.('data-precognitive') === 'true'
}

const ensureInitialized = async () => {
if (initPromise) return initPromise

initPromise = (async () => {
try {
// Determine provider id
let providerId = (props.provider || null) as null | string
if (!providerId) {
// No explicit provider, rely on already-registered providers
const installed = await detectInstalledProviders()
if (installed.length === 1) {
providerId = installed[0]
} else if (installed.length > 1) {
if (!warnedNoProvider) {
warnedNoProvider = true
console.warn(
`[Inertia][Form] multiple live validation providers registered (${installed.join(', ')}). Set the 'provider' prop to choose one.`,
)
}
return
} else {
if (isGlobalPrecogEnabled() && !warnedNoProvider) {
warnedNoProvider = true
console.warn(
"[Inertia][Form] precognitive live validation requested but no provider found. Install a live validation provider (e.g. 'laravel-precognition-vue-inertia') or set the 'provider' prop.",
)
}
return
}
}

const adapter = getLiveValidationProvider(providerId)
if (!adapter) {
if (isGlobalPrecogEnabled() && !warnedNoProvider) {
warnedNoProvider = true
console.warn(
`[Inertia][Form] provider '${providerId}' is not registered. Register it via registerLiveValidationProvider().`,
)
}
return
}

const mod: any = await adapter.import()
const { createValidator } = mod
toSimpleValidationErrors = mod.toSimpleValidationErrors ?? ((e: any) => e)
resolveName = mod.resolveName ?? null

const actionUrl = action()
const baseConfig = (typeof props.precognitive === 'object' ? props.precognitive : {}) as Record<string, any>

validator = createValidator(
(client: any) => client[method()](actionUrl, getData(), { ...baseConfig, precognitive: true }),
getData(),
)
.on('validatingChanged', () => {
validating.value = validator.validating()
})
.on('errorsChanged', () => {
// Sync full error bag from validator via adapter callback
const simple = toSimpleValidationErrors ? toSimpleValidationErrors(validator.errors()) : validator.errors()
try {
setErrors(simple as Record<string, string>)
} catch {}
})

if (typeof props.validationTimeout === 'number') {
validator.setTimeout?.(props.validationTimeout)
}
} catch (e) {
// Provider not installed or failed to load; leave validator as null
validator = null
if (isGlobalPrecogEnabled() && !warnedNoProvider) {
warnedNoProvider = true
// Dev-friendly warning
console.warn(
"[Inertia][Form] precognitive live validation requested but no provider found. Install 'laravel-precognition-vue-inertia' or register a provider.",
)
}
}
})()

return initPromise
}

const maybeValidate = async (event: Event) => {
if (!normalizedValidateOn.value.includes(event.type as ValidationEvent)) return
if (!shouldValidateField(event.target)) return

// Laravel provider path
await ensureInitialized()
if (!validator) return

try {
const name = resolveName ? resolveName(event) : ((event.target as HTMLInputElement)?.name || undefined)
const baseConfig = (typeof props.precognitive === 'object' ? props.precognitive : {}) as Record<string, any>
if (name) {
validator.validate(name, (getData() as any)[name], { ...baseConfig, precognitive: true })
} else {
validator.validate({ ...baseConfig, precognitive: true })
}
} catch {
// no-op
}
}

const validate = (name?: string) => {
if (!name && typeof window === 'undefined') return
// Laravel provider path
ensureInitialized().then(() => {
if (!validator) return
const baseConfig = (typeof props.precognitive === 'object' ? props.precognitive : {}) as Record<string, any>
if (name) {
validator.validate(name, (getData() as any)[name], { ...baseConfig, precognitive: true })
} else {
validator.validate({ ...baseConfig, precognitive: true })
}
})
}

const reset = (...fields: string[]) => {
try {
validator?.reset?.(...fields)
} catch {}
}

return { validating, maybeValidate, validate, reset, ensureInitialized }
}
28 changes: 28 additions & 0 deletions packages/vue3/src/validationProviders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Lightweight registry for live validation providers (Vue adapter)
// Built-in: laravel-precognition

export type ProviderModuleShape = {
// Provider must expose a factory compatible with our usage
createValidator: (requestFactory: (client: any) => Promise<any> | any, initialData: Record<string, any>) => any
toSimpleValidationErrors?: (errors: any) => Record<string, string>
resolveName?: (event: Event) => string | undefined
}

export type LiveValidationProviderAdapter = {
id: string
import: () => Promise<ProviderModuleShape>
}

const registry = new Map<string, LiveValidationProviderAdapter>()

export function registerLiveValidationProvider(adapter: LiveValidationProviderAdapter) {
registry.set(adapter.id, adapter)
}

export function getLiveValidationProvider(id: string): LiveValidationProviderAdapter | null {
return registry.get(id) ?? null
}

export async function detectInstalledProviders(): Promise<string[]> {
return Array.from(registry.keys())
}
Loading
Loading