From 8012d3d8851930213c5174f4261c296bfd581d13 Mon Sep 17 00:00:00 2001 From: Pascal Baljet Date: Fri, 7 Nov 2025 12:49:57 +0100 Subject: [PATCH 1/4] Add `defaults()` to core validator --- packages/core/src/types.ts | 1 + packages/core/src/validator.ts | 6 ++++ packages/core/tests/validator.test.js | 45 +++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 687876b..d60d781 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -65,6 +65,7 @@ export interface Validator { on(event: keyof ValidatorListeners, callback: () => void): Validator, validateFiles(): Validator, withoutFileValidation(): Validator, + defaults(data: Record): Validator, } export interface ValidatorListeners { diff --git a/packages/core/src/validator.ts b/packages/core/src/validator.ts index 2c9204d..2ba80cc 100644 --- a/packages/core/src/validator.ts +++ b/packages/core/src/validator.ts @@ -363,6 +363,12 @@ export const createValidator = (callback: ValidationCallback, initialData: Recor return form }, + defaults(data) { + initialData = data + oldData = data + + return form + }, reset(...names) { if (names.length === 0) { setTouched([]).forEach((listener) => listener()) diff --git a/packages/core/tests/validator.test.js b/packages/core/tests/validator.test.js index 8e6b44c..3411288 100644 --- a/packages/core/tests/validator.test.js +++ b/packages/core/tests/validator.test.js @@ -803,3 +803,48 @@ it('marks fields as touched when the input has been included in validation', asy await assertPendingValidateDebounceAndClear() }) + +it('can override the old data via the defaults function', () => { + let requests = 0 + axios.request.mockImplementation(() => { + requests++ + + return Promise.resolve(precognitionSuccessResponse()) + }) + + const validator = createValidator((client) => client.post('/foo', {}), { + name: 'Tim', + }) + + expect(validator.defaults({ + name: 'Jess', + })).toBe(validator) + + validator.validate('name', 'Jess') + expect(requests).toBe(0) +}) + +it('can override the initial data via the defaults function', async () => { + expect.assertions(2) + let requests = 0 + axios.request.mockImplementation(() => { + requests++ + + return Promise.resolve(precognitionSuccessResponse()) + }) + + const validator = createValidator((client) => client.post('/foo', {}), { + name: 'Tim', + }).defaults({ + name: 'Jess', + }) + + validator.validate('name', 'Taylor') + expect(requests).toBe(1) + + await vi.advanceTimersByTimeAsync(1500) + + validator.reset('name') + validator.validate('name', 'Jess') + expect(requests).toBe(1) +}) From 5d1be5c7d169b672b6bbd1b3b22407a9d5e954ed Mon Sep 17 00:00:00 2001 From: Pascal Baljet Date: Fri, 7 Nov 2025 13:45:29 +0100 Subject: [PATCH 2/4] Refined Inertia `defaults()`/`setDefaults()` methods --- packages/react-inertia/src/index.ts | 27 +++++++ packages/react-inertia/src/types.ts | 4 ++ packages/vue-inertia/src/index.ts | 27 +++++++ packages/vue-inertia/src/types.ts | 4 ++ packages/vue-inertia/tests/index.test.ts | 89 ++++++++++++++++++++++++ 5 files changed, 151 insertions(+) diff --git a/packages/react-inertia/src/index.ts b/packages/react-inertia/src/index.ts index 499a40d..865f001 100644 --- a/packages/react-inertia/src/index.ts +++ b/packages/react-inertia/src/index.ts @@ -21,6 +21,11 @@ export const useForm = >(method */ const precognitiveForm = usePrecognitiveForm(method, url, inputs, config) + /** + * The Inertia set defaults function. + */ + const inertiaSetDefaults = inertiaForm.setDefaults.bind(inertiaForm) + /** * The Inertia submit function. */ @@ -103,6 +108,28 @@ export const useForm = >(method return form }, + setDefaults(field?: keyof Data | Partial> | ((previousData: FormDataType) => FormDataType), value?: Data[keyof Data]){ + const data = ((): Partial> => { + if (typeof field === 'undefined') { + return inertiaForm.data + } + + if (typeof field === 'function') { + return field(inertiaForm.data) + } + + if (typeof field === 'object') { + return field + } + + // @ts-ignore + return { [field]: value } + })() + + inertiaSetDefaults(data) + + precognitiveForm.validator().defaults(data) + }, reset(...names: FormDataKeys>[]) { inertiaReset(...names) diff --git a/packages/react-inertia/src/types.ts b/packages/react-inertia/src/types.ts index f602054..d8cd734 100644 --- a/packages/react-inertia/src/types.ts +++ b/packages/react-inertia/src/types.ts @@ -17,6 +17,10 @@ export type Form> = Omit, setData(data: Record): Form, validate(name?: (keyof Data | NamedInputEvent) | ValidationConfig, config?: ValidationConfig): Form, + setDefaults(): void, + setDefaults(defaults: Partial): void, + setDefaults(defaults: (previousData: Data) => Data): void, + setDefaults(field: K, value: Data[K]): void, } // This type has been duplicated from @inertiajs/core to diff --git a/packages/vue-inertia/src/index.ts b/packages/vue-inertia/src/index.ts index 645e0da..79feb05 100644 --- a/packages/vue-inertia/src/index.ts +++ b/packages/vue-inertia/src/index.ts @@ -31,6 +31,11 @@ export const useForm = >(method ) }) + /** + * The Inertia defaults function. + */ + const inertiaDefaults = inertiaForm.defaults.bind(inertiaForm) + /** * The Inertia submit function. */ @@ -94,6 +99,28 @@ export const useForm = >(method return form }, + defaults(field?: keyof Data | Partial> | ((previousData: FormDataType) => FormDataType), value?: Data[keyof Data]){ + const data = ((): Partial> => { + if (typeof field === 'undefined') { + return inertiaForm.data() + } + + if (typeof field === 'function') { + return field(inertiaForm.data()) + } + + if (typeof field === 'object') { + return field + } + + // @ts-ignore + return { [field]: value } + })() + + inertiaDefaults(data) + + precognitiveForm.validator().defaults(data) + }, reset(...names: FormDataKeys>[]) { inertiaReset(...names) diff --git a/packages/vue-inertia/src/types.ts b/packages/vue-inertia/src/types.ts index ce79910..1eb579c 100644 --- a/packages/vue-inertia/src/types.ts +++ b/packages/vue-inertia/src/types.ts @@ -17,6 +17,10 @@ export type Form> = Omit, setData(data: Record): Data & Form, validate(name?: (keyof Data | NamedInputEvent) | ValidationConfig, config?: ValidationConfig): Data & Form, + defaults(): void, + defaults(defaults: Partial): void, + defaults(defaults: (previousData: Data) => Data): void, + defaults(field: K, value: Data[K]): void, } // This type has been duplicated from @inertiajs/core to diff --git a/packages/vue-inertia/tests/index.test.ts b/packages/vue-inertia/tests/index.test.ts index 46563bc..d9d5c27 100644 --- a/packages/vue-inertia/tests/index.test.ts +++ b/packages/vue-inertia/tests/index.test.ts @@ -158,3 +158,92 @@ it('can check it any fields have been touched', () => { expect(form.touched()).toBe(true) }) + +it('can set defaults with no arguments', () => { + let requests = 0 + axios.request.mockImplementation(async () => { + requests++ + }) + + const form = useForm('post', '/register', { + name: 'John', + }) + + form.name = 'Jane' + form.defaults() + + form.name = 'John' + form.reset() + expect(form.name).toBe('Jane') + + form.validate('name') + expect(requests).toBe(0) +}) + +it('can set defaults with an object', () => { + let requests = 0 + axios.request.mockImplementation(async () => { + requests++ + }) + + const form = useForm('post', '/register', { + name: 'John', + }) + + form.defaults({ name: 'Jane' }) + + form.name = 'John' + form.reset() + expect(form.name).toBe('Jane') + + form.validate('name') + expect(requests).toBe(0) +}) + +it('can set defaults with a function', () => { + let requests = 0 + axios.request.mockImplementation(async () => { + requests++ + }) + + const form = useForm('post', '/register', { + name: 'John', + }) + + form.defaults((prevData: any) => { + expect(prevData).toEqual({ + name: 'John', + }) + + return { + name: 'Jane', + } + }) + + form.name = 'John' + form.reset() + expect(form.name).toBe('Jane') + + form.validate('name') + expect(requests).toBe(0) +}) + +it('can set defaults with a field and value', () => { + let requests = 0 + axios.request.mockImplementation(async () => { + requests++ + }) + + const form = useForm('post', '/register', { + name: 'John', + }) + + form.defaults('name', 'Jane') + + form.name = 'John' + form.reset() + expect(form.name).toBe('Jane') + + form.validate('name') + expect(requests).toBe(0) +}) From 77370dd2e37698441b0d581debcffbe9f779ca7f Mon Sep 17 00:00:00 2001 From: Pascal Baljet Date: Wed, 26 Nov 2025 00:10:25 +0100 Subject: [PATCH 3/4] fix typo --- packages/vue-inertia/tests/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vue-inertia/tests/index.test.ts b/packages/vue-inertia/tests/index.test.ts index d9d5c27..0a85ca0 100644 --- a/packages/vue-inertia/tests/index.test.ts +++ b/packages/vue-inertia/tests/index.test.ts @@ -56,7 +56,7 @@ it('can clear specific errors via Inertia\'s clearErrors', () => { }) }) -it('provides default data for validation requets', () => { +it('provides default data for validation requests', () => { const response = { headers: { precognition: 'true', 'precognition-success': 'true' }, status: 204, data: 'data' } let config: Config From 8c02a4ef5416c4ffaf0b822cf47454086787697b Mon Sep 17 00:00:00 2001 From: Pascal Baljet Date: Wed, 26 Nov 2025 00:23:29 +0100 Subject: [PATCH 4/4] Port `vue-inertia` test to `react-inertia` --- packages/react-inertia/package.json | 6 +- packages/react-inertia/tests/index.test.ts | 235 +++++++++++++++++++++ packages/react-inertia/vitest.config.ts | 7 + 3 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 packages/react-inertia/tests/index.test.ts create mode 100644 packages/react-inertia/vitest.config.ts diff --git a/packages/react-inertia/package.json b/packages/react-inertia/package.json index ec41157..d8fbbbd 100644 --- a/packages/react-inertia/package.json +++ b/packages/react-inertia/package.json @@ -25,6 +25,7 @@ "build": "rm -rf dist && tsc", "typeCheck": "tsc --noEmit", "prepublishOnly": "npm run build", + "test": "vitest run", "version": "npm pkg set dependencies.laravel-precognition=$npm_package_version && npm pkg set dependencies.laravel-precognition-react=$npm_package_version" }, "peerDependencies": { @@ -36,7 +37,10 @@ "laravel-precognition-react": "0.7.3" }, "devDependencies": { + "@testing-library/react": "^16.3.0", "@types/react-dom": "^18.2.4 || ^19.0.0", - "typescript": "^5.0.0" + "jsdom": "^27.2.0", + "typescript": "^5.0.0", + "vitest": "^2.0.5" } } diff --git a/packages/react-inertia/tests/index.test.ts b/packages/react-inertia/tests/index.test.ts new file mode 100644 index 0000000..864ff0d --- /dev/null +++ b/packages/react-inertia/tests/index.test.ts @@ -0,0 +1,235 @@ +import { it, expect, beforeEach, afterEach, vi } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useForm, client } from '../src/index' +import axios from 'axios' +import { Config } from 'laravel-precognition' + +beforeEach(() => { + vi.mock('axios') + client.use(axios) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +it('can clear all errors via Inertia\'s clearErrors', () => { + const { result: form } = renderHook(() => useForm('post', '/register', { + name: '', + })) + + act(() => form.current.setErrors({ + name: 'xxxx', + other: 'xxxx', + })) + + expect(form.current.errors).toEqual({ + name: 'xxxx', + other: 'xxxx', + }) + + act(() => form.current.clearErrors()) + + expect(form.current.errors).toEqual({}) + expect(form.current.validator().errors()).toEqual({}) +}) + +it('can clear specific errors via Inertia\'s clearErrors', () => { + const { result: form } = renderHook(() => useForm('post', '/register', { + name: '', + })) + + act(() => form.current.setErrors({ + name: 'xxxx', + email: 'xxxx', + other: 'xxxx', + })) + + expect(form.current.errors).toEqual({ + name: 'xxxx', + email: 'xxxx', + other: 'xxxx', + }) + + act(() => form.current.clearErrors('name', 'email')) + + expect(form.current.errors).toEqual({ + other: 'xxxx', + }) + expect(form.current.validator().errors()).toEqual({ + other: ['xxxx'], + }) +}) + +it('provides default data for validation requests', () => { + const response = { headers: { precognition: 'true', 'precognition-success': 'true' }, status: 204, data: 'data' } + + let config: Config + axios.request.mockImplementation(async (c: Config) => { + config = c + + return response + }) + + const { result: form } = renderHook(() => useForm('post', '/register', { + emails: '', + })) + + act(() => form.current.setData('emails', 'taylor@laravel.com, tim@laravel.com')) + act(() => form.current.validate('emails')) + + expect(config!.data.emails).toEqual('taylor@laravel.com, tim@laravel.com') + expect(form.current.data.emails).toBe('taylor@laravel.com, tim@laravel.com') +}) + +it('transforms data for validation requests', () => { + const response = { headers: { precognition: 'true', 'precognition-success': 'true' }, status: 204, data: 'data' } + + let config: Config + axios.request.mockImplementation(async (c: Config) => { + config = c + + return response + }) + + const { result: form } = renderHook(() => useForm('post', '/register', { + emails: '', + })) + + act(() => form.current.transform((data) => ({ + emails: data.emails.split(',').map((email: string) => email.trim()), + }))) + + act(() => form.current.setData('emails', 'taylor@laravel.com, tim@laravel.com')) + act(() => form.current.validate('emails')) + + expect(config!.data.emails).toEqual(['taylor@laravel.com', 'tim@laravel.com']) + expect(form.current.data.emails).toBe('taylor@laravel.com, tim@laravel.com') +}) + +it('can set individual errors', function () { + const { result: form } = renderHook(() => useForm('post', '/register', { + name: '', + })) + + act(() => form.current.setError('name', 'The name is required.')) + + expect(form.current.errors.name).toBe('The name is required.') +}) + +it('can check that specific fields have been touched', () => { + const { result: form } = renderHook(() => useForm('post', '/register', { + name: '', + email: '', + })) + + expect(form.current.touched('name')).toBe(false) + expect(form.current.touched('email')).toBe(false) + + act(() => form.current.touch('name')) + + expect(form.current.touched('name')).toBe(true) + expect(form.current.touched('email')).toBe(false) +}) + +it('can check it any fields have been touched', () => { + const { result: form } = renderHook(() => useForm('post', '/register', { + name: '', + email: '', + })) + + expect(form.current.touched()).toBe(false) + + act(() => form.current.touch('name')) + + expect(form.current.touched()).toBe(true) +}) + +it('can set defaults with no arguments', () => { + let requests = 0 + axios.request.mockImplementation(async () => { + requests++ + }) + + const { result: form } = renderHook(() => useForm('post', '/register', { + name: 'John', + })) + + act(() => form.current.setData('name', 'Jane')) + act(() => form.current.setDefaults()) + + act(() => form.current.setData('name', 'John')) + act(() => form.current.reset()) + expect(form.current.data.name).toBe('Jane') + + act(() => form.current.validate('name')) + expect(requests).toBe(0) +}) + +it('can set defaults with an object', () => { + let requests = 0 + axios.request.mockImplementation(async () => { + requests++ + }) + + const { result: form } = renderHook(() => useForm('post', '/register', { + name: 'John', + })) + + act(() => form.current.setDefaults({ name: 'Jane' })) + + act(() => form.current.setData('name', 'John')) + act(() => form.current.reset()) + expect(form.current.data.name).toBe('Jane') + + act(() => form.current.validate('name')) + expect(requests).toBe(0) +}) + +it('can set defaults with a function', () => { + let requests = 0 + axios.request.mockImplementation(async () => { + requests++ + }) + + const { result: form } = renderHook(() => useForm('post', '/register', { + name: 'John', + })) + + act(() => form.current.setDefaults((prevData: any) => { + expect(prevData).toEqual({ + name: 'John', + }) + + return { + name: 'Jane', + } + })) + + act(() => form.current.setData('name', 'John')) + act(() => form.current.reset()) + expect(form.current.data.name).toBe('Jane') + + act(() => form.current.validate('name')) + expect(requests).toBe(0) +}) + +it('can set defaults with a field and value', () => { + let requests = 0 + axios.request.mockImplementation(async () => { + requests++ + }) + + const { result: form } = renderHook(() => useForm('post', '/register', { + name: 'John', + })) + + act(() => form.current.setDefaults('name', 'Jane')) + + act(() => form.current.setData('name', 'John')) + act(() => form.current.reset()) + expect(form.current.data.name).toBe('Jane') + + act(() => form.current.validate('name')) + expect(requests).toBe(0) +}) diff --git a/packages/react-inertia/vitest.config.ts b/packages/react-inertia/vitest.config.ts new file mode 100644 index 0000000..e7ef8ea --- /dev/null +++ b/packages/react-inertia/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'jsdom', + }, +})