diff --git a/Makefile b/Makefile
index bbe4dd34367..cfa89b624e1 100644
--- a/Makefile
+++ b/Makefile
@@ -118,7 +118,7 @@ test-unit: ## launch unit tests
test-unit-watch: ## launch unit tests and watch for changes
echo "Running unit tests..."; \
- yarn -s test-unit; \
+ yarn -s test-unit --watch; \
test-e2e: ## launch end-to-end tests
@if [ "$(build)" != "false" ]; then \
diff --git a/UPGRADE.md b/UPGRADE.md
index bb4267060dc..f452c412fc6 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -219,4 +219,57 @@ const App = () => (
// ...
);
-```
\ No newline at end of file
+```
+
+## Validators Should Return Non-Translated Messages
+
+Form validators used to return translated error messages - that's why they received the field `props` as argument, including the `translate` function. They don't receive these props anymore, and they must return unstranslated messages instead - react-admin translates validation messages afterwards.
+
+```diff
+// in validators/required.js
+-const required = () => (value, allValues, props) =>
++const required = () => (value, allValues) =>
+ value
+ ? undefined
+- : props.translate('myroot.validation.required');
++ : 'myroot.validation.required';
+```
+
+In case the error message depends on a variable, you can return an object `{ message, args }` instead of a message string:
+
+```diff
+-const minLength = (min) => (value, allValues, props) =>
++const minLength = (min) => (value, allValues) =>
+ value.length >= min
+ ? undefined
+- : props.translate('myroot.validation.minLength', { min });
++ : { message: 'myroot.validation.minLength', args: { min } };
+```
+
+React-admin core validators have been modified so you don't have to change anything when using them.
+
+```jsx
+import {
+ required,
+ minLength,
+ maxLength,
+ minValue,
+ number,
+ email,
+} from 'react-admin';
+
+// no change vs 2.x
+const validateFirstName = [required(), minLength(2), maxLength(15)];
+const validateEmail = email();
+const validateAge = [number(), minValue(18)];
+
+export const UserCreate = (props) => (
+
+
+
+
+
+
+
+);
+```
diff --git a/docs/CreateEdit.md b/docs/CreateEdit.md
index 567a1d781fd..daa4bb2220f 100644
--- a/docs/CreateEdit.md
+++ b/docs/CreateEdit.md
@@ -565,13 +565,46 @@ React-admin will combine all the input-level functions into a single function lo
Input validation functions receive the current field value, and the values of all fields of the current record. This allows for complex validation scenarios (e.g. validate that two passwords are the same).
-**Tip**: Validator functions receive the form `props` as third parameter, including the `translate` function. This lets you build internationalized validators:
+**Tip**: If your admin has multi-language support, validator functions should return message *identifiers* rather than messages themselves. React-admin automatically passes these identifiers to the translation function:
```jsx
-const required = (message = 'myroot.validation.required') =>
- (value, allValues, props) => value ? undefined : props.translate(message);
+// in validators/required.js
+const required = () => (value, allValues, props) =>
+ value
+ ? undefined
+ : 'myroot.validation.required';
+
+// in i18n/en.json
+export default {
+ myroot: {
+ validation: {
+ required: 'Required field',
+ }
+ }
+}
```
+If the translation depends on a variable, the validator can return an object rather than a translation identifier:
+
+```jsx
+// in validators/minLength.js
+const minLength = (min) => (value, allValues, props) =>
+ value.length >= min
+ ? undefined
+ : { message: 'myroot.validation.minLength', args: { min } };
+
+// in i18n/en.js
+export default {
+ myroot: {
+ validation: {
+ minLength: 'Must be %{min} characters at least',
+ }
+ }
+}
+```
+
+See the [Translation documentation](Translation.md#translating-error-messages) for details.
+
**Tip**: Make sure to define validation functions or array of functions in a variable, instead of defining them directly in JSX. This can result in a new function or array at every render, and trigger infinite rerender.
{% raw %}
diff --git a/docs/Translation.md b/docs/Translation.md
index acbfc081bcc..9d47985808b 100644
--- a/docs/Translation.md
+++ b/docs/Translation.md
@@ -276,6 +276,46 @@ const App = () => (
);
```
+## Translating Error Messages
+
+In Create and Edit views, forms can use custom validators. These validator functions should return translation keys rather than translated messages. React-admin automatically passes these identifiers to the translation function:
+
+```jsx
+// in validators/required.js
+const required = () => (value, allValues, props) =>
+ value
+ ? undefined
+ : 'myroot.validation.required';
+
+// in i18n/en.json
+export default {
+ myroot: {
+ validation: {
+ required: 'Required field',
+ }
+ }
+}
+```
+
+If the translation depends on a variable, the validator can return an object rather than a translation identifier:
+
+```jsx
+// in validators/minLength.js
+const minLength = (min) => (value, allValues, props) =>
+ value.length >= min
+ ? undefined
+ : { message: 'myroot.validation.minLength', args: { min } };
+
+// in i18n/en.js
+export default {
+ myroot: {
+ validation: {
+ minLength: 'Must be %{min} characters at least',
+ }
+ }
+}
+```
+
## `useTranslate` Hook
If you need to translate messages in your own components, React-admin provides a `useTranslate` hook, which returns the `translate` function:
diff --git a/examples/simple/src/comments/PostReferenceInput.js b/examples/simple/src/comments/PostReferenceInput.js
index 10587a95a25..57a8dd9cd04 100644
--- a/examples/simple/src/comments/PostReferenceInput.js
+++ b/examples/simple/src/comments/PostReferenceInput.js
@@ -49,28 +49,37 @@ const PostReferenceInput = props => {
);
}, [dispatch, newPostId]);
- const handleNewClick = useCallback(event => {
- event.preventDefault();
- setShowCreateDialog(true);
- }, []);
+ const handleNewClick = useCallback(
+ event => {
+ event.preventDefault();
+ setShowCreateDialog(true);
+ },
+ [setShowCreateDialog]
+ );
- const handleShowClick = useCallback(event => {
- event.preventDefault();
- setShowPreviewDialog(true);
- }, []);
+ const handleShowClick = useCallback(
+ event => {
+ event.preventDefault();
+ setShowPreviewDialog(true);
+ },
+ [setShowPreviewDialog]
+ );
const handleCloseCreate = useCallback(() => {
setShowCreateDialog(false);
- }, []);
+ }, [setShowCreateDialog]);
const handleCloseShow = useCallback(() => {
setShowPreviewDialog(false);
- }, []);
+ }, [setShowPreviewDialog]);
- const handleSave = useCallback(post => {
- setShowCreateDialog(false);
- setNewPostId(post.id);
- }, []);
+ const handleSave = useCallback(
+ post => {
+ setShowCreateDialog(false);
+ setNewPostId(post.id);
+ },
+ [setShowCreateDialog, setNewPostId]
+ );
return (
diff --git a/examples/simple/src/posts/PostCreate.js b/examples/simple/src/posts/PostCreate.js
index 7b0a0523418..04822264865 100644
--- a/examples/simple/src/posts/PostCreate.js
+++ b/examples/simple/src/posts/PostCreate.js
@@ -79,12 +79,12 @@ const PostCreate = ({ permissions, ...props }) => (
const errors = {};
['title', 'teaser'].forEach(field => {
if (!values[field]) {
- errors[field] = ['Required field'];
+ errors[field] = 'Required field';
}
});
if (values.average_note < 0 || values.average_note > 5) {
- errors.average_note = ['Should be between 0 and 5'];
+ errors.average_note = 'Should be between 0 and 5';
}
return errors;
diff --git a/packages/ra-core/package-lock.json b/packages/ra-core/package-lock.json
deleted file mode 100644
index f6035556092..00000000000
--- a/packages/ra-core/package-lock.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "name": "ra-core",
- "version": "2.4.1",
- "lockfileVersion": 1,
- "requires": true,
- "dependencies": {
- "@sheerun/mutationobserver-shim": {
- "version": "0.3.2",
- "resolved": "https://registry.npmjs.org/@sheerun/mutationobserver-shim/-/mutationobserver-shim-0.3.2.tgz",
- "integrity": "sha512-vTCdPp/T/Q3oSqwHmZ5Kpa9oI7iLtGl3RQaA/NyLHikvcrPxACkkKVr/XzkSPJWXHRhKGzVvb0urJsbMlRxi1Q==",
- "dev": true
- },
- "ansi-regex": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
- "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=",
- "dev": true
- },
- "ansi-styles": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
- "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
- "dev": true,
- "requires": {
- "color-convert": "^1.9.0"
- }
- },
- "color-convert": {
- "version": "1.9.3",
- "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
- "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
- "dev": true,
- "requires": {
- "color-name": "1.1.3"
- }
- },
- "color-name": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
- "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
- "dev": true
- },
- "dom-testing-library": {
- "version": "3.12.2",
- "resolved": "https://registry.npmjs.org/dom-testing-library/-/dom-testing-library-3.12.2.tgz",
- "integrity": "sha512-kMj5UFm5lSxnMDtD6XWkJuzK0CX+XdMd5NSnvtpneeYQFORTF5Q5C6mdweH6hhwFcKYzAsHCfnvldg9c8HuFOA==",
- "dev": true,
- "requires": {
- "@sheerun/mutationobserver-shim": "^0.3.2",
- "pretty-format": "^23.6.0",
- "wait-for-expect": "^1.0.0"
- }
- },
- "pretty-format": {
- "version": "23.6.0",
- "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.6.0.tgz",
- "integrity": "sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw==",
- "dev": true,
- "requires": {
- "ansi-regex": "^3.0.0",
- "ansi-styles": "^3.2.0"
- }
- },
- "react-testing-library": {
- "version": "5.2.3",
- "resolved": "https://registry.npmjs.org/react-testing-library/-/react-testing-library-5.2.3.tgz",
- "integrity": "sha512-Bw52++7uORuIQnL55lK/WQfppqAc9+8yFG4lWUp/kmSOvYDnt8J9oI5fNCfAGSQi9iIhAv9aNsI2G5rtid0nrA==",
- "dev": true,
- "requires": {
- "dom-testing-library": "^3.12.0"
- }
- },
- "wait-for-expect": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/wait-for-expect/-/wait-for-expect-1.0.1.tgz",
- "integrity": "sha512-TPZMSxGWUl2DWmqdspLDEy97/S1Mqq0pzbh2A7jTq0WbJurUb5GKli+bai6ayeYdeWTF0rQNWZmUvCVZ9gkrfA==",
- "dev": true
- }
- }
-}
diff --git a/packages/ra-core/src/form/ValidationError.spec.tsx b/packages/ra-core/src/form/ValidationError.spec.tsx
new file mode 100644
index 00000000000..0b3edbf0c2a
--- /dev/null
+++ b/packages/ra-core/src/form/ValidationError.spec.tsx
@@ -0,0 +1,110 @@
+import React from 'react';
+import { render, cleanup } from 'react-testing-library';
+
+import ValidationError from './ValidationError';
+import { TranslationProvider } from '../i18n';
+import TestContext from '../util/TestContext';
+
+const translate = jest.fn(key => key);
+
+const renderWithTranslations = content =>
+ render(
+
+ {content}
+
+ );
+
+describe('ValidationError', () => {
+ afterEach(() => {
+ cleanup();
+ translate.mockClear();
+ });
+
+ it('renders the error message if it is a string and no translation is found', () => {
+ const { getByText } = renderWithTranslations(
+
+ );
+
+ expect(getByText('message_missing')).toBeTruthy();
+ });
+
+ it('renders the error message translated if it is a string', () => {
+ const { getByText } = renderWithTranslations(
+
+ );
+
+ expect(getByText('Required')).toBeTruthy();
+ });
+
+ it('renders the error message if it is an object and no translation is found', () => {
+ const { getByText } = renderWithTranslations(
+
+ );
+
+ expect(getByText('message_missing')).toBeTruthy();
+ });
+
+ it('renders the error message translated if it is an object, interpolating numbers', () => {
+ const { getByText } = renderWithTranslations(
+
+ );
+
+ expect(getByText('Min Value 10')).toBeDefined();
+ });
+
+ it('renders the error message translated if it is an object, interpolating strings', () => {
+ const { getByText } = renderWithTranslations(
+
+ );
+
+ expect(getByText('Must match IAmMatch')).toBeDefined();
+ });
+
+ it('renders the error message translated if it is an object, interpolating arrays', () => {
+ const { getByText } = renderWithTranslations(
+
+ );
+
+ expect(getByText('Must be one of foo,bar')).toBeDefined();
+ });
+});
diff --git a/packages/ra-core/src/form/ValidationError.tsx b/packages/ra-core/src/form/ValidationError.tsx
new file mode 100644
index 00000000000..68ac5551c38
--- /dev/null
+++ b/packages/ra-core/src/form/ValidationError.tsx
@@ -0,0 +1,23 @@
+import React, { FunctionComponent } from 'react';
+import {
+ ValidationErrorMessage,
+ ValidationErrorMessageWithArgs,
+} from './validate';
+import { useTranslate } from '../i18n';
+
+interface Props {
+ error: ValidationErrorMessage;
+}
+
+const ValidationError: FunctionComponent = ({ error }) => {
+ const translate = useTranslate();
+
+ if ((error as ValidationErrorMessageWithArgs).message) {
+ const { message, args } = error as ValidationErrorMessageWithArgs;
+ return <>{translate(message, { _: message, ...args })}>;
+ }
+
+ return <>{translate(error as string, { _: error })}>;
+};
+
+export default ValidationError;
diff --git a/packages/ra-core/src/form/index.ts b/packages/ra-core/src/form/index.ts
index 2f36703da7d..661c8390161 100644
--- a/packages/ra-core/src/form/index.ts
+++ b/packages/ra-core/src/form/index.ts
@@ -4,6 +4,7 @@ import FormField from './FormField';
import formMiddleware from './formMiddleware';
import getDefaultValues from './getDefaultValues';
import withDefaultValue from './withDefaultValue';
+import ValidationError from './ValidationError';
export {
addField,
@@ -12,6 +13,7 @@ export {
formMiddleware,
getDefaultValues,
withDefaultValue,
+ ValidationError,
};
export { isRequired } from './FormField';
export * from './validate';
diff --git a/packages/ra-core/src/form/validate.spec.ts b/packages/ra-core/src/form/validate.spec.ts
index eb933e055f6..8f6da9717ab 100644
--- a/packages/ra-core/src/form/validate.spec.ts
+++ b/packages/ra-core/src/form/validate.spec.ts
@@ -1,4 +1,5 @@
import assert from 'assert';
+
import {
required,
minLength,
@@ -12,12 +13,11 @@ import {
} from './validate';
describe('Validators', () => {
- const translate = x => x;
const test = (validator, inputs, message) =>
assert.deepEqual(
inputs
- .map(input => validator(input, null, { translate }))
- .filter(m => m === message),
+ .map(input => validator(input, null))
+ .map(error => (error && error.message ? error.message : error)),
Array(...Array(inputs.length)).map(() => message)
);
describe('required', () => {
@@ -42,7 +42,6 @@ describe('Validators', () => {
args: undefined,
value: null,
values: null,
- translate,
});
});
});
@@ -71,7 +70,6 @@ describe('Validators', () => {
args: { min: 5 },
value: '12',
values: null,
- translate,
});
});
});
@@ -100,7 +98,6 @@ describe('Validators', () => {
args: { max: 10 },
value: '12345678901',
values: null,
- translate,
});
});
});
@@ -125,7 +122,6 @@ describe('Validators', () => {
args: { min: 10 },
value: 0,
values: null,
- translate,
});
});
});
@@ -154,7 +150,6 @@ describe('Validators', () => {
args: { max: 10 },
value: '11',
values: null,
- translate,
});
});
});
@@ -176,7 +171,6 @@ describe('Validators', () => {
args: undefined,
value: 'foo',
values: null,
- translate,
});
});
});
diff --git a/packages/ra-core/src/form/validate.ts b/packages/ra-core/src/form/validate.ts
index 373cbb19138..f07033141ee 100644
--- a/packages/ra-core/src/form/validate.ts
+++ b/packages/ra-core/src/form/validate.ts
@@ -1,5 +1,4 @@
import lodashMemoize from 'lodash/memoize';
-import { Translate } from '../types';
/* eslint-disable no-underscore-dangle */
/* @link http://stackoverflow.com/questions/46155/validate-email-address-in-javascript */
@@ -8,42 +7,47 @@ const EMAIL_REGEX = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"
const isEmpty = (value: any) =>
typeof value === 'undefined' || value === null || value === '';
+export interface ValidationErrorMessageWithArgs {
+ message: string;
+ args: {
+ [key: string]: ValidationErrorMessageWithArgs | any;
+ };
+}
+
+export type ValidationErrorMessage = string | ValidationErrorMessageWithArgs;
+
export type Validator = (
value: any,
values: any,
props: any
-) => string | null | undefined;
+) => ValidationErrorMessage | null | undefined;
interface MessageFuncParams {
args: any;
value: any;
values: any;
- translate: Translate;
- [key: string]: any;
}
-type MessageFunc = (params: MessageFuncParams) => string;
+type MessageFunc = (params: MessageFuncParams) => ValidationErrorMessage;
const getMessage = (
message: string | MessageFunc,
messageArgs: any,
value: any,
- values: any,
- props: {
- translate: Translate;
- }
+ values: any
) =>
typeof message === 'function'
? message({
args: messageArgs,
value,
values,
- ...props,
})
- : props.translate(message, {
- _: message,
- ...messageArgs,
- });
+ : messageArgs
+ ? {
+ message,
+ args: messageArgs,
+ }
+ : message;
type Memoize = any>(
func: T,
@@ -70,9 +74,9 @@ const memoize: Memoize = (fn: any) =>
*/
export const required = memoize((message = 'ra.validation.required') =>
Object.assign(
- (value, values, props) =>
+ (value, values) =>
isEmpty(value)
- ? getMessage(message, undefined, value, values, props)
+ ? getMessage(message, undefined, value, values)
: undefined,
{ isRequired: true }
)
@@ -92,9 +96,9 @@ export const required = memoize((message = 'ra.validation.required') =>
*
*/
export const minLength = memoize(
- (min, message = 'ra.validation.minLength') => (value, values, props) =>
+ (min, message = 'ra.validation.minLength') => (value, values) =>
!isEmpty(value) && value.length < min
- ? getMessage(message, { min }, value, values, props)
+ ? getMessage(message, { min }, value, values)
: undefined
);
@@ -112,9 +116,9 @@ export const minLength = memoize(
*
*/
export const maxLength = memoize(
- (max, message = 'ra.validation.maxLength') => (value, values, props) =>
+ (max, message = 'ra.validation.maxLength') => (value, values) =>
!isEmpty(value) && value.length > max
- ? getMessage(message, { max }, value, values, props)
+ ? getMessage(message, { max }, value, values)
: undefined
);
@@ -132,9 +136,9 @@ export const maxLength = memoize(
*
*/
export const minValue = memoize(
- (min, message = 'ra.validation.minValue') => (value, values, props) =>
+ (min, message = 'ra.validation.minValue') => (value, values) =>
!isEmpty(value) && value < min
- ? getMessage(message, { min }, value, values, props)
+ ? getMessage(message, { min }, value, values)
: undefined
);
@@ -152,9 +156,9 @@ export const minValue = memoize(
*
*/
export const maxValue = memoize(
- (max, message = 'ra.validation.maxValue') => (value, values, props) =>
+ (max, message = 'ra.validation.maxValue') => (value, values) =>
!isEmpty(value) && value > max
- ? getMessage(message, { max }, value, values, props)
+ ? getMessage(message, { max }, value, values)
: undefined
);
@@ -171,9 +175,9 @@ export const maxValue = memoize(
*
*/
export const number = memoize(
- (message = 'ra.validation.number') => (value, values, props) =>
+ (message = 'ra.validation.number') => (value, values) =>
!isEmpty(value) && isNaN(Number(value))
- ? getMessage(message, undefined, value, values, props)
+ ? getMessage(message, undefined, value, values)
: undefined
);
@@ -191,9 +195,9 @@ export const number = memoize(
*
*/
export const regex = lodashMemoize(
- (pattern, message = 'ra.validation.regex') => (value, values, props) =>
+ (pattern, message = 'ra.validation.regex') => (value, values) =>
!isEmpty(value) && typeof value === 'string' && !pattern.test(value)
- ? getMessage(message, { pattern }, value, values, props)
+ ? getMessage(message, { pattern }, value, values)
: undefined,
(pattern, message) => {
return pattern.toString() + message;
@@ -216,10 +220,10 @@ export const email = memoize((message = 'ra.validation.email') =>
regex(EMAIL_REGEX, message)
);
-const oneOfTypeMessage: MessageFunc = ({ list, value, values, translate }) =>
- translate('ra.validation.oneOf', {
- options: list.join(', '),
- });
+const oneOfTypeMessage: MessageFunc = ({ args }) => ({
+ message: 'ra.validation.oneOf',
+ args,
+});
/**
* Choices validator
@@ -235,8 +239,8 @@ const oneOfTypeMessage: MessageFunc = ({ list, value, values, translate }) =>
*
*/
export const choices = memoize(
- (list, message = oneOfTypeMessage) => (value, values, props) =>
+ (list, message = oneOfTypeMessage) => (value, values) =>
!isEmpty(value) && list.indexOf(value) === -1
- ? getMessage(message, { list }, value, values, props)
+ ? getMessage(message, { list }, value, values)
: undefined
);
diff --git a/packages/ra-input-rich-text/src/index.js b/packages/ra-input-rich-text/src/index.js
index a095e7eeca9..564f38dd123 100644
--- a/packages/ra-input-rich-text/src/index.js
+++ b/packages/ra-input-rich-text/src/index.js
@@ -3,6 +3,7 @@ import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Quill from 'quill';
import { addField } from 'ra-core';
+import { InputHelperText } from 'ra-ui-materialui';
import FormHelperText from '@material-ui/core/FormHelperText';
import FormControl from '@material-ui/core/FormControl';
import { withStyles } from '@material-ui/core/styles';
@@ -98,12 +99,18 @@ export class RichTextInput extends Component {
className="ra-rich-text-input"
>
- {touched && error && (
-
- {error}
+ {helperText || (touched && error) ? (
+
+
- )}
- {helperText && {helperText}}
+ ) : null}
);
}
diff --git a/packages/ra-ui-materialui/package.json b/packages/ra-ui-materialui/package.json
index 5d7497016eb..57a40b3f80c 100644
--- a/packages/ra-ui-materialui/package.json
+++ b/packages/ra-ui-materialui/package.json
@@ -37,6 +37,7 @@
"react": "~16.8.0",
"react-dom": "~16.8.0",
"react-test-renderer": "~16.8.6",
+ "react-testing-library": "^7.0.0",
"rimraf": "^2.6.3"
},
"peerDependencies": {
diff --git a/packages/ra-ui-materialui/src/input/ArrayInput.js b/packages/ra-ui-materialui/src/input/ArrayInput.js
index ce8c65d14ae..800ff7e9570 100644
--- a/packages/ra-ui-materialui/src/input/ArrayInput.js
+++ b/packages/ra-ui-materialui/src/input/ArrayInput.js
@@ -1,4 +1,4 @@
-import React, { cloneElement, Component, Children } from 'react';
+import React, { cloneElement, Children, useCallback } from 'react';
import PropTypes from 'prop-types';
import { isRequired, FieldTitle, withDefaultValue } from 'ra-core';
import { FieldArray } from 'redux-form';
@@ -48,54 +48,54 @@ import sanitizeRestProps from './sanitizeRestProps';
*
* @see https://redux-form.com/7.3.0/examples/fieldarrays/
*/
-export class ArrayInput extends Component {
- renderFieldArray = fieldProps => {
- const { children, record, resource, source } = this.props;
- return cloneElement(Children.only(children), {
- ...fieldProps,
- record,
- resource,
- source,
- });
- };
+export const ArrayInput = ({
+ className,
+ defaultValue,
+ label,
+ children,
+ record,
+ resource,
+ source,
+ validate,
+ ...rest
+}) => {
+ const renderFieldArray = useCallback(
+ fieldProps => {
+ return cloneElement(Children.only(children), {
+ ...fieldProps,
+ record,
+ resource,
+ source,
+ });
+ },
+ [resource, source, JSON.stringify(record), children] // eslint-disable-line
+ );
- render() {
- const {
- className,
- defaultValue,
- label,
- source,
- resource,
- validate,
- ...rest
- } = this.props;
-
- return (
-
-
-
-
-
+
+
-
- );
- }
-}
+
+
+
+ );
+};
ArrayInput.propTypes = {
children: PropTypes.node,
diff --git a/packages/ra-ui-materialui/src/input/ArrayInput.spec.js b/packages/ra-ui-materialui/src/input/ArrayInput.spec.js
index c42fc64c66a..a5fd5ce9b84 100644
--- a/packages/ra-ui-materialui/src/input/ArrayInput.spec.js
+++ b/packages/ra-ui-materialui/src/input/ArrayInput.spec.js
@@ -1,45 +1,16 @@
import React from 'react';
-import { shallow, mount } from 'enzyme';
+import { render } from 'react-testing-library';
import { reduxForm } from 'redux-form';
import { TestContext } from 'ra-core';
-import ArrayInput, { ArrayInput as ArrayInputView } from './ArrayInput';
+import ArrayInput from './ArrayInput';
import NumberInput from './NumberInput';
import TextInput from './TextInput';
import SimpleFormIterator from '../form/SimpleFormIterator';
describe('', () => {
- it('should render a FieldArray', () => {
- const wrapper = shallow();
- expect(wrapper.find('pure(FieldTitle)').length).toBe(1);
- expect(wrapper.find('FieldArray').length).toBe(1);
- });
-
- it('should pass an undefined fields to child when initialized with an undefined value', () => {
- const MockChild = () => ;
- const DummyForm = () => (
-
- );
- const DummyFormRF = reduxForm({ form: 'record-form' })(DummyForm);
- const wrapper = mount(
-
-
-
- );
- expect(
- wrapper
- .find('MockChild')
- .prop('fields')
- .getAll()
- ).toBeUndefined();
- });
-
it('should pass its record props to its child', () => {
- const MockChild = () => ;
+ const MockChild = jest.fn(() => );
const DummyForm = () => (
);
const DummyFormRF = reduxForm({ form: 'record-form' })(DummyForm);
- const wrapper = mount(
+ render(
);
- expect(wrapper.find(MockChild).props().record).toEqual({
+ expect(MockChild.mock.calls[0][0].record).toEqual({
iAmRecord: true,
});
});
it('should pass redux-form fields to child', () => {
- const MockChild = () => ;
+ const MockChild = jest.fn(() => );
const DummyForm = () => (
);
const DummyFormRF = reduxForm({ form: 'record-form' })(DummyForm);
- const wrapper = mount(
+ render(
', () => {
);
- expect(
- wrapper
- .find('MockChild')
- .prop('fields')
- .getAll()
- ).toEqual([{ id: 1 }, { id: 2 }]);
+ expect(MockChild.mock.calls[0][0].fields.getAll()).toEqual([
+ { id: 1 },
+ { id: 2 },
+ ]);
});
it('should not create any section subform when the value is undefined', () => {
@@ -95,12 +64,12 @@ describe('', () => {
);
const DummyFormRF = reduxForm({ form: 'record-form' })(DummyForm);
- const wrapper = mount(
+ const { baseElement } = render(
);
- expect(wrapper.find('section').length).toBe(0);
+ expect(baseElement.querySelectorAll('section')).toHaveLength(0);
});
it('should create one section subform per value in the array', () => {
@@ -112,7 +81,7 @@ describe('', () => {
);
const DummyFormRF = reduxForm({ form: 'record-form' })(DummyForm);
- const wrapper = mount(
+ const { baseElement } = render(
', () => {
/>
);
- expect(wrapper.find('section').length).toBe(3);
+ expect(baseElement.querySelectorAll('section')).toHaveLength(3);
});
it('should clone each input once per value in the array', () => {
const DummyForm = () => (
);
const DummyFormRF = reduxForm({ form: 'record-form' })(DummyForm);
- const wrapper = mount(
+ const { queryAllByLabelText } = render(
', () => {
/>
);
- expect(wrapper.find('NumberInput').length).toBe(2);
- expect(
- wrapper
- .find('NumberInput')
- .at(0)
- .prop('input').value
- ).toBe(123);
- expect(
- wrapper
- .find('NumberInput')
- .at(1)
- .prop('input').value
- ).toBe(456);
- expect(wrapper.find('TextInput').length).toBe(2);
- expect(
- wrapper
- .find('TextInput')
- .at(0)
- .prop('input').value
- ).toBe('bar');
- expect(
- wrapper
- .find('TextInput')
- .at(1)
- .prop('input').value
- ).toBe('baz');
+ expect(queryAllByLabelText('id')).toHaveLength(2);
+ expect(queryAllByLabelText('id').map(input => input.value)).toEqual([
+ '123',
+ '456',
+ ]);
+ expect(queryAllByLabelText('foo')).toHaveLength(2);
+ expect(queryAllByLabelText('foo').map(input => input.value)).toEqual([
+ 'bar',
+ 'baz',
+ ]);
});
});
diff --git a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.js b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.js
index f9b7d2b097e..34864a7a563 100644
--- a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.js
+++ b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.js
@@ -17,6 +17,7 @@ import classNames from 'classnames';
import { addField, translate, FieldTitle } from 'ra-core';
import AutocompleteArrayInputChip from './AutocompleteArrayInputChip';
+import InputHelperText from './InputHelperText';
const styles = theme =>
createStyles({
@@ -224,7 +225,7 @@ export class AutocompleteArrayInput extends React.Component {
};
renderInput = inputProps => {
- const { input } = this.props;
+ const { id, input, helperText } = this.props;
const {
autoFocus,
className,
@@ -246,9 +247,9 @@ export class AutocompleteArrayInput extends React.Component {
);
}
- const { touched, error, helperText = false } = meta;
+ const { touched, error } = meta;
- // We need to store the input reference for our Popper element containg the suggestions
+ // We need to store the input reference for our Popper element containing the suggestions
// but Autosuggest also needs this reference (it provides the ref prop)
const storeInputRef = input => {
this.inputEl = input;
@@ -257,14 +258,21 @@ export class AutocompleteArrayInput extends React.Component {
return (
+ }
chipRenderer={this.renderChip}
label={
', () => {
+ afterEach(cleanup);
+
const defaultProps = {
+ // We have to specify the id ourselves here because the
+ // TextInput is not wrapped inside a FormInput
+ id: 'foo',
source: 'foo',
+ resource: 'bar',
meta: {},
input: { onChange: () => {} },
translate: x => x,
};
- it('should use a react Autosuggest', () => {
- const wrapper = shallow(
-
- );
- const AutoCompleteElement = wrapper.find('Autosuggest');
- assert.equal(AutoCompleteElement.length, 1);
- });
-
it('should extract suggestions from choices', () => {
- const wrapper = shallow(
+ const { getByLabelText, getByText, queryAllByRole } = render(
', () => {
]}
/>
);
- expect(wrapper.state('suggestions')).toEqual([
- { id: 'M', name: 'Male' },
- { id: 'F', name: 'Female' },
- ]);
- });
- const context = {
- translate: () => 'translated',
- locale: 'en',
- };
- const childContextTypes = {
- translate: PropTypes.func.isRequired,
- locale: PropTypes.string.isRequired,
- };
+ fireEvent.click(getByLabelText('resources.bar.fields.foo'));
+
+ expect(queryAllByRole('option')).toHaveLength(2);
+ expect(getByText('Male')).toBeDefined();
+ expect(getByText('Female')).toBeDefined();
+ });
it('should use optionText with a string value as text identifier', () => {
- const wrapper = shallow(
- ,
- {
- context,
- childContextTypes,
- }
+ const { getByLabelText, getByText, queryAllByRole } = render(
+
);
- // This is necesary because we use the material-ui Popper element which does not includes
- // its children in the AutocompleteArrayInput dom hierarchy
- const menuItem = wrapper
- .instance()
- .renderSuggestion(
- { id: 'M', foobar: 'Male' },
- { query: '', highlighted: false }
- );
+ fireEvent.click(getByLabelText('resources.bar.fields.foo'));
- const MenuItem = render(menuItem);
- assert.equal(MenuItem.text(), 'Male');
+ expect(queryAllByRole('option')).toHaveLength(2);
+ expect(getByText('Male')).toBeDefined();
+ expect(getByText('Female')).toBeDefined();
});
it('should use optionText with a string value including "." as text identifier', () => {
- const wrapper = shallow(
+ const { getByLabelText, getByText, queryAllByRole } = render(
,
- { context, childContextTypes }
+ choices={[
+ { id: 'M', foobar: { name: 'Male' } },
+ { id: 'F', foobar: { name: 'Female' } },
+ ]}
+ />
);
- // This is necesary because we use the material-ui Popper element which does not includes
- // its children in the AutocompleteArrayInput dom hierarchy
- const menuItem = wrapper
- .instance()
- .renderSuggestion(
- { id: 'M', foobar: { name: 'Male' } },
- { query: '', highlighted: false }
- );
+ fireEvent.click(getByLabelText('resources.bar.fields.foo'));
- const MenuItem = render(menuItem);
- assert.equal(MenuItem.text(), 'Male');
+ expect(queryAllByRole('option')).toHaveLength(2);
+ expect(getByText('Male')).toBeDefined();
+ expect(getByText('Female')).toBeDefined();
});
it('should use optionText with a function value as text identifier', () => {
- const wrapper = shallow(
+ const { getByLabelText, getByText, queryAllByRole } = render(
choice.foobar}
- choices={[{ id: 'M', foobar: 'Male' }]}
- alwaysRenderSuggestions={true}
- />,
- { context, childContextTypes }
+ choices={[
+ { id: 'M', foobar: 'Male' },
+ { id: 'F', foobar: 'Female' },
+ ]}
+ />
);
- // This is necesary because we use the material-ui Popper element which does not includes
- // its children in the AutocompleteArrayInput dom hierarchy
- const menuItem = wrapper
- .instance()
- .renderSuggestion(
- { id: 'M', foobar: 'Male' },
- { query: '', highlighted: false }
- );
+ fireEvent.click(getByLabelText('resources.bar.fields.foo'));
- const MenuItem = render(menuItem);
- assert.equal(MenuItem.text(), 'Male');
+ expect(queryAllByRole('option')).toHaveLength(2);
+ expect(getByText('Male')).toBeDefined();
+ expect(getByText('Female')).toBeDefined();
});
it('should translate the choices by default', () => {
- const wrapper = shallow(
+ const { getByLabelText, getByText, queryAllByRole } = render(
`**${x}**`}
- alwaysRenderSuggestions={true}
- />,
- { context, childContextTypes }
+ choices={[
+ { id: 'M', name: 'Male' },
+ { id: 'F', name: 'Female' },
+ ]}
+ />
);
- // This is necesary because we use the material-ui Popper element which does not includes
- // its children in the AutocompleteArrayInput dom hierarchy
- const menuItem = wrapper
- .instance()
- .renderSuggestion(
- { id: 'M', name: 'Male' },
- { query: '', highlighted: false }
- );
- const MenuItem = render(menuItem);
- assert.equal(MenuItem.text(), '**Male**');
+ fireEvent.click(getByLabelText('resources.bar.fields.foo'));
+
+ expect(queryAllByRole('option')).toHaveLength(2);
+ expect(getByText('**Male**')).toBeDefined();
+ expect(getByText('**Female**')).toBeDefined();
});
it('should not translate the choices if translateChoice is false', () => {
- const wrapper = shallow(
+ const { getByLabelText, getByText, queryAllByRole } = render(
`**${x}**`}
+ choices={[
+ { id: 'M', name: 'Male' },
+ { id: 'F', name: 'Female' },
+ ]}
translateChoice={false}
- alwaysRenderSuggestions={true}
- />,
- { context, childContextTypes }
+ />
);
- // This is necesary because we use the material-ui Popper element which does not includes
- // its children in the AutocompleteArrayInput dom hierarchy
- const menuItem = wrapper
- .instance()
- .renderSuggestion(
- { id: 'M', name: 'Male' },
- { query: '', highlighted: false }
- );
- const MenuItem = render(menuItem);
- assert.equal(MenuItem.text(), 'Male');
+ fireEvent.click(getByLabelText('resources.bar.fields.foo'));
+
+ expect(queryAllByRole('option')).toHaveLength(2);
+ expect(getByText('Male')).toBeDefined();
+ expect(getByText('Female')).toBeDefined();
});
- it('should respect shouldRenderSuggestions over default if passed in', () => {
- const wrapper = mount(
+ it('should respect shouldRenderSuggestions over default if passed in', async () => {
+ const { getByLabelText, queryAllByRole } = render(
{} }}
choices={[{ id: 'M', name: 'Male' }]}
shouldRenderSuggestions={v => v.length > 2}
- />,
- { context, childContextTypes }
+ />
);
- wrapper.find('input').simulate('focus');
- wrapper.find('input').simulate('change', { target: { value: 'Ma' } });
- expect(wrapper.state('suggestions')).toHaveLength(1);
- expect(wrapper.find('ForwardRef(ListItem)')).toHaveLength(0);
-
- wrapper.find('input').simulate('change', { target: { value: 'Mal' } });
- expect(wrapper.state('suggestions')).toHaveLength(1);
- expect(wrapper.find('ForwardRef(ListItem)')).toHaveLength(1);
+ const input = getByLabelText('resources.bar.fields.foo');
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: 'Ma' } });
+ expect(queryAllByRole('option')).toHaveLength(0);
+
+ fireEvent.change(input, { target: { value: 'Mal' } });
+ expect(queryAllByRole('option')).toHaveLength(1);
});
describe('Fix issue #1410', () => {
it('should not fail when value is empty and new choices are applied', () => {
- const wrapper = shallow(
+ const { getByLabelText, rerender } = render(
+ {} }}
+ choices={[{ id: 'M', name: 'Male' }]}
+ />
+ );
+
+ rerender(
{} }}
choices={[{ id: 'M', name: 'Male' }]}
/>
);
- wrapper.setProps({
- choices: [{ id: 'M', name: 'Male' }],
- });
- expect(wrapper.state('searchText')).toBe('');
+ const input = getByLabelText('resources.bar.fields.foo');
+ expect(input.value).toEqual('');
});
it('should repopulate the suggestions after the suggestions are dismissed', () => {
- const wrapper = mount(
+ const { getByLabelText, queryAllByRole } = render(
{} }}
choices={[{ id: 'M', name: 'Male' }]}
- alwaysRenderSuggestions
- />,
- { context, childContextTypes }
+ />
);
- wrapper.find('input').simulate('focus');
- wrapper
- .find('input')
- .simulate('change', { target: { value: 'foo' } });
- expect(wrapper.state('searchText')).toBe('foo');
- expect(wrapper.state('suggestions')).toHaveLength(0);
- wrapper.find('input').simulate('blur');
- wrapper.find('input').simulate('change', { target: { value: '' } });
- expect(wrapper.state('suggestions')).toHaveLength(1);
+
+ const input = getByLabelText('resources.bar.fields.foo');
+
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: 'foo' } });
+ expect(queryAllByRole('option')).toHaveLength(0);
+
+ fireEvent.blur(input);
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: '' } });
+ expect(queryAllByRole('option')).toHaveLength(1);
});
- it('should allow optionText to be a function', () => {
+ it('should not rerender searchtext while having focus and new choices arrive', () => {
const optionText = jest.fn();
- mount(
+ const { getByLabelText, queryAllByRole, rerender } = render(
{} }}
+ meta={{ active: true }}
choices={[{ id: 'M', name: 'Male' }]}
optionText={v => {
optionText(v);
return v.name;
}}
- />,
- { context, childContextTypes }
+ />
);
- expect(optionText).toHaveBeenCalledTimes(1);
- expect(optionText).toHaveBeenCalledWith({ id: 'M', name: 'Male' });
- });
+ const input = getByLabelText('resources.bar.fields.foo');
- it('should not rerender searchtext while having focus and new choices arrive', () => {
- const optionText = jest.fn();
- const wrapper = mount(
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: 'foo' } });
+ expect(queryAllByRole('option')).toHaveLength(0);
+
+ rerender(
{} }}
meta={{ active: true }}
- choices={[{ id: 'M', name: 'Male' }]}
+ choices={[
+ { id: 'M', name: 'Male' },
+ { id: 'F', name: 'Female' },
+ ]}
optionText={v => {
optionText(v);
return v.name;
}}
- />,
- { context, childContextTypes }
+ />
+ );
+ expect(getByLabelText('resources.bar.fields.foo').value).toEqual(
+ 'foo'
);
- wrapper
- .find('input')
- .simulate('change', { target: { value: 'foo' } });
-
- wrapper.setProps({
- choices: [
- { id: 'M', name: 'Male' },
- { id: 'F', name: 'Female' },
- ],
- });
- expect(wrapper.state('searchText')).toBe('foo');
});
it('should allow input value to be cleared when allowEmpty is true and input text is empty', () => {
- const wrapper = mount(
+ const { getByLabelText, queryAllByRole } = render(
{} }}
choices={[{ id: 'M', name: 'Male' }]}
- />,
- { context, childContextTypes }
+ />
);
- wrapper
- .find('input')
- .simulate('change', { target: { value: 'foo' } });
- wrapper.find('input').simulate('blur');
- expect(wrapper.state('searchText')).toBe('');
+ const input = getByLabelText('resources.bar.fields.foo');
- wrapper.find('input').simulate('change', { target: { value: '' } });
- wrapper.find('input').simulate('blur');
- expect(wrapper.state('searchText')).toBe('');
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: 'foo' } });
+ expect(queryAllByRole('option')).toHaveLength(0);
+ fireEvent.blur(input);
+
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: '' } });
+ expect(queryAllByRole('option')).toHaveLength(1);
});
- it('should revert the searchText when allowEmpty is false', () => {
- const wrapper = mount(
+ it('should revert the searchText when allowEmpty is false', async () => {
+ const { getByLabelText, queryAllByRole } = render(
{} }}
choices={[{ id: 'M', name: 'Male' }]}
- />,
- { context, childContextTypes }
+ />
+ );
+
+ const input = getByLabelText('resources.bar.fields.foo');
+
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: 'foo' } });
+ expect(queryAllByRole('option')).toHaveLength(0);
+ fireEvent.blur(input);
+ await waitForDomChange();
+ expect(getByLabelText('resources.bar.fields.foo').value).toEqual(
+ ''
);
- wrapper
- .find('input')
- .simulate('change', { target: { value: 'foo' } });
- expect(wrapper.state('searchText')).toBe('foo');
- wrapper.find('input').simulate('blur');
- expect(wrapper.state('searchText')).toBe('');
});
it('should show the suggestions when the input value is empty and the input is focussed and choices arrived late', () => {
- const wrapper = mount(
+ const { getByLabelText, queryAllByRole, rerender } = render(
{} }}
- />,
- { context, childContextTypes }
+ />
);
- wrapper.setProps({
- choices: [
- { id: 'M', name: 'Male' },
- { id: 'F', name: 'Female' },
- ],
- });
- wrapper.find('input').simulate('focus');
- expect(wrapper.find('ForwardRef(ListItem)')).toHaveLength(2);
+ rerender(
+ {} }}
+ choices={[
+ { id: 'M', name: 'Male' },
+ { id: 'F', name: 'Female' },
+ ]}
+ />
+ );
+
+ fireEvent.focus(getByLabelText('resources.bar.fields.foo'));
+ expect(queryAllByRole('option')).toHaveLength(2);
});
- it('should resolve value from input value', () => {
+ it('should resolve value from input value', async () => {
const onChange = jest.fn();
- const wrapper = mount(
+ const { getByLabelText } = render(
,
- {
- context,
- childContextTypes,
- }
+ choices={[{ id: 'M', name: 'Male' }]}
+ />
);
- wrapper.setProps({
- choices: [{ id: 'M', name: 'Male' }],
- });
- wrapper
- .find('input')
- .simulate('change', { target: { value: 'male' } });
- wrapper.find('input').simulate('blur');
-
- expect.assertions(2);
- return new Promise((resolve, reject) => {
- setTimeout(() => {
- try {
- expect(onChange).toHaveBeenCalledTimes(1);
- expect(onChange).toHaveBeenCalledWith(['M']);
- } catch (error) {
- return reject(error);
- }
- resolve();
- }, 250);
- });
+
+ const input = getByLabelText('resources.bar.fields.foo');
+
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: 'male' } });
+ fireEvent.blur(input);
+
+ await waitForDomChange();
+ expect(onChange).toHaveBeenCalledTimes(1);
+ expect(onChange).toHaveBeenCalledWith(['M']);
});
it('should reset filter when input value changed', () => {
const setFilter = jest.fn();
- const wrapper = mount(
+ const { getByLabelText, rerender } = render(
,
- { context, childContextTypes }
+ />
);
- wrapper
- .find('input')
- .simulate('change', { target: { value: 'de' } });
+ const input = getByLabelText('resources.bar.fields.foo');
+ fireEvent.change(input, { target: { value: 'de' } });
expect(setFilter).toHaveBeenCalledTimes(1);
expect(setFilter).toHaveBeenCalledWith('de');
- wrapper.setProps({
- input: { value: [2] },
- });
+
+ rerender(
+
+ );
expect(setFilter).toHaveBeenCalledTimes(2);
- expect(setFilter).toHaveBeenLastCalledWith('');
});
it('should allow customized rendering of suggesting item', () => {
- const wrapper = mount(
+ const { getByLabelText } = render(
', () => {
{ id: 'M', name: 'Male' },
{ id: 'F', name: 'Female' },
]}
- alwaysRenderSuggestions
suggestionComponent={React.forwardRef(
(
{ suggestion, query, isHighlighted, ...props },
@@ -401,76 +364,53 @@ describe('', () => {
)
)}
- />,
- { context, childContextTypes }
+ />
);
- expect(wrapper.find('div[data-field]')).toHaveLength(2);
+ fireEvent.focus(getByLabelText('resources.bar.fields.foo'));
+ expect(getByLabelText('Male')).toBeDefined();
+ expect(getByLabelText('Female')).toBeDefined();
});
});
- it('should displayed helperText if prop is present in meta', () => {
- const wrapper = shallow(
+ it('should display helperText', () => {
+ const { getByText } = render(
);
- const AutoCompleteElement = wrapper.find('Autosuggest').first();
- assert.deepEqual(AutoCompleteElement.prop('inputProps').meta, {
- helperText: 'Can i help you?',
- });
+ expect(getByText('Can I help you?')).toBeDefined();
});
describe('error message', () => {
it('should not be displayed if field is pristine', () => {
- const wrapper = shallow(
+ const { queryByText } = render(
);
- const AutoCompleteElement = wrapper.find('Autosuggest').first();
- assert.deepEqual(AutoCompleteElement.prop('inputProps').meta, {
- touched: false,
- });
- });
-
- it('should not be displayed if field has been touched but is valid', () => {
- const wrapper = shallow(
-
- );
- const AutoCompleteElement = wrapper.find('Autosuggest').first();
- assert.deepEqual(AutoCompleteElement.prop('inputProps').meta, {
- touched: true,
- error: false,
- });
+ expect(queryByText('Required')).toBeNull();
});
it('should be displayed if field has been touched and is invalid', () => {
- const wrapper = shallow(
+ const { queryByText } = render(
);
- const AutoCompleteElement = wrapper.find('Autosuggest').first();
- assert.deepEqual(AutoCompleteElement.prop('inputProps').meta, {
- touched: true,
- error: 'Required field.',
- });
+ expect(queryByText('Required')).toBeDefined();
});
});
describe('Fix issue #2121', () => {
- it('updates suggestions when input is blurred and refocused', () => {
- const wrapper = mount(
+ it('updates suggestions when input is blurred and refocused', async () => {
+ const { getByLabelText, queryAllByRole } = render(
{} }}
@@ -479,25 +419,24 @@ describe('', () => {
{ id: 2, name: 'abc' },
{ id: 3, name: '123' },
]}
- />,
- { context, childContextTypes }
+ />
);
- wrapper.find('input').simulate('focus');
- wrapper
- .find('input')
- .simulate('change', { target: { value: 'a' } });
- expect(wrapper.state('suggestions')).toHaveLength(2);
- wrapper.find('input').simulate('blur');
- wrapper.find('input').simulate('focus');
- wrapper
- .find('input')
- .simulate('change', { target: { value: 'a' } });
- expect(wrapper.state('suggestions')).toHaveLength(2);
+ const input = getByLabelText('resources.bar.fields.foo');
+
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: 'ab' } });
+ expect(queryAllByRole('option')).toHaveLength(2);
+ fireEvent.blur(input);
+ await waitForDomChange();
+
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: 'ab' } });
+ expect(queryAllByRole('option')).toHaveLength(2);
});
});
it('does not automatically select a matched choice if there are more than one', () => {
- const wrapper = mount(
+ const { getByLabelText, queryAllByRole } = render(
{} }}
@@ -506,18 +445,19 @@ describe('', () => {
{ id: 2, name: 'abc' },
{ id: 3, name: '123' },
]}
- />,
- { context, childContextTypes }
+ />
);
- wrapper.find('input').simulate('focus');
- wrapper.find('input').simulate('change', { target: { value: 'ab' } });
- expect(wrapper.state('suggestions')).toHaveLength(2);
+
+ const input = getByLabelText('resources.bar.fields.foo');
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: 'ab' } });
+ expect(queryAllByRole('option')).toHaveLength(2);
});
- it('does not automatically select a matched choice if there is only one', () => {
+ it('does not automatically select a matched choice if there is only one', async () => {
const onChange = jest.fn();
- const wrapper = mount(
+ const { getByLabelText, queryAllByRole } = render(
', () => {
{ id: 2, name: 'abc' },
{ id: 3, name: '123' },
]}
- />,
- { context, childContextTypes }
+ />
);
- wrapper.find('input').simulate('focus');
- wrapper.find('input').simulate('change', { target: { value: 'abc' } });
- expect(wrapper.state('suggestions')).toHaveLength(1);
+ const input = getByLabelText('resources.bar.fields.foo');
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: 'abc' } });
+ expect(queryAllByRole('option')).toHaveLength(1);
+
expect(onChange).not.toHaveBeenCalled();
});
- it('automatically selects a matched choice on blur if there is only one', () => {
+ it('automatically selects a matched choice on blur if there is only one', async () => {
const onChange = jest.fn();
- const wrapper = mount(
+ const { getByLabelText } = render(
', () => {
{ id: 2, name: 'abc' },
{ id: 3, name: '123' },
]}
- />,
- { context, childContextTypes }
+ />
);
- wrapper.find('input').simulate('focus');
- wrapper.find('input').simulate('change', { target: { value: 'abc' } });
- wrapper.find('input').simulate('blur');
-
- expect.assertions(1);
- return new Promise((resolve, reject) => {
- setTimeout(() => {
- try {
- expect(onChange).toHaveBeenCalledWith([2]);
- } catch (error) {
- return reject(error);
- }
- resolve();
- }, 250);
- });
+ const input = getByLabelText('resources.bar.fields.foo');
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: 'abc' } });
+ fireEvent.blur(input);
+ await waitForDomChange();
+
+ expect(onChange).toHaveBeenCalled();
});
it('passes options.suggestionsContainerProps to the suggestions container', () => {
- const onChange = jest.fn();
-
- const wrapper = mount(
+ const { getByLabelText } = render(
,
- { context, childContextTypes }
+ />
);
- expect(
- wrapper.find('ForwardRef(Popper)').props().disablePortal
- ).toEqual(true);
+ const input = getByLabelText('resources.bar.fields.foo');
+ fireEvent.focus(input);
+
+ expect(getByLabelText('Me')).toBeDefined();
});
it('should limit suggestions when suggestionLimit is passed', () => {
- const wrapper = shallow(
+ const { getByLabelText, queryAllByRole } = render(
', () => {
suggestionLimit={1}
/>
);
- expect(wrapper.state('suggestions')).toHaveLength(1);
+ const input = getByLabelText('resources.bar.fields.foo');
+ fireEvent.focus(input);
+ expect(queryAllByRole('option')).toHaveLength(1);
});
});
diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.js b/packages/ra-ui-materialui/src/input/AutocompleteInput.js
index fde2549a11e..96e64eef7bd 100644
--- a/packages/ra-ui-materialui/src/input/AutocompleteInput.js
+++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.js
@@ -14,6 +14,7 @@ import compose from 'recompose/compose';
import classnames from 'classnames';
import { addField, translate, FieldTitle } from 'ra-core';
+import InputHelperText from './InputHelperText';
const styles = theme =>
createStyles({
@@ -289,7 +290,13 @@ export class AutocompleteInput extends React.Component {
className={classnames(classes.root, className)}
inputRef={storeInputRef}
error={!!(touched && error)}
- helperText={(touched && error) || helperText}
+ helperText={
+
+ }
name={input.name}
{...options}
InputProps={{
@@ -502,7 +509,10 @@ AutocompleteInput.propTypes = {
setFilter: PropTypes.func,
shouldRenderSuggestions: PropTypes.func,
source: PropTypes.string,
- suggestionComponent: PropTypes.func,
+ suggestionComponent: PropTypes.oneOfType([
+ PropTypes.element,
+ PropTypes.func,
+ ]),
translate: PropTypes.func.isRequired,
translateChoice: PropTypes.bool.isRequired,
};
diff --git a/packages/ra-ui-materialui/src/input/BooleanInput.js b/packages/ra-ui-materialui/src/input/BooleanInput.js
index 84461d3b830..d5f53da29d7 100644
--- a/packages/ra-ui-materialui/src/input/BooleanInput.js
+++ b/packages/ra-ui-materialui/src/input/BooleanInput.js
@@ -1,12 +1,13 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import FormControlLabel from '@material-ui/core/FormControlLabel';
-import FormGroup from '@material-ui/core/FormGroup';
import FormHelperText from '@material-ui/core/FormHelperText';
+import FormGroup from '@material-ui/core/FormGroup';
import Switch from '@material-ui/core/Switch';
import { addField, FieldTitle } from 'ra-core';
import sanitizeRestProps from './sanitizeRestProps';
+import InputHelperText from './InputHelperText';
export class BooleanInput extends Component {
handleChange = (event, value) => {
@@ -25,10 +26,12 @@ export class BooleanInput extends Component {
options,
fullWidth,
meta,
+ helperText,
...rest
} = this.props;
const { value, ...inputProps } = input;
+ const { touched, error } = meta;
return (
@@ -53,9 +56,15 @@ export class BooleanInput extends Component {
/>
}
/>
- {meta.error && (
- {meta.error}
- )}
+ {helperText || (touched && !!error) ? (
+
+
+
+ ) : null}
);
}
diff --git a/packages/ra-ui-materialui/src/input/BooleanInput.spec.js b/packages/ra-ui-materialui/src/input/BooleanInput.spec.js
index 264d1cf2430..a9d43758515 100644
--- a/packages/ra-ui-materialui/src/input/BooleanInput.spec.js
+++ b/packages/ra-ui-materialui/src/input/BooleanInput.spec.js
@@ -7,14 +7,15 @@ describe('', () => {
afterEach(cleanup);
const defaultProps = {
+ id: 'bar',
resource: 'foo',
+ source: 'bar',
+ input: {},
meta: {},
};
it('should render as a checkbox', () => {
- const { getByLabelText } = render(
-
- );
+ const { getByLabelText } = render();
expect(getByLabelText('resources.foo.fields.bar').type).toBe(
'checkbox'
);
@@ -22,29 +23,21 @@ describe('', () => {
it('should be checked if the value is true', () => {
const { getByLabelText } = render(
-
+
);
expect(getByLabelText('resources.foo.fields.bar').checked).toBe(true);
});
it('should not be checked if the value is false', () => {
const { getByLabelText } = render(
-
+
);
expect(getByLabelText('resources.foo.fields.bar').checked).toBe(false);
});
it('should not be checked if the value is undefined', () => {
const { getByLabelText } = render(
-
+
);
expect(getByLabelText('resources.foo.fields.bar').checked).toBe(false);
});
@@ -55,7 +48,7 @@ describe('', () => {
{...defaultProps}
source="foo"
input={{}}
- meta={{ error: 'foobar' }}
+ meta={{ touched: true, error: 'foobar' }}
/>
);
expect(queryAllByText('foobar')).toHaveLength(1);
diff --git a/packages/ra-ui-materialui/src/input/DateInput.js b/packages/ra-ui-materialui/src/input/DateInput.js
index 2803995a667..000231ede82 100644
--- a/packages/ra-ui-materialui/src/input/DateInput.js
+++ b/packages/ra-ui-materialui/src/input/DateInput.js
@@ -1,9 +1,10 @@
-import React, { Component } from 'react';
+import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import TextField from '@material-ui/core/TextField';
import { addField, FieldTitle } from 'ra-core';
import sanitizeRestProps from './sanitizeRestProps';
+import InputHelperText from './InputHelperText';
/**
* Convert Date object to String
@@ -41,59 +42,66 @@ const sanitizeValue = value => {
return dateFormatter(new Date(value));
};
-export class DateInput extends Component {
- onChange = event => {
- this.props.input.onChange(event.target.value);
- };
+export const DateInput = ({
+ className,
+ meta,
+ input,
+ isRequired,
+ label,
+ options,
+ source,
+ resource,
+ helperText,
+ ...rest
+}) => {
+ const handleChange = useCallback(
+ event => {
+ input.onChange(event.target.value);
+ },
+ [input]
+ );
- render() {
- const {
- className,
- meta,
- input,
- isRequired,
- label,
- options,
- source,
- resource,
- ...rest
- } = this.props;
- if (typeof meta === 'undefined') {
- throw new Error(
- "The DateInput component wasn't called within a redux-form . Did you decorate it and forget to add the addField prop to your component? See https://marmelab.com/react-admin/Inputs.html#writing-your-own-input-component for details."
- );
- }
- const { touched, error } = meta;
- const value = sanitizeValue(input.value);
-
- return (
-
- }
- InputLabelProps={{
- shrink: true,
- }}
- {...options}
- {...sanitizeRestProps(rest)}
- value={value}
- onChange={this.onChange}
- />
+ if (typeof meta === 'undefined') {
+ throw new Error(
+ "The DateInput component wasn't called within a redux-form . Did you decorate it and forget to add the addField prop to your component? See https://marmelab.com/react-admin/Inputs.html#writing-your-own-input-component for details."
);
}
-}
+ const { touched, error } = meta;
+ const value = sanitizeValue(input.value);
+
+ return (
+
+ }
+ label={
+
+ }
+ InputLabelProps={{
+ shrink: true,
+ }}
+ {...options}
+ {...sanitizeRestProps(rest)}
+ value={value}
+ onChange={handleChange}
+ />
+ );
+};
DateInput.propTypes = {
classes: PropTypes.object,
diff --git a/packages/ra-ui-materialui/src/input/DateInput.spec.js b/packages/ra-ui-materialui/src/input/DateInput.spec.js
index a5ee7d15fe0..36e225d8524 100644
--- a/packages/ra-ui-materialui/src/input/DateInput.spec.js
+++ b/packages/ra-ui-materialui/src/input/DateInput.spec.js
@@ -32,31 +32,23 @@ describe('', () => {
describe('error message', () => {
it('should not be displayed if field is pristine', () => {
- const { container } = render(
-
- );
- expect(container.querySelector('p')).toBeNull();
- });
-
- it('should not be displayed if field has been touched but is valid', () => {
- const { container } = render(
+ const { queryByText } = render(
);
- expect(container.querySelector('p')).toBeNull();
+ expect(queryByText('Required field.')).toBeNull();
});
it('should be displayed if field has been touched and is invalid', () => {
- const { container, queryByText } = render(
+ const { queryByText } = render(
);
- expect(container.querySelector('p')).not.toBeNull();
- expect(queryByText('Required field.')).not.toBeNull();
+ expect(queryByText('Required field.')).toBeDefined();
});
});
});
diff --git a/packages/ra-ui-materialui/src/input/DateTimeInput.js b/packages/ra-ui-materialui/src/input/DateTimeInput.js
index 5a168d5f4fc..026d36b9ca7 100644
--- a/packages/ra-ui-materialui/src/input/DateTimeInput.js
+++ b/packages/ra-ui-materialui/src/input/DateTimeInput.js
@@ -4,6 +4,7 @@ import TextField from '@material-ui/core/TextField';
import { addField, FieldTitle } from 'ra-core';
import sanitizeRestProps from './sanitizeRestProps';
+import InputHelperText from './InputHelperText';
const leftPad = (nb = 2) => value => ('0'.repeat(nb) + value).slice(-nb);
const leftPad4 = leftPad(4);
@@ -71,6 +72,7 @@ export const DateTimeInput = ({
options,
source,
resource,
+ helperText,
...rest
}) => (
+ }
label={
)}
- {meta && meta.touched && meta.error && (
- {translate(meta.error)}
- )}
+ {helperText || (touched && error) ? (
+
+
+
+ ) : null}
);
diff --git a/packages/ra-ui-materialui/src/input/FileInput.spec.js b/packages/ra-ui-materialui/src/input/FileInput.spec.js
index 2b8584553e7..6e126256d96 100644
--- a/packages/ra-ui-materialui/src/input/FileInput.spec.js
+++ b/packages/ra-ui-materialui/src/input/FileInput.spec.js
@@ -17,18 +17,19 @@ describe('', () => {
delete global.FileReader;
});
+ const defautProps = {
+ input: {
+ value: {
+ picture: null,
+ },
+ },
+ meta: {},
+ translate: x => x,
+ source: 'src',
+ };
+
it('should display a dropzone', () => {
- const wrapper = shallow(
- x}
- source="picture"
- />
- );
+ const wrapper = shallow();
assert.equal(wrapper.find('Dropzone').length, 1);
});
@@ -38,14 +39,13 @@ describe('', () => {
const wrapper = shallow(
x}
- source="src"
/>
);
@@ -61,14 +61,13 @@ describe('', () => {
const wrapper = shallow(
x}
- source="src"
/>
);
@@ -81,6 +80,7 @@ describe('', () => {
const wrapper = shallow(
', () => {
],
onBlur,
}}
- translate={x => x}
- source="pictures"
multiple
/>
);
@@ -108,6 +106,7 @@ describe('', () => {
const wrapper = shallow(
', () => {
],
onBlur,
}}
- translate={x => x}
- source="pictures"
multiple
/>
);
@@ -130,14 +127,13 @@ describe('', () => {
const test = (multiple, expectedLabel) => {
const wrapper = shallow(
x}
- source="picture"
/>
);
@@ -151,16 +147,7 @@ describe('', () => {
it('should display correct custom label', () => {
const test = expectedLabel => {
const wrapper = shallow(
- x}
- source="picture"
- />
+
);
assert.ok(wrapper.find('Dropzone').contains(expectedLabel));
@@ -176,13 +163,13 @@ describe('', () => {
it('should display file preview using child as preview component', () => {
const wrapper = shallow(
x}
>
@@ -202,6 +189,7 @@ describe('', () => {
it('should display all files (when several) previews using child as preview component', () => {
const wrapper = shallow(
', () => {
},
],
}}
- translate={x => x}
>
@@ -249,8 +236,7 @@ describe('', () => {
it('should update previews when updating input value', () => {
const wrapper = shallow(
x}
+ {...defautProps}
input={{
value: {
url: 'http://static.acme.com/foo.jpg',
@@ -282,7 +268,7 @@ describe('', () => {
it('should update previews when dropping a file', () => {
const wrapper = shallow(
- x} input={{}}>
+
);
@@ -306,8 +292,7 @@ describe('', () => {
it('should allow to remove an image from the input with `FileInputPreview.onRemove`', () => {
const wrapper = shallow(
x}
+ {...defautProps}
input={{
onBlur: () => {},
value: [
diff --git a/packages/ra-ui-materialui/src/input/ImageInput.spec.js b/packages/ra-ui-materialui/src/input/ImageInput.spec.js
index 9d8cbe54e97..0c0f27335ea 100644
--- a/packages/ra-ui-materialui/src/input/ImageInput.spec.js
+++ b/packages/ra-ui-materialui/src/input/ImageInput.spec.js
@@ -17,18 +17,19 @@ describe('', () => {
delete global.FileReader;
});
+ const defautProps = {
+ input: {
+ value: {
+ picture: null,
+ },
+ },
+ meta: {},
+ translate: x => x,
+ source: 'picture',
+ };
+
it('should display a dropzone', () => {
- const wrapper = shallow(
- x}
- source="picture"
- />
- );
+ const wrapper = shallow();
assert.equal(wrapper.find('Dropzone').length, 1);
});
@@ -36,16 +37,7 @@ describe('', () => {
it('should display correct label depending multiple property', () => {
const test = (multiple, expectedLabel) => {
const wrapper = shallow(
- x}
- source="picture"
- />
+
);
assert.equal(wrapper.find('Dropzone p').text(), expectedLabel);
@@ -58,16 +50,7 @@ describe('', () => {
it('should display correct custom label', () => {
const test = expectedLabel => {
const wrapper = shallow(
- x}
- source="picture"
- />
+
);
assert.ok(wrapper.find('Dropzone').contains(expectedLabel));
@@ -83,13 +66,13 @@ describe('', () => {
it('should display file preview using child as preview component', () => {
const wrapper = shallow(
x}
>
@@ -109,6 +92,7 @@ describe('', () => {
it('should display all files (when several) previews using child as preview component', () => {
const wrapper = shallow(
', () => {
},
],
}}
- translate={x => x}
>
@@ -156,8 +139,7 @@ describe('', () => {
it('should update previews when updating input value', () => {
const wrapper = shallow(
x}
+ {...defautProps}
input={{
value: {
url: 'http://static.acme.com/foo.jpg',
@@ -189,7 +171,7 @@ describe('', () => {
it('should update previews when dropping a file', () => {
const wrapper = shallow(
- x} input={{}}>
+
);
@@ -213,8 +195,7 @@ describe('', () => {
it('should allow to remove an image from the input with `FileInputPreview.onRemove`', () => {
const wrapper = shallow(
x}
+ {...defautProps}
input={{
onBlur: () => {},
value: [
diff --git a/packages/ra-ui-materialui/src/input/InputHelperText.spec.tsx b/packages/ra-ui-materialui/src/input/InputHelperText.spec.tsx
new file mode 100644
index 00000000000..a06fa5dc355
--- /dev/null
+++ b/packages/ra-ui-materialui/src/input/InputHelperText.spec.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import expect from 'expect';
+import { render, cleanup } from 'react-testing-library';
+import InputHelperText from './InputHelperText';
+
+describe('InputHelperText', () => {
+ afterEach(cleanup);
+
+ it('does not render anything when the input has not been touched yet and has no helper text', () => {
+ const { container } = render();
+ expect(container.innerHTML).toBe('');
+ });
+
+ it('renders the helperText when there is no error', () => {
+ const { getByText } = render(
+
+ );
+
+ expect(getByText('Please help!')).toBeDefined();
+ });
+
+ it('renders the helperText when there is an error but the input has not been touched yet', () => {
+ const { getByText, queryByText } = render(
+
+ );
+
+ expect(getByText('Please help!')).toBeDefined();
+ expect(queryByText('Crap!')).toBeNull();
+ });
+
+ it('renders the error instead of the helperText when there is an error and the input was touched', () => {
+ const { getByText, queryByText } = render(
+
+ );
+
+ expect(queryByText('Please help!')).toBeNull();
+ expect(getByText('Crap!')).toBeDefined();
+ });
+});
diff --git a/packages/ra-ui-materialui/src/input/InputHelperText.tsx b/packages/ra-ui-materialui/src/input/InputHelperText.tsx
new file mode 100644
index 00000000000..97598e8f847
--- /dev/null
+++ b/packages/ra-ui-materialui/src/input/InputHelperText.tsx
@@ -0,0 +1,24 @@
+import React, { FunctionComponent } from 'react';
+import { useTranslate, ValidationError, ValidationErrorMessage } from 'ra-core';
+
+interface Props {
+ helperText?: string;
+ error?: ValidationErrorMessage;
+ touched: boolean;
+}
+
+const InputHelperText: FunctionComponent = ({
+ helperText,
+ touched,
+ error,
+}) => {
+ const translate = useTranslate();
+
+ return touched && error ? (
+
+ ) : helperText ? (
+ <>{translate(helperText, { _: helperText })}>
+ ) : null;
+};
+
+export default InputHelperText;
diff --git a/packages/ra-ui-materialui/src/input/LongTextInput.js b/packages/ra-ui-materialui/src/input/LongTextInput.js
index 2630f2b59a0..8e9167ee014 100644
--- a/packages/ra-ui-materialui/src/input/LongTextInput.js
+++ b/packages/ra-ui-materialui/src/input/LongTextInput.js
@@ -1,70 +1,22 @@
import React from 'react';
-import PropTypes from 'prop-types';
-import { addField, FieldTitle } from 'ra-core';
-import ResettableTextField from './ResettableTextField';
+import { addField } from 'ra-core';
+import { TextInput } from './TextInput';
-import sanitizeRestProps from './sanitizeRestProps';
-
-export const LongTextInput = ({
- className,
- input,
- meta,
- isRequired,
- label,
- options,
- source,
- resource,
- ...rest
-}) => {
- if (typeof meta === 'undefined') {
- throw new Error(
- "The LongTextInput component wasn't called within a redux-form . Did you decorate it and forget to add the addField prop to your component? See https://marmelab.com/react-admin/Inputs.html#writing-your-own-input-component for details."
- );
- }
- const { touched, error } = meta;
- return (
-
- }
- error={!!(touched && error)}
- helperText={touched && error}
- {...sanitizeRestProps(rest)}
- {...options}
- />
+/**
+ * @deprecated use instead
+ */
+export const LongTextInput = props => {
+ console.warn(
+ 'The LongTextInput component is deprecated. You should instead use the TextInput component and set its multiline prop to true.'
);
-};
-LongTextInput.propTypes = {
- className: PropTypes.string,
- input: PropTypes.object,
- isRequired: PropTypes.bool,
- label: PropTypes.string,
- fullWidth: PropTypes.bool,
- meta: PropTypes.object,
- name: PropTypes.string,
- options: PropTypes.object,
- resource: PropTypes.string,
- source: PropTypes.string,
- validate: PropTypes.oneOfType([
- PropTypes.func,
- PropTypes.arrayOf(PropTypes.func),
- ]),
+ return ;
};
-const EnhancedLongTextInput = addField(LongTextInput);
-EnhancedLongTextInput.defaultProps = {
- options: {},
- fullWidth: true,
+LongTextInput.defaultProps = {
+ multiline: true,
};
-export default EnhancedLongTextInput;
+LongTextInput.displayName = 'LongTextInput';
+
+export default addField(LongTextInput);
diff --git a/packages/ra-ui-materialui/src/input/LongTextInput.spec.js b/packages/ra-ui-materialui/src/input/LongTextInput.spec.js
index 5730753f059..63178f26984 100644
--- a/packages/ra-ui-materialui/src/input/LongTextInput.spec.js
+++ b/packages/ra-ui-materialui/src/input/LongTextInput.spec.js
@@ -1,48 +1,66 @@
import React from 'react';
import assert from 'assert';
-import { shallow } from 'enzyme';
+import { render, cleanup } from 'react-testing-library';
import { LongTextInput } from './LongTextInput';
describe('', () => {
+ afterEach(cleanup);
+ const defaultProps = {
+ // We have to specify the id ourselves here because the
+ // TextInput is not wrapped inside a FormInput.
+ // This is needed to link the label to the input
+ id: 'foo',
+ source: 'foo',
+ resource: 'bar',
+ meta: {},
+ input: {
+ value: '',
+ },
+ onChange: jest.fn(),
+ };
+
+ it('should render the input as a textarea', () => {
+ const { getByLabelText } = render(
+
+ );
+ const TextFieldElement = getByLabelText('resources.bar.fields.foo');
+ assert.equal(TextFieldElement.tagName, 'TEXTAREA');
+ assert.equal(TextFieldElement.value, 'hello');
+ });
+
describe('error message', () => {
it('should not be displayed if field is pristine', () => {
- const wrapper = shallow(
-
- );
- const TextFieldElement = wrapper.find(
- 'translate(WithStyles(ResettableTextField))'
+ const { queryByText } = render(
+
);
- assert.equal(TextFieldElement.prop('helperText'), false);
+ const error = queryByText('Required field.');
+ assert.ok(!error);
});
it('should not be displayed if field has been touched but is valid', () => {
- const wrapper = shallow(
+ const { queryByText } = render(
);
- const TextFieldElement = wrapper.find(
- 'translate(WithStyles(ResettableTextField))'
- );
- assert.equal(TextFieldElement.prop('helperText'), false);
+ const error = queryByText('Required field.');
+ assert.ok(!error);
});
it('should be displayed if field has been touched and is invalid', () => {
- const wrapper = shallow(
+ const { getByText } = render(
);
- const TextFieldElement = wrapper.find(
- 'translate(WithStyles(ResettableTextField))'
- );
- assert.equal(
- TextFieldElement.prop('helperText'),
- 'Required field.'
- );
+ const error = getByText('Required field.');
+ assert.ok(error);
});
});
});
diff --git a/packages/ra-ui-materialui/src/input/NullableBooleanInput.js b/packages/ra-ui-materialui/src/input/NullableBooleanInput.js
index 9ee7f4ea2c6..473c6ed406a 100644
--- a/packages/ra-ui-materialui/src/input/NullableBooleanInput.js
+++ b/packages/ra-ui-materialui/src/input/NullableBooleanInput.js
@@ -8,6 +8,7 @@ import classnames from 'classnames';
import { addField, translate, FieldTitle } from 'ra-core';
import sanitizeRestProps from './sanitizeRestProps';
+import InputHelperText from './InputHelperText';
const styles = theme =>
createStyles({
@@ -55,6 +56,7 @@ export class NullableBooleanInput extends Component {
resource,
source,
translate,
+ helperText,
...rest
} = this.props;
const { touched, error } = meta;
@@ -72,7 +74,13 @@ export class NullableBooleanInput extends Component {
/>
}
error={!!(touched && error)}
- helperText={touched && error}
+ helperText={
+
+ }
className={classnames(classes.input, className)}
{...options}
{...sanitizeRestProps(rest)}
diff --git a/packages/ra-ui-materialui/src/input/NumberInput.js b/packages/ra-ui-materialui/src/input/NumberInput.js
index 29544a38375..83cf9ccd81c 100644
--- a/packages/ra-ui-materialui/src/input/NumberInput.js
+++ b/packages/ra-ui-materialui/src/input/NumberInput.js
@@ -1,8 +1,9 @@
-import React, { Component } from 'react';
+import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import TextField from '@material-ui/core/TextField';
import { addField, FieldTitle } from 'ra-core';
+import InputHelperText from './InputHelperText';
import sanitizeRestProps from './sanitizeRestProps';
/**
@@ -17,82 +18,98 @@ import sanitizeRestProps from './sanitizeRestProps';
*
* The object passed as `options` props is passed to the material-ui component
*/
-export class NumberInput extends Component {
- handleBlur = event => {
- /**
- * Necessary because of a React bug on
- * @see https://github.com/facebook/react/issues/1425
- */
- const numericValue = isNaN(parseFloat(event.target.value))
- ? null
- : parseFloat(event.target.value);
- this.props.onBlur(numericValue);
- this.props.input.onBlur(numericValue);
- };
+export const NumberInput = ({
+ className,
+ input,
+ isRequired,
+ label,
+ meta,
+ options,
+ source,
+ step,
+ resource,
+ helperText,
+ onBlur,
+ onFocus,
+ onChange,
+ ...rest
+}) => {
+ const handleBlur = useCallback(
+ event => {
+ /**
+ * Necessary because of a React bug on
+ * @see https://github.com/facebook/react/issues/1425
+ */
+ const numericValue = isNaN(parseFloat(event.target.value))
+ ? null
+ : parseFloat(event.target.value);
+ onBlur(numericValue);
+ input.onBlur(numericValue);
+ },
+ [input, onBlur]
+ );
- handleFocus = event => {
- this.props.onFocus(event);
- this.props.input.onFocus(event);
- };
+ const handleFocus = useCallback(
+ event => {
+ onFocus(event);
+ input.onFocus(event);
+ },
+ [input, onFocus]
+ );
- handleChange = event => {
- /**
- * Necessary because of a React bug on
- * @see https://github.com/facebook/react/issues/1425
- */
- const numericValue = isNaN(parseFloat(event.target.value))
- ? null
- : parseFloat(event.target.value);
- this.props.onChange(numericValue);
- this.props.input.onChange(numericValue);
- };
+ const handleChange = useCallback(
+ event => {
+ /**
+ * Necessary because of a React bug on
+ * @see https://github.com/facebook/react/issues/1425
+ */
+ const numericValue = isNaN(parseFloat(event.target.value))
+ ? null
+ : parseFloat(event.target.value);
+ onChange(numericValue);
+ input.onChange(numericValue);
+ },
+ [input, onChange]
+ );
- render() {
- const {
- className,
- input,
- isRequired,
- label,
- meta,
- options,
- source,
- step,
- resource,
- ...rest
- } = this.props;
- if (typeof meta === 'undefined') {
- throw new Error(
- "The NumberInput component wasn't called within a redux-form . Did you decorate it and forget to add the addField prop to your component? See https://marmelab.com/react-admin/Inputs.html#writing-your-own-input-component for details."
- );
- }
- const { touched, error } = meta;
-
- return (
-
- }
- className={className}
- {...options}
- {...sanitizeRestProps(rest)}
- {...input}
- onBlur={this.handleBlur}
- onFocus={this.handleFocus}
- onChange={this.handleChange}
- />
+ if (typeof meta === 'undefined') {
+ throw new Error(
+ "The NumberInput component wasn't called within a redux-form . Did you decorate it and forget to add the addField prop to your component? See https://marmelab.com/react-admin/Inputs.html#writing-your-own-input-component for details."
);
}
-}
+ const { touched, error } = meta;
+
+ return (
+
+ }
+ step={step}
+ label={
+
+ }
+ className={className}
+ {...options}
+ {...sanitizeRestProps(rest)}
+ {...input}
+ onBlur={handleBlur}
+ onFocus={handleFocus}
+ onChange={handleChange}
+ />
+ );
+};
NumberInput.propTypes = {
className: PropTypes.string,
diff --git a/packages/ra-ui-materialui/src/input/NumberInput.spec.js b/packages/ra-ui-materialui/src/input/NumberInput.spec.js
index 05c90d23b4a..94567f9e27a 100644
--- a/packages/ra-ui-materialui/src/input/NumberInput.spec.js
+++ b/packages/ra-ui-materialui/src/input/NumberInput.spec.js
@@ -1,12 +1,18 @@
import React from 'react';
import assert from 'assert';
-import { shallow } from 'enzyme';
+import { render, cleanup, fireEvent } from 'react-testing-library';
import { NumberInput } from './NumberInput';
describe('', () => {
+ afterEach(cleanup);
+
const defaultProps = {
+ // We have to specify the id ourselves here because the
+ // TextInput is not wrapped inside a FormInput
+ id: 'foo',
source: 'foo',
+ resource: 'bar',
meta: {},
input: {
onBlur: () => {},
@@ -19,57 +25,49 @@ describe('', () => {
};
it('should use a mui TextField', () => {
- const wrapper = shallow(
+ const { getByLabelText } = render(
);
- const TextFieldElement = wrapper.find(
- 'WithStyles(ForwardRef(TextField))'
- );
- assert.equal(TextFieldElement.length, 1);
- assert.equal(TextFieldElement.prop('value'), 12);
- assert.equal(TextFieldElement.prop('type'), 'number');
+ const TextFieldElement = getByLabelText('resources.bar.fields.foo');
+ assert.equal(TextFieldElement.value, '12');
+ assert.equal(TextFieldElement.getAttribute('type'), 'number');
});
describe('onChange event', () => {
it('should be customizable via the `onChange` prop', () => {
const onChange = jest.fn();
- const props = { ...defaultProps };
- const wrapper = shallow(
-
+ const { getByLabelText } = render(
+
);
-
- wrapper
- .find('WithStyles(ForwardRef(TextField))')
- .simulate('change', { target: { value: 3 } });
+ const TextFieldElement = getByLabelText('resources.bar.fields.foo');
+ fireEvent.change(TextFieldElement, { target: { value: 3 } });
assert.equal(onChange.mock.calls[0][0], 3);
});
it('should keep calling redux-form original event', () => {
const onChange = jest.fn();
- const wrapper = shallow(
+ const { getByLabelText } = render(
);
- wrapper
- .find('WithStyles(ForwardRef(TextField))')
- .simulate('change', { target: { value: 3 } });
+ const TextFieldElement = getByLabelText('resources.bar.fields.foo');
+ fireEvent.change(TextFieldElement, { target: { value: 3 } });
assert.equal(onChange.mock.calls[0][0], 3);
});
it('should cast value as a numeric one', () => {
const onChange = jest.fn();
- const wrapper = shallow(
+
+ const { getByLabelText } = render(
);
-
- wrapper
- .find('WithStyles(ForwardRef(TextField))')
- .simulate('change', { target: { value: '2' } });
- assert.equal(onChange.mock.calls[0][0], 2);
+ const TextFieldElement = getByLabelText('resources.bar.fields.foo');
+ fireEvent.change(TextFieldElement, { target: { value: '2' } });
+ assert.equal(onChange.mock.calls[0][0], '2');
});
});
@@ -77,29 +75,23 @@ describe('', () => {
it('should be customizable via the `onFocus` prop', () => {
const onFocus = jest.fn();
- const props = { ...defaultProps };
- const wrapper = shallow(
-
+ const { getByLabelText } = render(
+
);
-
- wrapper
- .find('WithStyles(ForwardRef(TextField))')
- .simulate('focus', 3);
- assert.equal(onFocus.mock.calls[0][0], 3);
+ const TextFieldElement = getByLabelText('resources.bar.fields.foo');
+ fireEvent.focus(TextFieldElement);
+ assert.equal(onFocus.mock.calls.length, 1);
});
it('should keep calling redux-form original event', () => {
const onFocus = jest.fn();
- const wrapper = shallow(
+ const { getByLabelText } = render(
);
- wrapper
- .find('WithStyles(ForwardRef(TextField))')
- .simulate('focus', { target: { value: 3 } });
- assert.deepEqual(onFocus.mock.calls[0][0], {
- target: { value: 3 },
- });
+ const TextFieldElement = getByLabelText('resources.bar.fields.foo');
+ fireEvent.focus(TextFieldElement);
+ assert.equal(onFocus.mock.calls.length, 1);
});
});
@@ -107,49 +99,69 @@ describe('', () => {
it('should be customizable via the `onBlur` prop', () => {
const onBlur = jest.fn();
- const props = { ...defaultProps };
- const wrapper = shallow();
-
- wrapper
- .find('WithStyles(ForwardRef(TextField))')
- .simulate('blur', { target: { value: 3 } });
+ const { getByLabelText } = render(
+
+ );
+ const TextFieldElement = getByLabelText('resources.bar.fields.foo');
+ fireEvent.blur(TextFieldElement, { target: { value: 3 } });
assert.equal(onBlur.mock.calls[0][0], 3);
});
it('should keep calling redux-form original event', () => {
const onBlur = jest.fn();
- const props = {
- ...defaultProps,
- input: {
- ...defaultProps.input,
- onBlur,
- },
- };
-
- const wrapper = shallow();
- wrapper
- .find('WithStyles(ForwardRef(TextField))')
- .simulate('blur', { target: { value: 3 } });
+ const { getByLabelText } = render(
+
+ );
+ const TextFieldElement = getByLabelText('resources.bar.fields.foo');
+ fireEvent.blur(TextFieldElement, { target: { value: 3 } });
assert.equal(onBlur.mock.calls[0][0], 3);
});
it('should cast value as a numeric one', () => {
const onBlur = jest.fn();
- const wrapper = shallow(
+
+ const { getByLabelText } = render(
+
+ );
+ const TextFieldElement = getByLabelText('resources.bar.fields.foo');
+ fireEvent.blur(TextFieldElement, { target: { value: '2' } });
+ assert.equal(onBlur.mock.calls[0][0], '2');
+ });
+ });
+
+ describe('error message', () => {
+ it('should not be displayed if field is pristine', () => {
+ const { queryByText } = render(
+
+ );
+ const error = queryByText('Required field.');
+ assert.ok(!error);
+ });
+
+ it('should not be displayed if field has been touched but is valid', () => {
+ const { queryByText } = render(
);
+ const error = queryByText('Required field.');
+ assert.ok(!error);
+ });
- wrapper
- .find('WithStyles(ForwardRef(TextField))')
- .simulate('blur', { target: { value: '2' } });
- assert.equal(onBlur.mock.calls[0][0], 2);
+ it('should be displayed if field has been touched and is invalid', () => {
+ const { getByText } = render(
+
+ );
+ const error = getByText('Required field.');
+ assert.ok(error);
});
});
});
diff --git a/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.js b/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.js
index 2976eff28bc..389318108e4 100644
--- a/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.js
+++ b/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.js
@@ -1,4 +1,4 @@
-import React, { Component } from 'react';
+import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import get from 'lodash/get';
import FormControl from '@material-ui/core/FormControl';
@@ -9,9 +9,10 @@ import RadioGroup from '@material-ui/core/RadioGroup';
import Radio from '@material-ui/core/Radio';
import { withStyles, createStyles } from '@material-ui/core/styles';
import compose from 'recompose/compose';
-import { addField, translate, FieldTitle } from 'ra-core';
+import { addField, FieldTitle, useTranslate } from 'ra-core';
import sanitizeRestProps from './sanitizeRestProps';
+import InputHelperText from './InputHelperText';
const styles = createStyles({
label: {
@@ -76,97 +77,103 @@ const styles = createStyles({
*
* The object passed as `options` props is passed to the material-ui component
*/
-export class RadioButtonGroupInput extends Component {
- handleChange = (event, value) => {
- this.props.input.onChange(value);
- };
+export const RadioButtonGroupInput = ({
+ classes,
+ className,
+ label,
+ resource,
+ source,
+ input,
+ isRequired,
+ choices,
+ options,
+ meta,
+ helperText,
+ optionText,
+ optionValue,
+ translateChoice,
+ ...rest
+}) => {
+ const translate = useTranslate();
- renderRadioButton = choice => {
- const {
- optionText,
- optionValue,
- translate,
- translateChoice,
- source,
- } = this.props;
+ const handleChange = useCallback(
+ (event, value) => {
+ input.onChange(value);
+ },
+ [input]
+ );
- const choiceName = React.isValidElement(optionText) // eslint-disable-line no-nested-ternary
- ? React.cloneElement(optionText, { record: choice })
- : typeof optionText === 'function'
- ? optionText(choice)
- : get(choice, optionText);
+ const renderRadioButton = useCallback(
+ choice => {
+ const choiceName = React.isValidElement(optionText) // eslint-disable-line no-nested-ternary
+ ? React.cloneElement(optionText, { record: choice })
+ : typeof optionText === 'function'
+ ? optionText(choice)
+ : get(choice, optionText);
- const nodeId = `${source}_${get(choice, optionValue)}`;
+ const nodeId = `${source}_${get(choice, optionValue)}`;
- return (
- }
- label={
- translateChoice
- ? translate(choiceName, { _: choiceName })
- : choiceName
- }
- />
+ return (
+ }
+ label={
+ translateChoice
+ ? translate(choiceName, { _: choiceName })
+ : choiceName
+ }
+ />
+ );
+ },
+ [optionText, optionValue, source, translate, translateChoice]
+ );
+
+ if (typeof meta === 'undefined') {
+ throw new Error(
+ "The RadioButtonGroupInput component wasn't called within a redux-form . Did you decorate it and forget to add the addField prop to your component? See https://marmelab.com/react-admin/Inputs.html#writing-your-own-input-component for details."
);
- };
+ }
- render() {
- const {
- classes,
- className,
- label,
- resource,
- source,
- input,
- isRequired,
- choices,
- options,
- meta,
- ...rest
- } = this.props;
- if (typeof meta === 'undefined') {
- throw new Error(
- "The RadioButtonGroupInput component wasn't called within a redux-form . Did you decorate it and forget to add the addField prop to your component? See https://marmelab.com/react-admin/Inputs.html#writing-your-own-input-component for details."
- );
- }
+ const { touched, error } = meta;
- const { touched, error, helperText = false } = meta;
+ return (
+
+
+
+
- return (
-
-
-
+ {helperText || (touched && error) ? (
+
+
-
-
-
- {choices.map(this.renderRadioButton)}
-
- {touched && error && (
- {error}
- )}
- {helperText && {helperText}}
-
- );
- }
-}
+
+ ) : null}
+
+ );
+};
RadioButtonGroupInput.propTypes = {
choices: PropTypes.arrayOf(PropTypes.object),
@@ -200,6 +207,5 @@ RadioButtonGroupInput.defaultProps = {
export default compose(
addField,
- translate,
withStyles(styles)
)(RadioButtonGroupInput);
diff --git a/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.spec.js b/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.spec.js
index 8ef3368e71b..ac19c76460b 100644
--- a/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.spec.js
+++ b/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.spec.js
@@ -3,6 +3,7 @@ import expect from 'expect';
import { render, cleanup } from 'react-testing-library';
import { RadioButtonGroupInput } from './RadioButtonGroupInput';
+import { TranslationContext } from 'ra-core';
describe('', () => {
const defaultProps = {
@@ -121,23 +122,33 @@ describe('', () => {
it('should translate the choices by default', () => {
const { queryByText } = render(
- `**${x}**`}
- />
+ `**${x}**`,
+ }}
+ >
+
+
);
expect(queryByText('**Male**')).not.toBeNull();
});
it('should not translate the choices if translateChoice is false', () => {
const { queryByText } = render(
- `**${x}**`}
- translateChoice={false}
- />
+ `**${x}**`,
+ }}
+ >
+
+
);
expect(queryByText('**Male**')).toBeNull();
expect(queryByText('Male')).not.toBeNull();
@@ -147,7 +158,7 @@ describe('', () => {
const { queryByText } = render(
);
expect(queryByText('Can I help you?')).not.toBeNull();
@@ -155,49 +166,38 @@ describe('', () => {
describe('error message', () => {
it('should not be displayed if field is pristine', () => {
- const { container } = render(
-
- );
- expect(container.querySelector('p')).toBeNull();
- });
-
- it('should not be displayed if field has been touched but is valid', () => {
- const { container } = render(
+ const { queryByText } = render(
);
- expect(container.querySelector('p')).toBeNull();
+ expect(queryByText('Required field.')).toBeNull();
});
it('should be displayed if field has been touched and is invalid', () => {
- const { container, queryByText } = render(
+ const { getByText } = render(
);
- expect(container.querySelector('p')).not.toBeNull();
- expect(queryByText('Required field.')).not.toBeNull();
+ expect(getByText('Required field.')).toBeDefined();
});
- it('should display the error and help text if helperText is present', () => {
- const { queryByText } = render(
+ it('should be displayed even with an helper Text', () => {
+ const { getByText, queryByText } = render(
);
- expect(queryByText('Required field.')).not.toBeNull();
- expect(queryByText('Can I help you?')).not.toBeNull();
+ expect(getByText('Required field.')).toBeDefined();
+ expect(queryByText('Can I help you?')).toBeNull();
});
});
});
diff --git a/packages/ra-ui-materialui/src/input/SelectArrayInput.js b/packages/ra-ui-materialui/src/input/SelectArrayInput.js
index 3511e2f1306..36937541906 100644
--- a/packages/ra-ui-materialui/src/input/SelectArrayInput.js
+++ b/packages/ra-ui-materialui/src/input/SelectArrayInput.js
@@ -12,6 +12,7 @@ import { withStyles, createStyles } from '@material-ui/core/styles';
import compose from 'recompose/compose';
import classnames from 'classnames';
import { addField, translate, FieldTitle } from 'ra-core';
+import InputHelperText from './InputHelperText';
const sanitizeRestProps = ({
addLabel,
@@ -163,14 +164,14 @@ export class SelectArrayInput extends Component {
renderMenuItem = choice => {
const { optionValue } = this.props;
- return (
+ return choice ? (
- );
+ ) : null;
};
render() {
@@ -186,6 +187,7 @@ export class SelectArrayInput extends Component {
source,
optionText,
optionValue,
+ helperText,
...rest
} = this.props;
if (typeof meta === 'undefined') {
@@ -193,13 +195,13 @@ export class SelectArrayInput extends Component {
"The SelectInput component wasn't called within a redux-form . Did you decorate it and forget to add the addField prop to your component? See https://marmelab.com/react-admin/Inputs.html#writing-your-own-input-component for details."
);
}
- const { touched, error, helperText = false } = meta;
+ const { touched, error } = meta;
return (
@@ -240,10 +242,15 @@ export class SelectArrayInput extends Component {
>
{choices.map(this.renderMenuItem)}
- {touched && error && (
- {error}
- )}
- {helperText && {helperText}}
+ {helperText || (touched && error) ? (
+
+
+
+ ) : null}
);
}
diff --git a/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.js b/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.js
index e1c7338f156..2e83ba5d705 100644
--- a/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.js
+++ b/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.js
@@ -20,7 +20,7 @@ describe('', () => {
const { queryByTestId } = render(
);
- expect(queryByTestId('selectArray')).not.toBeNull();
+ expect(queryByTestId('selectArray')).toBeDefined();
});
it('should use the input parameter value as the initial input value', () => {
@@ -153,56 +153,45 @@ describe('', () => {
it('should displayed helperText if prop is present in meta', () => {
const { queryByText } = render(
-
+
);
- expect(queryByText('Can I help you?')).not.toBeNull();
+ expect(queryByText('Can I help you?')).toBeDefined();
});
describe('error message', () => {
it('should not be displayed if field is pristine', () => {
- const { container } = render(
-
- );
- expect(container.querySelector('p')).toBeNull();
- });
-
- it('should not be displayed if field has been touched but is valid', () => {
- const { container } = render(
+ const { queryByText } = render(
);
- expect(container.querySelector('p')).toBeNull();
+ expect(queryByText('Required field.')).toBeNull();
});
it('should be displayed if field has been touched and is invalid', () => {
- const { container, queryByText } = render(
+ const { queryByText } = render(
);
- expect(container.querySelector('p')).not.toBeNull();
- expect(queryByText('Required field.')).not.toBeNull();
+ expect(queryByText('Required field.')).toBeDefined();
});
- it('should be displayed with an helper Text', () => {
+ it('should be displayed even with an helper Text', () => {
const { queryByText } = render(
);
- expect(queryByText('Required field.')).not.toBeNull();
- expect(queryByText('Can I help you?')).not.toBeNull();
+ expect(queryByText('Required field.')).toBeDefined();
+ expect(queryByText('Can I help you?')).toBeNull();
});
});
});
diff --git a/packages/ra-ui-materialui/src/input/SelectInput.js b/packages/ra-ui-materialui/src/input/SelectInput.js
index 90131e86d7e..75d23396986 100644
--- a/packages/ra-ui-materialui/src/input/SelectInput.js
+++ b/packages/ra-ui-materialui/src/input/SelectInput.js
@@ -1,11 +1,13 @@
-import React, { Component } from 'react';
+import React, { useState, useCallback, useEffect } from 'react';
import PropTypes from 'prop-types';
import get from 'lodash/get';
import MenuItem from '@material-ui/core/MenuItem';
import { withStyles, createStyles } from '@material-ui/core/styles';
import compose from 'recompose/compose';
-import { addField, translate, FieldTitle } from 'ra-core';
+import { addField, translate, FieldTitle, useTranslate } from 'ra-core';
+
import ResettableTextField from './ResettableTextField';
+import InputHelperText from './InputHelperText';
const sanitizeRestProps = ({
addLabel,
@@ -129,122 +131,119 @@ const styles = theme =>
*
*
*/
-export class SelectInput extends Component {
+export const SelectInput = ({
+ allowEmpty,
+ choices,
+ classes,
+ className,
+ disableValue,
+ emptyValue,
+ helperText,
+ input,
+ isRequired,
+ label,
+ meta,
+ options,
+ optionText,
+ optionValue,
+ resource,
+ source,
+ translateChoice,
+ ...rest
+}) => {
/*
* Using state to bypass a redux-form comparison but which prevents re-rendering
* @see https://github.com/erikras/redux-form/issues/2456
*/
- state = {
- value: this.props.input.value,
- };
-
- componentWillReceiveProps(nextProps) {
- if (nextProps.input.value !== this.props.input.value) {
- this.setState({ value: nextProps.input.value });
- }
- }
-
- handleChange = eventOrValue => {
- const value = eventOrValue.target
- ? eventOrValue.target.value
- : eventOrValue;
- this.props.input.onChange(value);
+ const [value, setValue] = useState(input.value);
+ const translate = useTranslate();
- // HACK: For some reason, redux-form does not consider this input touched without calling onBlur manually
- this.props.input.onBlur(value);
- this.setState({ value });
- };
+ useEffect(() => {
+ setValue(input.value);
+ }, [input]);
- addAllowEmpty = choices => {
- if (this.props.allowEmpty) {
- return [
- ,
- ...choices,
- ];
- }
+ const handleChange = useCallback(
+ eventOrValue => {
+ const value = eventOrValue.target
+ ? eventOrValue.target.value
+ : eventOrValue;
+ input.onChange(value);
- return choices;
- };
- renderMenuItemOption = choice => {
- const { optionText, translate, translateChoice } = this.props;
- if (React.isValidElement(optionText)) {
- return React.cloneElement(optionText, {
- record: choice,
- });
- }
-
- const choiceName =
- typeof optionText === 'function'
- ? optionText(choice)
- : get(choice, optionText);
+ // HACK: For some reason, redux-form does not consider this input touched without calling onBlur manually
+ input.onBlur(value);
+ setValue(value);
+ },
+ [input, setValue]
+ );
- return translateChoice
- ? translate(choiceName, { _: choiceName })
- : choiceName;
- };
+ const renderMenuItemOption = useCallback(
+ choice => {
+ if (React.isValidElement(optionText)) {
+ return React.cloneElement(optionText, {
+ record: choice,
+ });
+ }
- renderMenuItem = choice => {
- const { optionValue, disableValue } = this.props;
- return (
-
- );
- };
+ const choiceName =
+ typeof optionText === 'function'
+ ? optionText(choice)
+ : get(choice, optionText);
- render() {
- const {
- allowEmpty,
- choices,
- classes,
- className,
- input,
- isRequired,
- label,
- meta,
- options,
- resource,
- source,
- ...rest
- } = this.props;
- if (typeof meta === 'undefined') {
- throw new Error(
- "The SelectInput component wasn't called within a redux-form . Did you decorate it and forget to add the addField prop to your component? See https://marmelab.com/react-admin/Inputs.html#writing-your-own-input-component for details."
- );
- }
- const { touched, error, helperText = false } = meta;
+ return translateChoice
+ ? translate(choiceName, { _: choiceName })
+ : choiceName;
+ },
+ [optionText, translate, translateChoice]
+ );
- return (
-
- }
- name={input.name}
- className={`${classes.input} ${className}`}
- clearAlwaysVisible
- error={!!(touched && error)}
- helperText={(touched && error) || helperText}
- {...options}
- {...sanitizeRestProps(rest)}
- onChange={this.handleChange}
- >
- {this.addAllowEmpty(choices.map(this.renderMenuItem))}
-
+ if (typeof meta === 'undefined') {
+ throw new Error(
+ "The SelectInput component wasn't called within a redux-form . Did you decorate it and forget to add the addField prop to your component? See https://marmelab.com/react-admin/Inputs.html#writing-your-own-input-component for details."
);
}
-}
+ const { touched, error } = meta;
+
+ return (
+
+ }
+ name={input.name}
+ className={`${classes.input} ${className}`}
+ clearAlwaysVisible
+ error={!!(touched && error)}
+ helperText={
+
+ }
+ {...options}
+ {...sanitizeRestProps(rest)}
+ onChange={handleChange}
+ >
+ {allowEmpty ? : null}
+ {choices.map(choice => (
+
+ ))}
+
+ );
+};
SelectInput.propTypes = {
allowEmpty: PropTypes.bool.isRequired,
diff --git a/packages/ra-ui-materialui/src/input/SelectInput.spec.js b/packages/ra-ui-materialui/src/input/SelectInput.spec.js
index 93ec9c4f1a1..b006267ffe8 100644
--- a/packages/ra-ui-materialui/src/input/SelectInput.spec.js
+++ b/packages/ra-ui-materialui/src/input/SelectInput.spec.js
@@ -1,40 +1,33 @@
import React from 'react';
import assert from 'assert';
-import { shallow } from 'enzyme';
+import { render, cleanup, fireEvent } from 'react-testing-library';
+
import { SelectInput } from './SelectInput';
+import { TranslationContext } from 'ra-core';
describe('', () => {
+ afterEach(cleanup);
+
const defaultProps = {
+ // We have to specify the id ourselves here because the
+ // TextInput is not wrapped inside a FormInput
+ id: 'foo',
source: 'foo',
+ resource: 'bar',
meta: {},
- input: {},
- translate: x => x,
+ input: { value: '' },
};
- it('should use a ResettableTextField', () => {
- const wrapper = shallow(
-
- );
- const SelectFieldElement = wrapper.find(
- 'translate(WithStyles(ResettableTextField))'
- );
-
- assert.equal(SelectFieldElement.length, 1);
- assert.equal(SelectFieldElement.prop('value'), 'hello');
- });
-
it('should use the input parameter value as the initial input value', () => {
- const wrapper = shallow(
+ const { getByLabelText } = render(
);
- const SelectFieldElement = wrapper
- .find('translate(WithStyles(ResettableTextField))')
- .first();
- assert.equal(SelectFieldElement.prop('value'), '2');
+ const TextFieldElement = getByLabelText('resources.bar.fields.foo');
+ assert.equal(TextFieldElement.value, '2');
});
it('should render choices as mui MenuItem components', () => {
- const wrapper = shallow(
+ const { getByRole, getByText, queryAllByRole } = render(
', () => {
]}
/>
);
- const MenuItemElements = wrapper.find(
- 'WithStyles(ForwardRef(MenuItem))'
- );
- assert.equal(MenuItemElements.length, 2);
- const MenuItemElement1 = MenuItemElements.first();
- assert.equal(MenuItemElement1.prop('value'), 'M');
- assert.equal(MenuItemElement1.childAt(0).text(), 'Male');
- const MenuItemElement2 = MenuItemElements.at(1);
- assert.equal(MenuItemElement2.prop('value'), 'F');
- assert.equal(MenuItemElement2.childAt(0).text(), 'Female');
+ const TextFieldElement = getByRole('button');
+ fireEvent.click(TextFieldElement);
+ const options = queryAllByRole('option');
+ assert.equal(options.length, 2);
+
+ const optionMale = getByText('Male');
+ assert.equal(optionMale.getAttribute('data-value'), 'M');
+
+ const optionFemale = getByText('Female');
+ assert.equal(optionFemale.getAttribute('data-value'), 'F');
});
it('should render disable choices marked so', () => {
- const wrapper = shallow(
+ const { getByRole, getByText } = render(
);
- const MenuItemElements = wrapper.find(
- 'WithStyles(ForwardRef(MenuItem))'
- );
- const MenuItemElement = MenuItemElements.at(1);
- assert.equal(!!MenuItemElement.prop('disabled'), false);
- const MenuItemElement2 = MenuItemElements.at(2);
- assert.equal(MenuItemElement2.prop('disabled'), true);
+ const TextFieldElement = getByRole('button');
+ fireEvent.click(TextFieldElement);
+ const option1 = getByText('Leo Tolstoi');
+ assert.equal(option1.getAttribute('aria-disabled'), 'false');
+
+ const option2 = getByText('Jane Austen');
+ assert.equal(option2.getAttribute('aria-disabled'), 'false');
+
+ const option3 = getByText('System Administrator');
+ assert.equal(option3.getAttribute('aria-disabled'), 'true');
});
it('should add an empty menu when allowEmpty is true', () => {
- const wrapper = shallow(
+ const { getByRole, queryAllByRole } = render(
);
- const MenuItemElements = wrapper.find(
- 'WithStyles(ForwardRef(MenuItem))'
- );
- assert.equal(MenuItemElements.length, 3);
- const MenuItemElement1 = MenuItemElements.first();
- assert.equal(MenuItemElement1.prop('value'), '');
- assert.equal(MenuItemElement1.children().length, 0);
+ const TextFieldElement = getByRole('button');
+ fireEvent.click(TextFieldElement);
+
+ const options = queryAllByRole('option');
+ assert.equal(options.length, 3);
+ assert.equal(options[0].getAttribute('data-value'), '');
});
it('should add an empty menu with custom value when allowEmpty is true', () => {
const emptyValue = 'test';
- const wrapper = shallow(
+
+ const { getByRole, queryAllByRole } = render(
);
- const MenuItemElements = wrapper.find(
- 'WithStyles(ForwardRef(MenuItem))'
- );
- assert.equal(MenuItemElements.length, 3);
- const MenuItemElement1 = MenuItemElements.first();
- assert.equal(MenuItemElement1.prop('value'), emptyValue);
- assert.equal(MenuItemElement1.children().length, 0);
+ const TextFieldElement = getByRole('button');
+ fireEvent.click(TextFieldElement);
+
+ const options = queryAllByRole('option');
+ assert.equal(options.length, 3);
+ assert.equal(options[0].getAttribute('data-value'), emptyValue);
});
it('should not add a falsy (null or false) element when allowEmpty is false', () => {
- const wrapper = shallow(
+ const { getByRole, queryAllByRole } = render(
', () => {
]}
/>
);
- const MenuItemElements = wrapper.find(
- 'WithStyles(ForwardRef(MenuItem))'
- );
- assert.equal(MenuItemElements.length, 2);
+ const TextFieldElement = getByRole('button');
+ fireEvent.click(TextFieldElement);
+ const options = queryAllByRole('option');
+ assert.equal(options.length, 2);
});
it('should use optionValue as value identifier', () => {
- const wrapper = shallow(
+ const { getByRole, getByText } = render(
);
- const MenuItemElements = wrapper.find(
- 'WithStyles(ForwardRef(MenuItem))'
- );
- const MenuItemElement1 = MenuItemElements.first();
- assert.equal(MenuItemElement1.prop('value'), 'M');
- assert.equal(MenuItemElement1.childAt(0).text(), 'Male');
+ const TextFieldElement = getByRole('button');
+ fireEvent.click(TextFieldElement);
+
+ const optionMale = getByText('Male');
+ assert.equal(optionMale.getAttribute('data-value'), 'M');
});
it('should use optionValue including "." as value identifier', () => {
- const wrapper = shallow(
+ const { getByRole, getByText } = render(
);
- const MenuItemElements = wrapper.find(
- 'WithStyles(ForwardRef(MenuItem))'
- );
- const MenuItemElement1 = MenuItemElements.first();
- assert.equal(MenuItemElement1.prop('value'), 'M');
- assert.equal(MenuItemElement1.childAt(0).text(), 'Male');
+ const TextFieldElement = getByRole('button');
+ fireEvent.click(TextFieldElement);
+
+ const optionMale = getByText('Male');
+ assert.equal(optionMale.getAttribute('data-value'), 'M');
});
it('should use optionText with a string value as text identifier', () => {
- const wrapper = shallow(
+ const { getByRole, getByText } = render(
);
- const MenuItemElements = wrapper.find(
- 'WithStyles(ForwardRef(MenuItem))'
- );
- const MenuItemElement1 = MenuItemElements.first();
- assert.equal(MenuItemElement1.prop('value'), 'M');
- assert.equal(MenuItemElement1.childAt(0).text(), 'Male');
+ const TextFieldElement = getByRole('button');
+ fireEvent.click(TextFieldElement);
+
+ const optionMale = getByText('Male');
+ assert.equal(optionMale.getAttribute('data-value'), 'M');
});
it('should use optionText with a string value including "." as text identifier', () => {
- const wrapper = shallow(
+ const { getByRole, getByText } = render(
);
- const MenuItemElements = wrapper.find(
- 'WithStyles(ForwardRef(MenuItem))'
- );
- const MenuItemElement1 = MenuItemElements.first();
- assert.equal(MenuItemElement1.prop('value'), 'M');
- assert.equal(MenuItemElement1.childAt(0).text(), 'Male');
+ const TextFieldElement = getByRole('button');
+ fireEvent.click(TextFieldElement);
+
+ const optionMale = getByText('Male');
+ assert.equal(optionMale.getAttribute('data-value'), 'M');
});
it('should use optionText with a function value as text identifier', () => {
- const wrapper = shallow(
+ const { getByRole, getByText } = render(
choice.foobar}
choices={[{ id: 'M', foobar: 'Male' }]}
/>
);
- const MenuItemElements = wrapper.find(
- 'WithStyles(ForwardRef(MenuItem))'
- );
- const MenuItemElement1 = MenuItemElements.first();
- assert.equal(MenuItemElement1.prop('value'), 'M');
- assert.equal(MenuItemElement1.childAt(0).text(), 'Male');
+ const TextFieldElement = getByRole('button');
+ fireEvent.click(TextFieldElement);
+
+ const optionMale = getByText('Male');
+ assert.equal(optionMale.getAttribute('data-value'), 'M');
});
it('should use optionText with an element value as text identifier', () => {
- const Foobar = ({ record }) => {record.foobar};
- const wrapper = shallow(
+ const Foobar = ({ record }) => (
+
+ );
+
+ const { getByRole, getByLabelText } = render(
}
choices={[{ id: 'M', foobar: 'Male' }]}
/>
);
- const MenuItemElements = wrapper.find(
- 'WithStyles(ForwardRef(MenuItem))'
- );
- const MenuItemElement1 = MenuItemElements.first();
- assert.equal(MenuItemElement1.prop('value'), 'M');
- assert.equal(MenuItemElement1.childAt(0).type(), Foobar);
- assert.deepEqual(MenuItemElement1.childAt(0).prop('record'), {
- id: 'M',
- foobar: 'Male',
- });
+ const TextFieldElement = getByRole('button');
+ fireEvent.click(TextFieldElement);
+
+ const optionMale = getByLabelText('Male');
+ assert.equal(optionMale.getAttribute('data-value'), 'M');
});
it('should translate the choices by default', () => {
- const wrapper = shallow(
- `**${x}**`}
- />
- );
- const MenuItemElements = wrapper.find(
- 'WithStyles(ForwardRef(MenuItem))'
+ const { getByRole, getByText, queryAllByRole } = render(
+ `**${x}**`,
+ }}
+ >
+
+
);
- const MenuItemElement1 = MenuItemElements.first();
- assert.equal(MenuItemElement1.prop('value'), 'M');
- assert.equal(MenuItemElement1.childAt(0).text(), '**Male**');
+ const TextFieldElement = getByRole('button');
+ fireEvent.click(TextFieldElement);
+ const options = queryAllByRole('option');
+ assert.equal(options.length, 2);
+
+ const optionMale = getByText('**Male**');
+ assert.equal(optionMale.getAttribute('data-value'), 'M');
+
+ const optionFemale = getByText('**Female**');
+ assert.equal(optionFemale.getAttribute('data-value'), 'F');
});
it('should not translate the choices if translateChoice is false', () => {
- const wrapper = shallow(
- `**${x}**`}
- translateChoice={false}
- />
- );
- const MenuItemElements = wrapper.find(
- 'WithStyles(ForwardRef(MenuItem))'
+ const { getByRole, getByText, queryAllByRole } = render(
+ `**${x}**`,
+ }}
+ >
+
+
);
- const MenuItemElement1 = MenuItemElements.first();
- assert.equal(MenuItemElement1.prop('value'), 'M');
- assert.equal(MenuItemElement1.childAt(0).text(), 'Male');
+ const TextFieldElement = getByRole('button');
+ fireEvent.click(TextFieldElement);
+ const options = queryAllByRole('option');
+ assert.equal(options.length, 2);
+
+ const optionMale = getByText('Male');
+ assert.equal(optionMale.getAttribute('data-value'), 'M');
+
+ const optionFemale = getByText('Female');
+ assert.equal(optionFemale.getAttribute('data-value'), 'F');
});
it('should displayed helperText if prop is present in meta', () => {
- const wrapper = shallow(
-
- );
- const SelectFieldElement = wrapper.find(
- 'translate(WithStyles(ResettableTextField))'
+ const { getByText } = render(
+
);
- assert.equal(SelectFieldElement.prop('helperText'), 'Can i help you?');
+ const helperText = getByText('Can I help you?');
+ assert.ok(helperText);
});
describe('error message', () => {
it('should not be displayed if field is pristine', () => {
- const wrapper = shallow(
-
- );
- const SelectFieldElement = wrapper.find(
- 'translate(WithStyles(ResettableTextField))'
+ const { queryAllByText } = render(
+
);
- assert.equal(SelectFieldElement.prop('helperText'), false);
+ const error = queryAllByText('Required field.');
+ assert.equal(error.length, 0);
});
it('should not be displayed if field has been touched but is valid', () => {
- const wrapper = shallow(
+ const { queryAllByText } = render(
);
- const SelectFieldElement = wrapper.find(
- 'translate(WithStyles(ResettableTextField))'
- );
- assert.equal(SelectFieldElement.prop('helperText'), false);
+ const error = queryAllByText('Required field.');
+ assert.equal(error.length, 0);
});
it('should be displayed if field has been touched and is invalid', () => {
- const wrapper = shallow(
+ const { getByText } = render(
);
- const SelectFieldElement = wrapper.find(
- 'translate(WithStyles(ResettableTextField))'
- );
- assert.equal(
- SelectFieldElement.prop('helperText'),
- 'Required field.'
- );
+ const error = getByText('Required field.');
+ assert.ok(error);
});
it('should display the error even if helperText is present', () => {
- const wrapper = shallow(
+ const { getByText, queryByText } = render(
);
- const SelectFieldElement = wrapper.find(
- 'translate(WithStyles(ResettableTextField))'
- );
- assert.equal(
- SelectFieldElement.prop('helperText'),
- 'Required field.'
- );
+ assert.ok(getByText('Required field.'));
+ assert.ok(!queryByText('Can I help you?'));
});
});
});
diff --git a/packages/ra-ui-materialui/src/input/TextInput.js b/packages/ra-ui-materialui/src/input/TextInput.js
index 03f1b5395cb..736ada124fa 100644
--- a/packages/ra-ui-materialui/src/input/TextInput.js
+++ b/packages/ra-ui-materialui/src/input/TextInput.js
@@ -1,8 +1,9 @@
-import React, { Component } from 'react';
+import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { addField, FieldTitle } from 'ra-core';
import ResettableTextField from './ResettableTextField';
+import InputHelperText from './InputHelperText';
import sanitizeRestProps from './sanitizeRestProps';
/**
@@ -19,71 +20,88 @@ import sanitizeRestProps from './sanitizeRestProps';
*
* The object passed as `options` props is passed to the component
*/
-export class TextInput extends Component {
- handleBlur = eventOrValue => {
- this.props.onBlur(eventOrValue);
- this.props.input.onBlur(eventOrValue);
- };
+export const TextInput = ({
+ className,
+ input,
+ isRequired,
+ label,
+ meta,
+ options,
+ resource,
+ source,
+ type,
+ helperText,
+ FormHelperTextProps,
+ onBlur,
+ onFocus,
+ onChange,
+ ...rest
+}) => {
+ const handleBlur = useCallback(
+ eventOrValue => {
+ onBlur(eventOrValue);
+ input.onBlur(eventOrValue);
+ },
+ [input, onBlur]
+ );
- handleFocus = event => {
- this.props.onFocus(event);
- this.props.input.onFocus(event);
- };
+ const handleFocus = useCallback(
+ event => {
+ onFocus(event);
+ input.onFocus(event);
+ },
+ [input, onFocus]
+ );
- handleChange = eventOrValue => {
- this.props.onChange(eventOrValue);
- this.props.input.onChange(eventOrValue);
- };
+ const handleChange = useCallback(
+ eventOrValue => {
+ onChange(eventOrValue);
+ input.onChange(eventOrValue);
+ },
+ [input, onChange]
+ );
- render() {
- const {
- className,
- input,
- isRequired,
- label,
- meta,
- options,
- resource,
- source,
- type,
- ...rest
- } = this.props;
- if (typeof meta === 'undefined') {
- throw new Error(
- "The TextInput component wasn't called within a redux-form . Did you decorate it and forget to add the addField prop to your component? See https://marmelab.com/react-admin/Inputs.html#writing-your-own-input-component for details."
- );
- }
- const { touched, error } = meta;
-
- return (
-
- )
- }
- error={!!(touched && error)}
- helperText={touched && error}
- className={className}
- {...options}
- {...sanitizeRestProps(rest)}
- {...input}
- onBlur={this.handleBlur}
- onFocus={this.handleFocus}
- onChange={this.handleChange}
- />
+ if (typeof meta === 'undefined') {
+ throw new Error(
+ "The TextInput component wasn't called within a redux-form . Did you decorate it and forget to add the addField prop to your component? See https://marmelab.com/react-admin/Inputs.html#writing-your-own-input-component for details."
);
}
-}
+ const { touched, error } = meta;
+
+ return (
+
+ )
+ }
+ error={!!(touched && error)}
+ helperText={
+
+ }
+ className={className}
+ {...options}
+ {...sanitizeRestProps(rest)}
+ {...input}
+ onBlur={handleBlur}
+ onFocus={handleFocus}
+ onChange={handleChange}
+ />
+ );
+};
TextInput.propTypes = {
className: PropTypes.string,
diff --git a/packages/ra-ui-materialui/src/input/TextInput.spec.js b/packages/ra-ui-materialui/src/input/TextInput.spec.js
index 1f37c99b896..7132c5699cf 100644
--- a/packages/ra-ui-materialui/src/input/TextInput.spec.js
+++ b/packages/ra-ui-materialui/src/input/TextInput.spec.js
@@ -1,90 +1,85 @@
-import { shallow } from 'enzyme';
import assert from 'assert';
import React from 'react';
+import { render, cleanup, fireEvent } from 'react-testing-library';
import { TextInput } from './TextInput';
describe('', () => {
const defaultProps = {
+ // We have to specify the id ourselves here because the
+ // TextInput is not wrapped inside a FormInput.
+ // This is needed to link the label to the input
+ id: 'foo',
source: 'foo',
+ resource: 'bar',
meta: {},
- input: {},
+ input: {
+ value: '',
+ },
};
- it('should use a ResettableTextField when type is text', () => {
- const wrapper = shallow(
+ afterEach(cleanup);
+
+ it('should render the input correctly', () => {
+ const { getByLabelText } = render(
);
- const TextFieldElement = wrapper.find(
- 'translate(WithStyles(ResettableTextField))'
- );
- assert.equal(TextFieldElement.length, 1);
- assert.equal(TextFieldElement.prop('value'), 'hello');
- assert.equal(TextFieldElement.prop('type'), 'text');
+ const TextFieldElement = getByLabelText('resources.bar.fields.foo');
+ assert.equal(TextFieldElement.value, 'hello');
+ assert.equal(TextFieldElement.getAttribute('type'), 'text');
});
it('should use a ResettableTextField when type is password', () => {
- const wrapper = shallow(
+ const { getByLabelText } = render(
);
- const TextFieldElement = wrapper.find(
- 'translate(WithStyles(ResettableTextField))'
- );
- assert.equal(TextFieldElement.length, 1);
- assert.equal(TextFieldElement.prop('type'), 'password');
+ const TextFieldElement = getByLabelText('resources.bar.fields.foo');
+ assert.equal(TextFieldElement.getAttribute('type'), 'password');
});
it('should call redux-form onBlur handler when blurred', () => {
const onBlur = jest.fn();
- const wrapper = shallow(
-
+ const { getByLabelText } = render(
+
);
- const TextFieldElement = wrapper
- .find('translate(WithStyles(ResettableTextField))')
- .first();
- TextFieldElement.simulate('blur', 'event');
- assert.equal(onBlur.mock.calls[0][0], 'event');
+ const TextFieldElement = getByLabelText('resources.bar.fields.foo');
+ fireEvent.blur(TextFieldElement);
+ assert.equal(onBlur.mock.calls.length, 1);
});
describe('error message', () => {
it('should not be displayed if field is pristine', () => {
- const wrapper = shallow(
-
- );
- const TextFieldElement = wrapper.find(
- 'translate(WithStyles(ResettableTextField))'
+ const { queryByText } = render(
+
);
- assert.equal(TextFieldElement.prop('helperText'), false);
+ const error = queryByText('Required field.');
+ assert.ok(!error);
});
it('should not be displayed if field has been touched but is valid', () => {
- const wrapper = shallow(
+ const { queryByText } = render(
);
- const TextFieldElement = wrapper.find(
- 'translate(WithStyles(ResettableTextField))'
- );
- assert.equal(TextFieldElement.prop('helperText'), false);
+ const error = queryByText('Required field.');
+ assert.ok(!error);
});
it('should be displayed if field has been touched and is invalid', () => {
- const wrapper = shallow(
+ const { getByText } = render(
);
- const TextFieldElement = wrapper.find(
- 'translate(WithStyles(ResettableTextField))'
- );
- assert.equal(
- TextFieldElement.prop('helperText'),
- 'Required field.'
- );
+ const error = getByText('Required field.');
+ assert.ok(error);
});
});
});
diff --git a/packages/ra-ui-materialui/src/input/index.js b/packages/ra-ui-materialui/src/input/index.js
index 27b883ee201..df4ba324a94 100644
--- a/packages/ra-ui-materialui/src/input/index.js
+++ b/packages/ra-ui-materialui/src/input/index.js
@@ -8,6 +8,7 @@ import DateTimeInput from './DateTimeInput';
import DisabledInput from './DisabledInput';
import FileInput from './FileInput';
import ImageInput from './ImageInput';
+import InputHelperText from './InputHelperText';
import Labeled from './Labeled';
import LongTextInput from './LongTextInput';
import NullableBooleanInput from './NullableBooleanInput';
@@ -32,6 +33,7 @@ export {
DisabledInput,
FileInput,
ImageInput,
+ InputHelperText,
Labeled,
LongTextInput,
NullableBooleanInput,