diff --git a/packages/vue3/src/form.ts b/packages/vue3/src/form.ts index 88c72d9eb..8b2dbabfa 100644 --- a/packages/vue3/src/form.ts +++ b/packages/vue3/src/form.ts @@ -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 -type InertiaForm = DefineComponent type FormSubmitOptions = Omit const noop = () => undefined @@ -109,6 +113,23 @@ const Form: InertiaForm = defineComponent({ type: [String, Array] as PropType, default: () => [], }, + // Precognition integration (optional, only used when enabled) + precognitive: { + type: [Boolean, Object] as PropType>, // ValidationConfig-like + default: false, + }, + validateOn: { + type: [String, Array] as PropType, + default: 'input', + }, + validationTimeout: { + type: Number, + default: undefined, + }, + provider: { + type: String as PropType, + default: undefined, + }, }, setup(props, { slots, attrs, expose }) { const form = useForm>({}) @@ -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 => 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 @@ -130,20 +158,48 @@ const Form: InertiaForm = defineComponent({ } const formEvents: Array = ['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) + }, + }) onMounted(() => { defaultData.value = getFormData() formEvents.forEach((e) => formElement.value.addEventListener(e, onFormUpdate)) + // Attach validation listeners (includes blur if configured) + ;(validationEvents as Array).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 => formDataToObject(getFormData()) + onBeforeUnmount(() => { + formEvents.forEach((e) => formElement.value?.removeEventListener(e, onFormUpdate)) + ;(validationEvents as Array).forEach((e) => + formElement.value?.removeEventListener(e, maybeValidate), + ) + try { + resetValidation() + } catch {} + }) const submit = () => { const [action, data] = mergeDataIntoQueryString( @@ -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[]) => { @@ -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, diff --git a/packages/vue3/src/index.ts b/packages/vue3/src/index.ts index 5466f04ca..0ae9d68af 100755 --- a/packages/vue3/src/index.ts +++ b/packages/vue3/src/index.ts @@ -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' diff --git a/packages/vue3/src/types.ts b/packages/vue3/src/types.ts index 6c9237411..41d3520f2 100644 --- a/packages/vue3/src/types.ts +++ b/packages/vue3/src/types.ts @@ -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 + validateOn?: ValidationEvent | ValidationEvent[] + validationTimeout?: number + provider?: string +} diff --git a/packages/vue3/src/useValidate.ts b/packages/vue3/src/useValidate.ts new file mode 100644 index 000000000..c781dc4e7 --- /dev/null +++ b/packages/vue3/src/useValidate.ts @@ -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 + method: () => Method + action: () => string + props: LiveValidationProps + setErrors: (errors: Record) => void +}) { + const { getData, method, action, props, setErrors } = options + + const validating = ref(false) + let validator: any = null + let toSimpleValidationErrors: null | ((errors: any) => Record) = null + let resolveName: null | ((event: Event) => string) = null + let initPromise: Promise | 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.isArray(props.validateOn) + ? (props.validateOn as Array) + : [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 + + 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) + } 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 + 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 + 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 } +} diff --git a/packages/vue3/src/validationProviders.ts b/packages/vue3/src/validationProviders.ts new file mode 100644 index 000000000..5f5646de1 --- /dev/null +++ b/packages/vue3/src/validationProviders.ts @@ -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, initialData: Record) => any + toSimpleValidationErrors?: (errors: any) => Record + resolveName?: (event: Event) => string | undefined +} + +export type LiveValidationProviderAdapter = { + id: string + import: () => Promise +} + +const registry = new Map() + +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 { + return Array.from(registry.keys()) +} \ No newline at end of file diff --git a/packages/vue3/test-app/Pages/FormComponent/LiveValidation.vue b/packages/vue3/test-app/Pages/FormComponent/LiveValidation.vue new file mode 100644 index 000000000..6fb47f1d2 --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/LiveValidation.vue @@ -0,0 +1,60 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/LiveValidationNoProvider.vue b/packages/vue3/test-app/Pages/FormComponent/LiveValidationNoProvider.vue new file mode 100644 index 000000000..f401db737 --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/LiveValidationNoProvider.vue @@ -0,0 +1,17 @@ + + + diff --git a/packages/vue3/test-app/mockProvider.ts b/packages/vue3/test-app/mockProvider.ts new file mode 100644 index 000000000..4e3e119ad --- /dev/null +++ b/packages/vue3/test-app/mockProvider.ts @@ -0,0 +1,141 @@ +import { registerLiveValidationProvider } from '@inertiajs/vue3' + +// Very small in-memory validator used only for tests/demo +function createTestValidator( + requestFactory: (client: any) => Promise | any, + initialData: Record, +) { + let currentData = { ...initialData } + let validating = false + let errors: Record = {} + let timeoutMs: number | undefined + let timer: any = null + + const listeners: Record void>> = { + validatingChanged: new Set(), + errorsChanged: new Set(), + } + + const emit = (event: 'validatingChanged' | 'errorsChanged') => { + listeners[event].forEach((cb) => { + try { + cb() + } catch {} + }) + } + + const runRules = (name?: string) => { + const next: Record = {} + + const checkName = (val: any) => { + if (!val || String(val).trim().length < 3) { + next.name = 'Name must be at least 3 characters.' + } + } + + const checkEmail = (val: any) => { + if (!val || !String(val).includes('@')) { + next.email = 'Email must contain @.' + } + } + + if (name) { + if (name in currentData) { + if (name === 'name') checkName(currentData[name]) + if (name === 'email') checkEmail(currentData[name]) + } + return next + } + + checkName(currentData.name) + checkEmail(currentData.email) + return next + } + + const schedule = (fn: () => void) => { + if (timer) clearTimeout(timer) + if (typeof timeoutMs === 'number' && timeoutMs > 0) { + timer = setTimeout(fn, timeoutMs) + } else { + Promise.resolve().then(fn) + } + } + + return { + on(event: 'validatingChanged' | 'errorsChanged', cb: () => void) { + listeners[event].add(cb) + return this + }, + validating() { + return validating + }, + errors() { + return errors + }, + setTimeout(ms: number) { + timeoutMs = ms + }, + validate(nameOrConfig?: any, value?: any) { + if (typeof nameOrConfig === 'string') { + const name = nameOrConfig + currentData = { ...currentData, [name]: value } + schedule(() => { + validating = true + emit('validatingChanged') + // Simulate async without making any network calls + Promise.resolve().then(() => { + errors = runRules(name) + validating = false + emit('errorsChanged') + emit('validatingChanged') + }) + }) + } else { + schedule(() => { + validating = true + emit('validatingChanged') + Promise.resolve().then(() => { + errors = runRules() + validating = false + emit('errorsChanged') + emit('validatingChanged') + }) + }) + } + return this + }, + reset(...fields: string[]) { + if (!fields.length) { + errors = {} + } else { + for (const f of fields) delete errors[f] + } + emit('errorsChanged') + }, + } +} + +function toSimpleValidationErrors(e: Record) { + const out: Record = {} + for (const k of Object.keys(e)) { + const v = e[k] + out[k] = Array.isArray(v) ? (v[0] ?? '') : (v ?? '') + } + return out +} + +function resolveName(event: Event): string | undefined { + const target = event?.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | null + return target?.name +} + +// Register once on import, except when the test route requires no provider +const shouldRegister = + typeof window === 'undefined' || !window.location.pathname.startsWith('/form-component/live-validation-no-provider') + +if (shouldRegister) { + registerLiveValidationProvider({ + id: 'mock', + import: async () => ({ createValidator: createTestValidator, toSimpleValidationErrors, resolveName }), + }) +} diff --git a/tests/app/server.js b/tests/app/server.js index e8484c653..90734b824 100644 --- a/tests/app/server.js +++ b/tests/app/server.js @@ -591,6 +591,12 @@ app.get('/form-component/options', (req, res) => // TODO: see 'url' key in helpers.js, this should be req.originalUrl by default inertia.render(req, res, { component: 'FormComponent/Options', url: req.originalUrl }), ) +app.get('/form-component/live-validation', (req, res) => + inertia.render(req, res, { component: 'FormComponent/LiveValidation' }), +) +app.get('/form-component/live-validation-no-provider', (req, res) => + inertia.render(req, res, { component: 'FormComponent/LiveValidationNoProvider' }), +) app.get('/form-component/progress', (req, res) => inertia.render(req, res, { component: 'FormComponent/Progress' })) app.post('/form-component/progress', async (req, res) => setTimeout(() => inertia.render(req, res, { component: 'FormComponent/Progress' }), 500), diff --git a/tests/form-component-live-validation.spec.ts b/tests/form-component-live-validation.spec.ts new file mode 100644 index 000000000..81c6666d2 --- /dev/null +++ b/tests/form-component-live-validation.spec.ts @@ -0,0 +1,88 @@ +import test, { expect } from '@playwright/test' +import { consoleMessages, pageLoads } from './support' + +// These tests target the Vue 3 adapter demo page: +// /packages/vue3/test-app/Pages/FormComponent/LiveValidation.vue +// Route: /form-component/live-validation -> component: FormComponent/LiveValidation + +test.describe('Form Component - Live Validation (Vue 3)', () => { + test.beforeEach(async () => { + test.skip(process.env.PACKAGE !== 'vue3', 'Live validation tests are Vue 3-specific') + }) + + test('provider: validates on input for opted-in field and sets/clears errors', async ({ page }) => { + pageLoads.watch(page) + await page.goto('/form-component/live-validation') + const name = page.locator('#name') + const nameErr = page.locator('#error_name') + + await expect(nameErr).toHaveText('') + + // Trigger invalid, then valid + await name.type('Jo') + await page.waitForTimeout(180) + await expect(nameErr).toHaveText('Name must be at least 3 characters.') + + await name.type('hn') // total: 'John' + await page.waitForTimeout(180) + await expect(nameErr).toHaveText('') + }) + + test('provider: global precognitive validates on input', async ({ page }) => { + pageLoads.watch(page) + await page.goto('/form-component/live-validation') + const gpName = page.locator('#gp_name') + const gpErr = page.locator('#gp_error_name') + + await expect(gpErr).toHaveText('') + + await gpName.type('Jo') + await page.waitForTimeout(180) + await expect(gpErr).toHaveText('Name must be at least 3 characters.') + + await gpName.type('hn') + await page.waitForTimeout(180) + await expect(gpErr).toHaveText('') + }) + + test('provider: non-opted field does not trigger validation', async ({ page }) => { + pageLoads.watch(page) + await page.goto('/form-component/live-validation') + const email = page.locator('#email') + await email.type('invalid-email') + await page.waitForTimeout(200) + await expect(page.locator('#error_email')).toHaveText('') + }) + + test('provider: validate latest input yields final valid result', async ({ page }) => { + pageLoads.watch(page) + await page.goto('/form-component/live-validation') + const gpName = page.locator('#gp_name') + const gpErr = page.locator('#gp_error_name') + + await gpName.fill('Jo') // invalid + await page.waitForTimeout(140) + await gpName.type('hn') // becomes valid + + await page.waitForTimeout(200) + await expect(gpErr).toHaveText('') + }) + + test('provider detection: warns when precognitive is true but no provider present', async ({ page }) => { + consoleMessages.listen(page) + pageLoads.watch(page) + await page.goto('/form-component/live-validation-no-provider') + + const npName = page.locator('#np_name') + await npName.type('A') + + await page.waitForTimeout(200) + + const messages = consoleMessages.messages + const found = messages.some((m) => + m.includes('[Inertia][Form] precognitive live validation requested but no provider found.'), + ) + + expect(found).toBeTruthy() + }) +})