Skip to content
Open
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
96 changes: 91 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Form>` components. Individual `<Form>` components can override this with their own `customFetch` prop. See below. |

#### Examples

Expand Down Expand Up @@ -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(
<FormioProvider
projectUrl="https://examples.form.io"
customFetch={myCustomFetch}
>
{/* Both forms use myCustomFetch */}
<Form src="https://examples.form.io/example" />
<Form src="https://examples.form.io/other" />
</FormioProvider>,
);
```

### 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.
Expand All @@ -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 `<FormioProvider>` 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. |
Expand Down Expand Up @@ -442,6 +474,60 @@ const App = () => {
root.render(<App />);
```

#### 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 }) => (
<Form
form={formDefinition}
submission={submission}
customFetch={myCustomFetch}
/>
);
```

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 = () => (
<FormioProvider projectUrl="https://examples.form.io" customFetch={defaultFetch}>
<Form src="https://examples.form.io/form1" /> {/* uses defaultFetch */}
<Form src="https://examples.form.io/form2" customFetch={specialFetch} /> {/* uses specialFetch */}
</FormioProvider>
);
```

#### 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:
Expand Down
57 changes: 54 additions & 3 deletions src/components/Form.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
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<T, K extends keyof T> = Partial<Omit<T, K>> &
Required<Pick<T, K>>;

Check failure on line 8 in src/components/Form.tsx

View workflow job for this annotation

GitHub Actions / setup

Cannot find module '@formio/js' or its corresponding type declarations.

Check failure on line 9 in src/components/Form.tsx

View workflow job for this annotation

GitHub Actions / setup

Cannot find module '@formio/core' or its corresponding type declarations.
export type JSON =
| string
| number
Expand Down Expand Up @@ -53,6 +54,33 @@
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
* `<FormioProvider>`.
*
* @example
* ```tsx
* <Form
* form={formDefinition}
* customFetch={async (url, init) => {
* 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;
Expand All @@ -65,7 +93,7 @@
component: Component;
data: { [key: string]: JSON };
event?: Event;
}) => void;

Check warning on line 96 in src/components/Form.tsx

View workflow job for this annotation

GitHub Actions / setup

Unexpected any. Specify a different type

Check warning on line 96 in src/components/Form.tsx

View workflow job for this annotation

GitHub Actions / setup

Unexpected any. Specify a different type
onComponentChange?: (changed: {
instance: Webform;
component: Component;
Expand All @@ -75,17 +103,17 @@
onSubmit?: (submission: Submission, saved?: boolean) => void;
onSubmitDone?: (submission: Submission) => void;
onSubmitError?: (error: EventError) => void;
onFormLoad?: (form: JSON) => void;

Check warning on line 106 in src/components/Form.tsx

View workflow job for this annotation

GitHub Actions / setup

Unexpected any. Specify a different type
onError?: (error: EventError | false) => void;

Check warning on line 107 in src/components/Form.tsx

View workflow job for this annotation

GitHub Actions / setup

Unexpected any. Specify a different type
onRender?: (param: any) => void;
onAttach?: (param: any) => void;
onBuild?: (param: any) => void;
onFocus?: (instance: Webform) => void;
onBlur?: (instance: Webform) => void;
onInitialized?: () => void;
onLanguageChanged?: () => void;

Check warning on line 114 in src/components/Form.tsx

View workflow job for this annotation

GitHub Actions / setup

Unexpected any. Specify a different type
onBeforeSetSubmission?: (submission: Submission) => void;

Check warning on line 115 in src/components/Form.tsx

View workflow job for this annotation

GitHub Actions / setup

Unexpected any. Specify a different type
onSaveDraftBegin?: () => void;

Check warning on line 116 in src/components/Form.tsx

View workflow job for this annotation

GitHub Actions / setup

Unexpected any. Specify a different type
onSaveDraft?: (submission: Submission) => void;
onRestoreDraft?: (submission: Submission | null) => void;
onSubmissionDeleted?: (submission: Submission) => void;
Expand All @@ -97,7 +125,7 @@

const getDefaultEmitter = () => {
return new EventEmitter({
wildcard: false,

Check warning on line 128 in src/components/Form.tsx

View workflow job for this annotation

GitHub Actions / setup

Unexpected any. Specify a different type
maxListeners: 0,
});
};
Expand All @@ -113,6 +141,7 @@
| 'formReady'
| 'formioform'
| 'Formio'
| 'customFetch'
>,
...args: [string, ...any[]]
) => {
Expand All @@ -120,7 +149,7 @@
if (event.startsWith('formio.')) {
const funcName = `on${event.charAt(7).toUpperCase()}${event.slice(8)}`;
switch (funcName) {
case 'onPrevPage':

Check warning on line 152 in src/components/Form.tsx

View workflow job for this annotation

GitHub Actions / setup

Unexpected any. Specify a different type
if (handlers.onPrevPage)
handlers.onPrevPage(outputArgs[0], outputArgs[1]);
break;
Expand Down Expand Up @@ -265,10 +294,17 @@
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<Webform | null>(null);
const isMounted = useRef(false);
const previousFetchRef = useRef<typeof Formio.fetch | undefined>(undefined);

useEffect(() => {
return () => {
Expand Down Expand Up @@ -304,6 +340,13 @@
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)
Expand Down Expand Up @@ -339,13 +382,21 @@
}
return instance;
});
} else {

Check failure on line 385 in src/components/Form.tsx

View workflow job for this annotation

GitHub Actions / setup

Parameter 'prevInstance' implicitly has an 'any' type.
console.warn('Failed to create form instance');
}
};

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;
Expand All @@ -366,7 +417,7 @@
if (formInstance && submission) {
if (!Utils._.isEqual(formInstance.submission, submission)) {
formInstance.submission = submission;
}

Check warning on line 420 in src/components/Form.tsx

View workflow job for this annotation

GitHub Actions / setup

Unexpected any. Specify a different type
}
}, [formInstance, submission]);

Expand Down
118 changes: 118 additions & 0 deletions src/components/__tests__/customFetch.test.tsx
Original file line number Diff line number Diff line change
@@ -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<void>((resolve) => {
render(
<Form
src={simpleForm}
customFetch={mockFetch}
onFormReady={() => {
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(
<Form src={simpleForm} customFetch={mockFetch} />,
);

// 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<void>((resolve) => {
render(
<Form
src={simpleForm}
onFormReady={() => {
expect(Formio.fetch).toBe(originalFetch);
resolve();
}}
/>,
);
});
});

test('uses customFetch from FormioProvider context', async () => {
const mockFetch = jest.fn(async () => new Response());

await new Promise<void>((resolve) => {
render(
<FormioProvider customFetch={mockFetch}>
<Form
src={simpleForm}
onFormReady={() => {
expect(Formio.fetch).toBe(mockFetch);
resolve();
}}
/>
</FormioProvider>,
);
});
});

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<void>((resolve) => {
render(
<FormioProvider customFetch={providerFetch}>
<Form
src={simpleForm}
customFetch={formFetch}
onFormReady={() => {
expect(Formio.fetch).toBe(formFetch);
expect(Formio.fetch).not.toBe(providerFetch);
resolve();
}}
/>
</FormioProvider>,
);
});
});
});
Loading
Loading