Skip to content

Commit

Permalink
Merge pull request #10421 from marmelab/fix-array-input-dirty
Browse files Browse the repository at this point in the history
Fix ArrayInput makes the form dirty in strict mode
  • Loading branch information
slax57 authored Dec 19, 2024
2 parents a1b7be0 + ce33414 commit cd440fd
Show file tree
Hide file tree
Showing 3 changed files with 99 additions and 96 deletions.
70 changes: 11 additions & 59 deletions packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import userEvent from '@testing-library/user-event';
import {
RecordContextProvider,
ResourceContextProvider,
minLength,
required,
testDataProvider,
} from 'ra-core';

Expand All @@ -23,6 +21,7 @@ import {
NestedInline,
WithReferenceField,
NestedInlineNoTranslation,
Validation,
} from './ArrayInput.stories';
import { useArrayInput } from './useArrayInput';

Expand Down Expand Up @@ -136,66 +135,19 @@ describe('<ArrayInput />', () => {
});

it('should apply validation to both itself and its inner inputs', async () => {
render(
<AdminContext dataProvider={testDataProvider()}>
<ResourceContextProvider value="bar">
<SimpleForm
onSubmit={jest.fn}
defaultValues={{
arr: [],
}}
>
<ArrayInput
source="arr"
validate={[minLength(2, 'array_min_length')]}
>
<SimpleFormIterator>
<TextInput
source="id"
validate={[required('id_required')]}
/>
<TextInput
source="foo"
validate={[required('foo_required')]}
/>
</SimpleFormIterator>
</ArrayInput>
</SimpleForm>
</ResourceContextProvider>
</AdminContext>
);
render(<Validation />);

fireEvent.click(screen.getByLabelText('ra.action.add'));
fireEvent.click(screen.getByText('ra.action.save'));
await waitFor(() => {
expect(screen.queryByText('array_min_length')).not.toBeNull();
});
fireEvent.click(screen.getByLabelText('ra.action.add'));
const firstId = screen.getAllByLabelText(
'resources.bar.fields.arr.id *'
)[0];
fireEvent.change(firstId, {
target: { value: 'aaa' },
});
fireEvent.change(firstId, {
target: { value: '' },
});
fireEvent.blur(firstId);
const firstFoo = screen.getAllByLabelText(
'resources.bar.fields.arr.foo *'
)[0];
fireEvent.change(firstFoo, {
target: { value: 'aaa' },
});
fireEvent.change(firstFoo, {
target: { value: '' },
});
fireEvent.blur(firstFoo);
expect(screen.queryByText('array_min_length')).toBeNull();
fireEvent.click(await screen.findByLabelText('Add'));
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(screen.queryByText('id_required')).not.toBeNull();
expect(screen.queryByText('foo_required')).not.toBeNull();
// The two inputs in each item are required
expect(screen.queryAllByText('Required')).toHaveLength(2);
});
fireEvent.click(screen.getAllByLabelText('Remove')[2]);
fireEvent.click(screen.getAllByLabelText('Remove')[1]);
fireEvent.click(screen.getByText('Save'));

await screen.findByText('You need two authors at minimum');
});

it('should maintain its form value after having been unmounted', async () => {
Expand Down
104 changes: 82 additions & 22 deletions packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react';
import { Admin } from 'react-admin';
import {
minLength,
required,
Resource,
testI18nProvider,
Expand All @@ -19,6 +20,7 @@ import { AutocompleteInput } from '../AutocompleteInput';
import { TranslatableInputs } from '../TranslatableInputs';
import { ReferenceField, TextField, TranslatableFields } from '../../field';
import { Labeled } from '../../Labeled';
import { useFormContext, useWatch } from 'react-hook-form';

export default { title: 'ra-ui-materialui/input/ArrayInput' };

Expand Down Expand Up @@ -67,17 +69,35 @@ const BookEdit = () => {
<TextInput source="role" />
</SimpleFormIterator>
</ArrayInput>
<FormInspector />
</SimpleForm>
</Edit>
);
};

const FormInspector = () => {
const {
formState: { defaultValues, isDirty, dirtyFields },
} = useFormContext();
const values = useWatch();
return (
<div>
<div>isDirty: {isDirty.toString()}</div>
<div>dirtyFields: {JSON.stringify(dirtyFields, null, 2)}</div>
<div>defaultValues: {JSON.stringify(defaultValues, null, 2)}</div>
<div>values: {JSON.stringify(values, null, 2)}</div>
</div>
);
};

export const Basic = () => (
<TestMemoryRouter initialEntries={['/books/1']}>
<Admin dataProvider={dataProvider}>
<Resource name="books" edit={BookEdit} />
</Admin>
</TestMemoryRouter>
<React.StrictMode>
<TestMemoryRouter initialEntries={['/books/1']}>
<Admin dataProvider={dataProvider}>
<Resource name="books" edit={BookEdit} />
</Admin>
</TestMemoryRouter>
</React.StrictMode>
);

export const Disabled = () => (
Expand Down Expand Up @@ -669,24 +689,44 @@ export const ActionsLeft = () => (
</TestMemoryRouter>
);

const globalValidator = values => {
const errors: any = {};
if (!values.authors || !values.authors.length) {
errors.authors = 'ra.validation.required';
} else {
errors.authors = values.authors.map(author => {
const authorErrors: any = {};
if (!author?.name) {
authorErrors.name = 'A name is required';
}
if (!author?.role) {
authorErrors.role = 'ra.validation.required';
}
return authorErrors;
});
}
return errors;
const BookEditValidation = () => {
return (
<Edit
mutationMode="pessimistic"
mutationOptions={{
onSuccess: data => {
console.log(data);
},
}}
>
<SimpleForm>
<ArrayInput
source="authors"
fullWidth
validate={[
required(),
minLength(2, 'You need two authors at minimum'),
]}
helperText="At least two authors"
>
<SimpleFormIterator>
<TextInput source="name" validate={required()} />
<TextInput source="role" validate={required()} />
</SimpleFormIterator>
</ArrayInput>
</SimpleForm>
</Edit>
);
};

export const Validation = () => (
<TestMemoryRouter initialEntries={['/books/1']}>
<Admin dataProvider={dataProvider}>
<Resource name="books" edit={BookEditValidation} />
</Admin>
</TestMemoryRouter>
);

const BookEditGlobalValidation = () => {
return (
<Edit
Expand All @@ -712,6 +752,26 @@ const BookEditGlobalValidation = () => {
</Edit>
);
};

const globalValidator = values => {
const errors: any = {};
if (!values.authors || !values.authors.length) {
errors.authors = 'ra.validation.required';
} else {
errors.authors = values.authors.map(author => {
const authorErrors: any = {};
if (!author?.name) {
authorErrors.name = 'A name is required';
}
if (!author?.role) {
authorErrors.role = 'ra.validation.required';
}
return authorErrors;
});
}
return errors;
};

export const GlobalValidation = () => (
<TestMemoryRouter initialEntries={['/books/1']}>
<Admin dataProvider={dataProvider}>
Expand Down
21 changes: 6 additions & 15 deletions packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,7 @@ export const ArrayInput = (props: ArrayInputProps) => {
: validate;
const getValidationErrorMessage = useGetValidationErrorMessage();

const { getFieldState, formState, getValues, register, unregister } =
useFormContext();
const { getFieldState, formState, getValues } = useFormContext();

const fieldProps = useFieldArray({
name: finalSource,
Expand All @@ -121,25 +120,17 @@ export const ArrayInput = (props: ArrayInputProps) => {
},
});

// We need to register the array itself as a field to enable validation at its level
useEffect(() => {
register(finalSource);
formGroups &&
formGroupName != null &&
if (formGroups && formGroupName != null) {
formGroups.registerField(finalSource, formGroupName);
}

return () => {
unregister(finalSource, {
keepValue: true,
keepError: true,
keepDirty: true,
keepTouched: true,
});
formGroups &&
formGroupName != null &&
if (formGroups && formGroupName != null) {
formGroups.unregisterField(finalSource, formGroupName);
}
};
}, [register, unregister, finalSource, formGroups, formGroupName]);
}, [finalSource, formGroups, formGroupName]);

useApplyInputDefaultValues({
inputProps: props,
Expand Down

0 comments on commit cd440fd

Please sign in to comment.