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(
null} format={() => '23'} />
); 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(
null} format={() => '23'} />
); 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(
str} format={() => str} />
); 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(
date} format={() => 'date'} />
); 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(
arr} format={() => 'arr'} />
); 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(
obj} format={() => 'obj'} />
); 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(
obj} format={() => 'obj'} />
); 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(
obj} format={() => 'obj'} />
); 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(
obj} format={() => 'obj'} />
); 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(
obj} format={() => 'obj'} />
); 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(
({ foo: 'bar' })} record={{ id: 1 }} >
); 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(
values.foo} format={() => 'obj'} />
); 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(); } ); }); });