Skip to content

Commit 3161149

Browse files
authored
Merge pull request #9191 from marmelab/fix-validation-translations-when-using-resolvers
Fix validation errors from resolvers are not translated
2 parents 643bbe0 + 4f7b36a commit 3161149

13 files changed

+266
-39
lines changed

packages/ra-core/package.json

+3-2
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"watch": "tsc --outDir dist/esm --module es2015 --watch"
2727
},
2828
"devDependencies": {
29-
"@hookform/resolvers": "^2.8.8",
29+
"@hookform/resolvers": "^3.2.0",
3030
"@testing-library/react": "^11.2.3",
3131
"@testing-library/react-hooks": "^7.0.2",
3232
"@types/jest": "^29.5.2",
@@ -46,7 +46,8 @@
4646
"recharts": "^2.1.15",
4747
"rimraf": "^3.0.2",
4848
"typescript": "^5.1.3",
49-
"yup": "^0.32.11"
49+
"yup": "^0.32.11",
50+
"zod": "^3.22.1"
5051
},
5152
"peerDependencies": {
5253
"history": "^5.1.0",

packages/ra-core/src/form/Form.spec.tsx

+71-2
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,22 @@ import { useFormState, useFormContext } from 'react-hook-form';
44
import { yupResolver } from '@hookform/resolvers/yup';
55
import * as yup from 'yup';
66
import assert from 'assert';
7+
import polyglotI18nProvider from 'ra-i18n-polyglot';
8+
import englishMessages from 'ra-language-english';
79

810
import { CoreAdminContext } from '../core';
911
import { Form } from './Form';
1012
import { useNotificationContext } from '../notification';
1113
import { useInput } from './useInput';
1214
import { required } from './validate';
13-
import { SanitizeEmptyValues } from './Form.stories';
14-
import { NullValue } from './Form.stories';
15+
import {
16+
FormLevelValidation,
17+
InputLevelValidation,
18+
ZodResolver,
19+
SanitizeEmptyValues,
20+
NullValue,
21+
} from './Form.stories';
22+
import { mergeTranslations } from '../i18n';
1523

1624
describe('Form', () => {
1725
const Input = props => {
@@ -661,4 +669,65 @@ describe('Form', () => {
661669
expect(validate).toHaveBeenCalled();
662670
});
663671
});
672+
673+
const i18nProvider = polyglotI18nProvider(() =>
674+
mergeTranslations(englishMessages, {
675+
app: {
676+
validation: { required: 'This field must be provided' },
677+
},
678+
})
679+
);
680+
it('should support validation messages translations at the form level without warnings', async () => {
681+
const mock = jest.spyOn(console, 'error').mockImplementation(() => {});
682+
const translate = jest.spyOn(i18nProvider, 'translate');
683+
render(<FormLevelValidation i18nProvider={i18nProvider} />);
684+
fireEvent.click(screen.getByText('Submit'));
685+
await screen.findByText('Required');
686+
await screen.findByText('This field is required');
687+
await screen.findByText('This field must be provided');
688+
await screen.findByText('app.validation.missing');
689+
expect(mock).not.toHaveBeenCalledWith(
690+
expect.stringContaining('Missing translation for key:')
691+
);
692+
// Ensure we don't have double translations
693+
expect(translate).not.toHaveBeenCalledWith('Required');
694+
expect(translate).not.toHaveBeenCalledWith('This field is required');
695+
mock.mockRestore();
696+
});
697+
698+
it('should support validation messages translations at the input level without warnings', async () => {
699+
const mock = jest.spyOn(console, 'error').mockImplementation(() => {});
700+
const translate = jest.spyOn(i18nProvider, 'translate');
701+
render(<InputLevelValidation i18nProvider={i18nProvider} />);
702+
fireEvent.click(screen.getByText('Submit'));
703+
await screen.findByText('Required');
704+
await screen.findByText('This field is required');
705+
await screen.findByText('This field must be provided');
706+
await screen.findByText('app.validation.missing');
707+
expect(mock).not.toHaveBeenCalledWith(
708+
expect.stringContaining('Missing translation for key:')
709+
);
710+
// Ensure we don't have double translations
711+
expect(translate).not.toHaveBeenCalledWith('Required');
712+
expect(translate).not.toHaveBeenCalledWith('This field is required');
713+
mock.mockRestore();
714+
});
715+
716+
it('should support validation messages translations when using a custom resolver without warnings', async () => {
717+
const mock = jest.spyOn(console, 'error').mockImplementation(() => {});
718+
const translate = jest.spyOn(i18nProvider, 'translate');
719+
render(<ZodResolver i18nProvider={i18nProvider} />);
720+
fireEvent.click(screen.getByText('Submit'));
721+
await screen.findByText('Required');
722+
await screen.findByText('This field is required');
723+
await screen.findByText('This field must be provided');
724+
await screen.findByText('app.validation.missing');
725+
expect(mock).not.toHaveBeenCalledWith(
726+
expect.stringContaining('Missing translation for key:')
727+
);
728+
// Ensure we don't have double translations
729+
expect(translate).not.toHaveBeenCalledWith('Required');
730+
expect(translate).not.toHaveBeenCalledWith('This field is required');
731+
mock.mockRestore();
732+
});
664733
});

packages/ra-core/src/form/Form.stories.tsx

+127-1
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,18 @@ import {
44
UseControllerProps,
55
useFormState,
66
} from 'react-hook-form';
7+
import { zodResolver } from '@hookform/resolvers/zod';
8+
import * as z from 'zod';
9+
import polyglotI18nProvider from 'ra-i18n-polyglot';
10+
import englishMessages from 'ra-language-english';
711

812
import { CoreAdminContext } from '../core';
913
import { Form } from './Form';
1014
import { useInput } from './useInput';
15+
import { required } from './validate';
16+
import ValidationError from './ValidationError';
17+
import { mergeTranslations } from '../i18n';
18+
import { I18nProvider } from '../types';
1119

1220
export default {
1321
title: 'ra-core/form/Form',
@@ -32,7 +40,9 @@ const Input = props => {
3240
aria-invalid={fieldState.invalid}
3341
{...field}
3442
/>
35-
<p>{fieldState.error?.message}</p>
43+
{fieldState.error && fieldState.error.message ? (
44+
<ValidationError error={fieldState.error.message} />
45+
) : null}
3646
</div>
3747
);
3848
};
@@ -164,3 +174,119 @@ export const UndefinedValue = () => {
164174
</CoreAdminContext>
165175
);
166176
};
177+
178+
const defaultI18nProvider = polyglotI18nProvider(() =>
179+
mergeTranslations(englishMessages, {
180+
app: { validation: { required: 'This field must be provided' } },
181+
})
182+
);
183+
184+
export const FormLevelValidation = ({
185+
i18nProvider = defaultI18nProvider,
186+
}: {
187+
i18nProvider?: I18nProvider;
188+
}) => {
189+
const [submittedData, setSubmittedData] = React.useState<any>();
190+
return (
191+
<CoreAdminContext i18nProvider={i18nProvider}>
192+
<Form
193+
onSubmit={data => setSubmittedData(data)}
194+
record={{ id: 1, field1: 'bar', field6: null }}
195+
validate={(values: any) => {
196+
const errors: any = {};
197+
if (!values.defaultMessage) {
198+
errors.defaultMessage = 'ra.validation.required';
199+
}
200+
if (!values.customMessage) {
201+
errors.customMessage = 'This field is required';
202+
}
203+
if (!values.customMessageTranslationKey) {
204+
errors.customMessageTranslationKey =
205+
'app.validation.required';
206+
}
207+
if (!values.missingCustomMessageTranslationKey) {
208+
errors.missingCustomMessageTranslationKey =
209+
'app.validation.missing';
210+
}
211+
return errors;
212+
}}
213+
>
214+
<Input source="defaultMessage" />
215+
<Input source="customMessage" />
216+
<Input source="customMessageTranslationKey" />
217+
<Input source="missingCustomMessageTranslationKey" />
218+
<button type="submit">Submit</button>
219+
</Form>
220+
<pre>{JSON.stringify(submittedData, null, 2)}</pre>
221+
</CoreAdminContext>
222+
);
223+
};
224+
225+
export const InputLevelValidation = ({
226+
i18nProvider = defaultI18nProvider,
227+
}: {
228+
i18nProvider?: I18nProvider;
229+
}) => {
230+
const [submittedData, setSubmittedData] = React.useState<any>();
231+
return (
232+
<CoreAdminContext i18nProvider={i18nProvider}>
233+
<Form
234+
onSubmit={data => setSubmittedData(data)}
235+
record={{ id: 1, field1: 'bar', field6: null }}
236+
>
237+
<Input source="defaultMessage" validate={required()} />
238+
<Input
239+
source="customMessage"
240+
validate={required('This field is required')}
241+
/>
242+
<Input
243+
source="customMessageTranslationKey"
244+
validate={required('app.validation.required')}
245+
/>
246+
<Input
247+
source="missingCustomMessageTranslationKey"
248+
validate={required('app.validation.missing')}
249+
/>
250+
<button type="submit">Submit</button>
251+
</Form>
252+
<pre>{JSON.stringify(submittedData, null, 2)}</pre>
253+
</CoreAdminContext>
254+
);
255+
};
256+
257+
const zodSchema = z.object({
258+
defaultMessage: z.string(), //.min(1),
259+
customMessage: z.string({
260+
required_error: 'This field is required',
261+
}),
262+
customMessageTranslationKey: z.string({
263+
required_error: 'app.validation.required',
264+
}),
265+
missingCustomMessageTranslationKey: z.string({
266+
required_error: 'app.validation.missing',
267+
}),
268+
});
269+
270+
export const ZodResolver = ({
271+
i18nProvider = defaultI18nProvider,
272+
}: {
273+
i18nProvider?: I18nProvider;
274+
}) => {
275+
const [result, setResult] = React.useState<any>();
276+
return (
277+
<CoreAdminContext i18nProvider={i18nProvider}>
278+
<Form
279+
record={{}}
280+
onSubmit={data => setResult(data)}
281+
resolver={zodResolver(zodSchema)}
282+
>
283+
<Input source="defaultMessage" />
284+
<Input source="customMessage" />
285+
<Input source="customMessageTranslationKey" />
286+
<Input source="missingCustomMessageTranslationKey" />
287+
<button type="submit">Submit</button>
288+
</Form>
289+
<pre>{JSON.stringify(result, null, 2)}</pre>
290+
</CoreAdminContext>
291+
);
292+
};

packages/ra-core/src/form/ValidationError.spec.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { render } from '@testing-library/react';
44
import ValidationError from './ValidationError';
55
import { TestTranslationProvider } from '../i18n';
66

7-
const translate = jest.fn(key => key);
7+
const translate = jest.fn(key => {
8+
return key;
9+
});
810

911
const renderWithTranslations = content =>
1012
render(

packages/ra-core/src/form/ValidationError.tsx

+21-3
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,33 @@ export interface ValidationErrorProps {
99
error: ValidationErrorMessage;
1010
}
1111

12+
const ValidationErrorSpecialFormatPrefix = '@@react-admin@@';
1213
const ValidationError = (props: ValidationErrorProps) => {
1314
const { error } = props;
15+
let errorMessage = error;
1416
const translate = useTranslate();
15-
if ((error as ValidationErrorMessageWithArgs).message) {
16-
const { message, args } = error as ValidationErrorMessageWithArgs;
17+
// react-hook-form expects errors to be plain strings but our validators can return objects
18+
// that have message and args.
19+
// To avoid double translation for users that validate with a schema instead of our validators
20+
// we use a special format for our validators errors.
21+
// The useInput hook handle the special formatting
22+
if (
23+
typeof error === 'string' &&
24+
error.startsWith(ValidationErrorSpecialFormatPrefix)
25+
) {
26+
errorMessage = JSON.parse(
27+
error.substring(ValidationErrorSpecialFormatPrefix.length)
28+
);
29+
}
30+
if ((errorMessage as ValidationErrorMessageWithArgs).message) {
31+
const {
32+
message,
33+
args,
34+
} = errorMessage as ValidationErrorMessageWithArgs;
1735
return <>{translate(message, { _: message, ...args })}</>;
1836
}
1937

20-
return <>{translate(error as string, { _: error })}</>;
38+
return <>{translate(errorMessage as string, { _: errorMessage })}</>;
2139
};
2240

2341
export default ValidationError;

packages/ra-core/src/form/useGetValidationErrorMessage.ts

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
import { useTranslate } from '../i18n';
66

77
/**
8+
* @deprecated
89
* This internal hook returns a function that can translate an error message.
910
* It handles simple string errors and those which have a message and args.
1011
* Only useful if you are implementing custom inputs without leveraging our useInput hook.

packages/ra-core/src/form/useInput.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import { useRecordContext } from '../controller';
1313
import { composeValidators, Validator } from './validate';
1414
import isRequired from './isRequired';
1515
import { useFormGroupContext } from './useFormGroupContext';
16-
import { useGetValidationErrorMessage } from './useGetValidationErrorMessage';
1716
import { useFormGroups } from './useFormGroups';
1817
import { useApplyInputDefaultValues } from './useApplyInputDefaultValues';
1918
import { useEvent } from '../util';
@@ -43,7 +42,6 @@ export const useInput = <ValueType = any>(
4342
const formGroupName = useFormGroupContext();
4443
const formGroups = useFormGroups();
4544
const record = useRecordContext();
46-
const getValidationErrorMessage = useGetValidationErrorMessage();
4745

4846
useEffect(() => {
4947
if (!formGroups || formGroupName == null) {
@@ -74,7 +72,13 @@ export const useInput = <ValueType = any>(
7472
const error = await sanitizedValidate(value, values, props);
7573

7674
if (!error) return true;
77-
return getValidationErrorMessage(error);
75+
// react-hook-form expects errors to be plain strings but our validators can return objects
76+
// that have message and args.
77+
// To avoid double translation for users that validate with a schema instead of our validators
78+
// we use a special format for our validators errors.
79+
// The ValidationError component will check for this format and extract the message and args
80+
// to translate.
81+
return `@@react-admin@@${JSON.stringify(error)}`;
7882
},
7983
},
8084
...options,

packages/ra-core/src/form/useUnique.stories.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
DataProvider,
1111
EditBase,
1212
FormDataConsumer,
13+
ValidationError,
1314
mergeTranslations,
1415
useUnique,
1516
} from '..';
@@ -30,7 +31,9 @@ const Input = props => {
3031
aria-invalid={fieldState.invalid}
3132
{...field}
3233
/>
33-
<p>{fieldState.error?.message}</p>
34+
{fieldState.error && fieldState.error?.message ? (
35+
<ValidationError error={fieldState.error?.message} />
36+
) : null}
3437
</>
3538
);
3639
};

packages/ra-core/src/form/useUnique.ts

+11-9
Original file line numberDiff line numberDiff line change
@@ -103,16 +103,18 @@ export const useUnique = (options?: UseUniqueOptions) => {
103103
);
104104

105105
if (total > 0 && !data.some(r => r.id === record?.id)) {
106-
return translate(message, {
107-
_: message,
108-
source: props.source,
109-
value,
110-
field: translateLabel({
111-
label: props.label,
106+
return {
107+
message,
108+
args: {
112109
source: props.source,
113-
resource,
114-
}),
115-
});
110+
value,
111+
field: translateLabel({
112+
label: props.label,
113+
source: props.source,
114+
resource,
115+
}),
116+
},
117+
};
116118
}
117119
} catch (error) {
118120
return translate('ra.notification.http_error');

0 commit comments

Comments
 (0)