Skip to content
Merged
2 changes: 1 addition & 1 deletion examples/simple/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"react": "^18.3.1",
"react-admin": "^5.14.5",
"react-dom": "^18.3.1",
"react-hook-form": "^7.65.0",
"react-hook-form": "^7.72.0",
"react-router": "^6.28.1",
"react-router-dom": "^6.28.1"
},
Expand Down
4 changes: 2 additions & 2 deletions packages/ra-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
"jscodeshift": "^0.15.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.65.0",
"react-hook-form": "^7.72.0",
"react-router": "^6.28.1",
"react-router-dom": "^6.28.1",
"typescript": "^5.1.3",
Expand All @@ -65,7 +65,7 @@
"@tanstack/react-query": "^5.83.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"react-hook-form": "^7.65.0",
"react-hook-form": "^7.72.0",
"react-router": "^6.28.1 || ^7.1.1",
"react-router-dom": "^6.28.1 || ^7.1.1"
},
Expand Down
14 changes: 11 additions & 3 deletions packages/ra-core/src/form/FilterLiveForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import get from 'lodash/get.js';
import mergeWith from 'lodash/mergeWith.js';
import set from 'lodash/set.js';
import { ReactNode, useEffect } from 'react';
import { FormProvider, useForm, UseFormProps } from 'react-hook-form';
import {
FieldValues,
FormProvider,
useForm,
UseFormProps,
} from 'react-hook-form';
import {
SourceContextProvider,
SourceContextValue,
Expand Down Expand Up @@ -155,9 +160,12 @@ const HTMLForm = (props: React.HTMLAttributes<HTMLFormElement>) => (
);

export interface FilterLiveFormProps
extends Omit<UseFormProps, 'onSubmit' | 'defaultValues'> {
extends Omit<
UseFormProps<FieldValues>,
'onSubmit' | 'defaultValues' | 'validate'
> {
children: ReactNode;
validate?: ValidateForm;
validate?: ValidateForm<FieldValues>;
debounce?: number | false;
resource?: string;
formComponent?: React.ComponentType<
Expand Down
2 changes: 1 addition & 1 deletion packages/ra-input-rich-text/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
"ra-ui-materialui": "^5.14.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.65.0",
"react-hook-form": "^7.72.0",
"tippy.js": "^6.3.7",
"typescript": "^5.1.3",
"zshy": "^0.5.0"
Expand Down
2 changes: 1 addition & 1 deletion packages/ra-ui-materialui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
"ra-language-english": "^5.14.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.65.0",
"react-hook-form": "^7.72.0",
"react-is": "^18.2.0 || ^19.0.0",
"react-router": "^6.28.1",
"react-router-dom": "^6.28.1",
Expand Down
27 changes: 27 additions & 0 deletions packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
Validation,
Focus,
Reset,
ConditionalArrayInputValidationContent,
} from './ArrayInput.stories';

describe('<ArrayInput />', () => {
Expand Down Expand Up @@ -283,6 +284,32 @@ describe('<ArrayInput />', () => {
expect(screen.queryByText('test helper text')).not.toBeNull();
});

it('should not display a root-level array error immediately when mounted in onChange mode', async () => {
render(
<AdminContext dataProvider={testDataProvider()}>
<ResourceContextProvider value="books">
<SimpleForm mode="onChange" onSubmit={jest.fn()}>
<ConditionalArrayInputValidationContent />
</SimpleForm>
</ResourceContextProvider>
</AdminContext>
);

fireEvent.click(screen.getByText('Show array input'));

await screen.findByLabelText('ra.action.add');
expect(screen.queryByText('ra.validation.required')).toBeNull();

fireEvent.click(await screen.findByLabelText('ra.action.add'));
fireEvent.click(await screen.findByLabelText('ra.action.remove'));

await screen.findByText('ra.validation.required');

fireEvent.click(screen.getByText('ra.action.save'));

await screen.findByText('ra.validation.required');
});

it('should update the form state to dirty, and allow submit, on updating an array input with default value', async () => {
render(
<AdminContext dataProvider={testDataProvider()}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
TestMemoryRouter,
useSourceContext,
} from 'ra-core';
import { Button, InputAdornment, Stack } from '@mui/material';
import { Alert, Button, InputAdornment, Stack } from '@mui/material';

import { Edit, Create } from '../../detail';
import { SimpleForm, TabbedForm } from '../../form';
Expand All @@ -29,7 +29,10 @@ 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' };
export default {
title: 'ra-ui-materialui/input/ArrayInput',
excludeStories: ['ConditionalArrayInputValidationContent'],
};

const dataProvider = {
getOne: () =>
Expand Down Expand Up @@ -825,6 +828,49 @@ export const GlobalValidation = () => (
</TestMemoryRouter>
);

export const ConditionalArrayInputValidationContent = () => {
const [showArrayInput, setShowArrayInput] = React.useState(false);

return (
<>
<Alert severity="info" sx={{ mb: 2 }}>
This story renders a required ArrayInput only after clicking
&quot;Show array input&quot;. It should not display a validation
error when it first appears. To trigger the array-level
validation error, add an item, then remove it. The error should
also appear after an invalid submit.
</Alert>
<Button onClick={() => setShowArrayInput(true)} variant="outlined">
Show array input
</Button>
{showArrayInput ? (
<ArrayInput source="authors" fullWidth validate={required()}>
<SimpleFormIterator>
<TextInput source="name" />
</SimpleFormIterator>
</ArrayInput>
) : null}
</>
);
};

export const DisplayErrorOnlyAfterInteractionOrInvalidSubmit = () => (
<TestMemoryRouter initialEntries={['/books/create']}>
<Admin dataProvider={dataProvider}>
<Resource
name="books"
create={() => (
<Create>
<SimpleForm mode="onChange">
<ConditionalArrayInputValidationContent />
</SimpleForm>
</Create>
)}
/>
</Admin>
</TestMemoryRouter>
);

const CreateGlobalValidationInFormTab = () => {
return (
<Create
Expand Down
54 changes: 44 additions & 10 deletions packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
useThemeProps,
} from '@mui/material';
import get from 'lodash/get.js';
import isEqual from 'lodash/isEqual.js';

import { LinearProgress } from '../../layout/LinearProgress';
import { InputHelperText } from '../InputHelperText';
Expand Down Expand Up @@ -88,18 +89,51 @@ export const ArrayInput = (inProps: ArrayInputProps) => {
const parentSourceContext = useSourceContext();
const finalSource = parentSourceContext.getSource(arraySource);
const { subscribe } = useFormContext();

const [error, setError] = React.useState<any>();
const [{ error, hasBeenInteractedWith, isSubmitted }, setArrayInputState] =
React.useState<{
error: any;
hasBeenInteractedWith: boolean;
isSubmitted: boolean;
}>({
error: undefined,
hasBeenInteractedWith: false,
isSubmitted: false,
});
React.useEffect(() => {
return subscribe({
formState: { errors: true },
callback: ({ errors }) => {
formState: {
dirtyFields: true,
errors: true,
isSubmitted: true,
touchedFields: true,
},
callback: ({ dirtyFields, errors, isSubmitted, touchedFields }) => {
const error = get(errors ?? {}, finalSource);
setError(error);
const hasBeenInteractedWith =
get(dirtyFields ?? {}, finalSource, false) !== false ||
get(touchedFields ?? {}, finalSource, false) !== false;

setArrayInputState(previousState =>
isEqual(previousState, {
error,
hasBeenInteractedWith,
isSubmitted: !!isSubmitted,
})
? previousState
: {
error,
hasBeenInteractedWith,
isSubmitted: !!isSubmitted,
}
);
},
});
}, [finalSource, subscribe]);
const renderHelperText = helperText !== false || !!error;
const displayedError =
hasBeenInteractedWith || isSubmitted ? error : undefined;
const displayedErrorMessage = (displayedError?.root?.message ??
displayedError?.message) as any;
const renderHelperText = helperText !== false || !!displayedError;

if (isPending) {
// We handle the loading state here instead of using the loading prop
Expand All @@ -121,14 +155,14 @@ export const ArrayInput = (inProps: ArrayInputProps) => {
ArrayInputClasses.root,
className
)}
error={!!error}
error={!!displayedError}
{...sanitizeInputRestProps(rest)}
>
<InputLabel
component="span"
className={ArrayInputClasses.label}
shrink
error={!!error}
error={!!displayedError}
>
<FieldTitle
label={label}
Expand All @@ -146,12 +180,12 @@ export const ArrayInput = (inProps: ArrayInputProps) => {
{children}
</ArrayInputBase>
{renderHelperText ? (
<FormHelperText error={!!error}>
<FormHelperText error={!!displayedError}>
<InputHelperText
// root property is applicable to built-in validation only,
// Resolvers are yet to support useFieldArray root level validation.
// Reference: https://react-hook-form.com/docs/usefieldarray
error={error?.root?.message ?? error?.message}
error={displayedErrorMessage}
helperText={helperText}
/>
</FormHelperText>
Expand Down
2 changes: 1 addition & 1 deletion packages/react-admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
"ra-i18n-polyglot": "^5.14.5",
"ra-language-english": "^5.14.5",
"ra-ui-materialui": "^5.14.5",
"react-hook-form": "^7.65.0",
"react-hook-form": "^7.72.0",
"react-router": "^6.28.1 || ^7.1.1",
"react-router-dom": "^6.28.1 || ^7.1.1"
},
Expand Down
Loading
Loading