diff --git a/packages/react/src/Form.ts b/packages/react/src/Form.ts index 1de194c80..76fecdb03 100644 --- a/packages/react/src/Form.ts +++ b/packages/react/src/Form.ts @@ -12,10 +12,12 @@ import { } from '@inertiajs/core' import { isEqual } from 'lodash-es' import React, { + createContext, createElement, FormEvent, forwardRef, ReactNode, + useContext, useEffect, useImperativeHandle, useMemo, @@ -39,6 +41,8 @@ type FormSubmitOptions = Omit undefined +export const FormContext = createContext(undefined) + const Form = forwardRef( ( { @@ -172,27 +176,30 @@ const Form = forwardRef( setIsDirty(false) } - const exposed = () => ({ - errors: form.errors, - hasErrors: form.hasErrors, - processing: form.processing, - progress: form.progress, - wasSuccessful: form.wasSuccessful, - recentlySuccessful: form.recentlySuccessful, - isDirty, - clearErrors: form.clearErrors, - resetAndClearErrors, - setError: form.setError, - reset, - submit, - defaults, - getData, - getFormData, - }) - - useImperativeHandle(ref, exposed, [form, isDirty, submit]) - - return createElement( + const exposedValue = useMemo( + () => ({ + errors: form.errors, + hasErrors: form.hasErrors, + processing: form.processing, + progress: form.progress, + wasSuccessful: form.wasSuccessful, + recentlySuccessful: form.recentlySuccessful, + isDirty, + clearErrors: form.clearErrors, + resetAndClearErrors, + setError: form.setError, + reset, + submit, + defaults, + getData, + getFormData, + }), + [form, isDirty, submit], + ) + + useImperativeHandle(ref, () => exposedValue, [exposedValue]) + + const formElement_el = createElement( 'form', { ...props, @@ -209,11 +216,17 @@ const Form = forwardRef( // See: https://github.com/inertiajs/inertia/pull/2536 inert: disableWhileProcessing && form.processing && 'true', }, - typeof children === 'function' ? children(exposed()) : children, + typeof children === 'function' ? children(exposedValue) : children, ) + + return createElement(FormContext.Provider, { value: exposedValue }, formElement_el) }, ) Form.displayName = 'InertiaForm' +export function useFormContext(): FormComponentRef | undefined { + return useContext(FormContext) +} + export default Form diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index e5cc41547..38a3d20b4 100755 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -6,7 +6,7 @@ export const router = Router export { default as App } from './App' export { default as createInertiaApp } from './createInertiaApp' export { default as Deferred } from './Deferred' -export { default as Form } from './Form' +export { default as Form, FormContext, useFormContext } from './Form' export { default as Head } from './Head' export { default as InfiniteScroll } from './InfiniteScroll' export { InertiaLinkProps, default as Link } from './Link' diff --git a/packages/react/test-app/Pages/FormComponent/Context.tsx b/packages/react/test-app/Pages/FormComponent/Context.tsx new file mode 100644 index 000000000..25272dc4e --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/Context.tsx @@ -0,0 +1,66 @@ +import { Form } from '@inertiajs/react' +import ChildComponent from './Context/ChildComponent' +import NestedComponent from './Context/NestedComponent' +import OutsideFormComponent from './Context/OutsideFormComponent' + +export default function Context() { + return ( +
+

Form Context Test

+ +
+ {({ isDirty, hasErrors, errors, processing }) => ( + <> + {/* Parent form state display */} +
+
+ Parent: Form is {isDirty ? 'dirty' : 'clean'} +
+ {hasErrors &&
Parent: Form has errors
} + {processing &&
Parent: Form is processing
} + {errors.name &&
Parent Error: {errors.name}
} +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + {/* Child component that uses useFormContext */} + + + {/* Nested child component to test deep context propagation */} + + + )} + + + {/* Component outside the Form to test undefined context */} + + +
+

Instructions

+

+ This test demonstrates that child components can access the form context using useFormContext(). Both the + parent form and child components should display the same form state. +

+
    +
  • Child components inside the Form should have access to form state and methods
  • +
  • Context should propagate through multiple levels of nesting
  • +
  • Components outside the Form should receive undefined from useFormContext()
  • +
+
+
+ ) +} diff --git a/packages/react/test-app/Pages/FormComponent/Context/ChildComponent.tsx b/packages/react/test-app/Pages/FormComponent/Context/ChildComponent.tsx new file mode 100644 index 000000000..7dfc3b8bc --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/Context/ChildComponent.tsx @@ -0,0 +1,64 @@ +import { useFormContext } from '@inertiajs/react' + +export default function ChildComponent() { + const form = useFormContext() + + const submitFromChild = () => { + form?.submit() + } + + const resetFromChild = () => { + form?.reset() + } + + const clearErrorsFromChild = () => { + form?.clearErrors() + } + + const setTestError = () => { + form?.setError('name', 'Error set from child component') + } + + const setDefaultsFromChild = () => { + form?.defaults() + } + + return ( +
+

Child Component (using useFormContext)

+ + {form ? ( +
+
+ Child: Form is {form.isDirty ? 'dirty' : 'clean'} +
+ {form.hasErrors &&
Child: Form has errors
} + {form.processing &&
Child: Form is processing
} + {form.errors.name &&
Child Error: {form.errors.name}
} + {form.wasSuccessful &&
Child: Form was successful
} + {form.recentlySuccessful &&
Child: Form recently successful
} +
+ ) : ( +
No form context available
+ )} + +
+ + + + + +
+
+ ) +} diff --git a/packages/react/test-app/Pages/FormComponent/Context/DeeplyNestedComponent.tsx b/packages/react/test-app/Pages/FormComponent/Context/DeeplyNestedComponent.tsx new file mode 100644 index 000000000..18d7a68f3 --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/Context/DeeplyNestedComponent.tsx @@ -0,0 +1,40 @@ +import { useFormContext } from '@inertiajs/react' +import { useMemo } from 'react' + +export default function DeeplyNestedComponent() { + const form = useFormContext() + + // Test that we can use useMemo with the context + const formData = useMemo(() => { + if (!form) return null + return form.getData() + }, [form]) + + const dataDisplay = useMemo(() => { + if (!formData) return 'No data' + return JSON.stringify(formData, null, 2) + }, [formData]) + + return ( +
+

Deeply Nested Component (using useFormContext)

+ + {form ? ( +
+
+ Deeply Nested: Form is {form.isDirty ? 'dirty' : 'clean'} +
+ {form.hasErrors &&
Deeply Nested: Form has errors ({Object.keys(form.errors).length})
} + {form.processing &&
Deeply Nested: Form is processing
} + +
+ Form Data (from getData()) +
{dataDisplay}
+
+
+ ) : ( +
No form context available in deeply nested component
+ )} +
+ ) +} diff --git a/packages/react/test-app/Pages/FormComponent/Context/MethodsTestComponent.tsx b/packages/react/test-app/Pages/FormComponent/Context/MethodsTestComponent.tsx new file mode 100644 index 000000000..79817ac9a --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/Context/MethodsTestComponent.tsx @@ -0,0 +1,189 @@ +import { useFormContext } from '@inertiajs/react' +import { useState } from 'react' + +export default function MethodsTestComponent() { + const form = useFormContext() + + // State for displaying method results + const [getDataResult, setGetDataResult] = useState('') + const [getFormDataResult, setGetFormDataResult] = useState('') + + const testSubmit = () => { + form?.submit() + } + + const testResetAll = () => { + form?.reset() + } + + const testResetName = () => { + form?.reset('name') + } + + const testResetMultiple = () => { + form?.reset('name', 'email') + } + + const testClearAllErrors = () => { + form?.clearErrors() + } + + const testClearNameError = () => { + form?.clearErrors('name') + } + + const testSetSingleError = () => { + form?.setError('name', 'Name is invalid (set from child)') + } + + const testSetMultipleErrors = () => { + form?.setError({ + name: 'Name error from child', + email: 'Email error from child', + bio: 'Bio error from child', + }) + } + + const testResetAndClearErrors = () => { + form?.resetAndClearErrors() + } + + const testResetAndClearNameError = () => { + form?.resetAndClearErrors('name') + } + + const testSetDefaults = () => { + form?.defaults() + } + + const testGetData = () => { + if (form) { + const data = form.getData() + setGetDataResult(JSON.stringify(data, null, 2)) + } + } + + const testGetFormData = () => { + if (form) { + const formData = form.getFormData() + const obj: Record = {} + formData.forEach((value, key) => { + obj[key] = value + }) + setGetFormDataResult(JSON.stringify(obj, null, 2)) + } + } + + return ( +
+

Methods Test Component (using useFormContext)

+ + {form ? ( +
+ {/* Display current state */} +
+

Current State from Context

+
    +
  • + isDirty: {String(form.isDirty)} +
  • +
  • + hasErrors: {String(form.hasErrors)} +
  • +
  • + processing: {String(form.processing)} +
  • +
  • + wasSuccessful: {String(form.wasSuccessful)} +
  • +
  • + recentlySuccessful: {String(form.recentlySuccessful)} +
  • + {form.hasErrors && ( +
  • + Errors: +
    {JSON.stringify(form.errors, null, 2)}
    +
  • + )} +
+
+ + {/* Submit and Reset Methods */} +
+

Submit & Reset

+ + + + +
+ + {/* Error Methods */} +
+

Error Management

+ + + + +
+ + {/* Combined Methods */} +
+

Combined Methods

+ + + +
+ + {/* Data Retrieval Methods */} +
+

Data Retrieval

+ + + + {getDataResult && ( +
+ getData() result: +
{getDataResult}
+
+ )} + + {getFormDataResult && ( +
+ getFormData() result: +
{getFormDataResult}
+
+ )} +
+
+ ) : ( +
⚠ No form context available
+ )} +
+ ) +} diff --git a/packages/react/test-app/Pages/FormComponent/Context/NestedComponent.tsx b/packages/react/test-app/Pages/FormComponent/Context/NestedComponent.tsx new file mode 100644 index 000000000..384ac8948 --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/Context/NestedComponent.tsx @@ -0,0 +1,13 @@ +import DeeplyNestedComponent from './DeeplyNestedComponent' + +export default function NestedComponent() { + return ( +
+

Nested Component (wrapper, no context usage)

+

This component doesn't use the context, but passes it down to a deeply nested child.

+ + {/* Even deeper nested component */} + +
+ ) +} diff --git a/packages/react/test-app/Pages/FormComponent/Context/OutsideFormComponent.tsx b/packages/react/test-app/Pages/FormComponent/Context/OutsideFormComponent.tsx new file mode 100644 index 000000000..45c8df6e9 --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/Context/OutsideFormComponent.tsx @@ -0,0 +1,18 @@ +import { useFormContext } from '@inertiajs/react' + +export default function OutsideFormComponent() { + // This should return undefined since it's not inside a Form component + const form = useFormContext() + + return ( +
+

Component Outside Form (testing no context)

+ + {form === undefined ? ( +
✓ Correctly returns undefined when used outside a Form component
+ ) : ( +
⚠ Unexpectedly has form context!
+ )} +
+ ) +} diff --git a/packages/react/test-app/Pages/FormComponent/ContextMethods.tsx b/packages/react/test-app/Pages/FormComponent/ContextMethods.tsx new file mode 100644 index 000000000..d99fccea8 --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/ContextMethods.tsx @@ -0,0 +1,99 @@ +import { Form } from '@inertiajs/react' +import MethodsTestComponent from './Context/MethodsTestComponent' + +export default function ContextMethods() { + return ( +
+

Form Context Methods Test

+ +
+ {({ isDirty, hasErrors, errors, processing, wasSuccessful, recentlySuccessful }) => ( + <> + {/* Parent form state display */} +
+

Parent State (from slot props)

+
    +
  • + isDirty: {String(isDirty)} +
  • +
  • + hasErrors: {String(hasErrors)} +
  • +
  • + processing: {String(processing)} +
  • +
  • + wasSuccessful: {String(wasSuccessful)} +
  • +
  • + recentlySuccessful: {String(recentlySuccessful)} +
  • + {hasErrors && ( +
  • + Errors: +
    {JSON.stringify(errors, null, 2)}
    +
  • + )} +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + + + + +
+

Test Coverage

+

This test verifies that all form methods are accessible through useFormContext():

+
    +
  • submit() - Submit the form programmatically
  • +
  • reset() - Reset all or specific fields
  • +
  • resetAndClearErrors() - Reset fields and clear errors
  • +
  • clearErrors() - Clear all or specific errors
  • +
  • setError() - Set errors programmatically
  • +
  • defaults() - Set current values as defaults
  • +
  • getData() - Get form data as an object
  • +
  • getFormData() - Get form data as FormData
  • +
+
+
+ diff --git a/tests/app/server.js b/tests/app/server.js index faf5fe039..206caf2fa 100644 --- a/tests/app/server.js +++ b/tests/app/server.js @@ -1103,6 +1103,11 @@ app.get('/form-component/invalidate-tags/:propType', (req, res) => }), ) +app.get('/form-component/context', (req, res) => inertia.render(req, res, { component: 'FormComponent/Context' })) +app.get('/form-component/context-methods', (req, res) => + inertia.render(req, res, { component: 'FormComponent/ContextMethods' }), +) + function renderInfiniteScroll(req, res, component, total = 40, orderByDesc = false, perPage = 15) { const page = req.query.page ? parseInt(req.query.page) : 1 const partialReload = !!req.headers['x-inertia-partial-data'] diff --git a/tests/form-component-context.spec.ts b/tests/form-component-context.spec.ts new file mode 100644 index 000000000..1fad33f08 --- /dev/null +++ b/tests/form-component-context.spec.ts @@ -0,0 +1,307 @@ +import test, { expect } from '@playwright/test' +import { pageLoads } from './support' + +test.describe('Form Component Context', () => { + test.describe('Basic Context', () => { + test.beforeEach(async ({ page }) => { + pageLoads.watch(page) + await page.goto('/form-component/context') + }) + + test('provides context to child components', async ({ page }) => { + // Check that child component has access to form state + await expect(page.locator('#child-state')).toBeVisible() + await expect(page.locator('#child-state')).toContainText('Child: Form is clean') + }) + + test('provides context to deeply nested components', async ({ page }) => { + // Check that deeply nested component has access to form state + await expect(page.locator('#deeply-nested-state')).toBeVisible() + await expect(page.locator('#deeply-nested-state')).toContainText('Deeply Nested: Form is clean') + }) + + test('returns undefined outside Form component', async ({ page }) => { + // Check that component outside Form receives undefined + await expect(page.locator('#no-context-message')).toBeVisible() + await expect(page.locator('#no-context-message')).toContainText( + 'Correctly returns undefined when used outside a Form component', + ) + }) + + test('syncs isDirty state between parent and child', async ({ page }) => { + // Initial state - clean + await expect(page.locator('#parent-state')).toContainText('Parent: Form is clean') + await expect(page.locator('#child-state')).toContainText('Child: Form is clean') + + // Make form dirty + await page.fill('#name', 'Jane Doe') + + // Both parent and child should show dirty + await expect(page.locator('#parent-state')).toContainText('Parent: Form is dirty') + await expect(page.locator('#child-state')).toContainText('Child: Form is dirty') + }) + + test('can submit form from child component using context', async ({ page }) => { + await page.fill('#name', 'Test Name') + await page.fill('#email', 'test@example.com') + + // Submit from child component + await page.click('#child-submit-button') + + // Should navigate to dump page + await page.waitForURL('/dump/post') + }) + + test('can reset form from child component using context', async ({ page }) => { + // Change the default values + await page.fill('#name', 'Changed Name') + await page.fill('#email', 'changed@example.com') + + // Verify form is dirty + await expect(page.locator('#child-state')).toContainText('Child: Form is dirty') + + // Reset from child + await page.click('#child-reset-button') + + // Should be reset to defaults + await expect(page.locator('#name')).toHaveValue('John Doe') + await expect(page.locator('#email')).toHaveValue('john@example.com') + await expect(page.locator('#child-state')).toContainText('Child: Form is clean') + }) + + test('can set errors from child component using context', async ({ page }) => { + // Set error from child + await page.click('#child-set-error-button') + + // Both parent and child should show the error + await expect(page.locator('#parent_error_name')).toContainText('Error set from child component') + await expect(page.locator('#child_error_name')).toContainText('Error set from child component') + await expect(page.locator('#parent-state')).toContainText('Parent: Form has errors') + await expect(page.locator('#child-state')).toContainText('Child: Form has errors') + }) + + test('can clear errors from child component using context', async ({ page }) => { + // First set an error + await page.click('#child-set-error-button') + await expect(page.locator('#child_error_name')).toBeVisible() + + // Clear errors from child + await page.click('#child-clear-errors-button') + + // Errors should be cleared in both parent and child + await expect(page.locator('#parent_error_name')).not.toBeVisible() + await expect(page.locator('#child_error_name')).not.toBeVisible() + }) + + test('can set defaults from child component using context', async ({ page }) => { + // Change values + await page.fill('#name', 'New Default Name') + await page.fill('#email', 'newdefault@example.com') + + // Form should be dirty + await expect(page.locator('#child-state')).toContainText('Child: Form is dirty') + + // Set new defaults from child + await page.click('#child-defaults-button') + + // Form should now be clean (because current values are now the defaults) + await expect(page.locator('#child-state')).toContainText('Child: Form is clean') + + // Reset should now go back to the new defaults + await page.fill('#name', 'Another Name') + await expect(page.locator('#child-state')).toContainText('Child: Form is dirty') + + await page.click('#child-reset-button') + await expect(page.locator('#name')).toHaveValue('New Default Name') + await expect(page.locator('#email')).toHaveValue('newdefault@example.com') + }) + }) + + test.describe('Context Methods', () => { + test.beforeEach(async ({ page }) => { + pageLoads.watch(page) + await page.goto('/form-component/context-methods') + }) + + test('child can access all state properties through context', async ({ page }) => { + // Check initial state + await expect(page.locator('#child-is-dirty')).toContainText('false') + await expect(page.locator('#child-has-errors')).toContainText('false') + await expect(page.locator('#child-processing')).toContainText('false') + await expect(page.locator('#child-was-successful')).toContainText('false') + await expect(page.locator('#child-recently-successful')).toContainText('false') + }) + + test('can submit from child using context', async ({ page }) => { + await page.fill('#name', 'Test') + await page.fill('#email', 'test@example.com') + + await page.click('#child-submit') + + await page.waitForURL('/dump/post') + }) + + test('can reset all fields from child', async ({ page }) => { + // Change all fields + await page.fill('#name', 'Changed') + await page.fill('#email', 'changed@example.com') + await page.fill('#bio', 'Changed bio') + + // Reset all + await page.click('#child-reset-all') + + // Should be back to defaults + await expect(page.locator('#name')).toHaveValue('Initial Name') + await expect(page.locator('#email')).toHaveValue('initial@example.com') + await expect(page.locator('#bio')).toHaveValue('Initial bio') + }) + + test('can reset specific field from child', async ({ page }) => { + // Change fields + await page.fill('#name', 'Changed Name') + await page.fill('#email', 'changed@example.com') + + // Reset only name + await page.click('#child-reset-name') + + // Name should be reset, email should stay changed + await expect(page.locator('#name')).toHaveValue('Initial Name') + await expect(page.locator('#email')).toHaveValue('changed@example.com') + }) + + test('can reset multiple specific fields from child', async ({ page }) => { + // Change fields + await page.fill('#name', 'Changed Name') + await page.fill('#email', 'changed@example.com') + await page.fill('#bio', 'Changed bio') + + // Reset name and email + await page.click('#child-reset-multiple') + + // Name and email should be reset, bio should stay changed + await expect(page.locator('#name')).toHaveValue('Initial Name') + await expect(page.locator('#email')).toHaveValue('initial@example.com') + await expect(page.locator('#bio')).toHaveValue('Changed bio') + }) + + test('can set single error from child', async ({ page }) => { + await page.click('#child-set-single-error') + + // Check error appears in both parent and child + await expect(page.locator('#parent-has-errors')).toContainText('true') + await expect(page.locator('#child-has-errors')).toContainText('true') + await expect(page.locator('#child-errors')).toContainText('Name is invalid') + }) + + test('can set multiple errors from child', async ({ page }) => { + await page.click('#child-set-multiple-errors') + + // Check all errors appear + await expect(page.locator('#child-has-errors')).toContainText('true') + await expect(page.locator('#child-errors')).toContainText('Name error from child') + await expect(page.locator('#child-errors')).toContainText('Email error from child') + await expect(page.locator('#child-errors')).toContainText('Bio error from child') + }) + + test('can clear all errors from child', async ({ page }) => { + // Set errors first + await page.click('#child-set-multiple-errors') + await expect(page.locator('#child-has-errors')).toContainText('true') + + // Clear all errors + await page.click('#child-clear-all-errors') + + await expect(page.locator('#child-has-errors')).toContainText('false') + await expect(page.locator('#child-errors')).not.toBeVisible() + }) + + test('can clear specific error from child', async ({ page }) => { + // Set multiple errors + await page.click('#child-set-multiple-errors') + + // Clear only name error + await page.click('#child-clear-name-error') + + // Name error should be cleared, others should remain + await expect(page.locator('#child-errors')).not.toContainText('Name error from child') + await expect(page.locator('#child-errors')).toContainText('Email error from child') + await expect(page.locator('#child-errors')).toContainText('Bio error from child') + }) + + test('can reset and clear errors together from child', async ({ page }) => { + // Change values and set errors + await page.fill('#name', 'Changed') + await page.click('#child-set-single-error') + await expect(page.locator('#child-has-errors')).toContainText('true') + await expect(page.locator('#child-is-dirty')).toContainText('true') + + // Reset and clear errors + await page.click('#child-reset-clear-all') + + // Both should be cleared + await expect(page.locator('#child-has-errors')).toContainText('false') + await expect(page.locator('#child-is-dirty')).toContainText('false') + await expect(page.locator('#name')).toHaveValue('Initial Name') + }) + + test('can reset specific field and clear its error from child', async ({ page }) => { + // Set errors and change values + await page.fill('#name', 'Changed') + await page.fill('#email', 'changed@example.com') + await page.click('#child-set-multiple-errors') + + // Reset and clear only name + await page.click('#child-reset-clear-name') + + // Name should be reset and its error cleared + await expect(page.locator('#name')).toHaveValue('Initial Name') + await expect(page.locator('#child-errors')).not.toContainText('Name error from child') + + // Email should still be changed and have error + await expect(page.locator('#email')).toHaveValue('changed@example.com') + await expect(page.locator('#child-errors')).toContainText('Email error from child') + }) + + test('can set defaults from child', async ({ page }) => { + // Change values + await page.fill('#name', 'New Default') + await expect(page.locator('#child-is-dirty')).toContainText('true') + + // Set defaults + await page.click('#child-set-defaults') + + // Should now be clean + await expect(page.locator('#child-is-dirty')).toContainText('false') + }) + + test('can get data as object from child', async ({ page }) => { + await page.fill('#name', 'Test Name') + await page.fill('#email', 'test@example.com') + await page.fill('#bio', 'Test bio') + + await page.click('#child-get-data') + + // Check the result is displayed + const result = await page.locator('#get-data-result pre') + await expect(result).toBeVisible() + const text = await result.textContent() + expect(text).toContain('Test Name') + expect(text).toContain('test@example.com') + expect(text).toContain('Test bio') + }) + + test('can get FormData from child', async ({ page }) => { + await page.fill('#name', 'Test Name') + await page.fill('#email', 'test@example.com') + + await page.click('#child-get-form-data') + + // Check the result is displayed + const result = await page.locator('#get-form-data-result pre') + await expect(result).toBeVisible() + const text = await result.textContent() + expect(text).toContain('Test Name') + expect(text).toContain('test@example.com') + }) + }) +})