import * as React from 'react';
import { fireEvent, screen, render, waitFor } from '@testing-library/react';
import { useFormState, useFormContext } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import assert from 'assert';
import polyglotI18nProvider from 'ra-i18n-polyglot';
import englishMessages from 'ra-language-english';
import type { To } from 'react-router';
import { CoreAdminContext } from '../core';
import { Form } from './Form';
import { useNotificationContext } from '../notification';
import { useInput } from './useInput';
import { required } from './validation/validate';
import {
FormLevelValidation,
InputLevelValidation,
ZodResolver,
SanitizeEmptyValues,
NullValue,
InNonDataRouter,
ServerSideValidation,
MultiRoutesForm,
WarnWhenUnsavedChangesWithDelete,
} from './Form.stories';
import { mergeTranslations } from '../i18n';
describe('Form', () => {
const Input = props => {
const { field, fieldState } = useInput(props);
return (
<>
{fieldState.error?.message}
>
);
};
const IsDirty = () => {
const state = useFormState();
return isDirty: {state.isDirty.toString()}
;
};
it('Does not make the form dirty when reinitialized from a record', () => {
const { rerender } = render(
);
expect(screen.getByDisplayValue('Bar')).not.toBeNull();
expect(screen.getByText('isDirty: false')).not.toBeNull();
rerender(
);
expect(screen.getByDisplayValue('Foo')).not.toBeNull();
expect(screen.getByText('isDirty: false')).not.toBeNull();
});
it('Does not make the form dirty when initialized from a record with a missing field and this field has an defaultValue', () => {
render(
);
expect(screen.getByDisplayValue('Bar')).not.toBeNull();
expect(screen.getByText('isDirty: false')).not.toBeNull();
});
it('Does not make the form dirty when reinitialized from a different record', () => {
const { rerender } = render(
);
expect(screen.getByDisplayValue('Foo')).not.toBeNull();
expect(screen.getByText('isDirty: false')).not.toBeNull();
rerender(
);
expect(screen.getByDisplayValue('Foo')).not.toBeNull();
expect(screen.getByText('isDirty: false')).not.toBeNull();
});
it('should keep dirty values when record changes and resetOptions.keepDirtyValues is true', async () => {
const { rerender } = render(
);
expect(screen.getByDisplayValue('Initial')).not.toBeNull();
fireEvent.change(screen.getByLabelText('name'), {
target: { value: 'User Modified' },
});
await waitFor(() => {
expect(screen.getByDisplayValue('User Modified')).not.toBeNull();
});
rerender(
);
await waitFor(() => {
expect(screen.getByDisplayValue('User Modified')).not.toBeNull();
});
expect(screen.queryByDisplayValue('Server Updated')).toBeNull();
});
it('should NOT keep dirty values when record changes and resetOptions.keepDirtyValues is false', async () => {
const { rerender } = render(
);
fireEvent.change(screen.getByLabelText('name'), {
target: { value: 'User Modified' },
});
await waitFor(() => {
expect(screen.getByDisplayValue('User Modified')).not.toBeNull();
});
rerender(
);
await waitFor(() => {
expect(screen.getByDisplayValue('Server Updated')).not.toBeNull();
});
expect(screen.queryByDisplayValue('User Modified')).toBeNull();
});
it('should NOT keep dirty values when switching to a different record even with resetOptions.keepDirtyValues', async () => {
const { rerender } = render(
);
fireEvent.change(screen.getByLabelText('name'), {
target: { value: 'User Modified Record 1' },
});
await waitFor(() => {
expect(
screen.getByDisplayValue('User Modified Record 1')
).not.toBeNull();
});
rerender(
);
await waitFor(() => {
expect(screen.getByDisplayValue('Record 2')).not.toBeNull();
});
expect(screen.queryByDisplayValue('User Modified Record 1')).toBeNull();
});
it('should update Form state on submit', async () => {
let isSubmitting;
const CustomInput = props => {
const formContext = useFormContext();
isSubmitting = formContext.formState.isSubmitting;
return ;
};
render(
);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
assert.equal(isSubmitting, true);
});
});
it('Displays a notification on submit when invalid', async () => {
const Notification = () => {
const { notifications } = useNotificationContext();
return notifications.length > 0 ? (
{notifications[0].message}
) : null;
};
render(
<>
>
);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
screen.getByText('ra.message.invalid_form');
});
});
it('should not display a notification on submit when invalid', async () => {
const Notification = () => {
const { notifications } = useNotificationContext();
return notifications.length > 0 ? (
{notifications[0].message}
) : null;
};
render(
<>
>
);
fireEvent.click(screen.getByText('Submit'));
await screen.findByText('@@react-admin@@"ra.validation.required"');
expect(screen.queryByText('ra.message.invalid_form')).toBeNull();
});
it('Displays submission errors', async () => {
const Notification = () => {
const { notifications } = useNotificationContext();
return notifications.length > 0 ? (
{notifications[0].message}
) : null;
};
const onSubmit = jest.fn(() =>
Promise.resolve({
name: 'This name is already taken',
})
);
render(
<>
>
);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
screen.getByText('This name is already taken');
});
});
it('should set null or undefined values to null', async () => {
const onSubmit = jest.fn();
render(
);
fireEvent.change(screen.getByLabelText('name'), {
target: { value: '' },
});
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
{ foo: null },
expect.anything()
);
});
});
it('should set null or undefined deep values to null', async () => {
const onSubmit = jest.fn();
render(
);
fireEvent.change(screen.getByLabelText('name'), {
target: { value: '' },
});
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
{ foo: { bar: null } },
expect.anything()
);
});
});
it('should accept string values', async () => {
const str = 'hello';
const onSubmit = jest.fn();
render(
);
fireEvent.change(screen.getByLabelText('name'), {
target: { value: '' },
});
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
{ foo: str },
expect.anything()
);
});
});
it('should accept date values', async () => {
const date = new Date();
const onSubmit = jest.fn();
render(
);
fireEvent.change(screen.getByLabelText('name'), {
target: { value: '' },
});
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
{ foo: date },
expect.anything()
);
});
});
it('should accept array values', async () => {
const arr = [1, 2, 3];
const onSubmit = jest.fn();
render(
);
fireEvent.change(screen.getByLabelText('name'), {
target: { value: '' },
});
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
{ foo: arr },
expect.anything()
);
});
});
it('should accept object values', async () => {
const obj = { foo: 1 };
const onSubmit = jest.fn();
render(
);
fireEvent.change(screen.getByLabelText('name'), {
target: { value: '' },
});
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
{ foo: obj },
expect.anything()
);
});
});
it('should accept deep object values', async () => {
const obj = { foo: { bar: 1 } };
const onSubmit = jest.fn();
render(
);
fireEvent.change(screen.getByLabelText('name'), {
target: { value: '' },
});
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
{ foo: obj },
expect.anything()
);
});
});
it('should accept object values in arrays', async () => {
const obj = [{ foo: 1 }, { foo: 2 }];
const onSubmit = jest.fn();
render(
);
fireEvent.change(screen.getByLabelText('name'), {
target: { value: '' },
});
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
{ foo: obj },
expect.anything()
);
});
});
it('should accept adding objects in arrays', async () => {
const obj = [{ foo: 1, foo2: 2 }, { foo: 3 }, { foo: 4 }];
const onSubmit = jest.fn();
render(
);
fireEvent.change(screen.getByLabelText('name'), {
target: { value: '' },
});
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
{ foo: obj },
expect.anything()
);
});
});
it('should accept removing objects in array of objects', async () => {
const obj = [{ foo: 1 }, { foo: 4 }];
const onSubmit = jest.fn();
render(
);
fireEvent.change(screen.getByLabelText('name'), {
target: { value: '' },
});
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
{ foo: obj },
expect.anything()
);
});
});
describe('defaultValues', () => {
it('should use defaultValues when the record has no value for the field', () => {
render(
);
expect(
(screen.getByLabelText('name') as HTMLInputElement).value
).toBe('bar');
});
it('should not use defaultValues when the record has a value for the field', () => {
render(
);
expect(
(screen.getByLabelText('name') as HTMLInputElement).value
).toBe('hello');
});
it('should accept a function as defaultValues', () => {
render(
);
expect(
(screen.getByLabelText('name') as HTMLInputElement).value
).toBe('bar');
});
it("should not ignore defaultValues when it's not of the same type", async () => {
const defaultValues = { foo: 'foobar' };
const values = { foo: { hello: 'world' } };
const onSubmit = jest.fn();
render(
);
fireEvent.change(screen.getByLabelText('name'), {
target: { value: '' },
});
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
values,
expect.anything()
);
});
});
});
describe('sanitizeEmtpyValues', () => {
it('should remove empty values from the record', async () => {
render();
fireEvent.change(screen.getByLabelText('field1'), {
target: { value: '' },
});
fireEvent.change(screen.getByLabelText('field2'), {
target: { value: '' },
});
fireEvent.change(screen.getByLabelText('field4'), {
target: { value: 'hello' },
});
fireEvent.change(screen.getByLabelText('field4'), {
target: { value: '' },
});
fireEvent.change(screen.getByLabelText('field11'), {
target: { value: '' },
});
fireEvent.change(screen.getByLabelText('field12'), {
target: { value: '' },
});
fireEvent.change(screen.getByLabelText('field14'), {
target: { value: 'hello' },
});
fireEvent.change(screen.getByLabelText('field14'), {
target: { value: '' },
});
fireEvent.click(screen.getByText('Submit'));
await waitFor(() =>
expect(screen.getByTestId('result')?.textContent).toEqual(
'{\n "id": 1,\n "field1": null,\n "field6": null,\n "field11": null,\n "field16": null\n}'
)
);
});
});
it('should accept react-hook-form resolvers', async () => {
const onSubmit = jest.fn();
const schema = yup
.object({
title: yup.string().required(),
number: yup.number().positive().integer().required(),
})
.required();
render(
);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
screen.getByText('title is a required field');
});
screen.getByText('number must be a positive number');
});
it('should convert null values to empty strings', () => {
jest.spyOn(console, 'error').mockImplementation(message => {
// not very robust but there are other React warnings due to act()
// so we must check the exact message
if (
message ===
'Warning: `value` prop on `%s` should not be null. Consider using an empty string to clear the component or `undefined` for uncontrolled components.%s'
) {
fail(message);
}
});
render();
// no assertion needed: if there is a console error, the test fails
});
it('should only validate inputs on submit', async () => {
const validate = jest.fn();
render(
);
fireEvent.change(screen.getByLabelText('name'), {
target: { value: 'hello' },
});
expect(validate).not.toHaveBeenCalled();
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(validate).toHaveBeenCalled();
});
});
const i18nProvider = polyglotI18nProvider(() =>
mergeTranslations(englishMessages, {
app: {
validation: { required: 'This field must be provided' },
},
})
);
it('should support validation messages translations at the form level without warnings', async () => {
const mock = jest.spyOn(console, 'error').mockImplementation(() => {});
const translate = jest.spyOn(i18nProvider, 'translate');
render();
fireEvent.click(screen.getByText('Submit'));
await screen.findByText('Required');
await screen.findByText('This field is required');
await screen.findByText('This field must be provided');
await screen.findByText('app.validation.missing');
expect(mock).not.toHaveBeenCalledWith(
expect.stringContaining('Missing translation for key:')
);
// Ensure we don't have double translations
expect(translate).not.toHaveBeenCalledWith('Required');
expect(translate).not.toHaveBeenCalledWith('This field is required');
mock.mockRestore();
});
it('should support validation messages translations at the input level without warnings', async () => {
const mock = jest.spyOn(console, 'error').mockImplementation(() => {});
const translate = jest.spyOn(i18nProvider, 'translate');
render();
fireEvent.click(screen.getByText('Submit'));
await screen.findByText('Required');
await screen.findByText('This field is required');
await screen.findByText('This field must be provided');
await screen.findByText('app.validation.missing');
expect(mock).not.toHaveBeenCalledWith(
expect.stringContaining('Missing translation for key:')
);
// Ensure we don't have double translations
expect(translate).not.toHaveBeenCalledWith('Required');
expect(translate).not.toHaveBeenCalledWith('This field is required');
mock.mockRestore();
});
it('should support validation messages translations when using a custom resolver without warnings', async () => {
const mock = jest.spyOn(console, 'error').mockImplementation(() => {});
const translate = jest.spyOn(i18nProvider, 'translate');
render();
fireEvent.click(screen.getByText('Submit'));
await screen.findByText(
'Invalid input: expected string, received undefined'
);
await screen.findByText('This field is required');
await screen.findByText('This field must be provided');
await screen.findByText('app.validation.missing');
expect(mock).not.toHaveBeenCalledWith(
expect.stringContaining('Missing translation for key:')
);
// Ensure we don't have double translations
expect(translate).not.toHaveBeenCalledWith('Required');
expect(translate).not.toHaveBeenCalledWith('This field is required');
mock.mockRestore();
});
it('should work even inside a non-data router', async () => {
render();
fireEvent.click(screen.getByText('Go to form'));
await screen.findByText('title');
fireEvent.change(screen.getByLabelText('title'), {
target: { value: '' },
});
fireEvent.click(screen.getByText('Leave the form'));
await screen.findByText('Go to form');
});
it('should support server side validation', async () => {
render();
fireEvent.change(screen.getByLabelText('defaultMessage'), {
target: { value: '' },
});
fireEvent.click(screen.getByText('Submit'));
await screen.findByText('Required');
await screen.findByText('ra.message.invalid_form');
});
it('should support using a custom global message with server side validation', async () => {
render();
fireEvent.change(screen.getByLabelText('customGlobalMessage'), {
target: { value: '' },
});
fireEvent.click(screen.getByText('Submit'));
await screen.findByText('Required');
await screen.findByText(
'There are validation errors. Please fix them.'
);
});
it.each([
{
from: 'state',
url: {
pathname: '/form/general',
state: { record: { body: 'from-state' } },
},
expectedValue: 'from-state',
},
{
from: 'search query',
url: `/form/general?source=${encodeURIComponent(JSON.stringify({ body: 'from-search' }))}` as To,
expectedValue: 'from-search',
},
])(
'should support prefilling the from values from the location $from',
async ({ url, expectedValue }) => {
render();
expect(
(await screen.findByLabelText('title')).value
).toEqual('');
expect(
(screen.getByText('Submit') as HTMLInputElement).disabled
).toEqual(false);
fireEvent.click(screen.getByText('Settings'));
await screen.findByDisplayValue(expectedValue);
expect(
screen.getByText('Submit').disabled
).toEqual(false);
}
);
it.each([
{
from: 'state',
url: {
pathname: '/form/general',
state: { record: { body: 'from-state' } },
},
expectedValue: 'from-state',
expectedDefaultValue: '',
},
{
from: 'state with default values',
url: {
pathname: '/form/general',
state: { record: { body: 'from-state' } },
},
expectedValue: 'from-state',
defaultValues: { category: 'default category' },
expectedDefaultValue: 'default category',
},
{
from: 'search query',
url: `/form/general?source=${encodeURIComponent(JSON.stringify({ body: 'from-search' }))}` as To,
expectedValue: 'from-search',
expectedDefaultValue: '',
},
{
from: 'search query with default values',
url: `/form/general?source=${encodeURIComponent(JSON.stringify({ body: 'from-search' }))}` as To,
expectedValue: 'from-search',
defaultValues: { category: 'default category' },
expectedDefaultValue: 'default category',
},
])(
'should support overriding the record values from the location $from',
async ({ url, defaultValues, expectedValue, expectedDefaultValue }) => {
render(
);
await screen.findByDisplayValue('lorem');
expect(
(await screen.findByLabelText('category'))
.value
).toEqual(expectedDefaultValue);
expect(
(screen.getByText('Submit') as HTMLInputElement).disabled
).toEqual(false);
fireEvent.click(screen.getByText('Settings'));
await screen.findByDisplayValue(expectedValue);
expect(
screen.getByText('Submit').disabled
).toEqual(false);
}
);
describe('warnWhenUnsavedChanges with a delete button', () => {
it.each(['undoable', 'pessimistic', 'optimistic'] as const)(
'should not warn about unsaved changes after deleting a dirty record (%s)',
async mutationMode => {
// spy on "cancel": if the dialog were shown the navigation
// would be cancelled, failing the redirect assertion
const confirmSpy = jest
.spyOn(window, 'confirm')
.mockReturnValue(false);
render(
);
// wait for the record to load
const input =
await screen.findByDisplayValue('Hello');
// make the form dirty
fireEvent.change(input, {
target: { value: 'Hello modified' },
});
fireEvent.blur(input);
// delete the record (the headless DeleteButton has no
// confirmation dialog and always uses the controller onSuccess)
fireEvent.click(screen.getByText('Delete'));
// the app should redirect to the list without warning
await screen.findByText('Post list');
expect(confirmSpy).not.toHaveBeenCalled();
confirmSpy.mockRestore();
}
);
});
});