From 83abd2a450204a4c13af0257a130a5ecff96f12c Mon Sep 17 00:00:00 2001
From: Gildas Garcia <1122076+djhi@users.noreply.github.com>
Date: Fri, 9 Apr 2021 17:20:53 +0200
Subject: [PATCH 1/3] Fix Validation Documentation
---
docs/CreateEdit.md | 40 ++++++++++++++++++++++++++++------------
1 file changed, 28 insertions(+), 12 deletions(-)
diff --git a/docs/CreateEdit.md b/docs/CreateEdit.md
index e569fca10b7..75639b5b62c 100644
--- a/docs/CreateEdit.md
+++ b/docs/CreateEdit.md
@@ -1215,12 +1215,17 @@ The value of the form `validate` prop must be a function taking the record as in
const validateUserCreation = (values) => {
const errors = {};
if (!values.firstName) {
- errors.firstName = ['The firstName is required'];
+ errors.firstName = 'The firstName is required';
}
if (!values.age) {
- errors.age = ['The age is required'];
+ // You can return translation keys
+ errors.age = 'ra.validation.required';
} else if (values.age < 18) {
- errors.age = ['Must be over 18'];
+ // Or an object if the translation messages need parameters
+ errors.age = {
+ message: 'ra.validation.minValue',
+ args: { min: 18 }
+ };
}
return errors
};
@@ -1272,7 +1277,7 @@ const validateFirstName = [required(), minLength(2), maxLength(15)];
const validateEmail = email();
const validateAge = [number(), minValue(18)];
const validateZipCode = regex(/^\d{5}$/, 'Must be a valid Zip Code');
-const validateSex = choices(['m', 'f'], 'Must be Male or Female');
+const validateGenre = choices(['m', 'f', 'nc'], 'Please choose one of the values');
export const UserCreate = (props) => (
@@ -1281,10 +1286,11 @@ export const UserCreate = (props) => (
-
+ { id: 'nc', name: 'Prefer not say' },
+ ]} validate={validateGenre}/>
);
@@ -1319,7 +1325,7 @@ const ageValidation = (value, allValues) => {
if (value < 18) {
return 'Must be over 18';
}
- return [];
+ return undefined;
};
const validateFirstName = [required(), maxLength(15)];
@@ -1414,17 +1420,25 @@ You can validate the entire form data server-side by returning a Promise in the
const validateUserCreation = async (values) => {
const errors = {};
if (!values.firstName) {
- errors.firstName = ['The firstName is required'];
+ errors.firstName = 'The firstName is required';
}
if (!values.age) {
- errors.age = ['The age is required'];
+ errors.age = 'The age is required';
} else if (values.age < 18) {
- errors.age = ['Must be over 18'];
+ errors.age = 'Must be over 18';
}
- const isEmailUnique = await checkEmailIsUnique(values.userName);
+ const isEmailUnique = await checkEmailIsUnique(values.email);
if (!isEmailUnique) {
- errors.email = ['Email already used'];
+ // Return a message directly
+ errors.email = 'Email already used';
+ // Or a translation key
+ errors.email = 'myapp.validation.email_not_unique';
+ // Or an object if the translation needs parameters
+ errors.email = {
+ message: 'myapp.validation.email_not_unique',
+ args: { email: values.email }
+ };
}
return errors
};
@@ -1515,6 +1529,8 @@ export const UserCreate = (props) => {
**Tip**: The shape of the returned validation errors must correspond to the form: a key needs to match a `source` prop.
+**Tip**: The returned validation errors might have any validation format we support (simple strings or object with message and args) for each key.
+
## Submit On Enter
By default, pressing `ENTER` in any of the form fields submits the form - this is the expected behavior in most cases. However, some of your custom input components (e.g. Google Maps widget) may have special handlers for the `ENTER` key. In that case, to disable the automated form submission on enter, set the `submitOnEnter` prop of the form component to `false`:
From 1368e354d530d06d4ce66734de4b433be29642a9 Mon Sep 17 00:00:00 2001
From: Gildas Garcia <1122076+djhi@users.noreply.github.com>
Date: Fri, 9 Apr 2021 17:21:22 +0200
Subject: [PATCH 2/3] Fix Submission Errors Cannot Have Translatable Error
Objects
---
examples/simple/src/users/UserEdit.tsx | 40 +++-
packages/ra-core/package.json | 1 -
.../ra-core/src/form/FormWithRedirect.tsx | 2 +-
packages/ra-core/src/form/ValidationError.tsx | 1 -
.../src/form/submitErrorsMutators.spec.tsx | 174 ++++++++++++++++++
.../ra-core/src/form/submitErrorsMutators.ts | 128 +++++++++++++
packages/react-admin/package.json | 1 -
yarn.lock | 5 -
8 files changed, 334 insertions(+), 18 deletions(-)
create mode 100644 packages/ra-core/src/form/submitErrorsMutators.spec.tsx
create mode 100644 packages/ra-core/src/form/submitErrorsMutators.ts
diff --git a/examples/simple/src/users/UserEdit.tsx b/examples/simple/src/users/UserEdit.tsx
index 56c23578f74..b62afc9c66b 100644
--- a/examples/simple/src/users/UserEdit.tsx
+++ b/examples/simple/src/users/UserEdit.tsx
@@ -53,16 +53,26 @@ const EditActions = ({ basePath, data, hasShow }) => (
);
-const UserEdit = ({ permissions, ...props }) => (
- }
- aside={}
- actions={}
- {...props}
- >
+const UserEditForm = ({ permissions, save, ...props }) => {
+ const newSave = values =>
+ new Promise((resolve, reject) => {
+ if (values.name === 'test') {
+ return resolve({
+ name: {
+ message: 'ra.validation.minLength',
+ args: { min: 10 },
+ },
+ });
+ }
+ return save(values);
+ });
+
+ return (
}
+ {...props}
+ save={newSave}
>
{permissions === 'admin' && }
@@ -87,8 +97,20 @@ const UserEdit = ({ permissions, ...props }) => (
)}
-
-);
+ );
+};
+const UserEdit = ({ permissions, ...props }) => {
+ return (
+ }
+ aside={}
+ actions={}
+ {...props}
+ >
+
+
+ );
+};
UserEdit.propTypes = {
id: PropTypes.any.isRequired,
diff --git a/packages/ra-core/package.json b/packages/ra-core/package.json
index 0ee836e0def..4cd94ab687c 100644
--- a/packages/ra-core/package.json
+++ b/packages/ra-core/package.json
@@ -55,7 +55,6 @@
"peerDependencies": {
"connected-react-router": "^6.5.2",
"final-form": "^4.20.2",
- "final-form-submit-errors": "^0.1.2",
"react": "^16.9.0 || ^17.0.0",
"react-dom": "^16.9.0 || ^17.0.0",
"react-final-form": "^6.5.2",
diff --git a/packages/ra-core/src/form/FormWithRedirect.tsx b/packages/ra-core/src/form/FormWithRedirect.tsx
index 68dead23dd2..9d91028602c 100644
--- a/packages/ra-core/src/form/FormWithRedirect.tsx
+++ b/packages/ra-core/src/form/FormWithRedirect.tsx
@@ -2,7 +2,6 @@ import * as React from 'react';
import { useRef, useCallback, useEffect, useMemo } from 'react';
import { Form, FormProps, FormRenderProps } from 'react-final-form';
import arrayMutators from 'final-form-arrays';
-import { submitErrorsMutators } from 'final-form-submit-errors';
import useInitializeFormWithRecord from './useInitializeFormWithRecord';
import useWarnWhenUnsavedChanges from './useWarnWhenUnsavedChanges';
@@ -19,6 +18,7 @@ import { RedirectionSideEffect } from '../sideEffect';
import { useDispatch } from 'react-redux';
import { setAutomaticRefresh } from '../actions/uiActions';
import { FormContextProvider } from './FormContextProvider';
+import submitErrorsMutators from './submitErrorsMutators';
/**
* Wrapper around react-final-form's Form to handle redirection on submit,
diff --git a/packages/ra-core/src/form/ValidationError.tsx b/packages/ra-core/src/form/ValidationError.tsx
index 3dd85e247d7..55338593385 100644
--- a/packages/ra-core/src/form/ValidationError.tsx
+++ b/packages/ra-core/src/form/ValidationError.tsx
@@ -12,7 +12,6 @@ interface Props {
const ValidationError: FunctionComponent = ({ error }) => {
const translate = useTranslate();
-
if ((error as ValidationErrorMessageWithArgs).message) {
const { message, args } = error as ValidationErrorMessageWithArgs;
return <>{translate(message, { _: message, ...args })}>;
diff --git a/packages/ra-core/src/form/submitErrorsMutators.spec.tsx b/packages/ra-core/src/form/submitErrorsMutators.spec.tsx
new file mode 100644
index 00000000000..f85d59d79ab
--- /dev/null
+++ b/packages/ra-core/src/form/submitErrorsMutators.spec.tsx
@@ -0,0 +1,174 @@
+import { getIn, setIn } from 'final-form';
+
+import { resetSubmitErrors } from './submitErrorsMutators';
+
+const makeFormState = ({
+ submitErrors,
+ submitError,
+}: {
+ submitErrors?: any;
+ submitError?: any;
+}) => ({
+ formState: {
+ submitError,
+ submitErrors,
+ },
+});
+
+describe('submitErrorsMutators', () => {
+ test('should ignore when no changes occur', () => {
+ const prev = {
+ value: 'hello',
+ };
+
+ const current = {
+ value: 'hello',
+ };
+
+ const state = makeFormState({
+ submitErrors: {
+ value: 'error',
+ },
+ });
+
+ resetSubmitErrors([{ prev, current }], state, { getIn, setIn });
+
+ expect(state.formState.submitErrors).toEqual({
+ value: 'error',
+ });
+ });
+
+ test('should reset errors for basic types', () => {
+ const prev = {
+ bool: true,
+ null: null,
+ number: 1,
+ string: 'one',
+ };
+
+ const current = {
+ bool: false,
+ null: undefined,
+ number: 2,
+ string: 'two',
+ };
+
+ const state = makeFormState({
+ submitErrors: {
+ bool: 'error',
+ null: 'error',
+ number: 'error',
+ string: 'error',
+ },
+ });
+
+ resetSubmitErrors([{ prev, current }], state, { getIn, setIn });
+
+ expect(state.formState.submitErrors).toEqual({});
+ });
+
+ test('should reset errors for nested objects', () => {
+ const prev = {
+ nested: {
+ deep: {
+ field: 'one',
+ other: {
+ field: 'two',
+ },
+ },
+ },
+ };
+
+ const current = {
+ nested: {
+ deep: {
+ field: 'two',
+ },
+ },
+ };
+
+ const state = makeFormState({
+ submitErrors: {
+ nested: {
+ deep: {
+ field: 'error',
+ other: 'error',
+ },
+ },
+ },
+ });
+
+ resetSubmitErrors([{ prev, current }], state, { getIn, setIn });
+
+ expect(state.formState.submitErrors).toEqual({});
+ });
+
+ test('should reset errors for arrays', () => {
+ const prev = {
+ array: [
+ {
+ some: [1, 2],
+ },
+ {
+ value: 'one',
+ },
+ 1,
+ ],
+ };
+
+ const current = {
+ array: [
+ {
+ some: [2],
+ },
+ {
+ value: 'one',
+ },
+ 2,
+ ],
+ };
+
+ const state = makeFormState({
+ submitErrors: {
+ array: [
+ {
+ some: ['error', 'error'],
+ },
+ {
+ value: 'error',
+ },
+ 'error',
+ ],
+ },
+ });
+
+ resetSubmitErrors([{ prev, current }], state, { getIn, setIn });
+
+ expect(state.formState.submitErrors).toEqual({
+ array: [undefined, { value: 'error' }],
+ });
+ });
+
+ test('should reset errors for validation error objects', () => {
+ const prev = {
+ field: 'aaaa',
+ };
+
+ const current = {
+ field: 'aaaaa',
+ };
+
+ const state = makeFormState({
+ submitErrors: {
+ field: {
+ message: 'ra.validation.min_length',
+ args: { min: 5 },
+ },
+ },
+ });
+
+ resetSubmitErrors([{ prev, current }], state, { getIn, setIn });
+
+ expect(state.formState.submitErrors).toEqual({});
+ });
+});
diff --git a/packages/ra-core/src/form/submitErrorsMutators.ts b/packages/ra-core/src/form/submitErrorsMutators.ts
new file mode 100644
index 00000000000..079e46bd582
--- /dev/null
+++ b/packages/ra-core/src/form/submitErrorsMutators.ts
@@ -0,0 +1,128 @@
+/**
+ * Fork of the https://github.com/ignatevdev/final-form-submit-errors
+ *
+ * In react-admin, errors might be objects with `message` and `args` properties which
+ * are used for translation.
+ * The original final-form-submit-errors mutator was considering those errors as form
+ * nested field like `name.message` and `name.args`.
+ * This version detects those objects.
+ */
+
+export function resetSubmitErrors(
+ [{ prev, current }],
+ state,
+ { getIn, setIn }
+) {
+ // Reset the general submit error on any value change
+ if (state.formState.submitError) {
+ delete state.formState.submitError;
+ }
+
+ if (!isObjectEmpty(state.formState.submitErrors)) {
+ // Flatten nested errors object for easier comparison
+ const flatErrors = flatten(state.formState.submitErrors);
+
+ const changed = [];
+
+ // Iterate over each error
+ Object.keys(flatErrors).forEach(key => {
+ // Compare the value for the error field path
+ if (getIn(prev, key) !== getIn(current, key)) {
+ changed.push(key);
+ }
+ });
+
+ // Reset the error on value change
+ if (changed.length) {
+ let newErrors = state.formState.submitErrors;
+
+ changed.forEach(key => {
+ newErrors = setIn(newErrors, key, null);
+ });
+
+ // Clear submit errors from empty objects and arrays
+ const cleanErrors = clean(newErrors);
+
+ state.formState.submitErrors = cleanErrors;
+ }
+ }
+}
+
+export function clean(obj: any) {
+ const newObj: any = Array.isArray(obj) ? [] : {};
+
+ Object.keys(obj).forEach(key => {
+ if (obj[key] && typeof obj[key] === 'object') {
+ const newVal = clean(obj[key]);
+
+ if (!isObjectEmpty(newVal) && newVal.length !== 0) {
+ newObj[key] = newVal;
+ }
+ } else if (obj[key] !== null) {
+ newObj[key] = obj[key];
+ }
+ });
+
+ return newObj;
+}
+
+export function flatten(obj: any) {
+ const toReturn: any = {};
+
+ for (const i in obj) {
+ if (!obj.hasOwnProperty(i)) {
+ continue;
+ }
+
+ if (
+ typeof obj[i] === 'object' &&
+ obj[i] !== null &&
+ !isValidationError(obj[i])
+ ) {
+ const flatObject = flatten(obj[i]);
+
+ for (const x in flatObject) {
+ if (!flatObject.hasOwnProperty(x)) {
+ continue;
+ }
+
+ // Make a bracket array notation like some[1].array[0]
+ const key = `${i}.${x}`.split('.').reduce((str, value) => {
+ if (/^\[\d\]/.test(value)) {
+ return `${str}${value}`;
+ }
+
+ if (!isNaN(Number(value))) {
+ return `${str}[${value}]`;
+ }
+
+ if (str) {
+ return `${str}.${value}`;
+ }
+
+ return value;
+ }, '');
+
+ toReturn[key] = flatObject[x];
+ }
+ } else {
+ toReturn[i] = obj[i];
+ }
+ }
+
+ return toReturn;
+}
+
+export const isValidationError = (obj: any) => obj.message && obj.args;
+
+export function isObjectEmpty(obj: any) {
+ if (!obj) {
+ return true;
+ }
+
+ return Object.entries(obj).length === 0 && obj.constructor === Object;
+}
+
+export default {
+ resetSubmitErrors,
+};
diff --git a/packages/react-admin/package.json b/packages/react-admin/package.json
index a9a6ee1fd6f..4bf6e9f706c 100644
--- a/packages/react-admin/package.json
+++ b/packages/react-admin/package.json
@@ -40,7 +40,6 @@
"connected-react-router": "^6.5.2",
"final-form": "^4.20.2",
"final-form-arrays": "^3.0.2",
- "final-form-submit-errors": "^0.1.2",
"ra-core": "^3.14.2",
"ra-i18n-polyglot": "^3.14.2",
"ra-language-english": "^3.14.2",
diff --git a/yarn.lock b/yarn.lock
index c675d8fd963..2c2dcda3d7b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8219,11 +8219,6 @@ final-form-arrays@^3.0.2:
resolved "https://registry.yarnpkg.com/final-form-arrays/-/final-form-arrays-3.0.2.tgz#9f3bef778dec61432357744eb6f3abef7e7f3847"
integrity sha512-TfO8aZNz3RrsZCDx8GHMQcyztDNpGxSSi9w4wpSNKlmv2PfFWVVM8P7Yj5tj4n0OWax+x5YwTLhT5BnqSlCi+w==
-final-form-submit-errors@^0.1.2:
- version "0.1.2"
- resolved "https://registry.yarnpkg.com/final-form-submit-errors/-/final-form-submit-errors-0.1.2.tgz#1c59d10386d7b5a1a5e89f9389fa7cf85d1255c5"
- integrity sha512-2EwHSdf9Sy80bnsGRrDKBLp5C2uY7hL65o8tpqK/FWxyBpXtT519SsIZC4etLpGen2kjFCpYrxYkzFUfbIEXsQ==
-
final-form@^4.20.2:
version "4.20.2"
resolved "https://registry.yarnpkg.com/final-form/-/final-form-4.20.2.tgz#c820b37d7ebb73d71169480256a36c7e6e6c9155"
From e97d95e667d66cb62097d54e7c60101f98435d0e Mon Sep 17 00:00:00 2001
From: Gildas Garcia <1122076+djhi@users.noreply.github.com>
Date: Fri, 9 Apr 2021 17:45:57 +0200
Subject: [PATCH 3/3] Apply El Corrector review
---
docs/CreateEdit.md | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/docs/CreateEdit.md b/docs/CreateEdit.md
index 75639b5b62c..94484181a0c 100644
--- a/docs/CreateEdit.md
+++ b/docs/CreateEdit.md
@@ -1277,7 +1277,7 @@ const validateFirstName = [required(), minLength(2), maxLength(15)];
const validateEmail = email();
const validateAge = [number(), minValue(18)];
const validateZipCode = regex(/^\d{5}$/, 'Must be a valid Zip Code');
-const validateGenre = choices(['m', 'f', 'nc'], 'Please choose one of the values');
+const validateGender = choices(['m', 'f', 'nc'], 'Please choose one of the values');
export const UserCreate = (props) => (
@@ -1286,11 +1286,11 @@ export const UserCreate = (props) => (
-
+ ]} validate={validateGender}/>
);