Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix ArrayInput makes the form dirty in strict mode #10421

Merged
merged 2 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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
103 changes: 81 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 @@ -60,6 +62,7 @@ const BookEdit = () => {
}}
>
<SimpleForm>
<FormInspector />
slax57 marked this conversation as resolved.
Show resolved Hide resolved
<TextInput source="title" />
<ArrayInput source="authors">
<SimpleFormIterator>
Expand All @@ -72,12 +75,28 @@ const BookEdit = () => {
);
};

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 +688,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 +751,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
Loading