From d10f68d4b4868bedbea647569dcbceb6a4a2e33b Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 17 Aug 2023 16:47:14 +0200 Subject: [PATCH 1/9] Fix validation errors from resolvers are not translated --- packages/ra-core/package.json | 5 ++-- packages/ra-core/src/form/Form.stories.tsx | 24 +++++++++++++++++++ packages/ra-core/src/form/ValidationError.tsx | 19 ++++++++++++--- packages/ra-core/src/form/useInput.ts | 10 +++++--- .../ra-core/src/form/useUnique.stories.tsx | 5 +++- packages/ra-core/src/form/useUnique.ts | 20 +++++++++------- .../src/form/SimpleForm.spec.tsx | 2 +- .../src/input/InputHelperText.tsx | 12 ++-------- yarn.lock | 18 ++++++++++---- 9 files changed, 81 insertions(+), 34 deletions(-) diff --git a/packages/ra-core/package.json b/packages/ra-core/package.json index bcba211eccc..3b87c76ca89 100644 --- a/packages/ra-core/package.json +++ b/packages/ra-core/package.json @@ -26,7 +26,7 @@ "watch": "tsc --outDir dist/esm --module es2015 --watch" }, "devDependencies": { - "@hookform/resolvers": "^2.8.8", + "@hookform/resolvers": "^3.2.0", "@testing-library/react": "^11.2.3", "@testing-library/react-hooks": "^7.0.2", "@types/jest": "^29.5.2", @@ -46,7 +46,8 @@ "recharts": "^2.1.15", "rimraf": "^3.0.2", "typescript": "^5.1.3", - "yup": "^0.32.11" + "yup": "^0.32.11", + "zod": "^3.22.1" }, "peerDependencies": { "history": "^5.1.0", diff --git a/packages/ra-core/src/form/Form.stories.tsx b/packages/ra-core/src/form/Form.stories.tsx index e2da4a3915d..da9b376e9ca 100644 --- a/packages/ra-core/src/form/Form.stories.tsx +++ b/packages/ra-core/src/form/Form.stories.tsx @@ -4,6 +4,8 @@ import { UseControllerProps, useFormState, } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; import { CoreAdminContext } from '../core'; import { Form } from './Form'; @@ -164,3 +166,25 @@ export const UndefinedValue = () => { ); }; + +const zodSchema = z.object({ + preTranslated: z.string().min(5, { message: 'Required' }), + translationKey: z.string().min(5, { message: 'ra.validation.required' }), +}); +export const ZodResolver = () => { + const [result, setResult] = React.useState(); + return ( + +
setResult(data)} + resolver={zodResolver(zodSchema)} + > + + + +
+
{JSON.stringify(result, null, 2)}
+
+ ); +}; diff --git a/packages/ra-core/src/form/ValidationError.tsx b/packages/ra-core/src/form/ValidationError.tsx index e4a7e6330e2..5872aed594f 100644 --- a/packages/ra-core/src/form/ValidationError.tsx +++ b/packages/ra-core/src/form/ValidationError.tsx @@ -11,13 +11,26 @@ export interface ValidationErrorProps { const ValidationError = (props: ValidationErrorProps) => { const { error } = props; + let errorMessage = error; const translate = useTranslate(); - if ((error as ValidationErrorMessageWithArgs).message) { - const { message, args } = error as ValidationErrorMessageWithArgs; + // react-hook-form expects errors to be plain strings but our validators can return objects + // that have message and args. + // To avoid double translation for users that validate with a schema instead of our validators + // we use a special format for our validators errors. + // The ValidationError component will check for this format and extract the message and args + // to translate. + if (typeof error === 'string' && error.startsWith('@@react-admin@@')) { + errorMessage = JSON.parse(error.replace('@@react-admin@@', '')); + } + if ((errorMessage as ValidationErrorMessageWithArgs).message) { + const { + message, + args, + } = errorMessage as ValidationErrorMessageWithArgs; return <>{translate(message, { _: message, ...args })}; } - return <>{translate(error as string, { _: error })}; + return <>{translate(errorMessage as string, { _: errorMessage })}; }; export default ValidationError; diff --git a/packages/ra-core/src/form/useInput.ts b/packages/ra-core/src/form/useInput.ts index eebc1ba1632..c83dfbd4401 100644 --- a/packages/ra-core/src/form/useInput.ts +++ b/packages/ra-core/src/form/useInput.ts @@ -13,7 +13,6 @@ import { useRecordContext } from '../controller'; import { composeValidators, Validator } from './validate'; import isRequired from './isRequired'; import { useFormGroupContext } from './useFormGroupContext'; -import { useGetValidationErrorMessage } from './useGetValidationErrorMessage'; import { useFormGroups } from './useFormGroups'; import { useApplyInputDefaultValues } from './useApplyInputDefaultValues'; import { useEvent } from '../util'; @@ -43,7 +42,6 @@ export const useInput = ( const formGroupName = useFormGroupContext(); const formGroups = useFormGroups(); const record = useRecordContext(); - const getValidationErrorMessage = useGetValidationErrorMessage(); useEffect(() => { if (!formGroups || formGroupName == null) { @@ -74,7 +72,13 @@ export const useInput = ( const error = await sanitizedValidate(value, values, props); if (!error) return true; - return getValidationErrorMessage(error); + // react-hook-form expects errors to be plain strings but our validators can return objects + // that have message and args. + // To avoid double translation for users that validate with a schema instead of our validators + // we use a special format for our validators errors. + // The ValidationError component will check for this format and extract the message and args + // to translate. + return `@@react-admin@@${JSON.stringify(error)}`; }, }, ...options, diff --git a/packages/ra-core/src/form/useUnique.stories.tsx b/packages/ra-core/src/form/useUnique.stories.tsx index 182edefe9f1..fe65de47d59 100644 --- a/packages/ra-core/src/form/useUnique.stories.tsx +++ b/packages/ra-core/src/form/useUnique.stories.tsx @@ -10,6 +10,7 @@ import { DataProvider, EditBase, FormDataConsumer, + ValidationError, mergeTranslations, useUnique, } from '..'; @@ -30,7 +31,9 @@ const Input = props => { aria-invalid={fieldState.invalid} {...field} /> -

{fieldState.error?.message}

+ {fieldState.error && fieldState.error?.message ? ( + + ) : null} ); }; diff --git a/packages/ra-core/src/form/useUnique.ts b/packages/ra-core/src/form/useUnique.ts index 58de868c031..c75861909a8 100644 --- a/packages/ra-core/src/form/useUnique.ts +++ b/packages/ra-core/src/form/useUnique.ts @@ -103,16 +103,18 @@ export const useUnique = (options?: UseUniqueOptions) => { ); if (total > 0 && !data.some(r => r.id === record?.id)) { - return translate(message, { - _: message, - source: props.source, - value, - field: translateLabel({ - label: props.label, + return { + message, + args: { source: props.source, - resource, - }), - }); + value, + field: translateLabel({ + label: props.label, + source: props.source, + resource, + }), + }, + }; } } catch (error) { return translate('ra.notification.http_error'); diff --git a/packages/ra-ui-materialui/src/form/SimpleForm.spec.tsx b/packages/ra-ui-materialui/src/form/SimpleForm.spec.tsx index e5908499b52..d9d57568ae3 100644 --- a/packages/ra-ui-materialui/src/form/SimpleForm.spec.tsx +++ b/packages/ra-ui-materialui/src/form/SimpleForm.spec.tsx @@ -69,7 +69,7 @@ describe('', () => { mock.mockRestore(); }); - it('should support translations with per input validation', async () => { + it.only('should support translations with per input validation', async () => { const mock = jest .spyOn(console, 'warn') .mockImplementation(() => {}); diff --git a/packages/ra-ui-materialui/src/input/InputHelperText.tsx b/packages/ra-ui-materialui/src/input/InputHelperText.tsx index ac1ab4517c4..ccb4420162c 100644 --- a/packages/ra-ui-materialui/src/input/InputHelperText.tsx +++ b/packages/ra-ui-materialui/src/input/InputHelperText.tsx @@ -1,21 +1,13 @@ import * as React from 'react'; import { isValidElement, ReactElement } from 'react'; -import { - useTranslate, - ValidationError, - ValidationErrorMessage, - ValidationErrorMessageWithArgs, -} from 'ra-core'; +import { useTranslate, ValidationError, ValidationErrorMessage } from 'ra-core'; export const InputHelperText = (props: InputHelperTextProps) => { const { helperText, touched, error } = props; const translate = useTranslate(); if (touched && error) { - if ((error as ValidationErrorMessageWithArgs).message) { - return ; - } - return <>{error}; + return ; } if (helperText === false) { diff --git a/yarn.lock b/yarn.lock index 90ecd86cdd6..74c0b85a70d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2433,12 +2433,12 @@ __metadata: languageName: node linkType: hard -"@hookform/resolvers@npm:^2.8.8": - version: 2.8.8 - resolution: "@hookform/resolvers@npm:2.8.8" +"@hookform/resolvers@npm:^3.2.0": + version: 3.2.0 + resolution: "@hookform/resolvers@npm:3.2.0" peerDependencies: react-hook-form: ^7.0.0 - checksum: 0c8814f1116a145c433300f079c3289d5ece71f36c6b782fb0f5b2388c766140cadd72bd2f7b77bc570988359d686e0c88ba513ab0ed79e70073747b7a19e8f8 + checksum: 7eb79c480e006f08fcfe803e70b7b67eda03cc5c5bb8ce68a5399a0c6fdc34ee0fcc677fed9bea4a0baaa455ba39b15f86c8d2e3a702acdf762d6667988085b6 languageName: node linkType: hard @@ -18212,7 +18212,7 @@ __metadata: version: 0.0.0-use.local resolution: "ra-core@workspace:packages/ra-core" dependencies: - "@hookform/resolvers": ^2.8.8 + "@hookform/resolvers": ^3.2.0 "@testing-library/react": ^11.2.3 "@testing-library/react-hooks": ^7.0.2 "@types/jest": ^29.5.2 @@ -18243,6 +18243,7 @@ __metadata: rimraf: ^3.0.2 typescript: ^5.1.3 yup: ^0.32.11 + zod: ^3.22.1 peerDependencies: history: ^5.1.0 react: ^16.9.0 || ^17.0.0 || ^18.0.0 @@ -22827,3 +22828,10 @@ __metadata: checksum: 71cc2f2bbb537300c3f569e25693d37b3bc91f225cefce251a71c30bc6bb3e7f8e9420ca0eb57f2ac9e492b085b8dfa075fd1e8195c40b83c951dd59c6e4fbf8 languageName: node linkType: hard + +"zod@npm:^3.22.1": + version: 3.22.1 + resolution: "zod@npm:3.22.1" + checksum: fe7112dd8080136652f0be10670a2a44868b097198f3be6264294a62d6c6b280099db5e1bc4a327ec4f738f58bc600445d373ecadf5d51fb5585fa0ab76ee67a + languageName: node + linkType: hard From 53553608d27a86b9635b07e6dbac4db5bfe0790e Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 17 Aug 2023 17:08:56 +0200 Subject: [PATCH 2/9] Fix tests --- packages/ra-ui-materialui/src/form/SimpleForm.spec.tsx | 2 +- packages/ra-ui-materialui/src/input/NumberInput.spec.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ra-ui-materialui/src/form/SimpleForm.spec.tsx b/packages/ra-ui-materialui/src/form/SimpleForm.spec.tsx index d9d57568ae3..e5908499b52 100644 --- a/packages/ra-ui-materialui/src/form/SimpleForm.spec.tsx +++ b/packages/ra-ui-materialui/src/form/SimpleForm.spec.tsx @@ -69,7 +69,7 @@ describe('', () => { mock.mockRestore(); }); - it.only('should support translations with per input validation', async () => { + it('should support translations with per input validation', async () => { const mock = jest .spyOn(console, 'warn') .mockImplementation(() => {}); diff --git a/packages/ra-ui-materialui/src/input/NumberInput.spec.tsx b/packages/ra-ui-materialui/src/input/NumberInput.spec.tsx index 6b94d91556b..986f2da5e3e 100644 --- a/packages/ra-ui-materialui/src/input/NumberInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/NumberInput.spec.tsx @@ -212,7 +212,7 @@ describe('', () => { fireEvent.blur(input); fireEvent.click(screen.getByText('ra.action.save')); await screen.findByText( - 'views:{"invalid":true,"isDirty":true,"isTouched":true,"error":{"type":"validate","message":"error","ref":{}}}' + 'views:{"invalid":true,"isDirty":true,"isTouched":true,"error":{"type":"validate","message":"@@react-admin@@\\"error\\"","ref":{}}}' ); }); }); From d9af246525abb1589e22accf6f3df66456789a8a Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 17 Aug 2023 17:28:55 +0200 Subject: [PATCH 3/9] Apply review suggestions --- packages/ra-core/src/form/Form.stories.tsx | 13 ++++++++++++- packages/ra-core/src/form/ValidationError.tsx | 13 +++++++++---- .../src/form/useGetValidationErrorMessage.ts | 1 + 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/ra-core/src/form/Form.stories.tsx b/packages/ra-core/src/form/Form.stories.tsx index da9b376e9ca..68f5c04ac4a 100644 --- a/packages/ra-core/src/form/Form.stories.tsx +++ b/packages/ra-core/src/form/Form.stories.tsx @@ -168,7 +168,7 @@ export const UndefinedValue = () => { }; const zodSchema = z.object({ - preTranslated: z.string().min(5, { message: 'Required' }), + preTranslated: z.string().min(5, { message: 'This field is required' }), translationKey: z.string().min(5, { message: 'ra.validation.required' }), }); export const ZodResolver = () => { @@ -180,7 +180,18 @@ export const ZodResolver = () => { onSubmit={data => setResult(data)} resolver={zodResolver(zodSchema)} > +

+ This field has "This field is required" as its error message + in the zod schema. We shouldn't see a missing translation + error: +

+
+
+

+ This field has "ra.validation.required" as its error message + in the zod schema: +

diff --git a/packages/ra-core/src/form/ValidationError.tsx b/packages/ra-core/src/form/ValidationError.tsx index 5872aed594f..49501c0e8b8 100644 --- a/packages/ra-core/src/form/ValidationError.tsx +++ b/packages/ra-core/src/form/ValidationError.tsx @@ -9,6 +9,7 @@ export interface ValidationErrorProps { error: ValidationErrorMessage; } +const ValidationErrorSpecialFormatPrefix = '@@react-admin@@'; const ValidationError = (props: ValidationErrorProps) => { const { error } = props; let errorMessage = error; @@ -17,10 +18,14 @@ const ValidationError = (props: ValidationErrorProps) => { // that have message and args. // To avoid double translation for users that validate with a schema instead of our validators // we use a special format for our validators errors. - // The ValidationError component will check for this format and extract the message and args - // to translate. - if (typeof error === 'string' && error.startsWith('@@react-admin@@')) { - errorMessage = JSON.parse(error.replace('@@react-admin@@', '')); + // The useInput hook handle the special formatting + if ( + typeof error === 'string' && + error.startsWith(ValidationErrorSpecialFormatPrefix) + ) { + errorMessage = JSON.parse( + error.substring(ValidationErrorSpecialFormatPrefix.length) + ); } if ((errorMessage as ValidationErrorMessageWithArgs).message) { const { diff --git a/packages/ra-core/src/form/useGetValidationErrorMessage.ts b/packages/ra-core/src/form/useGetValidationErrorMessage.ts index 6209b4d6d3e..5ac07152aed 100644 --- a/packages/ra-core/src/form/useGetValidationErrorMessage.ts +++ b/packages/ra-core/src/form/useGetValidationErrorMessage.ts @@ -5,6 +5,7 @@ import { import { useTranslate } from '../i18n'; /** + * @deprecated * This internal hook returns a function that can translate an error message. * It handles simple string errors and those which have a message and args. * Only useful if you are implementing custom inputs without leveraging our useInput hook. From 2343b858b8072c8928b2faffff96bf4d7025a1e6 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Fri, 18 Aug 2023 11:34:07 +0200 Subject: [PATCH 4/9] Add more tests --- packages/ra-core/src/form/Form.spec.tsx | 78 +++++++++++- packages/ra-core/src/form/Form.stories.tsx | 112 +++++++++++++++--- packages/ra-core/src/form/ValidationError.tsx | 4 +- 3 files changed, 173 insertions(+), 21 deletions(-) diff --git a/packages/ra-core/src/form/Form.spec.tsx b/packages/ra-core/src/form/Form.spec.tsx index c7eb4798a85..c4882bc6a0e 100644 --- a/packages/ra-core/src/form/Form.spec.tsx +++ b/packages/ra-core/src/form/Form.spec.tsx @@ -10,8 +10,13 @@ import { Form } from './Form'; import { useNotificationContext } from '../notification'; import { useInput } from './useInput'; import { required } from './validate'; -import { SanitizeEmptyValues } from './Form.stories'; -import { NullValue } from './Form.stories'; +import { + FormLevelValidation, + InputLevelValidation, + ZodResolver, + SanitizeEmptyValues, + NullValue, +} from './Form.stories'; describe('Form', () => { const Input = props => { @@ -661,4 +666,73 @@ describe('Form', () => { expect(validate).toHaveBeenCalled(); }); }); + + it('should support validation messages translations at the form level without warnings', async () => { + const mock = jest.spyOn(console, 'error').mockImplementation(() => {}); + 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( + 'Missing translation for key: "ra.validation.required"' + ); + expect(mock).not.toHaveBeenCalledWith( + 'Missing translation for key: "app.validation.required"' + ); + expect(mock).toHaveBeenCalledWith( + 'Warning: Missing translation for key: "This field is required"' + ); + expect(mock).toHaveBeenCalledWith( + 'Warning: Missing translation for key: "app.validation.missing"' + ); + mock.mockRestore(); + }); + + it('should support validation messages translations at the input level without warnings', async () => { + const mock = jest.spyOn(console, 'error').mockImplementation(() => {}); + 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( + 'Missing translation for key: "ra.validation.required"' + ); + expect(mock).not.toHaveBeenCalledWith( + 'Missing translation for key: "app.validation.required"' + ); + expect(mock).toHaveBeenCalledWith( + 'Warning: Missing translation for key: "This field is required"' + ); + expect(mock).toHaveBeenCalledWith( + 'Warning: Missing translation for key: "app.validation.missing"' + ); + mock.mockRestore(); + }); + + it('should support validation messages translations when using a custom resolver without warnings', async () => { + const mock = jest.spyOn(console, 'error').mockImplementation(() => {}); + 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( + 'Missing translation for key: "ra.validation.required"' + ); + expect(mock).not.toHaveBeenCalledWith( + 'Missing translation for key: "app.validation.required"' + ); + expect(mock).toHaveBeenCalledWith( + 'Warning: Missing translation for key: "This field is required"' + ); + expect(mock).toHaveBeenCalledWith( + 'Warning: Missing translation for key: "app.validation.missing"' + ); + mock.mockRestore(); + }); }); diff --git a/packages/ra-core/src/form/Form.stories.tsx b/packages/ra-core/src/form/Form.stories.tsx index 68f5c04ac4a..878859e6252 100644 --- a/packages/ra-core/src/form/Form.stories.tsx +++ b/packages/ra-core/src/form/Form.stories.tsx @@ -6,10 +6,15 @@ import { } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; +import polyglotI18nProvider from 'ra-i18n-polyglot'; +import englishMessages from 'ra-language-english'; import { CoreAdminContext } from '../core'; import { Form } from './Form'; import { useInput } from './useInput'; +import { required } from './validate'; +import ValidationError from './ValidationError'; +import { mergeTranslations } from '../i18n'; export default { title: 'ra-core/form/Form', @@ -34,7 +39,9 @@ const Input = props => { aria-invalid={fieldState.invalid} {...field} /> -

{fieldState.error?.message}

+ {fieldState.error && fieldState.error.message ? ( + + ) : null} ); }; @@ -167,32 +174,103 @@ export const UndefinedValue = () => { ); }; +const i18nProvider = polyglotI18nProvider(() => + mergeTranslations(englishMessages, { + app: { validation: { required: 'This field must be provided' } }, + }) +); + +export const FormLevelValidation = () => { + const [submittedData, setSubmittedData] = React.useState(); + return ( + +
setSubmittedData(data)} + record={{ id: 1, field1: 'bar', field6: null }} + validate={(values: any) => { + const errors: any = {}; + if (!values.defaultMessage) { + errors.defaultMessage = 'ra.validation.required'; + } + if (!values.customMessage) { + errors.customMessage = 'This field is required'; + } + if (!values.customMessageTranslationKey) { + errors.customMessageTranslationKey = + 'app.validation.required'; + } + if (!values.missingCustomMessageTranslationKey) { + errors.missingCustomMessageTranslationKey = + 'app.validation.missing'; + } + return errors; + }} + > + + + + + +
+
{JSON.stringify(submittedData, null, 2)}
+
+ ); +}; + +export const InputLevelValidation = () => { + const [submittedData, setSubmittedData] = React.useState(); + return ( + +
setSubmittedData(data)} + record={{ id: 1, field1: 'bar', field6: null }} + > + + + + + +
+
{JSON.stringify(submittedData, null, 2)}
+
+ ); +}; + const zodSchema = z.object({ - preTranslated: z.string().min(5, { message: 'This field is required' }), - translationKey: z.string().min(5, { message: 'ra.validation.required' }), + defaultMessage: z.string(), //.min(1), + customMessage: z.string({ + required_error: 'This field is required', + }), + customMessageTranslationKey: z.string({ + required_error: 'app.validation.required', + }), + missingCustomMessageTranslationKey: z.string({ + required_error: 'app.validation.missing', + }), }); + export const ZodResolver = () => { const [result, setResult] = React.useState(); return ( - +
setResult(data)} resolver={zodResolver(zodSchema)} > -

- This field has "This field is required" as its error message - in the zod schema. We shouldn't see a missing translation - error: -

- -
-
-

- This field has "ra.validation.required" as its error message - in the zod schema: -

- + + + +
{JSON.stringify(result, null, 2)}
diff --git a/packages/ra-core/src/form/ValidationError.tsx b/packages/ra-core/src/form/ValidationError.tsx index 49501c0e8b8..a6b95a8bbcf 100644 --- a/packages/ra-core/src/form/ValidationError.tsx +++ b/packages/ra-core/src/form/ValidationError.tsx @@ -32,10 +32,10 @@ const ValidationError = (props: ValidationErrorProps) => { message, args, } = errorMessage as ValidationErrorMessageWithArgs; - return <>{translate(message, { _: message, ...args })}; + return <>{translate(message, args)}; } - return <>{translate(errorMessage as string, { _: errorMessage })}; + return <>{translate(errorMessage as string)}; }; export default ValidationError; From 3f8b05225a84c7bdc1752eb9e65b61f6d93459b9 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Fri, 18 Aug 2023 12:07:02 +0200 Subject: [PATCH 5/9] Fix ValidationError tests --- packages/ra-core/src/form/ValidationError.spec.tsx | 4 +++- packages/ra-core/src/form/ValidationError.tsx | 1 + packages/ra-core/src/i18n/TestTranslationProvider.tsx | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/ra-core/src/form/ValidationError.spec.tsx b/packages/ra-core/src/form/ValidationError.spec.tsx index e28d641ee2f..27e79bcded5 100644 --- a/packages/ra-core/src/form/ValidationError.spec.tsx +++ b/packages/ra-core/src/form/ValidationError.spec.tsx @@ -4,7 +4,9 @@ import { render } from '@testing-library/react'; import ValidationError from './ValidationError'; import { TestTranslationProvider } from '../i18n'; -const translate = jest.fn(key => key); +const translate = jest.fn(key => { + return key; +}); const renderWithTranslations = content => render( diff --git a/packages/ra-core/src/form/ValidationError.tsx b/packages/ra-core/src/form/ValidationError.tsx index a6b95a8bbcf..57cc3dd5c76 100644 --- a/packages/ra-core/src/form/ValidationError.tsx +++ b/packages/ra-core/src/form/ValidationError.tsx @@ -32,6 +32,7 @@ const ValidationError = (props: ValidationErrorProps) => { message, args, } = errorMessage as ValidationErrorMessageWithArgs; + console.log({ message, args }); return <>{translate(message, args)}; } diff --git a/packages/ra-core/src/i18n/TestTranslationProvider.tsx b/packages/ra-core/src/i18n/TestTranslationProvider.tsx index 42877b280f2..aad86867de0 100644 --- a/packages/ra-core/src/i18n/TestTranslationProvider.tsx +++ b/packages/ra-core/src/i18n/TestTranslationProvider.tsx @@ -13,11 +13,12 @@ export const TestTranslationProvider = ({ translate: messages ? (key: string, options?: any) => { const message = lodashGet(messages, key); + console.log({ key, options, message }); return message ? typeof message === 'function' ? message(options) : message - : options._; + : options?._ || key; } : translate, changeLocale: () => Promise.resolve(), From 1dbb806c306925621d94e3fd72570ce5df3b7383 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Fri, 18 Aug 2023 14:03:41 +0200 Subject: [PATCH 6/9] Remove debug code --- packages/ra-core/src/form/ValidationError.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ra-core/src/form/ValidationError.tsx b/packages/ra-core/src/form/ValidationError.tsx index 57cc3dd5c76..a6b95a8bbcf 100644 --- a/packages/ra-core/src/form/ValidationError.tsx +++ b/packages/ra-core/src/form/ValidationError.tsx @@ -32,7 +32,6 @@ const ValidationError = (props: ValidationErrorProps) => { message, args, } = errorMessage as ValidationErrorMessageWithArgs; - console.log({ message, args }); return <>{translate(message, args)}; } From 5fcc1b71cf9dc03fd8c3e87b2f2c6b0423a956c1 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Fri, 18 Aug 2023 14:43:30 +0200 Subject: [PATCH 7/9] Ensure no translations warnings are printed --- packages/ra-core/src/form/Form.spec.tsx | 12 ++++++------ packages/ra-core/src/form/ValidationError.tsx | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/ra-core/src/form/Form.spec.tsx b/packages/ra-core/src/form/Form.spec.tsx index c4882bc6a0e..4387f43b0fe 100644 --- a/packages/ra-core/src/form/Form.spec.tsx +++ b/packages/ra-core/src/form/Form.spec.tsx @@ -681,10 +681,10 @@ describe('Form', () => { expect(mock).not.toHaveBeenCalledWith( 'Missing translation for key: "app.validation.required"' ); - expect(mock).toHaveBeenCalledWith( + expect(mock).not.toHaveBeenCalledWith( 'Warning: Missing translation for key: "This field is required"' ); - expect(mock).toHaveBeenCalledWith( + expect(mock).not.toHaveBeenCalledWith( 'Warning: Missing translation for key: "app.validation.missing"' ); mock.mockRestore(); @@ -704,10 +704,10 @@ describe('Form', () => { expect(mock).not.toHaveBeenCalledWith( 'Missing translation for key: "app.validation.required"' ); - expect(mock).toHaveBeenCalledWith( + expect(mock).not.toHaveBeenCalledWith( 'Warning: Missing translation for key: "This field is required"' ); - expect(mock).toHaveBeenCalledWith( + expect(mock).not.toHaveBeenCalledWith( 'Warning: Missing translation for key: "app.validation.missing"' ); mock.mockRestore(); @@ -727,10 +727,10 @@ describe('Form', () => { expect(mock).not.toHaveBeenCalledWith( 'Missing translation for key: "app.validation.required"' ); - expect(mock).toHaveBeenCalledWith( + expect(mock).not.toHaveBeenCalledWith( 'Warning: Missing translation for key: "This field is required"' ); - expect(mock).toHaveBeenCalledWith( + expect(mock).not.toHaveBeenCalledWith( 'Warning: Missing translation for key: "app.validation.missing"' ); mock.mockRestore(); diff --git a/packages/ra-core/src/form/ValidationError.tsx b/packages/ra-core/src/form/ValidationError.tsx index a6b95a8bbcf..49501c0e8b8 100644 --- a/packages/ra-core/src/form/ValidationError.tsx +++ b/packages/ra-core/src/form/ValidationError.tsx @@ -32,10 +32,10 @@ const ValidationError = (props: ValidationErrorProps) => { message, args, } = errorMessage as ValidationErrorMessageWithArgs; - return <>{translate(message, args)}; + return <>{translate(message, { _: message, ...args })}; } - return <>{translate(errorMessage as string)}; + return <>{translate(errorMessage as string, { _: errorMessage })}; }; export default ValidationError; From 6a751398f37095d592633beb9c04e2e259262abd Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Fri, 18 Aug 2023 15:06:28 +0200 Subject: [PATCH 8/9] Ensure no double translations --- packages/ra-core/src/form/Form.spec.tsx | 62 ++++++++++------------ packages/ra-core/src/form/Form.stories.tsx | 21 ++++++-- 2 files changed, 46 insertions(+), 37 deletions(-) diff --git a/packages/ra-core/src/form/Form.spec.tsx b/packages/ra-core/src/form/Form.spec.tsx index 4387f43b0fe..1507738ed27 100644 --- a/packages/ra-core/src/form/Form.spec.tsx +++ b/packages/ra-core/src/form/Form.spec.tsx @@ -4,6 +4,8 @@ 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 { CoreAdminContext } from '../core'; import { Form } from './Form'; @@ -17,6 +19,8 @@ import { SanitizeEmptyValues, NullValue, } from './Form.stories'; +import { mergeTranslations } from '../i18n'; +import { de } from 'date-fns/locale'; describe('Form', () => { const Input = props => { @@ -667,72 +671,64 @@ describe('Form', () => { }); }); + 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(() => {}); - render(); + 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( - 'Missing translation for key: "ra.validation.required"' - ); - expect(mock).not.toHaveBeenCalledWith( - 'Missing translation for key: "app.validation.required"' - ); - expect(mock).not.toHaveBeenCalledWith( - 'Warning: Missing translation for key: "This field is required"' - ); - expect(mock).not.toHaveBeenCalledWith( - 'Warning: Missing translation for key: "app.validation.missing"' + 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(() => {}); - render(); + 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( - 'Missing translation for key: "ra.validation.required"' - ); - expect(mock).not.toHaveBeenCalledWith( - 'Missing translation for key: "app.validation.required"' - ); - expect(mock).not.toHaveBeenCalledWith( - 'Warning: Missing translation for key: "This field is required"' - ); - expect(mock).not.toHaveBeenCalledWith( - 'Warning: Missing translation for key: "app.validation.missing"' + 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(() => {}); - render(); + 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( - 'Missing translation for key: "ra.validation.required"' - ); - expect(mock).not.toHaveBeenCalledWith( - 'Missing translation for key: "app.validation.required"' - ); - expect(mock).not.toHaveBeenCalledWith( - 'Warning: Missing translation for key: "This field is required"' - ); - expect(mock).not.toHaveBeenCalledWith( - 'Warning: Missing translation for key: "app.validation.missing"' + 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(); }); }); diff --git a/packages/ra-core/src/form/Form.stories.tsx b/packages/ra-core/src/form/Form.stories.tsx index 878859e6252..b5397e5cdb4 100644 --- a/packages/ra-core/src/form/Form.stories.tsx +++ b/packages/ra-core/src/form/Form.stories.tsx @@ -15,6 +15,7 @@ import { useInput } from './useInput'; import { required } from './validate'; import ValidationError from './ValidationError'; import { mergeTranslations } from '../i18n'; +import { I18nProvider } from '../types'; export default { title: 'ra-core/form/Form', @@ -174,13 +175,17 @@ export const UndefinedValue = () => { ); }; -const i18nProvider = polyglotI18nProvider(() => +const defaultI18nProvider = polyglotI18nProvider(() => mergeTranslations(englishMessages, { app: { validation: { required: 'This field must be provided' } }, }) ); -export const FormLevelValidation = () => { +export const FormLevelValidation = ({ + i18nProvider = defaultI18nProvider, +}: { + i18nProvider?: I18nProvider; +}) => { const [submittedData, setSubmittedData] = React.useState(); return ( @@ -217,7 +222,11 @@ export const FormLevelValidation = () => { ); }; -export const InputLevelValidation = () => { +export const InputLevelValidation = ({ + i18nProvider = defaultI18nProvider, +}: { + i18nProvider?: I18nProvider; +}) => { const [submittedData, setSubmittedData] = React.useState(); return ( @@ -258,7 +267,11 @@ const zodSchema = z.object({ }), }); -export const ZodResolver = () => { +export const ZodResolver = ({ + i18nProvider = defaultI18nProvider, +}: { + i18nProvider?: I18nProvider; +}) => { const [result, setResult] = React.useState(); return ( From 4f7b36a909f48350075936379b012d30354d57a8 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Fri, 18 Aug 2023 15:37:13 +0200 Subject: [PATCH 9/9] Remove unused import --- packages/ra-core/src/form/Form.spec.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ra-core/src/form/Form.spec.tsx b/packages/ra-core/src/form/Form.spec.tsx index 1507738ed27..87c5d19942d 100644 --- a/packages/ra-core/src/form/Form.spec.tsx +++ b/packages/ra-core/src/form/Form.spec.tsx @@ -20,7 +20,6 @@ import { NullValue, } from './Form.stories'; import { mergeTranslations } from '../i18n'; -import { de } from 'date-fns/locale'; describe('Form', () => { const Input = props => {