diff --git a/README.md b/README.md index 447b7df34..c7c66a1ac 100644 --- a/README.md +++ b/README.md @@ -259,11 +259,12 @@ A React context provider component that is required when using some hooks and co #### Props -| Name | Type | Description | -| ---------- | ------ | ---------------------------------------- | -| Formio | object | The Formio object to be used. | -| baseUrl | string | The base url of a Form.io server. | -| projectUrl | string | The url of a Form.io enterprise project. | +| Name | Type | Description | +| ----------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Formio | object | The Formio object to be used. | +| baseUrl | string | The base url of a Form.io server. | +| projectUrl | string | The url of a Form.io enterprise project. | +| customFetch | FormioCustomFetch | A custom fetch function to override the default fetch used by Form.io for all HTTP requests. Applied to all child `
` components. Individual `` components can override this with their own `customFetch` prop. See below. | #### Examples @@ -302,6 +303,36 @@ root.render( ); ``` +Apply a custom fetch function to all child forms: + +```tsx +import { createRoot } from 'react-dom/client'; +import { Form, FormioProvider } from '@formio/react'; +import type { FormioCustomFetch } from '@formio/react'; + +const myCustomFetch: FormioCustomFetch = async (url, init) => { + console.log('Custom fetch intercepted:', url); + return fetch(url, { + ...init, + headers: { ...init?.headers, 'X-Custom-Header': 'my-value' }, + }); +}; + +const domNode = document.getElementById('root'); +const root = createRoot(domNode); + +root.render( + + {/* Both forms use myCustomFetch */} + + + , +); +``` + ### Form A React component wrapper around [a Form.io form](https://help.form.io/developers/form-development/form-renderer#introduction). Able to take a JSON form definition or a Form.io form URL and render the form in your React application. @@ -314,6 +345,7 @@ A React component wrapper around [a Form.io form](https://help.form.io/developer | `url` | `string` | | The url of the form definition. Used in conjunction with a JSON form definition passed to `src`, this is used for file upload, OAuth, and other components or actions that need to know the URL of the Form.io form for further processing. The form will not be loaded from this url and the submission will not be saved here either. | | `submission` | `JSON` | | Submission data to fill the form. You can either load a previous submission or create a submission with some pre-filled data. If you do not provide a submissions the form will initialize an empty submission using default values from the form. | | `options` | `FormOptions` | | The form options. See [here](https://help.form.io/developers/form-development/form-renderer#form-renderer-options) for more details. | +| `customFetch` | `FormioCustomFetch` | | A custom fetch function to override the default fetch behavior used by Form.io for all HTTP requests. Overrides `customFetch` from `` if both are provided. Restored to the original on unmount. | | `onFormReady` | `(instance: Webform) => void` | | A callback function that gets called when the form has rendered. It is useful for accessing the underlying @formio/js Webform instance. | | `onSubmit` | `(submission: JSON, saved?: boolean) => void` | | A callback function that gets called when the submission has started. If `src` is not a Form.io server URL, this will be the final submit event. | | `onCancelSubmit` | `() => void` | | A callback function that gets called when the submission has been canceled. | @@ -442,6 +474,60 @@ const App = () => { root.render(); ``` +#### Using `customFetch` + +Pass a custom fetch function to a single form instance. This is useful for adding custom headers, caching, error handling, or integrating with libraries like React Query: + +```tsx +import { Form } from '@formio/react'; +import type { FormioCustomFetch } from '@formio/react'; + +const myCustomFetch: FormioCustomFetch = async (url, init) => { + const response = await fetch(url, { + ...init, + headers: { + ...init?.headers, + Authorization: `Bearer ${getToken()}`, + }, + }); + if (!response.ok) { + showErrorToast(response.statusText); + } + return response; +}; + +const MyFormComponent = ({ formDefinition, submission }) => ( + +); +``` + +Override a provider-level `customFetch` for a specific form: + +```tsx +import { Form, FormioProvider } from '@formio/react'; + +const defaultFetch = async (url, init) => { + // Default custom fetch for all forms + return fetch(url, { ...init, credentials: 'include' }); +}; + +const specialFetch = async (url, init) => { + // Special fetch for one specific form + return fetch(url, { ...init, cache: 'no-store' }); +}; + +const App = () => ( + + {/* uses defaultFetch */} + {/* uses specialFetch */} + +); +``` + #### Usage in Next.js A number of dependencies in the `@formio/js` rely on web APIs and browser-specific globals like `window`. Because Next.js includes a server-side rendering stage, this makes it difficult to import the Form component directly, even when used in [client components](https://nextjs.org/docs/app/building-your-application/rendering/client-components). For this reason, we recommend dynamically importing the Form component using Next.js' `dynamic` API: diff --git a/src/components/Form.tsx b/src/components/Form.tsx index 2520b3f51..7dae8863d 100644 --- a/src/components/Form.tsx +++ b/src/components/Form.tsx @@ -1,7 +1,8 @@ -import { CSSProperties, useEffect, useRef, useState } from 'react'; -import { EventEmitter, Form as FormClass, Webform, Utils } from '@formio/js'; +import { CSSProperties, useContext, useEffect, useRef, useState } from 'react'; +import { EventEmitter, Form as FormClass, Formio, Webform, Utils } from '@formio/js'; import { Component, Form as CoreFormType } from '@formio/core'; import structuredClone from '@ungap/structured-clone'; +import { FormioContext, FormioCustomFetch } from '../contexts/FormioContext'; export type PartialExcept = Partial> & Required>; @@ -53,6 +54,33 @@ export type FormProps = { options?: FormOptions; formioform?: FormConstructor; FormClass?: FormConstructor; + /** + * A custom fetch function to override the default fetch behavior + * used by Form.io for all HTTP requests (loading form definitions, + * submitting data, fetching resources, etc.). + * + * When provided, this function is set on the Formio class's static + * `fetch` property for this form's lifecycle. When the component + * unmounts, the original fetch is restored. + * + * This prop takes precedence over `customFetch` provided via + * ``. + * + * @example + * ```tsx + * { + * const response = await fetch(url, { + * ...init, + * headers: { ...init?.headers, 'X-Custom-Header': 'value' }, + * }); + * return response; + * }} + * /> + * ``` + */ + customFetch?: FormioCustomFetch; formReady?: (instance: Webform) => void; onFormReady?: (instance: Webform) => void; onPrevPage?: (page: number, submission: Submission) => void; @@ -113,6 +141,7 @@ const onAnyEvent = ( | 'formReady' | 'formioform' | 'Formio' + | 'customFetch' >, ...args: [string, ...any[]] ) => { @@ -265,10 +294,17 @@ export const Form = (props: FormProps) => { FormClass, style, className, + customFetch, ...handlers } = props; + + // Try to get customFetch from FormioProvider context as fallback + const formioContext = useContext(FormioContext); + const effectiveCustomFetch = customFetch ?? formioContext?.customFetch; + const [formInstance, setFormInstance] = useState(null); const isMounted = useRef(false); + const previousFetchRef = useRef(undefined); useEffect(() => { return () => { @@ -304,6 +340,13 @@ export const Form = (props: FormProps) => { console.warn('Form source not found'); return; } + + // Apply customFetch to Formio.fetch before creating the form instance + if (effectiveCustomFetch) { + previousFetchRef.current = Formio.fetch; + Formio.fetch = effectiveCustomFetch; + } + currentFormJson.current = formSource && typeof formSource !== 'string' ? structuredClone(formSource) @@ -345,7 +388,15 @@ export const Form = (props: FormProps) => { }; createInstance(); - }, [formConstructor, formReadyCallback, formSource, options, url]); + + // Cleanup: restore original fetch when this effect re-runs or on unmount + return () => { + if (effectiveCustomFetch && previousFetchRef.current !== undefined) { + Formio.fetch = previousFetchRef.current; + previousFetchRef.current = undefined; + } + }; + }, [formConstructor, formReadyCallback, formSource, options, url, effectiveCustomFetch]); useEffect(() => { let onAnyHandler = null; diff --git a/src/components/__tests__/customFetch.test.tsx b/src/components/__tests__/customFetch.test.tsx new file mode 100644 index 000000000..2c3ef5621 --- /dev/null +++ b/src/components/__tests__/customFetch.test.tsx @@ -0,0 +1,118 @@ +import { render, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { Formio } from '@formio/js'; + +import { Form } from '../Form'; +import { FormioProvider } from '../../contexts/FormioContext'; + +const simpleForm = { + display: 'form' as const, + components: [ + { + label: 'First Name', + key: 'firstName', + type: 'textfield', + input: true, + }, + ], +}; + +describe('customFetch prop', () => { + let originalFetch: typeof Formio.fetch; + + beforeEach(() => { + originalFetch = Formio.fetch; + }); + + afterEach(() => { + // Always restore the original fetch after each test + Formio.fetch = originalFetch; + cleanup(); + }); + + test('applies customFetch to Formio.fetch when provided as a prop', async () => { + const mockFetch = jest.fn(async () => new Response()); + + await new Promise((resolve) => { + render( + { + expect(Formio.fetch).toBe(mockFetch); + resolve(); + }} + />, + ); + }); + }); + + test('restores Formio.fetch to original value after unmount', async () => { + const mockFetch = jest.fn(async () => new Response()); + + const { unmount } = render( + , + ); + + // Wait for the form to be created + await new Promise((resolve) => setTimeout(resolve, 500)); + + unmount(); + + // After unmount, Formio.fetch should be restored + expect(Formio.fetch).toBe(originalFetch); + }); + + test('does not modify Formio.fetch when customFetch is not provided', async () => { + await new Promise((resolve) => { + render( + { + expect(Formio.fetch).toBe(originalFetch); + resolve(); + }} + />, + ); + }); + }); + + test('uses customFetch from FormioProvider context', async () => { + const mockFetch = jest.fn(async () => new Response()); + + await new Promise((resolve) => { + render( + + { + expect(Formio.fetch).toBe(mockFetch); + resolve(); + }} + /> + , + ); + }); + }); + + test('Form-level customFetch overrides FormioProvider-level customFetch', async () => { + const providerFetch = jest.fn(async () => new Response()); + const formFetch = jest.fn(async () => new Response()); + + await new Promise((resolve) => { + render( + + { + expect(Formio.fetch).toBe(formFetch); + expect(Formio.fetch).not.toBe(providerFetch); + resolve(); + }} + /> + , + ); + }); + }); +}); diff --git a/src/contexts/FormioContext.tsx b/src/contexts/FormioContext.tsx index d6fed86b5..9e6c8615b 100644 --- a/src/contexts/FormioContext.tsx +++ b/src/contexts/FormioContext.tsx @@ -1,16 +1,28 @@ import { createContext, useState, useEffect } from 'react'; import { Formio as ImportedFormio } from '@formio/js'; +/** + * A custom fetch function type that matches the native fetch API signature. + * Can be passed to `` or `` to override the default + * fetch behavior used by Form.io for all HTTP requests. + */ +export type FormioCustomFetch = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + type BaseConfigurationArgs = { baseUrl?: string; projectUrl?: string; Formio?: typeof ImportedFormio; + customFetch?: FormioCustomFetch; }; const useBaseConfiguration = ({ baseUrl, projectUrl, Formio, + customFetch, }: BaseConfigurationArgs) => { if (!Formio) { if (baseUrl) { @@ -23,6 +35,7 @@ const useBaseConfiguration = ({ Formio: ImportedFormio, baseUrl: ImportedFormio.baseUrl, projectUrl: ImportedFormio.projectUrl, + customFetch, }; } @@ -37,6 +50,7 @@ const useBaseConfiguration = ({ Formio, baseUrl: Formio.baseUrl, projectUrl: Formio.projectUrl, + customFetch, }; }; @@ -94,8 +108,9 @@ export function FormioProvider({ baseUrl, projectUrl, Formio, + customFetch, }: { children: React.ReactNode } & BaseConfigurationArgs) { - const baseConfig = useBaseConfiguration({ baseUrl, projectUrl, Formio }); + const baseConfig = useBaseConfiguration({ baseUrl, projectUrl, Formio, customFetch }); const auth = useAuthentication({ Formio: baseConfig.Formio }); const formio = { ...baseConfig, ...auth }; return ( diff --git a/src/index.ts b/src/index.ts index a72881708..f5633c705 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ export * from './components'; export { useFormioContext } from './hooks/useFormioContext'; export { usePagination } from './hooks/usePagination'; export { FormioProvider } from './contexts/FormioContext'; +export type { FormioCustomFetch } from './contexts/FormioContext'; export * from './constants'; export * from './modules'; export * from './types';