From 9ea7db7f2756bfe1da4e53d1276d6b74bdb38e8e Mon Sep 17 00:00:00 2001 From: Rax Canaan Layumas Date: Mon, 15 Nov 2021 19:21:35 +0800 Subject: [PATCH 001/122] ENG-2811 Rewrite user form to use formik instead of redux-form --- src/ui/common/form/RenderSelectInput.js | 1 + src/ui/common/formik-field/RenderTextInput.js | 2 +- src/ui/common/formik-field/SelectInput.js | 134 +++++ src/ui/common/formik-field/SwitchInput.js | 102 ++++ src/ui/users/add/AddFormContainer.js | 62 ++- src/ui/users/common/UserForm.js | 456 ++++++++-------- src/ui/users/edit/EditFormContainer.js | 88 +++- test/ui/users/add/AddFormContainer.test.js | 138 +++-- test/ui/users/common/UserForm.test.js | 489 ++++++++++++------ test/ui/users/edit/EditFormContainer.test.js | 162 ++++-- 10 files changed, 1129 insertions(+), 505 deletions(-) create mode 100644 src/ui/common/formik-field/SelectInput.js create mode 100644 src/ui/common/formik-field/SwitchInput.js diff --git a/src/ui/common/form/RenderSelectInput.js b/src/ui/common/form/RenderSelectInput.js index 708ddef0a..b93e3af60 100644 --- a/src/ui/common/form/RenderSelectInput.js +++ b/src/ui/common/form/RenderSelectInput.js @@ -57,6 +57,7 @@ const RenderSelectInputBody = ({ + {defaultOption} + {optionsList} + + {errorBox} + + + ); +}; + +SelectInput.propTypes = { + intl: intlShape.isRequired, + field: PropTypes.shape({ + name: PropTypes.string.isRequired, + }).isRequired, + form: PropTypes.shape({ + touched: PropTypes.shape({}), + errors: PropTypes.shape({}), + }), + forwardedRef: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape({ current: PropTypes.instanceOf(Element) }), + ]), + defaultOptionId: PropTypes.string, + options: PropTypes.arrayOf(PropTypes.shape({ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + text: PropTypes.string, + })), + label: PropTypes.node, + labelSize: PropTypes.number, + alignClass: PropTypes.string, + xsClass: PropTypes.string, + help: PropTypes.node, + optionReducer: PropTypes.func, + optionValue: PropTypes.string, + optionDisplayName: PropTypes.string, + size: PropTypes.number, + inputSize: PropTypes.number, + disabled: PropTypes.bool, + hasLabel: PropTypes.bool, +}; + +SelectInput.defaultProps = { + form: {}, + defaultOptionId: '', + options: [], + label: null, + labelSize: 2, + alignClass: 'text-right', + xsClass: 'mobile-left', + help: null, + optionReducer: null, + optionValue: 'value', + optionDisplayName: 'text', + size: null, + inputSize: null, + disabled: false, + hasLabel: true, + forwardedRef: null, +}; + +const IntlWrappedSelectInput = injectIntl(SelectInput); + +export default React.forwardRef((props, ref) => ( + +)); diff --git a/src/ui/common/formik-field/SwitchInput.js b/src/ui/common/formik-field/SwitchInput.js new file mode 100644 index 000000000..0b74ea379 --- /dev/null +++ b/src/ui/common/formik-field/SwitchInput.js @@ -0,0 +1,102 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Switch, Col, ControlLabel } from 'patternfly-react'; +import { getTouchErrorByField } from 'helpers/formikUtils'; + +const switchField = ( + field, form, switchValue, trueValue, + falseValue, onToggleValue, dataTestId, +) => { + const handleChange = (el, val) => { + const returnVal = val ? trueValue : falseValue; + form.setFieldValue(field.name, returnVal); + if (onToggleValue) { + onToggleValue(returnVal); + } + }; + + return ( +
+ +
+ ); +}; + +const SwitchInput = ({ + field, form, append, label, labelSize, inputSize, alignClass, + help, trueValue, falseValue, disabled, onToggleValue, +}) => { + const switchValue = field.value === 'true' || field.value === true || field.value === trueValue; + const dataTestId = `${field.name}-switchField`; + const { touched, error } = getTouchErrorByField(field.name, form); + + if (label) { + return ( +
+ + + {label} {help} + + + + {switchField( + { ...field, disabled }, form, switchValue, trueValue, falseValue, + onToggleValue, dataTestId, + )} + {append && {append}} + {touched && ((error && {error}))} + +
); + } + + return switchField( + { ...field, disabled }, form, switchValue, trueValue, falseValue, + onToggleValue, dataTestId, + ); +}; + +SwitchInput.propTypes = { + trueValue: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + falseValue: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + field: PropTypes.shape({ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + onChange: PropTypes.func, + name: PropTypes.string, + }).isRequired, + form: PropTypes.shape({ + touched: PropTypes.shape({}), + errors: PropTypes.shape({}), + setFieldValue: PropTypes.func.isRequired, + }).isRequired, + label: PropTypes.node, + meta: PropTypes.shape({}), + help: PropTypes.node, + disabled: PropTypes.bool, + type: PropTypes.string, + labelSize: PropTypes.number, + inputSize: PropTypes.number, + append: PropTypes.string, + alignClass: PropTypes.string, + onToggleValue: PropTypes.func, +}; + +SwitchInput.defaultProps = { + trueValue: true, + falseValue: false, + label: '', + meta: {}, + help: null, + disabled: false, + type: 'text', + labelSize: 2, + inputSize: null, + append: '', + alignClass: 'text-right', + onToggleValue: null, +}; + +export default SwitchInput; diff --git a/src/ui/users/add/AddFormContainer.js b/src/ui/users/add/AddFormContainer.js index 399588b95..0bdd2e102 100644 --- a/src/ui/users/add/AddFormContainer.js +++ b/src/ui/users/add/AddFormContainer.js @@ -1,6 +1,6 @@ -import { connect } from 'react-redux'; -import { destroy, submit } from 'redux-form'; -import { withRouter } from 'react-router-dom'; +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; import { routeConverter } from '@entando/utils'; import { fetchProfileTypes } from 'state/profile-types/actions'; @@ -12,26 +12,42 @@ import { ROUTE_USER_LIST } from 'app-init/router'; import UserForm from 'ui/users/common/UserForm'; -export const mapStateToProps = state => ({ - profileTypes: getProfileTypesOptions(state), -}); - -export const mapDispatchToProps = (dispatch, { history }) => ({ - onSubmit: (user) => { - const { saveType } = user; - dispatch(sendPostUser(user, saveType === 'editProfile')); - }, - onWillMount: () => { - dispatch(destroy('user')); +const AddFormContainer = () => { + const dispatch = useDispatch(); + const history = useHistory(); + const profileTypes = useSelector(getProfileTypesOptions); + + const handleMount = useCallback(() => { dispatch(fetchProfileTypes({ page: 1, pageSize: 0 })); - }, - onSave: () => { dispatch(setVisibleModal('')); dispatch(submit('user')); }, - onCancel: () => dispatch(setVisibleModal(ConfirmCancelModalID)), - onDiscard: () => { dispatch(setVisibleModal('')); history.push(routeConverter(ROUTE_USER_LIST)); }, -}); + }, [dispatch]); + + const handleSubmit = useCallback((user, submitType) => { + dispatch(sendPostUser(user, submitType === 'saveAndEditProfile')); + }, [dispatch]); + + const handleCancel = useCallback(() => { + dispatch(setVisibleModal(ConfirmCancelModalID)); + }, [dispatch]); + + const handleDiscard = useCallback(() => { + dispatch(setVisibleModal('')); + history.push(routeConverter(ROUTE_USER_LIST)); + }, [dispatch, history]); + + const handleModalSave = useCallback(() => { + dispatch(setVisibleModal('')); + }, [dispatch]); -const AddFormContainer = connect(mapStateToProps, mapDispatchToProps, null, { - pure: false, -})(UserForm); + return ( + + ); +}; -export default withRouter(AddFormContainer); +export default AddFormContainer; diff --git a/src/ui/users/common/UserForm.js b/src/ui/users/common/UserForm.js index 9639157fc..0fcd74357 100644 --- a/src/ui/users/common/UserForm.js +++ b/src/ui/users/common/UserForm.js @@ -1,36 +1,35 @@ -import React, { Component } from 'react'; +import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; -import { Field, reduxForm } from 'redux-form'; -import { Button, Row, Col, FormGroup } from 'patternfly-react'; -import { - required, - maxLength, - minLength, - matchPassword, - userFormText, - formatDate, -} from '@entando/utils'; +import { Formik, Form, Field } from 'formik'; +import * as Yup from 'yup'; +import { Button, Row, Col } from 'patternfly-react'; +import { formatDate } from '@entando/utils'; import { FormattedMessage, defineMessages, injectIntl, intlShape } from 'react-intl'; -import RenderTextInput from 'ui/common/form/RenderTextInput'; -import SwitchRenderer from 'ui/common/form/SwitchRenderer'; -import RenderSelectInput from 'ui/common/form/RenderSelectInput'; +import RenderTextInput from 'ui/common/formik-field/RenderTextInput'; +import SelectInput from 'ui/common/formik-field/SelectInput'; +import SwitchInput from 'ui/common/formik-field/SwitchInput'; + import FormLabel from 'ui/common/form/FormLabel'; import FormSectionTitle from 'ui/common/form/FormSectionTitle'; import ConfirmCancelModalContainer from 'ui/common/cancel-modal/ConfirmCancelModalContainer'; import { TEST_ID_USER_FORM } from 'ui/test-const/user-test-const'; -const EDIT_MODE = 'edit'; -const NEW_MODE = 'new'; - -const minLength4 = minLength(4); -const minLength8 = minLength(8); -const maxLength20 = maxLength(20); -const maxLength80 = maxLength(80); +const msgs = defineMessages({ + username: { + id: 'user.username', + }, + password: { + id: 'user.password', + }, + passwordConfirm: { + id: 'user.passwordConfirm', + }, +}); -export const renderStaticField = (field) => { - const { input, label, name } = field; - let fieldValue = input.value.title || input.value; - if (!input.value) { +const renderStaticField = (fieldProps) => { + const { field, label, name } = fieldProps; + let fieldValue = field.value && (field.value.title || field.value); + if (!field.value) { fieldValue = ; } else if (!Number.isNaN(Date.parse(fieldValue))) { fieldValue = formatDate(fieldValue); @@ -48,227 +47,236 @@ export const renderStaticField = (field) => { ); }; -const msgs = defineMessages({ - username: { - id: 'user.table.username', - defaultMessage: 'Username', - }, - password: { - id: 'user.password', - defaultMessage: 'Password', - }, - passwordConfirm: { - id: 'user.passwordConfirm', - defaultMessage: 'Confirm Password', - }, +const userPassCharsValid = (value, { createError, path }) => { + if (!/^[0-9a-zA-Z_.]+$/i.test(value)) { + return createError({ + message: , + path, + }); + } + + return true; +}; + +const addFormSchema = Yup.object().shape({ + username: Yup.string() + .required() + .min(4, ) + .max(80, ) + .test('usernameCharsValid', userPassCharsValid), + password: Yup.string() + .required() + .min(8, ) + .max(20, ) + .test('passwordCharsValid', userPassCharsValid), + passwordConfirm: Yup.string() + .required() + .oneOf([Yup.ref('password')], ), + profileType: Yup.string() + .required(), + status: Yup.string(), }); -export class UserFormBody extends Component { - componentWillMount() { - this.props.onWillMount(this.props); - } +const editFormSchema = Yup.object().shape({ + username: Yup.string(), + password: Yup.string() + .min(8, ) + .max(20, ) + .test('passwordCharsValid', userPassCharsValid), + passwordConfirm: Yup.string() + .when('password', (password, field) => (password ? (field + .required() + .oneOf([Yup.ref('password')], ) + ) : field)), + registration: Yup.string(), + lastLogin: Yup.string().nullable(), + lastPasswordChange: Yup.string().nullable(), + reset: Yup.boolean(), + status: Yup.string(), +}); - render() { - const { - intl, onSubmit, handleSubmit, invalid, submitting, mode, profileTypes, - password, dirty, onCancel, onDiscard, onSave, - } = this.props; +const getFormSchema = editing => (editing ? editFormSchema : addFormSchema); - const handleCancelClick = () => { - if (dirty) { - onCancel(); - } else { - onDiscard(); - } - }; +const UserForm = ({ + intl, initialValues, profileTypes, onMount, onSubmit, + onCancel, onDiscard, onModalSave, editing, +}) => { + useEffect(() => { + onMount(); + }, [onMount]); - const showUsername = ( - } - placeholder={intl.formatMessage(msgs.username)} - validate={mode !== EDIT_MODE ? - [required, minLength4, maxLength80, userFormText] : undefined} - disabled={mode === EDIT_MODE} - disallowedInput={/[^0-9a-zA-Z_.]/g} - forceLowerCase - /> - ); - const showEdit = () => { - if (mode === NEW_MODE) { - return null; - } - return ( -
- } - /> - } - /> - } - /> - - - - - - -
- ); - }; + const handleSubmit = ({ submitType, ...values }) => onSubmit(values, submitType); - const showProfileType = ( - mode !== EDIT_MODE ? - (} - name="profileType" - validate={required} - />) : null - ); + const handleCancelClick = ({ dirty }) => { + if (dirty) { + onCancel(); + } else { + onDiscard(); + } + }; - return ( -
- - -
- - {showUsername} - } - placeholder={intl.formatMessage(msgs.password)} - validate={[ - ...(mode === NEW_MODE ? [required] : []), - ...(password ? [userFormText, minLength8, maxLength20] : []), - ]} - /> - } - placeholder={intl.formatMessage(msgs.passwordConfirm)} - validate={[ - ...(mode === NEW_MODE ? [required] : []), - ...(password ? [matchPassword] : []), - ]} - /> - {/* Insert user info and reset button on EDIT */} - {showEdit()} - {showProfileType} - - - + const handleModalSave = ({ submitForm }) => { + onModalSave(); + submitForm(); + }; + + return ( + + {formik => ( + + + +
+ + } + placeholder={intl.formatMessage(msgs.username)} + disabled={editing} + /> + } + placeholder={intl.formatMessage(msgs.password)} + /> + } + placeholder={intl.formatMessage(msgs.passwordConfirm)} + /> + {editing ? ( +
+ } + /> + } + /> + } + /> + } + /> +
+ ) : ( } + options={profileTypes} + defaultOptionId="form.select.chooseOne" /> - - -
- -
-
- - - - - { - mode !== EDIT_MODE && ( + )} + } + trueValue="active" + falseValue="inactive" + /> +
+ +
+ + + handleModalSave(formik)} + onDiscard={onDiscard} + /> + + {!editing && ( - ) - } - - - -
- ); - } -} + )} + + + + + )} + + ); +}; -UserFormBody.propTypes = { +UserForm.propTypes = { intl: intlShape.isRequired, - onWillMount: PropTypes.func, - handleSubmit: PropTypes.func.isRequired, - onSubmit: PropTypes.func.isRequired, - invalid: PropTypes.bool, - submitting: PropTypes.bool, - mode: PropTypes.string, + initialValues: PropTypes.shape({ + username: PropTypes.string, + password: PropTypes.string, + passwordConfirm: PropTypes.string, + profileType: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + status: PropTypes.string, + registration: PropTypes.string, + lastLogin: PropTypes.string, + lastPasswordChange: PropTypes.string, + reset: PropTypes.bool, + }), profileTypes: PropTypes.arrayOf(PropTypes.shape({ - value: PropTypes.string, - text: PropTypes.string, + value: PropTypes.string.isRequired, + text: PropTypes.string.isRequired, })), - password: PropTypes.string, - dirty: PropTypes.bool, - onSave: PropTypes.func.isRequired, - onDiscard: PropTypes.func.isRequired, + onMount: PropTypes.func, + onSubmit: PropTypes.func.isRequired, onCancel: PropTypes.func.isRequired, + onDiscard: PropTypes.func.isRequired, + onModalSave: PropTypes.func.isRequired, + editing: PropTypes.bool, }; -UserFormBody.defaultProps = { - invalid: false, - submitting: false, - mode: NEW_MODE, - onWillMount: null, +UserForm.defaultProps = { + initialValues: { + username: '', + password: '', + passwordConfirm: '', + profileType: '', + status: 'inactive', + }, profileTypes: [], - password: '', - dirty: false, + onMount: () => {}, + editing: false, }; -const UserForm = reduxForm({ - form: 'user', -})(UserFormBody); - export default injectIntl(UserForm); diff --git a/src/ui/users/edit/EditFormContainer.js b/src/ui/users/edit/EditFormContainer.js index 9ecf17401..a44fb43e7 100644 --- a/src/ui/users/edit/EditFormContainer.js +++ b/src/ui/users/edit/EditFormContainer.js @@ -1,34 +1,68 @@ -import { connect } from 'react-redux'; -import { withRouter } from 'react-router-dom'; -import { formValueSelector, submit } from 'redux-form'; +import React, { useCallback, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory, useParams } from 'react-router-dom'; import { routeConverter } from '@entando/utils'; -import { fetchUserForm, sendPutUser } from 'state/users/actions'; +import { fetchCurrentPageUserDetail, sendPutUser } from 'state/users/actions'; import { setVisibleModal } from 'state/modal/actions'; import { ConfirmCancelModalID } from 'ui/common/cancel-modal/ConfirmCancelModal'; import { ROUTE_USER_LIST } from 'app-init/router'; import UserForm from 'ui/users/common/UserForm'; +import { getSelectedUser } from 'state/users/selectors'; -const EDIT_MODE = 'edit'; - -export const mapStateToProps = (state, { match: { params } }) => ({ - mode: EDIT_MODE, - username: params.username, - password: formValueSelector('user')(state, 'password'), -}); - -export const mapDispatchToProps = (dispatch, { history }) => ({ - onWillMount: ({ username }) => { dispatch(fetchUserForm(username)); }, - onSubmit: (user) => { - const editUser = { ...user, profileType: (user.profileType || {}).typeCode || '' }; - dispatch(sendPutUser(editUser)); - }, - onSave: () => { dispatch(setVisibleModal('')); dispatch(submit('user')); }, - onCancel: () => dispatch(setVisibleModal(ConfirmCancelModalID)), - onDiscard: () => { dispatch(setVisibleModal('')); history.push(routeConverter(ROUTE_USER_LIST)); }, -}); - - -export default withRouter(connect(mapStateToProps, mapDispatchToProps, null, { - pure: false, -})(UserForm)); + +const EditFormContainer = () => { + const dispatch = useDispatch(); + const history = useHistory(); + const { username } = useParams(); + const selectedUser = useSelector(getSelectedUser); + + const initialValues = useMemo(() => ({ + ...selectedUser, + username, + password: '', + passwordConfirm: '', + reset: false, + }), [selectedUser, username]); + + const handleMount = useCallback(() => { + dispatch(fetchCurrentPageUserDetail(username)); + }, [dispatch, username]); + + const handleSubmit = useCallback((user) => { + const updatedUser = { + ...user, + profileType: (user.profileType || {}).typeCode || '', + password: user.password || undefined, + passwordConfirm: user.passwordConfirm || undefined, + }; + dispatch(sendPutUser(updatedUser)); + }, [dispatch]); + + const handleCancel = useCallback(() => { + dispatch(setVisibleModal(ConfirmCancelModalID)); + }, [dispatch]); + + const handleDiscard = useCallback(() => { + dispatch(setVisibleModal('')); + history.push(routeConverter(ROUTE_USER_LIST)); + }, [dispatch, history]); + + const handleModalSave = useCallback(() => { + dispatch(setVisibleModal('')); + }, [dispatch]); + + return ( + + ); +}; + +export default EditFormContainer; diff --git a/test/ui/users/add/AddFormContainer.test.js b/test/ui/users/add/AddFormContainer.test.js index db06c24e4..5546c1838 100644 --- a/test/ui/users/add/AddFormContainer.test.js +++ b/test/ui/users/add/AddFormContainer.test.js @@ -1,43 +1,123 @@ -import 'test/enzyme-init'; +import React from 'react'; +import * as reactRedux from 'react-redux'; + +import { renderWithIntlRouterState } from 'test/testUtils'; +import AddFormContainer from 'ui/users/add/AddFormContainer'; +import UserForm from 'ui/users/common/UserForm'; import { PROFILE_TYPES_NORMALIZED, PROFILE_TYPES_OPTIONS } from 'test/mocks/profileTypes'; -import { mapStateToProps, mapDispatchToProps } from 'ui/users/add/AddFormContainer'; -import { sendPostUser } from 'state/users/actions'; -import { fetchProfileTypes } from 'state/profile-types/actions'; -const dispatchMock = jest.fn(); +jest.unmock('react-redux'); + +const useDispatchSpy = jest.spyOn(reactRedux, 'useDispatch'); +const mockDispatch = jest.fn(); +useDispatchSpy.mockReturnValue(mockDispatch); + +const mockHistoryPush = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: mockHistoryPush, + }), +})); jest.mock('state/users/actions', () => ({ - sendPostUser: jest.fn(), + sendPostUser: jest.fn(payload => ({ type: 'sendPostUser_test', payload })), })); jest.mock('state/profile-types/actions', () => ({ - fetchProfileTypes: jest.fn(), + fetchProfileTypes: jest.fn(() => ({ type: 'fetchProfileTypes_test' })), })); +jest.mock('state/modal/actions', () => ({ + setVisibleModal: jest.fn(payload => ({ type: 'setVisibleModal_test', payload })), +})); + +jest.mock('ui/users/common/UserForm', () => jest.fn(mockProps => ( +
+ + + + + +
+))); + +const setupAddFormContainer = () => { + const state = { + ...PROFILE_TYPES_NORMALIZED, + }; + const utils = renderWithIntlRouterState(, { state }); + const simulateMount = () => utils.getByText('onMount').click(); + const simulateSubmit = () => utils.getByText('onSubmit').click(); + const simulateCancel = () => utils.getByText('onCancel').click(); + const simulateDiscard = () => utils.getByText('onDiscard').click(); + const simulateModalSave = () => utils.getByText('onModalSave').click(); + + return { + ...utils, + simulateMount, + simulateSubmit, + simulateCancel, + simulateDiscard, + simulateModalSave, + }; +}; + describe('AddFormContainer', () => { - describe('mapDispatchToProps', () => { - let result; - beforeEach(() => { - result = mapDispatchToProps(dispatchMock, {}); - }); - it('verify that onSubmit is defined by mapDispatchToProps', () => { - expect(result).toHaveProperty('onSubmit'); - result.onSubmit({}); - expect(sendPostUser).toHaveBeenCalled(); - }); - - it('verify that onWillMount is defined by mapDispatchToProps', () => { - expect(result).toHaveProperty('onWillMount'); - result.onWillMount(); - expect(fetchProfileTypes).toHaveBeenCalled(); - }); + afterEach(() => { + mockDispatch.mockClear(); + }); + + it('passes profileTypes to UserForm with the correct value', () => { + setupAddFormContainer(); + + expect(UserForm) + .toHaveBeenCalledWith(expect.objectContaining({ profileTypes: PROFILE_TYPES_OPTIONS }), {}); + }); + + it('fetches relevant data when UserForm mounts', () => { + const { simulateMount } = setupAddFormContainer(); + + simulateMount(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'fetchProfileTypes_test' }); + }); + + it('calls the correct user action when UserForm is submitted', () => { + const { simulateSubmit } = setupAddFormContainer(); + + simulateSubmit(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'sendPostUser_test', payload: 'testValue' }); }); - describe('mapStateToProps', () => { - it('verify that profileTypes prop is defined and properly valued', () => { - const props = mapStateToProps(PROFILE_TYPES_NORMALIZED); - expect(props.profileTypes).toBeDefined(); - expect(props.profileTypes).toEqual(PROFILE_TYPES_OPTIONS); - }); + it('calls the correct modal action when UserForm is cancelled', () => { + const { simulateCancel } = setupAddFormContainer(); + + simulateCancel(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'setVisibleModal_test', payload: 'ConfirmCancelModal' }); + }); + + it('calls the correct modal and history actions when UserForm is discarded', () => { + const { simulateDiscard } = setupAddFormContainer(); + + simulateDiscard(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'setVisibleModal_test', payload: '' }); + expect(mockHistoryPush).toHaveBeenCalledWith('/user'); + }); + + it('calls the correct modal action when UserForm modal is saved', () => { + const { simulateModalSave } = setupAddFormContainer(); + + simulateModalSave(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'setVisibleModal_test', payload: '' }); }); }); diff --git a/test/ui/users/common/UserForm.test.js b/test/ui/users/common/UserForm.test.js index d547cc28d..67a37f45a 100644 --- a/test/ui/users/common/UserForm.test.js +++ b/test/ui/users/common/UserForm.test.js @@ -1,210 +1,375 @@ import React from 'react'; -import 'test/enzyme-init'; -import { shallow } from 'enzyme'; -import { UserFormBody, renderStaticField } from 'ui/users/common/UserForm'; -import { runValidators, mockIntl } from 'test/legacyTestUtils'; +import { screen, within, waitFor, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom/extend-expect'; -const handleSubmit = jest.fn(); -const onSubmit = jest.fn(); -const onWillMount = jest.fn(); -const EDIT_MODE = 'edit'; +import { renderWithIntl } from 'test/testUtils'; +import UserForm from 'ui/users/common/UserForm'; +import ConfirmCancelModalContainer from 'ui/common/cancel-modal/ConfirmCancelModalContainer'; -describe('UserForm', () => { - let userForm; - let submitting; - let invalid; - let profileTypes; - - beforeEach(() => { - submitting = false; - invalid = false; - }); - const buildUserForm = (mode) => { - const props = { - profileTypes, - submitting, - invalid, - handleSubmit, - onWillMount, - onSubmit, - mode, - msgs: { - username: { id: 'username', defaultMessage: 'username' }, - }, - password: 'test', - intl: mockIntl, - }; +jest.mock('ui/common/cancel-modal/ConfirmCancelModalContainer', () => jest.fn(() => null)); + +const setupUserForm = (initialValues, editing = false) => { + const mockHandleMount = jest.fn(); + const mockHandleSubmit = jest.fn(); + const mockHandleCancel = jest.fn(); + const mockHandleDiscard = jest.fn(); + const mockHandleModalSave = jest.fn(); + const profileTypes = [{ value: 'PFL', text: 'Default' }]; + const utils = renderWithIntl(( + + )); + const formView = within(screen.getByRole('form')); + + const getUsernameTextInput = () => formView.getByRole('textbox', { name: /username/i }); + const getPasswordTextInput = () => formView.getByPlaceholderText(/^password$/i); + const getPasswordConfirmTextInput = () => formView.getByLabelText(/confirm password/i); + const getProfileTypeSelectInput = () => formView.getByRole('combobox', { name: /profile type/i }); + const getStatusSwitchInput = () => formView.getByLabelText(/status/i).children[0]; + const getSaveButton = () => formView.getByRole('button', { name: /^save$/i }); + const getSaveAndEditProfileButton = () => formView.getByRole('button', { name: /save and edit profile/i }); + const getCancelButton = () => formView.getByRole('button', { name: /cancel/i }); + const getErrorMessage = () => formView.getByRole('alert'); + const queryProfileTypeSelectInput = () => formView.queryByRole('combobox', { name: /profile type/i }); + const queryResetSwitchInput = () => formView.queryByLabelText(/reset/i).children[0]; + const queryErrorMessage = () => formView.queryByRole('alert'); + const querySaveAndEditProfileButton = () => formView.queryByRole('button', { name: /save and edit profile/i }); + + const typeUsername = + value => userEvent.type(getUsernameTextInput(), value); + const typePassword = + value => userEvent.type(getPasswordTextInput(), value); + const typePasswordConfirm = + value => userEvent.type(getPasswordConfirmTextInput(), value); + const selectProfileType = + value => userEvent.selectOptions(getProfileTypeSelectInput(), value); + const clearPassword = () => userEvent.clear(getPasswordTextInput()); + + const toggleStatus = () => userEvent.click(getStatusSwitchInput()); - return shallow(); + const clickSave = () => userEvent.click(getSaveButton()); + const clickSaveAndEditProfile = () => userEvent.click(getSaveAndEditProfileButton()); + const clickCancel = () => userEvent.click(getCancelButton()); + + return { + ...utils, + mockHandleMount, + mockHandleSubmit, + mockHandleCancel, + mockHandleDiscard, + getUsernameTextInput, + getPasswordTextInput, + getPasswordConfirmTextInput, + getProfileTypeSelectInput, + getStatusSwitchInput, + getSaveButton, + getSaveAndEditProfileButton, + getErrorMessage, + queryProfileTypeSelectInput, + queryResetSwitchInput, + queryErrorMessage, + querySaveAndEditProfileButton, + typeUsername, + typePassword, + typePasswordConfirm, + selectProfileType, + toggleStatus, + clearPassword, + clickSave, + clickSaveAndEditProfile, + clickCancel, }; +}; + +const setupUserFormAndFillValues = ({ + username, password, passwordConfirm, profileType, +}) => { + const utils = setupUserForm(); + utils.typeUsername(username); + utils.typePassword(password); + utils.typePasswordConfirm(passwordConfirm); + utils.selectProfileType(profileType); + + fireEvent.blur(utils.getProfileTypeSelectInput()); - it('root component renders without crashing', () => { - userForm = buildUserForm(); - expect(userForm.exists()).toEqual(true); + return utils; +}; + +describe('UserForm', () => { + const user = { + username: 'testuser', + password: 'testpass', + passwordConfirm: 'testpass', + profileType: 'PFL', + status: 'inactive', + }; + + it('calls onMount when form has been rendered', () => { + const { mockHandleMount } = setupUserForm(); + + expect(mockHandleMount).toHaveBeenCalledTimes(1); }); - it('root component render minus icon if staticField value is null', () => { - const input = { name: 'registration', value: '' }; - const name = 'registration'; - const label = ; - const element = shallow(renderStaticField({ input, label, name })); - expect(element.find('.icon')).toExist(); - expect(element.find('.icon').hasClass('fa-minus')).toBe(true); + it('calls onSubmit with all the fields when save is clicked', async () => { + const { mockHandleSubmit, clickSave } = setupUserFormAndFillValues(user); + + clickSave(); + + await waitFor(() => { + expect(mockHandleSubmit).toHaveBeenCalledTimes(1); + expect(mockHandleSubmit).toHaveBeenCalledWith(user, 'save'); + }); }); - it('root component renders registration Field if its value is not null', () => { - const input = { name: 'registration', value: 'registration' }; - const name = 'registration'; - const label = ; - const element = renderStaticField({ input, label, name }); - const registration = shallow(element); - expect(registration.find('.form-group').exists()).toBe(true); + it('calls onSubmit with all the fields and a saveAndEditProfile submit type when save and edit profile button is clicked', async () => { + const { clickSaveAndEditProfile, mockHandleSubmit } = setupUserFormAndFillValues(user); + + clickSaveAndEditProfile(); + + await waitFor(() => { + expect(mockHandleSubmit).toHaveBeenCalledTimes(1); + expect(mockHandleSubmit).toHaveBeenCalledWith(user, 'saveAndEditProfile'); + }); }); - describe('test with mode = new', () => { - beforeEach(() => { - userForm = buildUserForm(); + it('calls onDiscard when cancel is clicked and form is not dirty', async () => { + const { mockHandleDiscard, clickCancel } = setupUserForm(); + + clickCancel(); + + await waitFor(() => { + expect(mockHandleDiscard).toHaveBeenCalledTimes(1); }); + }); + + it('calls onCancel when cancel is clicked and form is dirty', async () => { + const { mockHandleCancel, clickCancel } = setupUserFormAndFillValues(user); - it('root component renders username field', () => { - const username = userForm.find('[name="username"]'); - expect(username.exists()).toEqual(true); + clickCancel(); + + await waitFor(() => { + expect(mockHandleCancel).toHaveBeenCalledTimes(1); }); + }); - describe('username validation', () => { - let validatorArray; - beforeEach(() => { - validatorArray = userForm.find('[name="username"]').prop('validate'); - }); + it('renders ConfirmCancelModalContainer with the correct props', () => { + setupUserForm(); - it('is required', () => { - expect(runValidators(validatorArray, '').props.id).toBe('validateForm.required'); - }); + expect(ConfirmCancelModalContainer).toHaveBeenCalledWith(expect.objectContaining({ + contentText: expect.stringMatching(/save/i), + invalid: expect.any(Boolean), + submitting: expect.any(Boolean), + onSave: expect.any(Function), + onDiscard: expect.any(Function), + 'data-testid': expect.any(String), + }), {}); + }); - it('is invalid if input is shorter than 4 chars', () => { - expect(runValidators(validatorArray, '123').props.id).toBe('validateForm.minLength'); - expect(runValidators(validatorArray, '1234')).toBeFalsy(); - }); + it('disables save buttons and shows an error message when username is not provided', async () => { + const { getSaveButton, getSaveAndEditProfileButton, getErrorMessage } = setupUserFormAndFillValues({ ...user, username: '' }); - it('is invalid if input is longer than 80 chars', () => { - expect(runValidators(validatorArray, '123456789abcdefghijk')).toBeFalsy(); - expect(runValidators(validatorArray, '123456789abcdefghijk123456789abcdefghijk123456789abcdefghijk123456789abcdefghijkl').props.id) - .toBe('validateForm.maxLength'); - }); + await waitFor(() => { + expect(getSaveButton()).toHaveAttribute('disabled'); + expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); + expect(getErrorMessage()).toHaveTextContent(/field required/i); }); + }); + + it('disables save buttons and shows an error message when password is not provided', async () => { + const { + clearPassword, getPasswordTextInput, getSaveButton, + getSaveAndEditProfileButton, getErrorMessage, + } = setupUserForm({ ...user, passwordConfirm: '' }); + + clearPassword(); + fireEvent.blur(getPasswordTextInput()); - it('root component renders status field', () => { - const status = userForm.find('[name="status"]'); - expect(status.exists()).toEqual(true); + await waitFor(() => { + expect(getSaveButton()).toHaveAttribute('disabled'); + expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); + expect(getErrorMessage()).toHaveTextContent(/field required/i); }); + }); + + it('disables save buttons and shows an error message when confirm password is not provided', async () => { + const { getSaveButton, getSaveAndEditProfileButton, getErrorMessage } = setupUserFormAndFillValues({ ...user, passwordConfirm: '' }); - it('root component renders profileType field', () => { - const status = userForm.find('[name="profileType"]'); - expect(status.exists()).toEqual(true); + await waitFor(() => { + expect(getSaveButton()).toHaveAttribute('disabled'); + expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); + expect(getErrorMessage()).toHaveTextContent(/field required/i); }); + }); - describe('password field', () => { - let passwordField; - beforeEach(() => { - passwordField = userForm.find('[name="password"]'); - }); + it('disables save buttons and shows an error message when profile type is not provided', async () => { + const { getSaveButton, getSaveAndEditProfileButton, getErrorMessage } = setupUserFormAndFillValues({ ...user, profileType: '' }); - it('is rendered', () => { - expect(passwordField).toExist(); - }); + await waitFor(() => { + expect(getSaveButton()).toHaveAttribute('disabled'); + expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); + expect(getErrorMessage()).toHaveTextContent(/field required/i); + }); + }); - describe('validation', () => { - let validatorArray; - beforeEach(() => { - validatorArray = passwordField.prop('validate'); - }); - - it('is required', () => { - expect(runValidators(validatorArray, '').props.id).toBe('validateForm.required'); - }); - - it('is invalid if input is shorter than 8 chars', () => { - expect(runValidators(validatorArray, '1234567').props.id).toBe('validateForm.minLength'); - expect(runValidators(validatorArray, '12345678')).toBeFalsy(); - }); - - it('is invalid if input is longer than 20 chars', () => { - expect(runValidators(validatorArray, '123456789abcdefghijk')).toBeFalsy(); - expect(runValidators(validatorArray, '123456789abcdefghijkl').props.id) - .toBe('validateForm.maxLength'); - }); - }); + it('disables save buttons and shows an error message when username is too short', async () => { + const { getSaveButton, getSaveAndEditProfileButton, getErrorMessage } = setupUserFormAndFillValues({ ...user, username: 'abc' }); + + await waitFor(() => { + expect(getSaveButton()).toHaveAttribute('disabled'); + expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); + expect(getErrorMessage()).toHaveTextContent(/must be 4 characters or more/i); }); + }); - describe('passwordConfirm field', () => { - const ALL_VALUES = { password: '12345678' }; - let passwordConfirmField; - beforeEach(() => { - passwordConfirmField = userForm.find('[name="passwordConfirm"]'); + it('disables save buttons and shows an error message when username is too long', async () => { + const { getSaveButton, getSaveAndEditProfileButton, getErrorMessage } = + setupUserFormAndFillValues({ + ...user, username: 'thisisastringthathasmorethan80characters_thisisastringthathasmorethan80characters', }); - it('is rendered', () => { - expect(passwordConfirmField).toExist(); - }); + await waitFor(() => { + expect(getSaveButton()).toHaveAttribute('disabled'); + expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); + expect(getErrorMessage()).toHaveTextContent(/must be 80 characters or less/i); + }); + }); - describe('validation', () => { - let validatorArray; - beforeEach(() => { - validatorArray = passwordConfirmField.prop('validate'); - }); - - it('is required', () => { - expect(runValidators(validatorArray, '').props.id).toBe('validateForm.required'); - }); - - it('is invalid if input does not match the password', () => { - expect(runValidators(validatorArray, 'abcdefgh', ALL_VALUES).props.id) - .toBe('validateForm.passwordNotMatch'); - expect(runValidators(validatorArray, ALL_VALUES.password, ALL_VALUES)).toBeFalsy(); - }); - }); + it('disables save buttons and shows an error message when username contains invalid characters', async () => { + const { getSaveButton, getSaveAndEditProfileButton, getErrorMessage } = setupUserFormAndFillValues({ ...user, username: '-invalid-' }); + + await waitFor(() => { + expect(getSaveButton()).toHaveAttribute('disabled'); + expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); + expect(getErrorMessage()).toHaveTextContent(/contains invalid characters/i); + }); + }); + + it('disables save buttons and shows an error message when password is too short', async () => { + const shortPassword = 'abc'; + const { + getSaveButton, getSaveAndEditProfileButton, getErrorMessage, + } = setupUserFormAndFillValues({ + ...user, password: shortPassword, passwordConfirm: shortPassword, + }); + + await waitFor(() => { + expect(getSaveButton()).toHaveAttribute('disabled'); + expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); + expect(getErrorMessage()).toHaveTextContent(/must be 8 characters or more/i); + }); + }); + + it('disables save buttons and shows an error message when password is too long', async () => { + const longPassword = 'stringwithover20chars'; + const { + getSaveButton, getSaveAndEditProfileButton, getErrorMessage, + } = setupUserFormAndFillValues({ + ...user, password: longPassword, passwordConfirm: longPassword, + }); + + await waitFor(() => { + expect(getSaveButton()).toHaveAttribute('disabled'); + expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); + expect(getErrorMessage()).toHaveTextContent(/must be 20 characters or less/i); }); }); - describe('test with mode = edit', () => { - beforeEach(() => { - submitting = false; - invalid = false; - userForm = buildUserForm(EDIT_MODE); + it('disables save buttons and shows an error message when password contains invalid characters', async () => { + const invalidPassword = '-invalid-'; + const { + getSaveButton, getSaveAndEditProfileButton, getErrorMessage, + } = setupUserFormAndFillValues({ + ...user, password: invalidPassword, passwordConfirm: invalidPassword, }); - it('root component has class UserForm__content-edit', () => { - expect(userForm.find('.UserForm__content-edit').exists()).toBe(true); + + await waitFor(() => { + expect(getSaveButton()).toHaveAttribute('disabled'); + expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); + expect(getErrorMessage()).toHaveTextContent(/contains invalid characters/i); }); + }); - it('root component contains edit fields', () => { - expect(userForm.find('.UserForm__content-edit').find('Field')).toHaveLength(4); - expect(userForm.find('[name="registration"]').exists()).toBe(true); - expect(userForm.find('[name="lastLogin"]').exists()).toBe(true); - expect(userForm.find('[name="lastPasswordChange"]').exists()).toBe(true); - expect(userForm.find('[name="reset"]').exists()).toBe(true); + it('disables save buttons and shows an error message when password doesn\'t match confirm password', async () => { + const { getSaveButton, getSaveAndEditProfileButton, getErrorMessage } = + setupUserFormAndFillValues({ + ...user, password: 'password', passwordConfirm: 'differentpass', + }); + + await waitFor(() => { + expect(getSaveButton()).toHaveAttribute('disabled'); + expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); + expect(getErrorMessage()).toHaveTextContent(/value doesn't match with password/i); }); }); - describe('test buttons and handlers', () => { - it('disables submit button while submitting', () => { - submitting = true; - userForm = buildUserForm(); - const submitButton = userForm.find('Button').first(); - expect(submitButton.prop('disabled')).toEqual(true); + describe('When editing', () => { + const detailedUser = { + ...user, + lastLogin: '2021-11-11 00:00:00', + lastPasswordChange: '2021-11-12 00:00:00', + registration: '2021-11-10 00:00:00', + reset: false, + }; + + it('calls onSubmit with all the fields when save is clicked', async () => { + const { mockHandleSubmit, clickSave } = setupUserForm(detailedUser, true); + + clickSave(); + + await waitFor(() => { + expect(mockHandleSubmit).toHaveBeenCalledTimes(1); + expect(mockHandleSubmit).toHaveBeenCalledWith(detailedUser, 'save'); + }); + }); + + it('disables username', () => { + const { getUsernameTextInput } = setupUserForm(detailedUser, true); + + expect(getUsernameTextInput()).toHaveAttribute('disabled'); }); - it('disables submit button if form is invalid', () => { - invalid = true; - userForm = buildUserForm(); - const submitButton = userForm.find('Button').first(); - expect(submitButton.prop('disabled')).toEqual(true); + it('doesn\'t show profile type field', () => { + const { queryProfileTypeSelectInput } = setupUserForm(detailedUser, true); + + expect(queryProfileTypeSelectInput()).not.toBeInTheDocument(); + }); + + it('shows the reset switch and static fields -- registration, last login, last password change', () => { + const { queryResetSwitchInput } = setupUserForm(detailedUser, true); + + expect(screen.getByText(detailedUser.lastLogin)).toBeInTheDocument(); + expect(screen.getByText(detailedUser.lastPasswordChange)).toBeInTheDocument(); + expect(screen.getByText(detailedUser.registration)).toBeInTheDocument(); + expect(queryResetSwitchInput()).toBeInTheDocument(); + }); + + it('doesn\'t show save and edit profile button', () => { + const { querySaveAndEditProfileButton } = setupUserForm(detailedUser, true); + + expect(querySaveAndEditProfileButton()).not.toBeInTheDocument(); }); - it('on form submit calls handleSubmit', () => { - userForm = buildUserForm(); - const preventDefault = jest.fn(); - userForm.find('form').simulate('submit', { preventDefault }); - expect(handleSubmit).toHaveBeenCalled(); + it('doesn\'t disable save button and doesn\'t show an error message when password is empty', async () => { + const { + clearPassword, getPasswordTextInput, queryErrorMessage, getSaveButton, + } = setupUserForm({ ...detailedUser, passwordConfirm: '' }, true); + + clearPassword(''); + fireEvent.blur(getPasswordTextInput()); + + await waitFor(() => { + expect(getSaveButton()).not.toHaveAttribute('disabled'); + expect(queryErrorMessage()).not.toBeInTheDocument(); + }); }); }); }); diff --git a/test/ui/users/edit/EditFormContainer.test.js b/test/ui/users/edit/EditFormContainer.test.js index a3433428c..ff0e3c617 100644 --- a/test/ui/users/edit/EditFormContainer.test.js +++ b/test/ui/users/edit/EditFormContainer.test.js @@ -1,46 +1,130 @@ -import { mapDispatchToProps, mapStateToProps } from 'ui/users/edit/EditFormContainer'; +import React from 'react'; +import * as reactRedux from 'react-redux'; -const ownProps = { - match: { - params: { - mode: 'edit', - username: 'test', +import { renderWithIntlRouterState } from 'test/testUtils'; +import EditFormContainer from 'ui/users/edit/EditFormContainer'; +import UserForm from 'ui/users/common/UserForm'; + +jest.unmock('react-redux'); + +const useDispatchSpy = jest.spyOn(reactRedux, 'useDispatch'); +const mockDispatch = jest.fn(); +useDispatchSpy.mockReturnValue(mockDispatch); + +const mockHistoryPush = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: mockHistoryPush, + }), +})); + +jest.mock('state/users/actions', () => ({ + sendPutUser: jest.fn(payload => ({ type: 'sendPutUser_test', payload })), + fetchCurrentPageUserDetail: jest.fn(payload => ({ type: 'fetchCurrentPageUserDetail_test', payload })), +})); + +jest.mock('state/profile-types/actions', () => ({ + fetchProfileTypes: jest.fn(() => ({ type: 'fetchProfileTypes_test' })), +})); + +jest.mock('state/modal/actions', () => ({ + setVisibleModal: jest.fn(payload => ({ type: 'setVisibleModal_test', payload })), +})); + +jest.mock('ui/users/common/UserForm', () => jest.fn(mockProps => ( +
+ + + + + +
+))); + +const setupEditFormContainer = () => { + const state = { + users: { + selected: {}, }, - }, -}; + }; + const utils = renderWithIntlRouterState(, { + state, initialRoute: '/user/edit/testuser', path: '/user/edit/:username', + }); + const simulateMount = () => utils.getByText('onMount').click(); + const simulateSubmit = () => utils.getByText('onSubmit').click(); + const simulateCancel = () => utils.getByText('onCancel').click(); + const simulateDiscard = () => utils.getByText('onDiscard').click(); + const simulateModalSave = () => utils.getByText('onModalSave').click(); -const dispatchProps = { - history: {}, + return { + ...utils, + simulateMount, + simulateSubmit, + simulateCancel, + simulateDiscard, + simulateModalSave, + }; }; -describe('EditFormContainer', () => { - const dispatchMock = jest.fn(); - let props; - beforeEach(() => { - jest.clearAllMocks(); - props = mapDispatchToProps(dispatchMock, dispatchProps); - }); - - describe('mapDispatchToProps', () => { - it('should map the correct function properties', () => { - expect(props.onWillMount).toBeDefined(); - expect(props.onSubmit).toBeDefined(); - }); - it('verify thant onWillMount is called', () => { - props.onWillMount('username'); - expect(dispatchMock).toHaveBeenCalled(); - }); - it('verify thant onSubmit is called', () => { - props.onSubmit({}); - expect(dispatchMock).toHaveBeenCalled(); - }); - }); - - describe('mapStateToProps', () => { - it('verify that username prop is defined and properly valued', () => { - props = mapStateToProps({}, ownProps); - expect(props.mode).toEqual('edit'); - expect(props.username).toEqual('test'); - }); +describe('AddFormContainer', () => { + afterEach(() => { + mockDispatch.mockClear(); + }); + + it('passes initialValues to UserForm with the correct values', () => { + setupEditFormContainer(); + + expect(UserForm).toHaveBeenCalledWith(expect.objectContaining({ + initialValues: { + username: 'testuser', password: '', passwordConfirm: '', reset: false, + }, + }), {}); + }); + + it('fetches user data when UserForm mounts', () => { + const { simulateMount } = setupEditFormContainer(); + + simulateMount(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'fetchCurrentPageUserDetail_test', payload: 'testuser' }); + }); + + it('calls the correct user action when UserForm is submitted', () => { + const { simulateSubmit } = setupEditFormContainer(); + + simulateSubmit(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'sendPutUser_test', payload: expect.any(Object) }); + }); + + it('calls the correct modal action when UserForm is cancelled', () => { + const { simulateCancel } = setupEditFormContainer(); + + simulateCancel(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'setVisibleModal_test', payload: 'ConfirmCancelModal' }); + }); + + it('calls the correct modal and history actions when UserForm is discarded', () => { + const { simulateDiscard } = setupEditFormContainer(); + + simulateDiscard(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'setVisibleModal_test', payload: '' }); + expect(mockHistoryPush).toHaveBeenCalledWith('/user'); + }); + + it('calls the correct modal action when UserForm modal is saved', () => { + const { simulateModalSave } = setupEditFormContainer(); + + simulateModalSave(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'setVisibleModal_test', payload: '' }); }); }); From 7f5e6926bb733b84810bda51993c536f841c4d5e Mon Sep 17 00:00:00 2001 From: Rax Canaan Layumas Date: Mon, 15 Nov 2021 20:53:05 +0800 Subject: [PATCH 002/122] ENG-2811 Move user form validation function to formikValidations module --- src/helpers/formikValidations.js | 13 +++++++++++++ src/ui/users/common/UserForm.js | 12 +----------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/helpers/formikValidations.js b/src/helpers/formikValidations.js index 3bca903b6..9d1783895 100644 --- a/src/helpers/formikValidations.js +++ b/src/helpers/formikValidations.js @@ -1,3 +1,6 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + export const formatMessageRequired = { id: 'validateForm.required', defaultMessage: 'Required', @@ -38,3 +41,13 @@ export const validateFragmentCodeField = intl => (value, { createError, path }) path, }) ); +export const userPassCharsValid = (value, { createError, path }) => { + if (!/^[0-9a-zA-Z_.]+$/i.test(value)) { + return createError({ + message: , + path, + }); + } + + return true; +}; diff --git a/src/ui/users/common/UserForm.js b/src/ui/users/common/UserForm.js index 0fcd74357..5db48ac97 100644 --- a/src/ui/users/common/UserForm.js +++ b/src/ui/users/common/UserForm.js @@ -13,6 +13,7 @@ import FormLabel from 'ui/common/form/FormLabel'; import FormSectionTitle from 'ui/common/form/FormSectionTitle'; import ConfirmCancelModalContainer from 'ui/common/cancel-modal/ConfirmCancelModalContainer'; import { TEST_ID_USER_FORM } from 'ui/test-const/user-test-const'; +import { userPassCharsValid } from 'helpers/formikValidations'; const msgs = defineMessages({ username: { @@ -47,17 +48,6 @@ const renderStaticField = (fieldProps) => { ); }; -const userPassCharsValid = (value, { createError, path }) => { - if (!/^[0-9a-zA-Z_.]+$/i.test(value)) { - return createError({ - message: , - path, - }); - } - - return true; -}; - const addFormSchema = Yup.object().shape({ username: Yup.string() .required() From 97e5aefdb0973d6e2fbd2cea95bef91b84f67a9f Mon Sep 17 00:00:00 2001 From: Rax Canaan Layumas Date: Tue, 16 Nov 2021 21:34:30 +0800 Subject: [PATCH 003/122] Refactor formik SwitchInput by moving unnecessary function into the component --- src/ui/common/formik-field/SwitchInput.js | 55 +++++++++++------------ 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/src/ui/common/formik-field/SwitchInput.js b/src/ui/common/formik-field/SwitchInput.js index 0b74ea379..75393c099 100644 --- a/src/ui/common/formik-field/SwitchInput.js +++ b/src/ui/common/formik-field/SwitchInput.js @@ -3,29 +3,6 @@ import PropTypes from 'prop-types'; import { Switch, Col, ControlLabel } from 'patternfly-react'; import { getTouchErrorByField } from 'helpers/formikUtils'; -const switchField = ( - field, form, switchValue, trueValue, - falseValue, onToggleValue, dataTestId, -) => { - const handleChange = (el, val) => { - const returnVal = val ? trueValue : falseValue; - form.setFieldValue(field.name, returnVal); - if (onToggleValue) { - onToggleValue(returnVal); - } - }; - - return ( -
- -
- ); -}; - const SwitchInput = ({ field, form, append, label, labelSize, inputSize, alignClass, help, trueValue, falseValue, disabled, onToggleValue, @@ -34,6 +11,14 @@ const SwitchInput = ({ const dataTestId = `${field.name}-switchField`; const { touched, error } = getTouchErrorByField(field.name, form); + const handleChange = (el, val) => { + const returnVal = val ? trueValue : falseValue; + form.setFieldValue(field.name, returnVal); + if (onToggleValue) { + onToggleValue(returnVal); + } + }; + if (label) { return (
@@ -43,19 +28,29 @@ const SwitchInput = ({ - {switchField( - { ...field, disabled }, form, switchValue, trueValue, falseValue, - onToggleValue, dataTestId, - )} +
+ +
{append && {append}} {touched && ((error && {error}))}
); } - return switchField( - { ...field, disabled }, form, switchValue, trueValue, falseValue, - onToggleValue, dataTestId, + return ( +
+ +
); }; From 65b709e1c52c1a9bbdeebd590c48d1a782650ce8 Mon Sep 17 00:00:00 2001 From: Jeff Go Date: Thu, 18 Nov 2021 08:17:06 +0800 Subject: [PATCH 004/122] ENG-2821 user authority form migrate to formik progress --- src/helpers/formikUtils.js | 33 +++- src/state/users/actions.js | 1 - src/state/users/reducer.js | 12 +- src/state/users/selectors.js | 29 ++-- src/ui/users/authority/UserAuthorityTable.js | 160 ++++++++--------- src/ui/users/common/UserAuthorityPageForm.js | 161 ++++++++++-------- .../common/UserAuthorityPageFormContainer.js | 23 +-- 7 files changed, 226 insertions(+), 193 deletions(-) diff --git a/src/helpers/formikUtils.js b/src/helpers/formikUtils.js index b0ab59301..fb2786118 100644 --- a/src/helpers/formikUtils.js +++ b/src/helpers/formikUtils.js @@ -1,5 +1,36 @@ -// eslint-disable-next-line import/prefer-default-export +import React from 'react'; +import PropTypes from 'prop-types'; +import { FieldArray } from 'formik'; + export const getTouchErrorByField = (fieldName, { touched, errors }) => ({ touched: touched[fieldName], error: errors[fieldName], }); + +export const MultiField = ({ + name, + component: Component, + validateOnChange, + ...otherProps +}) => ( + ( + + )} + /> +); + +MultiField.propTypes = { + name: PropTypes.string.isRequired, + component: PropTypes.elementType.isRequired, + validateOnChange: PropTypes.bool, +}; + +MultiField.defaultProps = { + validateOnChange: true, +}; diff --git a/src/state/users/actions.js b/src/state/users/actions.js index a9a16d292..6e423dcc7 100644 --- a/src/state/users/actions.js +++ b/src/state/users/actions.js @@ -202,7 +202,6 @@ export const fetchUserAuthorities = username => async (dispatch) => { const json = await response.json(); if (response.ok) { dispatch(setSelectedUserAuthorities(username, json.payload)); - dispatch(initialize('autorityForm', { groupRolesCombo: json.payload })); } else { dispatch(addErrors(json.errors.map(e => e.message))); json.errors.forEach(err => dispatch(addToast(err.message, TOAST_ERROR))); diff --git a/src/state/users/reducer.js b/src/state/users/reducer.js index 16e09903a..b6cfb2fd4 100644 --- a/src/state/users/reducer.js +++ b/src/state/users/reducer.js @@ -39,13 +39,11 @@ export const selected = (state = {}, action = {}) => { export const authorities = (state = [], action = {}) => { switch (action.type) { case SET_SELECTED_USER_AUTHORITIES: { - let result = { username: action.payload.username, list: action.payload.authorities }; - if (action.payload.authorities.length > 0) { - result = { ...result, action: ACTION_UPDATE }; - } else { - result = { ...result, action: ACTION_SAVE }; - } - return result; + return { + username: action.payload.username, + list: action.payload.authorities, + action: action.payload.authorities.length > 0 ? ACTION_UPDATE : ACTION_SAVE, + }; } default: return state; } diff --git a/src/state/users/selectors.js b/src/state/users/selectors.js index 45625604e..4e2dfc695 100644 --- a/src/state/users/selectors.js +++ b/src/state/users/selectors.js @@ -1,5 +1,4 @@ import { createSelector } from 'reselect'; -import { formValueSelector } from 'redux-form'; import { getGroupsMap } from 'state/groups/selectors'; import { getRolesMap } from 'state/roles/selectors'; import { isEmpty } from 'lodash'; @@ -17,19 +16,17 @@ export const getUserList = createSelector( (UsersMap, idList) => idList.map(id => (UsersMap[id])), ); -const getGroupRolesComboValue = state => formValueSelector('autorityForm')(state, 'groupRolesCombo'); +export const makeGroupRolesCombo = (groupRoleCombo, groups, roles) => { + if (!isEmpty(groupRoleCombo) && !isEmpty(groups) && !isEmpty(roles)) { + return groupRoleCombo.map(item => ({ + group: item.group ? { code: item.group, name: groups[item.group].name } : {}, + role: item.role ? { code: item.role, name: roles[item.role].name } : {}, + })); + } + return []; +}; -export const getGroupRolesCombo = - createSelector( - [getGroupRolesComboValue, getGroupsMap, getRolesMap], - (groupRoleCombo, groups, roles) => { - if (!isEmpty(groupRoleCombo) && !isEmpty(groups) && !isEmpty(roles)) { - return groupRoleCombo.map(item => ({ - group: item.group ? { code: item.group, name: groups[item.group].name } : {}, - role: item.role ? { code: item.role, name: roles[item.role].name } : {}, - })); - } - return []; - }, - - ); +export const getGroupRolesCombo = createSelector( + [getSelectedUserAuthoritiesList, getGroupsMap, getRolesMap], + makeGroupRolesCombo, +); diff --git a/src/ui/users/authority/UserAuthorityTable.js b/src/ui/users/authority/UserAuthorityTable.js index d6075fd10..6dcd4ca5d 100644 --- a/src/ui/users/authority/UserAuthorityTable.js +++ b/src/ui/users/authority/UserAuthorityTable.js @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import React, { useRef } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage, defineMessages, injectIntl, intlShape } from 'react-intl'; import { Row, Col, Button, Alert } from 'patternfly-react'; @@ -13,42 +13,35 @@ const msgs = defineMessages({ }, }); -class UserAuthorityTable extends Component { - constructor(props) { - super(props); - this.onClickAdd = this.onClickAdd.bind(this); - this.group = null; - this.role = null; - } +const UserAuthorityTable = ({ + push, remove, + groupRolesCombo, onCloseModal, + intl, groups, roles, onAddNewClicked, +}) => { + const setGroupRef = useRef(null); + const setRoleRef = useRef(null); - onClickAdd() { - const { fields, groupRolesCombo, onCloseModal } = this.props; + const onClickAdd = () => { + const group = setGroupRef.current; + const role = setRoleRef.current; const isPresent = Boolean(groupRolesCombo - .find(item => (item.group.code === this.group.value || - (!this.group.value && !item.group.code)) - && - (item.role.code === this.role.value || - (!this.role.value && !item.role.code)))); - - if (!isPresent && (this.group.value || this.role.value)) { - fields.push({ - group: this.group.value || null, - role: this.role.value || null, + .find(item => (group.value === '' || item.group.code === group.value) && + (role.value === '' || item.role.code === role.value))); + if (!isPresent) { + console.log('toadd', { + group: group.value || null, + role: role.value || null, + }); + push({ + group: group.value || null, + role: role.value || null, }); } onCloseModal(); - } - - setGroupRef = (group) => { - this.group = group; - } + }; - setRoleRef = (role) => { - this.role = role; - } - - renderTable(renderRow) { - if (this.props.groupRolesCombo.length === 0) { + const renderTable = (renderRow) => { + if (groupRolesCombo.length === 0) { return (
@@ -78,65 +71,60 @@ class UserAuthorityTable extends Component { ); - } + }; - render() { - const { - intl, groupRolesCombo, groups, roles, fields, onAddNewClicked, - } = this.props; - const groupsWithEmpty = - [{ code: '', name: intl.formatMessage(msgs.chooseOption) }].concat(groups); - const rolesWithEmpty = - [{ code: '', name: intl.formatMessage(msgs.chooseOption) }].concat(roles); - const groupOptions = + const groupsWithEmpty = + [{ code: '', name: intl.formatMessage(msgs.chooseOption) }].concat(groups); + const rolesWithEmpty = + [{ code: '', name: intl.formatMessage(msgs.chooseOption) }].concat(roles); + const groupOptions = groupsWithEmpty.map(gr => ()); - const rolesOptions = + const rolesOptions = rolesWithEmpty.map(rl => ()); - const renderRow = groupRolesCombo.map((item, index) => ( - - {item.group.name || } - {item.role.name || } - + const renderRow = groupRolesCombo.map((item, index) => ( + + {item.group.name || } + {item.role.name || } + + + + + )); + + return ( +
+ + - - - )); - - return ( -
- - - - - - {this.renderTable(renderRow)} - -
- ); - } -} + +
+ {renderTable(renderRow)} + +
+ ); +}; UserAuthorityTable.propTypes = { intl: intlShape.isRequired, @@ -148,16 +136,14 @@ UserAuthorityTable.propTypes = { name: PropTypes.string, code: PropTypes.string, })), - fields: PropTypes.shape({ - push: PropTypes.func, - remove: PropTypes.func, - }).isRequired, groupRolesCombo: PropTypes.arrayOf(PropTypes.shape({ group: PropTypes.shape({ code: PropTypes.string, name: PropTypes.string }), role: PropTypes.shape({ code: PropTypes.string, name: PropTypes.string }), })), onAddNewClicked: PropTypes.func.isRequired, onCloseModal: PropTypes.func.isRequired, + push: PropTypes.func.isRequired, + remove: PropTypes.func.isRequired, }; UserAuthorityTable.defaultProps = { diff --git a/src/ui/users/common/UserAuthorityPageForm.js b/src/ui/users/common/UserAuthorityPageForm.js index 9c02acf40..d534d1507 100644 --- a/src/ui/users/common/UserAuthorityPageForm.js +++ b/src/ui/users/common/UserAuthorityPageForm.js @@ -1,77 +1,72 @@ -import React, { Component } from 'react'; +import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import { Grid, Row, Col, Button, Spinner } from 'patternfly-react'; -import { reduxForm, FieldArray } from 'redux-form'; +import { withFormik, Form } from 'formik'; +import { MultiField } from 'helpers/formikUtils'; +import { makeGroupRolesCombo } from 'state/users/selectors'; +import * as Yup from 'yup'; import { FormattedMessage } from 'react-intl'; -import { ACTION_SAVE, ACTION_UPDATE } from 'state/users/const'; +import { ACTION_SAVE } from 'state/users/const'; import UserAuthorityTable from 'ui/users/authority/UserAuthorityTable'; import { TEST_ID_USER_AUTHORITY_PAGE_FORM } from 'ui/test-const/user-test-const'; -export class UserAuthorityPageFormBody extends Component { - constructor(props) { - super(props); - this.group = null; - this.role = null; - } +export const UserAuthorityPageFormBody = ({ + groups, roles, values, loading, + groupsMap, rolesMap, + onDidMount, isValid, isSubmitting: submitting, + onAddNewClicked, onCloseModal, +}) => { + useEffect(() => { + onDidMount(); + }, []); - componentWillMount() { - this.props.onWillMount(); - } + const invalid = !isValid; + const groupRolesCombo = makeGroupRolesCombo(values.groupRolesCombo, groupsMap, rolesMap); - render() { - const { - invalid, submitting, handleSubmit, onAddNewClicked, onCloseModal, - } = this.props; - - return ( - -
this.props.onSubmit(values, this.props.actionOnSave))} - className="UserAuthorityPageForm form-horizontal" - > + return ( + + + + + + + + + + - - - - - - - - - - + - -
- ); - } -} + + +
+ ); +}; UserAuthorityPageFormBody.propTypes = { - handleSubmit: PropTypes.func.isRequired, - onSubmit: PropTypes.func.isRequired, - onWillMount: PropTypes.func.isRequired, + onDidMount: PropTypes.func.isRequired, onAddNewClicked: PropTypes.func.isRequired, onCloseModal: PropTypes.func.isRequired, - actionOnSave: PropTypes.oneOf([ACTION_SAVE, ACTION_UPDATE]), - invalid: PropTypes.bool, - submitting: PropTypes.bool, + isValid: PropTypes.bool, + isSubmitting: PropTypes.bool, groups: PropTypes.arrayOf(PropTypes.shape({ name: PropTypes.string, code: PropTypes.string, @@ -80,26 +75,52 @@ UserAuthorityPageFormBody.propTypes = { name: PropTypes.string, code: PropTypes.string, })), - groupRolesCombo: PropTypes.arrayOf(PropTypes.shape({ - group: PropTypes.shape({ code: PropTypes.string, name: PropTypes.string }), - role: PropTypes.shape({ code: PropTypes.string, name: PropTypes.string }), - })), + groupsMap: PropTypes.shape({}), + rolesMap: PropTypes.shape({}), + values: PropTypes.shape({ + groupRolesCombo: { + group: PropTypes.shape({ code: PropTypes.string, name: PropTypes.string }), + role: PropTypes.shape({ code: PropTypes.string, name: PropTypes.string }), + }, + }), loading: PropTypes.bool, }; UserAuthorityPageFormBody.defaultProps = { - invalid: false, - submitting: false, + isValid: false, + isSubmitting: false, groups: [], roles: [], - groupRolesCombo: [], - actionOnSave: ACTION_SAVE, + groupsMap: {}, + rolesMap: {}, + values: { + groupRolesCombo: [], + }, loading: false, }; -const UserAuthorityPageForm = reduxForm({ - form: 'autorityForm', +const UserAuthorityPageForm = withFormik({ + enableReinitialize: true, + initialValues: ({ initialValues }) => initialValues, + validationSchema: Yup.object().shape({ + groupRolesCombo: Yup.array().of(Yup.object().shape({ + group: Yup.string(), + role: Yup.string(), + })), + }), + handleSubmit: ( + values, + { + props: { onSubmit, actionOnSave }, + setSubmitting, + }, + ) => { + onSubmit(values, actionOnSave || ACTION_SAVE).then(() => ( + setSubmitting(false) + )); + }, + displayName: 'autorityForm', })(UserAuthorityPageFormBody); export default UserAuthorityPageForm; diff --git a/src/ui/users/common/UserAuthorityPageFormContainer.js b/src/ui/users/common/UserAuthorityPageFormContainer.js index a9eb73528..180c8714a 100644 --- a/src/ui/users/common/UserAuthorityPageFormContainer.js +++ b/src/ui/users/common/UserAuthorityPageFormContainer.js @@ -2,8 +2,8 @@ import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; import { fetchAllGroupEntries } from 'state/groups/actions'; import { getLoading } from 'state/loading/selectors'; -import { getGroupsList } from 'state/groups/selectors'; -import { getRolesList } from 'state/roles/selectors'; +import { getGroupsList, getGroupsMap } from 'state/groups/selectors'; +import { getRolesList, getRolesMap } from 'state/roles/selectors'; import { fetchRoles } from 'state/roles/actions'; import UserAuthorityPageForm from 'ui/users/common/UserAuthorityPageForm'; import { ACTION_UPDATE } from 'state/users/const'; @@ -12,17 +12,18 @@ import { getGroupRolesCombo, getSelectedUserActionAuthorities } from 'state/user import { setVisibleModal } from 'state/modal/actions'; -export const mapStateToProps = state => - ({ - loading: getLoading(state).users, - groups: getGroupsList(state), - roles: getRolesList(state), - groupRolesCombo: getGroupRolesCombo(state), - actionOnSave: getSelectedUserActionAuthorities(state), - }); +export const mapStateToProps = state => ({ + loading: getLoading(state).users, + groups: getGroupsList(state), + roles: getRolesList(state), + groupsMap: getGroupsMap(state), + rolesMap: getRolesMap(state), + initialValues: getGroupRolesCombo(state), + actionOnSave: getSelectedUserActionAuthorities(state), +}); export const mapDispatchToProps = (dispatch, { match: { params } }) => ({ - onWillMount: () => { + onDidMount: () => { dispatch(fetchAllGroupEntries({ page: 1, pageSize: 0 })); dispatch(fetchRoles({ page: 1, pageSize: 0 })); dispatch(fetchUserAuthorities(params.username)); From 21757c40228afaedbb2a59d729107979bc5a9cfa Mon Sep 17 00:00:00 2001 From: Jeff Go Date: Wed, 19 Jan 2022 22:54:32 +0800 Subject: [PATCH 005/122] ENG-2821 field array issue resolved --- src/locales/en.js | 1 + src/locales/it.js | 1 + src/locales/pt.js | 1 + src/ui/users/authority/UserAuthorityTable.js | 6 +----- src/ui/users/common/UserAuthorityPageForm.js | 10 +++++----- src/ui/users/common/UserAuthorityPageFormContainer.js | 4 ++-- 6 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/locales/en.js b/src/locales/en.js index 1b28feb5a..955e8c4fb 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -601,6 +601,7 @@ export default { 'user.authority.groups': 'User Group', 'user.authority.roles': 'User Role', 'user.authority.new': 'New authorizations', + 'user.authority.addNew': 'Add new Authorization', 'user.authority.noAuthYet': 'No authorizations yet', 'user.username': 'Username', 'user.password': 'Password', diff --git a/src/locales/it.js b/src/locales/it.js index 3e0ddd6b6..71b0830e7 100644 --- a/src/locales/it.js +++ b/src/locales/it.js @@ -601,6 +601,7 @@ export default { 'user.authority.groups': 'Gruppo utenti', 'user.authority.roles': 'Ruolo utenti', 'user.authority.new': 'Nuove autorizzazioni', + 'user.authority.addNew': 'Aggiungi nuova autorizzazione', 'user.authority.noAuthYet': 'Non ci sono autorizzazione presenti', 'user.username': 'Username', 'user.password': 'Password', diff --git a/src/locales/pt.js b/src/locales/pt.js index 5c9fd69bb..02a2171ac 100644 --- a/src/locales/pt.js +++ b/src/locales/pt.js @@ -572,6 +572,7 @@ export default { 'user.authority.groups': 'Grupo de Usuários', 'user.authority.roles': 'Papéis de Usuários', 'user.authority.new': 'Novas autorizações', + 'user.authority.addNew': 'Adicionar nova autorização', 'user.authority.noAuthYet': 'Nenhuma autorização ainda', 'user.username': 'Username', 'user.password': 'Senha', diff --git a/src/ui/users/authority/UserAuthorityTable.js b/src/ui/users/authority/UserAuthorityTable.js index 6dcd4ca5d..694fa52e6 100644 --- a/src/ui/users/authority/UserAuthorityTable.js +++ b/src/ui/users/authority/UserAuthorityTable.js @@ -28,10 +28,6 @@ const UserAuthorityTable = ({ .find(item => (group.value === '' || item.group.code === group.value) && (role.value === '' || item.role.code === role.value))); if (!isPresent) { - console.log('toadd', { - group: group.value || null, - role: role.value || null, - }); push({ group: group.value || null, role: role.value || null, @@ -110,7 +106,7 @@ const UserAuthorityTable = ({ onClick={onAddNewClicked} data-testid={TEST_ID_USER_AUTHORITY_TABLE.ADD_BUTTON} > - Add new Authorization + diff --git a/src/ui/users/common/UserAuthorityPageForm.js b/src/ui/users/common/UserAuthorityPageForm.js index d534d1507..eace7b384 100644 --- a/src/ui/users/common/UserAuthorityPageForm.js +++ b/src/ui/users/common/UserAuthorityPageForm.js @@ -78,10 +78,10 @@ UserAuthorityPageFormBody.propTypes = { groupsMap: PropTypes.shape({}), rolesMap: PropTypes.shape({}), values: PropTypes.shape({ - groupRolesCombo: { - group: PropTypes.shape({ code: PropTypes.string, name: PropTypes.string }), - role: PropTypes.shape({ code: PropTypes.string, name: PropTypes.string }), - }, + groupRolesCombo: PropTypes.arrayOf(PropTypes.shape({ + group: PropTypes.string, + role: PropTypes.string, + })), }), loading: PropTypes.bool, @@ -102,7 +102,7 @@ UserAuthorityPageFormBody.defaultProps = { const UserAuthorityPageForm = withFormik({ enableReinitialize: true, - initialValues: ({ initialValues }) => initialValues, + mapPropsToValues: ({ initialValues }) => initialValues, validationSchema: Yup.object().shape({ groupRolesCombo: Yup.array().of(Yup.object().shape({ group: Yup.string(), diff --git a/src/ui/users/common/UserAuthorityPageFormContainer.js b/src/ui/users/common/UserAuthorityPageFormContainer.js index 180c8714a..56c54fdee 100644 --- a/src/ui/users/common/UserAuthorityPageFormContainer.js +++ b/src/ui/users/common/UserAuthorityPageFormContainer.js @@ -8,7 +8,7 @@ import { fetchRoles } from 'state/roles/actions'; import UserAuthorityPageForm from 'ui/users/common/UserAuthorityPageForm'; import { ACTION_UPDATE } from 'state/users/const'; import { fetchUserAuthorities, sendPostUserAuthorities, sendPutUserAuthorities, sendDeleteUserAuthorities } from 'state/users/actions'; -import { getGroupRolesCombo, getSelectedUserActionAuthorities } from 'state/users/selectors'; +import { getSelectedUserActionAuthorities, getSelectedUserAuthoritiesList } from 'state/users/selectors'; import { setVisibleModal } from 'state/modal/actions'; @@ -18,7 +18,7 @@ export const mapStateToProps = state => ({ roles: getRolesList(state), groupsMap: getGroupsMap(state), rolesMap: getRolesMap(state), - initialValues: getGroupRolesCombo(state), + initialValues: { groupRolesCombo: getSelectedUserAuthoritiesList(state) }, actionOnSave: getSelectedUserActionAuthorities(state), }); From e6d03af978679e9f23afd8fbd14177da5ec0710a Mon Sep 17 00:00:00 2001 From: Jeff Go Date: Thu, 20 Jan 2022 14:42:19 +0800 Subject: [PATCH 006/122] ENG-2821 unit test updated --- src/ui/users/common/UserAuthorityPageForm.js | 1 + test/state/users/actions.test.js | 3 +- .../common/UserAuthorityPageForm.test.js | 44 ++++++++++--------- .../UserAuthorityPageFormContainer.test.js | 12 +++-- 4 files changed, 34 insertions(+), 26 deletions(-) diff --git a/src/ui/users/common/UserAuthorityPageForm.js b/src/ui/users/common/UserAuthorityPageForm.js index eace7b384..d58ca644e 100644 --- a/src/ui/users/common/UserAuthorityPageForm.js +++ b/src/ui/users/common/UserAuthorityPageForm.js @@ -18,6 +18,7 @@ export const UserAuthorityPageFormBody = ({ }) => { useEffect(() => { onDidMount(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const invalid = !isValid; diff --git a/test/state/users/actions.test.js b/test/state/users/actions.test.js index 619590f93..eb40d200e 100644 --- a/test/state/users/actions.test.js +++ b/test/state/users/actions.test.js @@ -248,8 +248,7 @@ describe('state/users/actions', () => { const actions = store.getActions(); expect(actions[0]).toHaveProperty('type', TOGGLE_LOADING); expect(actions[1]).toHaveProperty('type', SET_SELECTED_USER_AUTHORITIES); - expect(actions[2]).toHaveProperty('type', '@@redux-form/INITIALIZE'); - expect(actions[3]).toHaveProperty('type', TOGGLE_LOADING); + expect(actions[2]).toHaveProperty('type', TOGGLE_LOADING); done(); }).catch(done.fail); }); diff --git a/test/ui/users/common/UserAuthorityPageForm.test.js b/test/ui/users/common/UserAuthorityPageForm.test.js index 00441308f..0a0e45be5 100644 --- a/test/ui/users/common/UserAuthorityPageForm.test.js +++ b/test/ui/users/common/UserAuthorityPageForm.test.js @@ -1,34 +1,38 @@ import React from 'react'; -import 'test/enzyme-init'; +import '@testing-library/jest-dom/extend-expect'; +import { render, screen } from '@testing-library/react'; -import { shallow } from 'enzyme'; -import { UserAuthorityPageFormBody } from 'ui/users/common/UserAuthorityPageForm'; +import UserAuthorityPageForm from 'ui/users/common/UserAuthorityPageForm'; +import { mockRenderWithIntlAndStore } from 'test/legacyTestUtils'; const props = { - handleSubmit: jest.fn(), - onSubmit: jest.fn(), - onWillMount: jest.fn(), + onAddNewClicked: jest.fn(), + onCloseModal: jest.fn(), + loading: false, + onDidMount: jest.fn(), + initialValues: { groupRolesCombo: [] }, }; +jest.unmock('react-redux'); + describe('UserAuthorityPageForm', () => { - let component; + const renderForm = (initialValues = props.initialValues, addProps = {}) => { + const formProps = { ...props, ...addProps, initialValues }; + render(mockRenderWithIntlAndStore( + , + { modal: { visibleModal: '', info: {} } }, + )); + }; beforeEach(() => { - component = shallow(); - }); - - it('renders without crashing', () => { - expect(component.exists()).toBe(true); + renderForm(); }); -}); -describe('with onWillMount callback', () => { - beforeEach(() => { - shallow(( - - )); + it('has class PageTemplateForm', () => { + expect(screen.getByTestId('common_UserAuthorityPageForm_Form')).toBeInTheDocument(); }); - it('calls onWillMount', () => { - expect(props.onWillMount).toHaveBeenCalled(); + it('calls onDidMount', () => { + expect(props.onDidMount).toHaveBeenCalled(); }); }); + diff --git a/test/ui/users/common/UserAuthorityPageFormContainer.test.js b/test/ui/users/common/UserAuthorityPageFormContainer.test.js index 5a880eb2f..949b4ff9f 100644 --- a/test/ui/users/common/UserAuthorityPageFormContainer.test.js +++ b/test/ui/users/common/UserAuthorityPageFormContainer.test.js @@ -12,13 +12,15 @@ import { LIST_ROLES_OK } from 'test/mocks/roles'; jest.mock('state/groups/selectors', () => ({ getGroupsList: jest.fn(), + getGroupsMap: jest.fn(), })); jest.mock('state/roles/selectors', () => ({ getRolesList: jest.fn(), + getRolesMap: jest.fn(), })); jest.mock('state/users/selectors', () => ({ - getGroupRolesCombo: jest.fn(), + getSelectedUserAuthoritiesList: jest.fn(), getSelectedUserActionAuthorities: jest.fn(), })); @@ -44,15 +46,17 @@ describe('UserAuthorityPageFormContainer', () => { expect(props).toHaveProperty('loading'); expect(props).toHaveProperty('groups'); expect(props).toHaveProperty('roles'); - expect(props).toHaveProperty('groupRolesCombo'); + expect(props).toHaveProperty('groupsMap'); + expect(props).toHaveProperty('rolesMap'); + expect(props).toHaveProperty('initialValues'); expect(props).toHaveProperty('actionOnSave'); }); it('verify that onWillMount and onSubmit are defined in mapDispatchToProps', () => { const dispatchMock = jest.fn(); const result = mapDispatchToProps(dispatchMock, ownProps); - expect(result.onWillMount).toBeDefined(); - result.onWillMount(); + expect(result.onDidMount).toBeDefined(); + result.onDidMount(); expect(dispatchMock).toHaveBeenCalled(); expect(result.onSubmit).toBeDefined(); }); From 80828fd046e648e3ea4b2f76c13bc7fffdc49eed Mon Sep 17 00:00:00 2001 From: Jeff Go Date: Thu, 20 Jan 2022 16:02:12 +0800 Subject: [PATCH 007/122] ENG-2821 revert change from different base branch --- src/ui/internal-page/VerticalMenuContainer.js | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/ui/internal-page/VerticalMenuContainer.js b/src/ui/internal-page/VerticalMenuContainer.js index 311cb5cc1..50632660f 100644 --- a/src/ui/internal-page/VerticalMenuContainer.js +++ b/src/ui/internal-page/VerticalMenuContainer.js @@ -8,7 +8,6 @@ import { routeConverter, hasAccess } from '@entando/utils'; import { clearAppTourProgress, setAppTourLastStep, setWizardEnabled } from 'state/app-tour/actions'; -import { adminConsoleUrl } from 'helpers/urlUtils'; import UserMenuContainer from 'ui/internal-page/UserMenuContainer'; import LanguageSelectContainer from 'ui/internal-page/LanguageSelectContainer'; @@ -230,13 +229,7 @@ const EntandoMenu = ({ hiddenIcons={false} hideMasthead={false} hoverDisabled - onNavigate={({ href, onClick }) => { - if (href) { - window.location.href = href; - } else { - onClick(); - } - }} + onNavigate={e => e.onClick()} pinnableMenus={false} hoverPath={openPath} onItemClick={handleItemClick} From d437bb6ee0b17f474d58d5720c3105c91bb5e1cc Mon Sep 17 00:00:00 2001 From: "Rax Canaan W. Layumas" Date: Thu, 20 Jan 2022 20:20:14 +0800 Subject: [PATCH 008/122] Revert "ENG-2811 Re-implement user form to use formik instead of redux-form" --- src/helpers/formikValidations.js | 3 - src/ui/common/form/RenderSelectInput.js | 1 - src/ui/common/formik-field/RenderTextInput.js | 2 +- src/ui/common/formik-field/SelectInput.js | 134 ----- src/ui/common/formik-field/SwitchInput.js | 97 ---- src/ui/users/add/AddFormContainer.js | 62 +-- src/ui/users/common/UserForm.js | 446 ++++++++-------- src/ui/users/edit/EditFormContainer.js | 88 +--- test/ui/users/add/AddFormContainer.test.js | 138 ++--- test/ui/users/common/UserForm.test.js | 489 ++++++------------ test/ui/users/edit/EditFormContainer.test.js | 162 ++---- 11 files changed, 505 insertions(+), 1117 deletions(-) delete mode 100644 src/ui/common/formik-field/SelectInput.js delete mode 100644 src/ui/common/formik-field/SwitchInput.js diff --git a/src/helpers/formikValidations.js b/src/helpers/formikValidations.js index 9d1783895..1add44edc 100644 --- a/src/helpers/formikValidations.js +++ b/src/helpers/formikValidations.js @@ -1,6 +1,3 @@ -import React from 'react'; -import { FormattedMessage } from 'react-intl'; - export const formatMessageRequired = { id: 'validateForm.required', defaultMessage: 'Required', diff --git a/src/ui/common/form/RenderSelectInput.js b/src/ui/common/form/RenderSelectInput.js index b93e3af60..708ddef0a 100644 --- a/src/ui/common/form/RenderSelectInput.js +++ b/src/ui/common/form/RenderSelectInput.js @@ -57,7 +57,6 @@ const RenderSelectInputBody = ({ - {defaultOption} - {optionsList} - - {errorBox} - -
- ); -}; - -SelectInput.propTypes = { - intl: intlShape.isRequired, - field: PropTypes.shape({ - name: PropTypes.string.isRequired, - }).isRequired, - form: PropTypes.shape({ - touched: PropTypes.shape({}), - errors: PropTypes.shape({}), - }), - forwardedRef: PropTypes.oneOfType([ - PropTypes.func, - PropTypes.shape({ current: PropTypes.instanceOf(Element) }), - ]), - defaultOptionId: PropTypes.string, - options: PropTypes.arrayOf(PropTypes.shape({ - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - text: PropTypes.string, - })), - label: PropTypes.node, - labelSize: PropTypes.number, - alignClass: PropTypes.string, - xsClass: PropTypes.string, - help: PropTypes.node, - optionReducer: PropTypes.func, - optionValue: PropTypes.string, - optionDisplayName: PropTypes.string, - size: PropTypes.number, - inputSize: PropTypes.number, - disabled: PropTypes.bool, - hasLabel: PropTypes.bool, -}; - -SelectInput.defaultProps = { - form: {}, - defaultOptionId: '', - options: [], - label: null, - labelSize: 2, - alignClass: 'text-right', - xsClass: 'mobile-left', - help: null, - optionReducer: null, - optionValue: 'value', - optionDisplayName: 'text', - size: null, - inputSize: null, - disabled: false, - hasLabel: true, - forwardedRef: null, -}; - -const IntlWrappedSelectInput = injectIntl(SelectInput); - -export default React.forwardRef((props, ref) => ( - -)); diff --git a/src/ui/common/formik-field/SwitchInput.js b/src/ui/common/formik-field/SwitchInput.js deleted file mode 100644 index 75393c099..000000000 --- a/src/ui/common/formik-field/SwitchInput.js +++ /dev/null @@ -1,97 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { Switch, Col, ControlLabel } from 'patternfly-react'; -import { getTouchErrorByField } from 'helpers/formikUtils'; - -const SwitchInput = ({ - field, form, append, label, labelSize, inputSize, alignClass, - help, trueValue, falseValue, disabled, onToggleValue, -}) => { - const switchValue = field.value === 'true' || field.value === true || field.value === trueValue; - const dataTestId = `${field.name}-switchField`; - const { touched, error } = getTouchErrorByField(field.name, form); - - const handleChange = (el, val) => { - const returnVal = val ? trueValue : falseValue; - form.setFieldValue(field.name, returnVal); - if (onToggleValue) { - onToggleValue(returnVal); - } - }; - - if (label) { - return ( -
- - - {label} {help} - - - -
- -
- {append && {append}} - {touched && ((error && {error}))} - -
); - } - - return ( -
- -
- ); -}; - -SwitchInput.propTypes = { - trueValue: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), - falseValue: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), - field: PropTypes.shape({ - value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), - onChange: PropTypes.func, - name: PropTypes.string, - }).isRequired, - form: PropTypes.shape({ - touched: PropTypes.shape({}), - errors: PropTypes.shape({}), - setFieldValue: PropTypes.func.isRequired, - }).isRequired, - label: PropTypes.node, - meta: PropTypes.shape({}), - help: PropTypes.node, - disabled: PropTypes.bool, - type: PropTypes.string, - labelSize: PropTypes.number, - inputSize: PropTypes.number, - append: PropTypes.string, - alignClass: PropTypes.string, - onToggleValue: PropTypes.func, -}; - -SwitchInput.defaultProps = { - trueValue: true, - falseValue: false, - label: '', - meta: {}, - help: null, - disabled: false, - type: 'text', - labelSize: 2, - inputSize: null, - append: '', - alignClass: 'text-right', - onToggleValue: null, -}; - -export default SwitchInput; diff --git a/src/ui/users/add/AddFormContainer.js b/src/ui/users/add/AddFormContainer.js index 0bdd2e102..399588b95 100644 --- a/src/ui/users/add/AddFormContainer.js +++ b/src/ui/users/add/AddFormContainer.js @@ -1,6 +1,6 @@ -import React, { useCallback } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { useHistory } from 'react-router-dom'; +import { connect } from 'react-redux'; +import { destroy, submit } from 'redux-form'; +import { withRouter } from 'react-router-dom'; import { routeConverter } from '@entando/utils'; import { fetchProfileTypes } from 'state/profile-types/actions'; @@ -12,42 +12,26 @@ import { ROUTE_USER_LIST } from 'app-init/router'; import UserForm from 'ui/users/common/UserForm'; -const AddFormContainer = () => { - const dispatch = useDispatch(); - const history = useHistory(); - const profileTypes = useSelector(getProfileTypesOptions); - - const handleMount = useCallback(() => { +export const mapStateToProps = state => ({ + profileTypes: getProfileTypesOptions(state), +}); + +export const mapDispatchToProps = (dispatch, { history }) => ({ + onSubmit: (user) => { + const { saveType } = user; + dispatch(sendPostUser(user, saveType === 'editProfile')); + }, + onWillMount: () => { + dispatch(destroy('user')); dispatch(fetchProfileTypes({ page: 1, pageSize: 0 })); - }, [dispatch]); - - const handleSubmit = useCallback((user, submitType) => { - dispatch(sendPostUser(user, submitType === 'saveAndEditProfile')); - }, [dispatch]); - - const handleCancel = useCallback(() => { - dispatch(setVisibleModal(ConfirmCancelModalID)); - }, [dispatch]); - - const handleDiscard = useCallback(() => { - dispatch(setVisibleModal('')); - history.push(routeConverter(ROUTE_USER_LIST)); - }, [dispatch, history]); - - const handleModalSave = useCallback(() => { - dispatch(setVisibleModal('')); - }, [dispatch]); + }, + onSave: () => { dispatch(setVisibleModal('')); dispatch(submit('user')); }, + onCancel: () => dispatch(setVisibleModal(ConfirmCancelModalID)), + onDiscard: () => { dispatch(setVisibleModal('')); history.push(routeConverter(ROUTE_USER_LIST)); }, +}); - return ( - - ); -}; +const AddFormContainer = connect(mapStateToProps, mapDispatchToProps, null, { + pure: false, +})(UserForm); -export default AddFormContainer; +export default withRouter(AddFormContainer); diff --git a/src/ui/users/common/UserForm.js b/src/ui/users/common/UserForm.js index 5db48ac97..9639157fc 100644 --- a/src/ui/users/common/UserForm.js +++ b/src/ui/users/common/UserForm.js @@ -1,36 +1,36 @@ -import React, { useEffect } from 'react'; +import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { Formik, Form, Field } from 'formik'; -import * as Yup from 'yup'; -import { Button, Row, Col } from 'patternfly-react'; -import { formatDate } from '@entando/utils'; +import { Field, reduxForm } from 'redux-form'; +import { Button, Row, Col, FormGroup } from 'patternfly-react'; +import { + required, + maxLength, + minLength, + matchPassword, + userFormText, + formatDate, +} from '@entando/utils'; import { FormattedMessage, defineMessages, injectIntl, intlShape } from 'react-intl'; -import RenderTextInput from 'ui/common/formik-field/RenderTextInput'; -import SelectInput from 'ui/common/formik-field/SelectInput'; -import SwitchInput from 'ui/common/formik-field/SwitchInput'; - +import RenderTextInput from 'ui/common/form/RenderTextInput'; +import SwitchRenderer from 'ui/common/form/SwitchRenderer'; +import RenderSelectInput from 'ui/common/form/RenderSelectInput'; import FormLabel from 'ui/common/form/FormLabel'; import FormSectionTitle from 'ui/common/form/FormSectionTitle'; import ConfirmCancelModalContainer from 'ui/common/cancel-modal/ConfirmCancelModalContainer'; import { TEST_ID_USER_FORM } from 'ui/test-const/user-test-const'; -import { userPassCharsValid } from 'helpers/formikValidations'; -const msgs = defineMessages({ - username: { - id: 'user.username', - }, - password: { - id: 'user.password', - }, - passwordConfirm: { - id: 'user.passwordConfirm', - }, -}); +const EDIT_MODE = 'edit'; +const NEW_MODE = 'new'; -const renderStaticField = (fieldProps) => { - const { field, label, name } = fieldProps; - let fieldValue = field.value && (field.value.title || field.value); - if (!field.value) { +const minLength4 = minLength(4); +const minLength8 = minLength(8); +const maxLength20 = maxLength(20); +const maxLength80 = maxLength(80); + +export const renderStaticField = (field) => { + const { input, label, name } = field; + let fieldValue = input.value.title || input.value; + if (!input.value) { fieldValue = ; } else if (!Number.isNaN(Date.parse(fieldValue))) { fieldValue = formatDate(fieldValue); @@ -48,225 +48,227 @@ const renderStaticField = (fieldProps) => { ); }; -const addFormSchema = Yup.object().shape({ - username: Yup.string() - .required() - .min(4, ) - .max(80, ) - .test('usernameCharsValid', userPassCharsValid), - password: Yup.string() - .required() - .min(8, ) - .max(20, ) - .test('passwordCharsValid', userPassCharsValid), - passwordConfirm: Yup.string() - .required() - .oneOf([Yup.ref('password')], ), - profileType: Yup.string() - .required(), - status: Yup.string(), -}); - -const editFormSchema = Yup.object().shape({ - username: Yup.string(), - password: Yup.string() - .min(8, ) - .max(20, ) - .test('passwordCharsValid', userPassCharsValid), - passwordConfirm: Yup.string() - .when('password', (password, field) => (password ? (field - .required() - .oneOf([Yup.ref('password')], ) - ) : field)), - registration: Yup.string(), - lastLogin: Yup.string().nullable(), - lastPasswordChange: Yup.string().nullable(), - reset: Yup.boolean(), - status: Yup.string(), +const msgs = defineMessages({ + username: { + id: 'user.table.username', + defaultMessage: 'Username', + }, + password: { + id: 'user.password', + defaultMessage: 'Password', + }, + passwordConfirm: { + id: 'user.passwordConfirm', + defaultMessage: 'Confirm Password', + }, }); -const getFormSchema = editing => (editing ? editFormSchema : addFormSchema); +export class UserFormBody extends Component { + componentWillMount() { + this.props.onWillMount(this.props); + } -const UserForm = ({ - intl, initialValues, profileTypes, onMount, onSubmit, - onCancel, onDiscard, onModalSave, editing, -}) => { - useEffect(() => { - onMount(); - }, [onMount]); + render() { + const { + intl, onSubmit, handleSubmit, invalid, submitting, mode, profileTypes, + password, dirty, onCancel, onDiscard, onSave, + } = this.props; - const handleSubmit = ({ submitType, ...values }) => onSubmit(values, submitType); + const handleCancelClick = () => { + if (dirty) { + onCancel(); + } else { + onDiscard(); + } + }; - const handleCancelClick = ({ dirty }) => { - if (dirty) { - onCancel(); - } else { - onDiscard(); - } - }; + const showUsername = ( + } + placeholder={intl.formatMessage(msgs.username)} + validate={mode !== EDIT_MODE ? + [required, minLength4, maxLength80, userFormText] : undefined} + disabled={mode === EDIT_MODE} + disallowedInput={/[^0-9a-zA-Z_.]/g} + forceLowerCase + /> + ); + const showEdit = () => { + if (mode === NEW_MODE) { + return null; + } + return ( +
+ } + /> + } + /> + } + /> + + + + + + +
+ ); + }; - const handleModalSave = ({ submitForm }) => { - onModalSave(); - submitForm(); - }; + const showProfileType = ( + mode !== EDIT_MODE ? + (} + name="profileType" + validate={required} + />) : null + ); - return ( - - {formik => ( -
- - -
- - } - placeholder={intl.formatMessage(msgs.username)} - disabled={editing} - /> - } - placeholder={intl.formatMessage(msgs.password)} - /> - } - placeholder={intl.formatMessage(msgs.passwordConfirm)} - /> - {editing ? ( -
- } - /> - } - /> - } - /> - } - /> -
- ) : ( + return ( + + + +
+ + {showUsername} + } + placeholder={intl.formatMessage(msgs.password)} + validate={[ + ...(mode === NEW_MODE ? [required] : []), + ...(password ? [userFormText, minLength8, maxLength20] : []), + ]} + /> + } + placeholder={intl.formatMessage(msgs.passwordConfirm)} + validate={[ + ...(mode === NEW_MODE ? [required] : []), + ...(password ? [matchPassword] : []), + ]} + /> + {/* Insert user info and reset button on EDIT */} + {showEdit()} + {showProfileType} + + + } - options={profileTypes} - defaultOptionId="form.select.chooseOne" + component={SwitchRenderer} + name="status" + trueValue="active" + falseValue="inactive" /> - )} - } - trueValue="active" - falseValue="inactive" - /> -
- -
- - - handleModalSave(formik)} - onDiscard={onDiscard} - /> - - {!editing && ( + + +
+ +
+
+ + + + + { + mode !== EDIT_MODE && ( - )} - - - -
- )} -
- ); -}; + ) + } + + + + + ); + } +} -UserForm.propTypes = { +UserFormBody.propTypes = { intl: intlShape.isRequired, - initialValues: PropTypes.shape({ - username: PropTypes.string, - password: PropTypes.string, - passwordConfirm: PropTypes.string, - profileType: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), - status: PropTypes.string, - registration: PropTypes.string, - lastLogin: PropTypes.string, - lastPasswordChange: PropTypes.string, - reset: PropTypes.bool, - }), + onWillMount: PropTypes.func, + handleSubmit: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + invalid: PropTypes.bool, + submitting: PropTypes.bool, + mode: PropTypes.string, profileTypes: PropTypes.arrayOf(PropTypes.shape({ - value: PropTypes.string.isRequired, - text: PropTypes.string.isRequired, + value: PropTypes.string, + text: PropTypes.string, })), - onMount: PropTypes.func, - onSubmit: PropTypes.func.isRequired, - onCancel: PropTypes.func.isRequired, + password: PropTypes.string, + dirty: PropTypes.bool, + onSave: PropTypes.func.isRequired, onDiscard: PropTypes.func.isRequired, - onModalSave: PropTypes.func.isRequired, - editing: PropTypes.bool, + onCancel: PropTypes.func.isRequired, }; -UserForm.defaultProps = { - initialValues: { - username: '', - password: '', - passwordConfirm: '', - profileType: '', - status: 'inactive', - }, +UserFormBody.defaultProps = { + invalid: false, + submitting: false, + mode: NEW_MODE, + onWillMount: null, profileTypes: [], - onMount: () => {}, - editing: false, + password: '', + dirty: false, }; +const UserForm = reduxForm({ + form: 'user', +})(UserFormBody); + export default injectIntl(UserForm); diff --git a/src/ui/users/edit/EditFormContainer.js b/src/ui/users/edit/EditFormContainer.js index a44fb43e7..9ecf17401 100644 --- a/src/ui/users/edit/EditFormContainer.js +++ b/src/ui/users/edit/EditFormContainer.js @@ -1,68 +1,34 @@ -import React, { useCallback, useMemo } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { useHistory, useParams } from 'react-router-dom'; +import { connect } from 'react-redux'; +import { withRouter } from 'react-router-dom'; +import { formValueSelector, submit } from 'redux-form'; import { routeConverter } from '@entando/utils'; -import { fetchCurrentPageUserDetail, sendPutUser } from 'state/users/actions'; +import { fetchUserForm, sendPutUser } from 'state/users/actions'; import { setVisibleModal } from 'state/modal/actions'; import { ConfirmCancelModalID } from 'ui/common/cancel-modal/ConfirmCancelModal'; import { ROUTE_USER_LIST } from 'app-init/router'; import UserForm from 'ui/users/common/UserForm'; -import { getSelectedUser } from 'state/users/selectors'; - -const EditFormContainer = () => { - const dispatch = useDispatch(); - const history = useHistory(); - const { username } = useParams(); - const selectedUser = useSelector(getSelectedUser); - - const initialValues = useMemo(() => ({ - ...selectedUser, - username, - password: '', - passwordConfirm: '', - reset: false, - }), [selectedUser, username]); - - const handleMount = useCallback(() => { - dispatch(fetchCurrentPageUserDetail(username)); - }, [dispatch, username]); - - const handleSubmit = useCallback((user) => { - const updatedUser = { - ...user, - profileType: (user.profileType || {}).typeCode || '', - password: user.password || undefined, - passwordConfirm: user.passwordConfirm || undefined, - }; - dispatch(sendPutUser(updatedUser)); - }, [dispatch]); - - const handleCancel = useCallback(() => { - dispatch(setVisibleModal(ConfirmCancelModalID)); - }, [dispatch]); - - const handleDiscard = useCallback(() => { - dispatch(setVisibleModal('')); - history.push(routeConverter(ROUTE_USER_LIST)); - }, [dispatch, history]); - - const handleModalSave = useCallback(() => { - dispatch(setVisibleModal('')); - }, [dispatch]); - - return ( - - ); -}; - -export default EditFormContainer; +const EDIT_MODE = 'edit'; + +export const mapStateToProps = (state, { match: { params } }) => ({ + mode: EDIT_MODE, + username: params.username, + password: formValueSelector('user')(state, 'password'), +}); + +export const mapDispatchToProps = (dispatch, { history }) => ({ + onWillMount: ({ username }) => { dispatch(fetchUserForm(username)); }, + onSubmit: (user) => { + const editUser = { ...user, profileType: (user.profileType || {}).typeCode || '' }; + dispatch(sendPutUser(editUser)); + }, + onSave: () => { dispatch(setVisibleModal('')); dispatch(submit('user')); }, + onCancel: () => dispatch(setVisibleModal(ConfirmCancelModalID)), + onDiscard: () => { dispatch(setVisibleModal('')); history.push(routeConverter(ROUTE_USER_LIST)); }, +}); + + +export default withRouter(connect(mapStateToProps, mapDispatchToProps, null, { + pure: false, +})(UserForm)); diff --git a/test/ui/users/add/AddFormContainer.test.js b/test/ui/users/add/AddFormContainer.test.js index 5546c1838..db06c24e4 100644 --- a/test/ui/users/add/AddFormContainer.test.js +++ b/test/ui/users/add/AddFormContainer.test.js @@ -1,123 +1,43 @@ -import React from 'react'; -import * as reactRedux from 'react-redux'; - -import { renderWithIntlRouterState } from 'test/testUtils'; -import AddFormContainer from 'ui/users/add/AddFormContainer'; -import UserForm from 'ui/users/common/UserForm'; +import 'test/enzyme-init'; import { PROFILE_TYPES_NORMALIZED, PROFILE_TYPES_OPTIONS } from 'test/mocks/profileTypes'; +import { mapStateToProps, mapDispatchToProps } from 'ui/users/add/AddFormContainer'; +import { sendPostUser } from 'state/users/actions'; +import { fetchProfileTypes } from 'state/profile-types/actions'; -jest.unmock('react-redux'); - -const useDispatchSpy = jest.spyOn(reactRedux, 'useDispatch'); -const mockDispatch = jest.fn(); -useDispatchSpy.mockReturnValue(mockDispatch); - -const mockHistoryPush = jest.fn(); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useHistory: () => ({ - push: mockHistoryPush, - }), -})); +const dispatchMock = jest.fn(); jest.mock('state/users/actions', () => ({ - sendPostUser: jest.fn(payload => ({ type: 'sendPostUser_test', payload })), + sendPostUser: jest.fn(), })); jest.mock('state/profile-types/actions', () => ({ - fetchProfileTypes: jest.fn(() => ({ type: 'fetchProfileTypes_test' })), + fetchProfileTypes: jest.fn(), })); -jest.mock('state/modal/actions', () => ({ - setVisibleModal: jest.fn(payload => ({ type: 'setVisibleModal_test', payload })), -})); - -jest.mock('ui/users/common/UserForm', () => jest.fn(mockProps => ( -
- - - - - -
-))); - -const setupAddFormContainer = () => { - const state = { - ...PROFILE_TYPES_NORMALIZED, - }; - const utils = renderWithIntlRouterState(, { state }); - const simulateMount = () => utils.getByText('onMount').click(); - const simulateSubmit = () => utils.getByText('onSubmit').click(); - const simulateCancel = () => utils.getByText('onCancel').click(); - const simulateDiscard = () => utils.getByText('onDiscard').click(); - const simulateModalSave = () => utils.getByText('onModalSave').click(); - - return { - ...utils, - simulateMount, - simulateSubmit, - simulateCancel, - simulateDiscard, - simulateModalSave, - }; -}; - describe('AddFormContainer', () => { - afterEach(() => { - mockDispatch.mockClear(); - }); - - it('passes profileTypes to UserForm with the correct value', () => { - setupAddFormContainer(); - - expect(UserForm) - .toHaveBeenCalledWith(expect.objectContaining({ profileTypes: PROFILE_TYPES_OPTIONS }), {}); - }); - - it('fetches relevant data when UserForm mounts', () => { - const { simulateMount } = setupAddFormContainer(); - - simulateMount(); - - expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledWith({ type: 'fetchProfileTypes_test' }); - }); - - it('calls the correct user action when UserForm is submitted', () => { - const { simulateSubmit } = setupAddFormContainer(); - - simulateSubmit(); - - expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledWith({ type: 'sendPostUser_test', payload: 'testValue' }); + describe('mapDispatchToProps', () => { + let result; + beforeEach(() => { + result = mapDispatchToProps(dispatchMock, {}); + }); + it('verify that onSubmit is defined by mapDispatchToProps', () => { + expect(result).toHaveProperty('onSubmit'); + result.onSubmit({}); + expect(sendPostUser).toHaveBeenCalled(); + }); + + it('verify that onWillMount is defined by mapDispatchToProps', () => { + expect(result).toHaveProperty('onWillMount'); + result.onWillMount(); + expect(fetchProfileTypes).toHaveBeenCalled(); + }); }); - it('calls the correct modal action when UserForm is cancelled', () => { - const { simulateCancel } = setupAddFormContainer(); - - simulateCancel(); - - expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledWith({ type: 'setVisibleModal_test', payload: 'ConfirmCancelModal' }); - }); - - it('calls the correct modal and history actions when UserForm is discarded', () => { - const { simulateDiscard } = setupAddFormContainer(); - - simulateDiscard(); - - expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledWith({ type: 'setVisibleModal_test', payload: '' }); - expect(mockHistoryPush).toHaveBeenCalledWith('/user'); - }); - - it('calls the correct modal action when UserForm modal is saved', () => { - const { simulateModalSave } = setupAddFormContainer(); - - simulateModalSave(); - - expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledWith({ type: 'setVisibleModal_test', payload: '' }); + describe('mapStateToProps', () => { + it('verify that profileTypes prop is defined and properly valued', () => { + const props = mapStateToProps(PROFILE_TYPES_NORMALIZED); + expect(props.profileTypes).toBeDefined(); + expect(props.profileTypes).toEqual(PROFILE_TYPES_OPTIONS); + }); }); }); diff --git a/test/ui/users/common/UserForm.test.js b/test/ui/users/common/UserForm.test.js index 67a37f45a..d547cc28d 100644 --- a/test/ui/users/common/UserForm.test.js +++ b/test/ui/users/common/UserForm.test.js @@ -1,375 +1,210 @@ import React from 'react'; -import { screen, within, waitFor, fireEvent } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import '@testing-library/jest-dom/extend-expect'; +import 'test/enzyme-init'; +import { shallow } from 'enzyme'; +import { UserFormBody, renderStaticField } from 'ui/users/common/UserForm'; +import { runValidators, mockIntl } from 'test/legacyTestUtils'; -import { renderWithIntl } from 'test/testUtils'; -import UserForm from 'ui/users/common/UserForm'; -import ConfirmCancelModalContainer from 'ui/common/cancel-modal/ConfirmCancelModalContainer'; - -jest.mock('ui/common/cancel-modal/ConfirmCancelModalContainer', () => jest.fn(() => null)); - -const setupUserForm = (initialValues, editing = false) => { - const mockHandleMount = jest.fn(); - const mockHandleSubmit = jest.fn(); - const mockHandleCancel = jest.fn(); - const mockHandleDiscard = jest.fn(); - const mockHandleModalSave = jest.fn(); - const profileTypes = [{ value: 'PFL', text: 'Default' }]; - const utils = renderWithIntl(( - - )); - const formView = within(screen.getByRole('form')); - - const getUsernameTextInput = () => formView.getByRole('textbox', { name: /username/i }); - const getPasswordTextInput = () => formView.getByPlaceholderText(/^password$/i); - const getPasswordConfirmTextInput = () => formView.getByLabelText(/confirm password/i); - const getProfileTypeSelectInput = () => formView.getByRole('combobox', { name: /profile type/i }); - const getStatusSwitchInput = () => formView.getByLabelText(/status/i).children[0]; - const getSaveButton = () => formView.getByRole('button', { name: /^save$/i }); - const getSaveAndEditProfileButton = () => formView.getByRole('button', { name: /save and edit profile/i }); - const getCancelButton = () => formView.getByRole('button', { name: /cancel/i }); - const getErrorMessage = () => formView.getByRole('alert'); - const queryProfileTypeSelectInput = () => formView.queryByRole('combobox', { name: /profile type/i }); - const queryResetSwitchInput = () => formView.queryByLabelText(/reset/i).children[0]; - const queryErrorMessage = () => formView.queryByRole('alert'); - const querySaveAndEditProfileButton = () => formView.queryByRole('button', { name: /save and edit profile/i }); - - const typeUsername = - value => userEvent.type(getUsernameTextInput(), value); - const typePassword = - value => userEvent.type(getPasswordTextInput(), value); - const typePasswordConfirm = - value => userEvent.type(getPasswordConfirmTextInput(), value); - const selectProfileType = - value => userEvent.selectOptions(getProfileTypeSelectInput(), value); - const clearPassword = () => userEvent.clear(getPasswordTextInput()); - - const toggleStatus = () => userEvent.click(getStatusSwitchInput()); - - const clickSave = () => userEvent.click(getSaveButton()); - const clickSaveAndEditProfile = () => userEvent.click(getSaveAndEditProfileButton()); - const clickCancel = () => userEvent.click(getCancelButton()); - - return { - ...utils, - mockHandleMount, - mockHandleSubmit, - mockHandleCancel, - mockHandleDiscard, - getUsernameTextInput, - getPasswordTextInput, - getPasswordConfirmTextInput, - getProfileTypeSelectInput, - getStatusSwitchInput, - getSaveButton, - getSaveAndEditProfileButton, - getErrorMessage, - queryProfileTypeSelectInput, - queryResetSwitchInput, - queryErrorMessage, - querySaveAndEditProfileButton, - typeUsername, - typePassword, - typePasswordConfirm, - selectProfileType, - toggleStatus, - clearPassword, - clickSave, - clickSaveAndEditProfile, - clickCancel, - }; -}; - -const setupUserFormAndFillValues = ({ - username, password, passwordConfirm, profileType, -}) => { - const utils = setupUserForm(); - utils.typeUsername(username); - utils.typePassword(password); - utils.typePasswordConfirm(passwordConfirm); - utils.selectProfileType(profileType); - - fireEvent.blur(utils.getProfileTypeSelectInput()); - - return utils; -}; +const handleSubmit = jest.fn(); +const onSubmit = jest.fn(); +const onWillMount = jest.fn(); +const EDIT_MODE = 'edit'; describe('UserForm', () => { - const user = { - username: 'testuser', - password: 'testpass', - passwordConfirm: 'testpass', - profileType: 'PFL', - status: 'inactive', - }; - - it('calls onMount when form has been rendered', () => { - const { mockHandleMount } = setupUserForm(); - - expect(mockHandleMount).toHaveBeenCalledTimes(1); - }); - - it('calls onSubmit with all the fields when save is clicked', async () => { - const { mockHandleSubmit, clickSave } = setupUserFormAndFillValues(user); - - clickSave(); - - await waitFor(() => { - expect(mockHandleSubmit).toHaveBeenCalledTimes(1); - expect(mockHandleSubmit).toHaveBeenCalledWith(user, 'save'); - }); - }); - - it('calls onSubmit with all the fields and a saveAndEditProfile submit type when save and edit profile button is clicked', async () => { - const { clickSaveAndEditProfile, mockHandleSubmit } = setupUserFormAndFillValues(user); - - clickSaveAndEditProfile(); - - await waitFor(() => { - expect(mockHandleSubmit).toHaveBeenCalledTimes(1); - expect(mockHandleSubmit).toHaveBeenCalledWith(user, 'saveAndEditProfile'); - }); - }); - - it('calls onDiscard when cancel is clicked and form is not dirty', async () => { - const { mockHandleDiscard, clickCancel } = setupUserForm(); + let userForm; + let submitting; + let invalid; + let profileTypes; + + beforeEach(() => { + submitting = false; + invalid = false; + }); + const buildUserForm = (mode) => { + const props = { + profileTypes, + submitting, + invalid, + handleSubmit, + onWillMount, + onSubmit, + mode, + msgs: { + username: { id: 'username', defaultMessage: 'username' }, + }, + password: 'test', + intl: mockIntl, + }; - clickCancel(); + return shallow(); + }; - await waitFor(() => { - expect(mockHandleDiscard).toHaveBeenCalledTimes(1); - }); + it('root component renders without crashing', () => { + userForm = buildUserForm(); + expect(userForm.exists()).toEqual(true); }); - it('calls onCancel when cancel is clicked and form is dirty', async () => { - const { mockHandleCancel, clickCancel } = setupUserFormAndFillValues(user); - - clickCancel(); - - await waitFor(() => { - expect(mockHandleCancel).toHaveBeenCalledTimes(1); - }); + it('root component render minus icon if staticField value is null', () => { + const input = { name: 'registration', value: '' }; + const name = 'registration'; + const label = ; + const element = shallow(renderStaticField({ input, label, name })); + expect(element.find('.icon')).toExist(); + expect(element.find('.icon').hasClass('fa-minus')).toBe(true); }); - it('renders ConfirmCancelModalContainer with the correct props', () => { - setupUserForm(); - - expect(ConfirmCancelModalContainer).toHaveBeenCalledWith(expect.objectContaining({ - contentText: expect.stringMatching(/save/i), - invalid: expect.any(Boolean), - submitting: expect.any(Boolean), - onSave: expect.any(Function), - onDiscard: expect.any(Function), - 'data-testid': expect.any(String), - }), {}); + it('root component renders registration Field if its value is not null', () => { + const input = { name: 'registration', value: 'registration' }; + const name = 'registration'; + const label = ; + const element = renderStaticField({ input, label, name }); + const registration = shallow(element); + expect(registration.find('.form-group').exists()).toBe(true); }); - it('disables save buttons and shows an error message when username is not provided', async () => { - const { getSaveButton, getSaveAndEditProfileButton, getErrorMessage } = setupUserFormAndFillValues({ ...user, username: '' }); - - await waitFor(() => { - expect(getSaveButton()).toHaveAttribute('disabled'); - expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); - expect(getErrorMessage()).toHaveTextContent(/field required/i); + describe('test with mode = new', () => { + beforeEach(() => { + userForm = buildUserForm(); }); - }); - - it('disables save buttons and shows an error message when password is not provided', async () => { - const { - clearPassword, getPasswordTextInput, getSaveButton, - getSaveAndEditProfileButton, getErrorMessage, - } = setupUserForm({ ...user, passwordConfirm: '' }); - clearPassword(); - fireEvent.blur(getPasswordTextInput()); - - await waitFor(() => { - expect(getSaveButton()).toHaveAttribute('disabled'); - expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); - expect(getErrorMessage()).toHaveTextContent(/field required/i); + it('root component renders username field', () => { + const username = userForm.find('[name="username"]'); + expect(username.exists()).toEqual(true); }); - }); - - it('disables save buttons and shows an error message when confirm password is not provided', async () => { - const { getSaveButton, getSaveAndEditProfileButton, getErrorMessage } = setupUserFormAndFillValues({ ...user, passwordConfirm: '' }); - await waitFor(() => { - expect(getSaveButton()).toHaveAttribute('disabled'); - expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); - expect(getErrorMessage()).toHaveTextContent(/field required/i); - }); - }); - - it('disables save buttons and shows an error message when profile type is not provided', async () => { - const { getSaveButton, getSaveAndEditProfileButton, getErrorMessage } = setupUserFormAndFillValues({ ...user, profileType: '' }); - - await waitFor(() => { - expect(getSaveButton()).toHaveAttribute('disabled'); - expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); - expect(getErrorMessage()).toHaveTextContent(/field required/i); - }); - }); - - it('disables save buttons and shows an error message when username is too short', async () => { - const { getSaveButton, getSaveAndEditProfileButton, getErrorMessage } = setupUserFormAndFillValues({ ...user, username: 'abc' }); - - await waitFor(() => { - expect(getSaveButton()).toHaveAttribute('disabled'); - expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); - expect(getErrorMessage()).toHaveTextContent(/must be 4 characters or more/i); - }); - }); - - it('disables save buttons and shows an error message when username is too long', async () => { - const { getSaveButton, getSaveAndEditProfileButton, getErrorMessage } = - setupUserFormAndFillValues({ - ...user, username: 'thisisastringthathasmorethan80characters_thisisastringthathasmorethan80characters', + describe('username validation', () => { + let validatorArray; + beforeEach(() => { + validatorArray = userForm.find('[name="username"]').prop('validate'); }); - await waitFor(() => { - expect(getSaveButton()).toHaveAttribute('disabled'); - expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); - expect(getErrorMessage()).toHaveTextContent(/must be 80 characters or less/i); - }); - }); + it('is required', () => { + expect(runValidators(validatorArray, '').props.id).toBe('validateForm.required'); + }); - it('disables save buttons and shows an error message when username contains invalid characters', async () => { - const { getSaveButton, getSaveAndEditProfileButton, getErrorMessage } = setupUserFormAndFillValues({ ...user, username: '-invalid-' }); + it('is invalid if input is shorter than 4 chars', () => { + expect(runValidators(validatorArray, '123').props.id).toBe('validateForm.minLength'); + expect(runValidators(validatorArray, '1234')).toBeFalsy(); + }); - await waitFor(() => { - expect(getSaveButton()).toHaveAttribute('disabled'); - expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); - expect(getErrorMessage()).toHaveTextContent(/contains invalid characters/i); + it('is invalid if input is longer than 80 chars', () => { + expect(runValidators(validatorArray, '123456789abcdefghijk')).toBeFalsy(); + expect(runValidators(validatorArray, '123456789abcdefghijk123456789abcdefghijk123456789abcdefghijk123456789abcdefghijkl').props.id) + .toBe('validateForm.maxLength'); + }); }); - }); - it('disables save buttons and shows an error message when password is too short', async () => { - const shortPassword = 'abc'; - const { - getSaveButton, getSaveAndEditProfileButton, getErrorMessage, - } = setupUserFormAndFillValues({ - ...user, password: shortPassword, passwordConfirm: shortPassword, + it('root component renders status field', () => { + const status = userForm.find('[name="status"]'); + expect(status.exists()).toEqual(true); }); - await waitFor(() => { - expect(getSaveButton()).toHaveAttribute('disabled'); - expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); - expect(getErrorMessage()).toHaveTextContent(/must be 8 characters or more/i); + it('root component renders profileType field', () => { + const status = userForm.find('[name="profileType"]'); + expect(status.exists()).toEqual(true); }); - }); - it('disables save buttons and shows an error message when password is too long', async () => { - const longPassword = 'stringwithover20chars'; - const { - getSaveButton, getSaveAndEditProfileButton, getErrorMessage, - } = setupUserFormAndFillValues({ - ...user, password: longPassword, passwordConfirm: longPassword, - }); + describe('password field', () => { + let passwordField; + beforeEach(() => { + passwordField = userForm.find('[name="password"]'); + }); - await waitFor(() => { - expect(getSaveButton()).toHaveAttribute('disabled'); - expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); - expect(getErrorMessage()).toHaveTextContent(/must be 20 characters or less/i); - }); - }); + it('is rendered', () => { + expect(passwordField).toExist(); + }); - it('disables save buttons and shows an error message when password contains invalid characters', async () => { - const invalidPassword = '-invalid-'; - const { - getSaveButton, getSaveAndEditProfileButton, getErrorMessage, - } = setupUserFormAndFillValues({ - ...user, password: invalidPassword, passwordConfirm: invalidPassword, + describe('validation', () => { + let validatorArray; + beforeEach(() => { + validatorArray = passwordField.prop('validate'); + }); + + it('is required', () => { + expect(runValidators(validatorArray, '').props.id).toBe('validateForm.required'); + }); + + it('is invalid if input is shorter than 8 chars', () => { + expect(runValidators(validatorArray, '1234567').props.id).toBe('validateForm.minLength'); + expect(runValidators(validatorArray, '12345678')).toBeFalsy(); + }); + + it('is invalid if input is longer than 20 chars', () => { + expect(runValidators(validatorArray, '123456789abcdefghijk')).toBeFalsy(); + expect(runValidators(validatorArray, '123456789abcdefghijkl').props.id) + .toBe('validateForm.maxLength'); + }); + }); }); - await waitFor(() => { - expect(getSaveButton()).toHaveAttribute('disabled'); - expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); - expect(getErrorMessage()).toHaveTextContent(/contains invalid characters/i); - }); - }); + describe('passwordConfirm field', () => { + const ALL_VALUES = { password: '12345678' }; + let passwordConfirmField; + beforeEach(() => { + passwordConfirmField = userForm.find('[name="passwordConfirm"]'); + }); - it('disables save buttons and shows an error message when password doesn\'t match confirm password', async () => { - const { getSaveButton, getSaveAndEditProfileButton, getErrorMessage } = - setupUserFormAndFillValues({ - ...user, password: 'password', passwordConfirm: 'differentpass', + it('is rendered', () => { + expect(passwordConfirmField).toExist(); }); - await waitFor(() => { - expect(getSaveButton()).toHaveAttribute('disabled'); - expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); - expect(getErrorMessage()).toHaveTextContent(/value doesn't match with password/i); + describe('validation', () => { + let validatorArray; + beforeEach(() => { + validatorArray = passwordConfirmField.prop('validate'); + }); + + it('is required', () => { + expect(runValidators(validatorArray, '').props.id).toBe('validateForm.required'); + }); + + it('is invalid if input does not match the password', () => { + expect(runValidators(validatorArray, 'abcdefgh', ALL_VALUES).props.id) + .toBe('validateForm.passwordNotMatch'); + expect(runValidators(validatorArray, ALL_VALUES.password, ALL_VALUES)).toBeFalsy(); + }); + }); }); }); - describe('When editing', () => { - const detailedUser = { - ...user, - lastLogin: '2021-11-11 00:00:00', - lastPasswordChange: '2021-11-12 00:00:00', - registration: '2021-11-10 00:00:00', - reset: false, - }; - - it('calls onSubmit with all the fields when save is clicked', async () => { - const { mockHandleSubmit, clickSave } = setupUserForm(detailedUser, true); - - clickSave(); - - await waitFor(() => { - expect(mockHandleSubmit).toHaveBeenCalledTimes(1); - expect(mockHandleSubmit).toHaveBeenCalledWith(detailedUser, 'save'); - }); + describe('test with mode = edit', () => { + beforeEach(() => { + submitting = false; + invalid = false; + userForm = buildUserForm(EDIT_MODE); }); - - it('disables username', () => { - const { getUsernameTextInput } = setupUserForm(detailedUser, true); - - expect(getUsernameTextInput()).toHaveAttribute('disabled'); + it('root component has class UserForm__content-edit', () => { + expect(userForm.find('.UserForm__content-edit').exists()).toBe(true); }); - it('doesn\'t show profile type field', () => { - const { queryProfileTypeSelectInput } = setupUserForm(detailedUser, true); - - expect(queryProfileTypeSelectInput()).not.toBeInTheDocument(); + it('root component contains edit fields', () => { + expect(userForm.find('.UserForm__content-edit').find('Field')).toHaveLength(4); + expect(userForm.find('[name="registration"]').exists()).toBe(true); + expect(userForm.find('[name="lastLogin"]').exists()).toBe(true); + expect(userForm.find('[name="lastPasswordChange"]').exists()).toBe(true); + expect(userForm.find('[name="reset"]').exists()).toBe(true); }); + }); - it('shows the reset switch and static fields -- registration, last login, last password change', () => { - const { queryResetSwitchInput } = setupUserForm(detailedUser, true); - - expect(screen.getByText(detailedUser.lastLogin)).toBeInTheDocument(); - expect(screen.getByText(detailedUser.lastPasswordChange)).toBeInTheDocument(); - expect(screen.getByText(detailedUser.registration)).toBeInTheDocument(); - expect(queryResetSwitchInput()).toBeInTheDocument(); + describe('test buttons and handlers', () => { + it('disables submit button while submitting', () => { + submitting = true; + userForm = buildUserForm(); + const submitButton = userForm.find('Button').first(); + expect(submitButton.prop('disabled')).toEqual(true); }); - it('doesn\'t show save and edit profile button', () => { - const { querySaveAndEditProfileButton } = setupUserForm(detailedUser, true); - - expect(querySaveAndEditProfileButton()).not.toBeInTheDocument(); + it('disables submit button if form is invalid', () => { + invalid = true; + userForm = buildUserForm(); + const submitButton = userForm.find('Button').first(); + expect(submitButton.prop('disabled')).toEqual(true); }); - it('doesn\'t disable save button and doesn\'t show an error message when password is empty', async () => { - const { - clearPassword, getPasswordTextInput, queryErrorMessage, getSaveButton, - } = setupUserForm({ ...detailedUser, passwordConfirm: '' }, true); - - clearPassword(''); - fireEvent.blur(getPasswordTextInput()); - - await waitFor(() => { - expect(getSaveButton()).not.toHaveAttribute('disabled'); - expect(queryErrorMessage()).not.toBeInTheDocument(); - }); + it('on form submit calls handleSubmit', () => { + userForm = buildUserForm(); + const preventDefault = jest.fn(); + userForm.find('form').simulate('submit', { preventDefault }); + expect(handleSubmit).toHaveBeenCalled(); }); }); }); diff --git a/test/ui/users/edit/EditFormContainer.test.js b/test/ui/users/edit/EditFormContainer.test.js index ff0e3c617..a3433428c 100644 --- a/test/ui/users/edit/EditFormContainer.test.js +++ b/test/ui/users/edit/EditFormContainer.test.js @@ -1,130 +1,46 @@ -import React from 'react'; -import * as reactRedux from 'react-redux'; +import { mapDispatchToProps, mapStateToProps } from 'ui/users/edit/EditFormContainer'; -import { renderWithIntlRouterState } from 'test/testUtils'; -import EditFormContainer from 'ui/users/edit/EditFormContainer'; -import UserForm from 'ui/users/common/UserForm'; - -jest.unmock('react-redux'); - -const useDispatchSpy = jest.spyOn(reactRedux, 'useDispatch'); -const mockDispatch = jest.fn(); -useDispatchSpy.mockReturnValue(mockDispatch); - -const mockHistoryPush = jest.fn(); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useHistory: () => ({ - push: mockHistoryPush, - }), -})); - -jest.mock('state/users/actions', () => ({ - sendPutUser: jest.fn(payload => ({ type: 'sendPutUser_test', payload })), - fetchCurrentPageUserDetail: jest.fn(payload => ({ type: 'fetchCurrentPageUserDetail_test', payload })), -})); - -jest.mock('state/profile-types/actions', () => ({ - fetchProfileTypes: jest.fn(() => ({ type: 'fetchProfileTypes_test' })), -})); - -jest.mock('state/modal/actions', () => ({ - setVisibleModal: jest.fn(payload => ({ type: 'setVisibleModal_test', payload })), -})); - -jest.mock('ui/users/common/UserForm', () => jest.fn(mockProps => ( -
- - - - - -
-))); - -const setupEditFormContainer = () => { - const state = { - users: { - selected: {}, +const ownProps = { + match: { + params: { + mode: 'edit', + username: 'test', }, - }; - const utils = renderWithIntlRouterState(, { - state, initialRoute: '/user/edit/testuser', path: '/user/edit/:username', - }); - const simulateMount = () => utils.getByText('onMount').click(); - const simulateSubmit = () => utils.getByText('onSubmit').click(); - const simulateCancel = () => utils.getByText('onCancel').click(); - const simulateDiscard = () => utils.getByText('onDiscard').click(); - const simulateModalSave = () => utils.getByText('onModalSave').click(); - - return { - ...utils, - simulateMount, - simulateSubmit, - simulateCancel, - simulateDiscard, - simulateModalSave, - }; + }, }; -describe('AddFormContainer', () => { - afterEach(() => { - mockDispatch.mockClear(); - }); - - it('passes initialValues to UserForm with the correct values', () => { - setupEditFormContainer(); - - expect(UserForm).toHaveBeenCalledWith(expect.objectContaining({ - initialValues: { - username: 'testuser', password: '', passwordConfirm: '', reset: false, - }, - }), {}); - }); - - it('fetches user data when UserForm mounts', () => { - const { simulateMount } = setupEditFormContainer(); - - simulateMount(); - - expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledWith({ type: 'fetchCurrentPageUserDetail_test', payload: 'testuser' }); - }); - - it('calls the correct user action when UserForm is submitted', () => { - const { simulateSubmit } = setupEditFormContainer(); - - simulateSubmit(); - - expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledWith({ type: 'sendPutUser_test', payload: expect.any(Object) }); - }); - - it('calls the correct modal action when UserForm is cancelled', () => { - const { simulateCancel } = setupEditFormContainer(); - - simulateCancel(); - - expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledWith({ type: 'setVisibleModal_test', payload: 'ConfirmCancelModal' }); - }); - - it('calls the correct modal and history actions when UserForm is discarded', () => { - const { simulateDiscard } = setupEditFormContainer(); - - simulateDiscard(); - - expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledWith({ type: 'setVisibleModal_test', payload: '' }); - expect(mockHistoryPush).toHaveBeenCalledWith('/user'); - }); - - it('calls the correct modal action when UserForm modal is saved', () => { - const { simulateModalSave } = setupEditFormContainer(); - - simulateModalSave(); +const dispatchProps = { + history: {}, +}; - expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledWith({ type: 'setVisibleModal_test', payload: '' }); +describe('EditFormContainer', () => { + const dispatchMock = jest.fn(); + let props; + beforeEach(() => { + jest.clearAllMocks(); + props = mapDispatchToProps(dispatchMock, dispatchProps); + }); + + describe('mapDispatchToProps', () => { + it('should map the correct function properties', () => { + expect(props.onWillMount).toBeDefined(); + expect(props.onSubmit).toBeDefined(); + }); + it('verify thant onWillMount is called', () => { + props.onWillMount('username'); + expect(dispatchMock).toHaveBeenCalled(); + }); + it('verify thant onSubmit is called', () => { + props.onSubmit({}); + expect(dispatchMock).toHaveBeenCalled(); + }); + }); + + describe('mapStateToProps', () => { + it('verify that username prop is defined and properly valued', () => { + props = mapStateToProps({}, ownProps); + expect(props.mode).toEqual('edit'); + expect(props.username).toEqual('test'); + }); }); }); From 98f829ad04b932d65f45cd1abc3a535a02df634a Mon Sep 17 00:00:00 2001 From: Jeff Go Date: Thu, 20 Jan 2022 20:21:51 +0800 Subject: [PATCH 009/122] Revert "ENG-2821 convert User Authority Form with Formik" --- src/helpers/formikUtils.js | 33 +--- src/locales/en.js | 1 - src/locales/it.js | 1 - src/locales/pt.js | 1 - src/state/users/actions.js | 1 + src/state/users/reducer.js | 12 +- src/state/users/selectors.js | 29 ++-- src/ui/users/authority/UserAuthorityTable.js | 156 +++++++++-------- src/ui/users/common/UserAuthorityPageForm.js | 162 ++++++++---------- .../common/UserAuthorityPageFormContainer.js | 25 ++- test/state/users/actions.test.js | 3 +- .../common/UserAuthorityPageForm.test.js | 44 +++-- .../UserAuthorityPageFormContainer.test.js | 12 +- 13 files changed, 219 insertions(+), 261 deletions(-) diff --git a/src/helpers/formikUtils.js b/src/helpers/formikUtils.js index fb2786118..b0ab59301 100644 --- a/src/helpers/formikUtils.js +++ b/src/helpers/formikUtils.js @@ -1,36 +1,5 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { FieldArray } from 'formik'; - +// eslint-disable-next-line import/prefer-default-export export const getTouchErrorByField = (fieldName, { touched, errors }) => ({ touched: touched[fieldName], error: errors[fieldName], }); - -export const MultiField = ({ - name, - component: Component, - validateOnChange, - ...otherProps -}) => ( - ( - - )} - /> -); - -MultiField.propTypes = { - name: PropTypes.string.isRequired, - component: PropTypes.elementType.isRequired, - validateOnChange: PropTypes.bool, -}; - -MultiField.defaultProps = { - validateOnChange: true, -}; diff --git a/src/locales/en.js b/src/locales/en.js index 955e8c4fb..1b28feb5a 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -601,7 +601,6 @@ export default { 'user.authority.groups': 'User Group', 'user.authority.roles': 'User Role', 'user.authority.new': 'New authorizations', - 'user.authority.addNew': 'Add new Authorization', 'user.authority.noAuthYet': 'No authorizations yet', 'user.username': 'Username', 'user.password': 'Password', diff --git a/src/locales/it.js b/src/locales/it.js index 71b0830e7..3e0ddd6b6 100644 --- a/src/locales/it.js +++ b/src/locales/it.js @@ -601,7 +601,6 @@ export default { 'user.authority.groups': 'Gruppo utenti', 'user.authority.roles': 'Ruolo utenti', 'user.authority.new': 'Nuove autorizzazioni', - 'user.authority.addNew': 'Aggiungi nuova autorizzazione', 'user.authority.noAuthYet': 'Non ci sono autorizzazione presenti', 'user.username': 'Username', 'user.password': 'Password', diff --git a/src/locales/pt.js b/src/locales/pt.js index 02a2171ac..5c9fd69bb 100644 --- a/src/locales/pt.js +++ b/src/locales/pt.js @@ -572,7 +572,6 @@ export default { 'user.authority.groups': 'Grupo de Usuários', 'user.authority.roles': 'Papéis de Usuários', 'user.authority.new': 'Novas autorizações', - 'user.authority.addNew': 'Adicionar nova autorização', 'user.authority.noAuthYet': 'Nenhuma autorização ainda', 'user.username': 'Username', 'user.password': 'Senha', diff --git a/src/state/users/actions.js b/src/state/users/actions.js index 6e423dcc7..a9a16d292 100644 --- a/src/state/users/actions.js +++ b/src/state/users/actions.js @@ -202,6 +202,7 @@ export const fetchUserAuthorities = username => async (dispatch) => { const json = await response.json(); if (response.ok) { dispatch(setSelectedUserAuthorities(username, json.payload)); + dispatch(initialize('autorityForm', { groupRolesCombo: json.payload })); } else { dispatch(addErrors(json.errors.map(e => e.message))); json.errors.forEach(err => dispatch(addToast(err.message, TOAST_ERROR))); diff --git a/src/state/users/reducer.js b/src/state/users/reducer.js index b6cfb2fd4..16e09903a 100644 --- a/src/state/users/reducer.js +++ b/src/state/users/reducer.js @@ -39,11 +39,13 @@ export const selected = (state = {}, action = {}) => { export const authorities = (state = [], action = {}) => { switch (action.type) { case SET_SELECTED_USER_AUTHORITIES: { - return { - username: action.payload.username, - list: action.payload.authorities, - action: action.payload.authorities.length > 0 ? ACTION_UPDATE : ACTION_SAVE, - }; + let result = { username: action.payload.username, list: action.payload.authorities }; + if (action.payload.authorities.length > 0) { + result = { ...result, action: ACTION_UPDATE }; + } else { + result = { ...result, action: ACTION_SAVE }; + } + return result; } default: return state; } diff --git a/src/state/users/selectors.js b/src/state/users/selectors.js index 4e2dfc695..45625604e 100644 --- a/src/state/users/selectors.js +++ b/src/state/users/selectors.js @@ -1,4 +1,5 @@ import { createSelector } from 'reselect'; +import { formValueSelector } from 'redux-form'; import { getGroupsMap } from 'state/groups/selectors'; import { getRolesMap } from 'state/roles/selectors'; import { isEmpty } from 'lodash'; @@ -16,17 +17,19 @@ export const getUserList = createSelector( (UsersMap, idList) => idList.map(id => (UsersMap[id])), ); -export const makeGroupRolesCombo = (groupRoleCombo, groups, roles) => { - if (!isEmpty(groupRoleCombo) && !isEmpty(groups) && !isEmpty(roles)) { - return groupRoleCombo.map(item => ({ - group: item.group ? { code: item.group, name: groups[item.group].name } : {}, - role: item.role ? { code: item.role, name: roles[item.role].name } : {}, - })); - } - return []; -}; +const getGroupRolesComboValue = state => formValueSelector('autorityForm')(state, 'groupRolesCombo'); -export const getGroupRolesCombo = createSelector( - [getSelectedUserAuthoritiesList, getGroupsMap, getRolesMap], - makeGroupRolesCombo, -); +export const getGroupRolesCombo = + createSelector( + [getGroupRolesComboValue, getGroupsMap, getRolesMap], + (groupRoleCombo, groups, roles) => { + if (!isEmpty(groupRoleCombo) && !isEmpty(groups) && !isEmpty(roles)) { + return groupRoleCombo.map(item => ({ + group: item.group ? { code: item.group, name: groups[item.group].name } : {}, + role: item.role ? { code: item.role, name: roles[item.role].name } : {}, + })); + } + return []; + }, + + ); diff --git a/src/ui/users/authority/UserAuthorityTable.js b/src/ui/users/authority/UserAuthorityTable.js index 694fa52e6..136b89492 100644 --- a/src/ui/users/authority/UserAuthorityTable.js +++ b/src/ui/users/authority/UserAuthorityTable.js @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage, defineMessages, injectIntl, intlShape } from 'react-intl'; import { Row, Col, Button, Alert } from 'patternfly-react'; @@ -13,31 +13,40 @@ const msgs = defineMessages({ }, }); -const UserAuthorityTable = ({ - push, remove, - groupRolesCombo, onCloseModal, - intl, groups, roles, onAddNewClicked, -}) => { - const setGroupRef = useRef(null); - const setRoleRef = useRef(null); +class UserAuthorityTable extends Component { + constructor(props) { + super(props); + this.onClickAdd = this.onClickAdd.bind(this); + this.group = null; + this.role = null; + } + + onClickAdd() { + const { fields, groupRolesCombo, onCloseModal } = this.props; - const onClickAdd = () => { - const group = setGroupRef.current; - const role = setRoleRef.current; const isPresent = Boolean(groupRolesCombo - .find(item => (group.value === '' || item.group.code === group.value) && - (role.value === '' || item.role.code === role.value))); + .find(item => (this.group.value === '' || item.group.code === this.group.value) && + (this.role.value === '' || item.role.code === this.role.value))); + if (!isPresent) { - push({ - group: group.value || null, - role: role.value || null, + fields.push({ + group: this.group.value || null, + role: this.role.value || null, }); } onCloseModal(); - }; + } + + setGroupRef = (group) => { + this.group = group; + } - const renderTable = (renderRow) => { - if (groupRolesCombo.length === 0) { + setRoleRef = (role) => { + this.role = role; + } + + renderTable(renderRow) { + if (this.props.groupRolesCombo.length === 0) { return (
@@ -67,60 +76,65 @@ const UserAuthorityTable = ({ ); - }; - - const groupsWithEmpty = - [{ code: '', name: intl.formatMessage(msgs.chooseOption) }].concat(groups); - const rolesWithEmpty = - [{ code: '', name: intl.formatMessage(msgs.chooseOption) }].concat(roles); - const groupOptions = - groupsWithEmpty.map(gr => ()); - const rolesOptions = - rolesWithEmpty.map(rl => ()); + } - const renderRow = groupRolesCombo.map((item, index) => ( - - {item.group.name || } - {item.role.name || } - - - - - )); + render() { + const { + intl, groupRolesCombo, groups, roles, fields, onAddNewClicked, + } = this.props; + const groupsWithEmpty = + [{ code: '', name: intl.formatMessage(msgs.chooseOption) }].concat(groups); + const rolesWithEmpty = + [{ code: '', name: intl.formatMessage(msgs.chooseOption) }].concat(roles); + const groupOptions = + groupsWithEmpty.map(gr => ()); + const rolesOptions = + rolesWithEmpty.map(rl => ()); - return ( -
- - + const renderRow = groupRolesCombo.map((item, index) => ( + + {item.group.name || } + {item.role.name || } + - - - {renderTable(renderRow)} - -
- ); -}; + + + )); + + return ( +
+ + + + + + {this.renderTable(renderRow)} + +
+ ); + } +} UserAuthorityTable.propTypes = { intl: intlShape.isRequired, @@ -132,14 +146,16 @@ UserAuthorityTable.propTypes = { name: PropTypes.string, code: PropTypes.string, })), + fields: PropTypes.shape({ + push: PropTypes.func, + remove: PropTypes.func, + }).isRequired, groupRolesCombo: PropTypes.arrayOf(PropTypes.shape({ group: PropTypes.shape({ code: PropTypes.string, name: PropTypes.string }), role: PropTypes.shape({ code: PropTypes.string, name: PropTypes.string }), })), onAddNewClicked: PropTypes.func.isRequired, onCloseModal: PropTypes.func.isRequired, - push: PropTypes.func.isRequired, - remove: PropTypes.func.isRequired, }; UserAuthorityTable.defaultProps = { diff --git a/src/ui/users/common/UserAuthorityPageForm.js b/src/ui/users/common/UserAuthorityPageForm.js index d58ca644e..9c02acf40 100644 --- a/src/ui/users/common/UserAuthorityPageForm.js +++ b/src/ui/users/common/UserAuthorityPageForm.js @@ -1,73 +1,77 @@ -import React, { useEffect } from 'react'; +import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { Grid, Row, Col, Button, Spinner } from 'patternfly-react'; -import { withFormik, Form } from 'formik'; -import { MultiField } from 'helpers/formikUtils'; -import { makeGroupRolesCombo } from 'state/users/selectors'; -import * as Yup from 'yup'; +import { reduxForm, FieldArray } from 'redux-form'; import { FormattedMessage } from 'react-intl'; -import { ACTION_SAVE } from 'state/users/const'; +import { ACTION_SAVE, ACTION_UPDATE } from 'state/users/const'; import UserAuthorityTable from 'ui/users/authority/UserAuthorityTable'; import { TEST_ID_USER_AUTHORITY_PAGE_FORM } from 'ui/test-const/user-test-const'; -export const UserAuthorityPageFormBody = ({ - groups, roles, values, loading, - groupsMap, rolesMap, - onDidMount, isValid, isSubmitting: submitting, - onAddNewClicked, onCloseModal, -}) => { - useEffect(() => { - onDidMount(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); +export class UserAuthorityPageFormBody extends Component { + constructor(props) { + super(props); + this.group = null; + this.role = null; + } - const invalid = !isValid; - const groupRolesCombo = makeGroupRolesCombo(values.groupRolesCombo, groupsMap, rolesMap); + componentWillMount() { + this.props.onWillMount(); + } - return ( - -
- - - - - - - - + render() { + const { + invalid, submitting, handleSubmit, onAddNewClicked, onCloseModal, + } = this.props; + + return ( + + this.props.onSubmit(values, this.props.actionOnSave))} + className="UserAuthorityPageForm form-horizontal" + > - + + + + + + + + + + - - -
- ); -}; + +
+ ); + } +} UserAuthorityPageFormBody.propTypes = { - onDidMount: PropTypes.func.isRequired, + handleSubmit: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + onWillMount: PropTypes.func.isRequired, onAddNewClicked: PropTypes.func.isRequired, onCloseModal: PropTypes.func.isRequired, - isValid: PropTypes.bool, - isSubmitting: PropTypes.bool, + actionOnSave: PropTypes.oneOf([ACTION_SAVE, ACTION_UPDATE]), + invalid: PropTypes.bool, + submitting: PropTypes.bool, groups: PropTypes.arrayOf(PropTypes.shape({ name: PropTypes.string, code: PropTypes.string, @@ -76,52 +80,26 @@ UserAuthorityPageFormBody.propTypes = { name: PropTypes.string, code: PropTypes.string, })), - groupsMap: PropTypes.shape({}), - rolesMap: PropTypes.shape({}), - values: PropTypes.shape({ - groupRolesCombo: PropTypes.arrayOf(PropTypes.shape({ - group: PropTypes.string, - role: PropTypes.string, - })), - }), + groupRolesCombo: PropTypes.arrayOf(PropTypes.shape({ + group: PropTypes.shape({ code: PropTypes.string, name: PropTypes.string }), + role: PropTypes.shape({ code: PropTypes.string, name: PropTypes.string }), + })), loading: PropTypes.bool, }; UserAuthorityPageFormBody.defaultProps = { - isValid: false, - isSubmitting: false, + invalid: false, + submitting: false, groups: [], roles: [], - groupsMap: {}, - rolesMap: {}, - values: { - groupRolesCombo: [], - }, + groupRolesCombo: [], + actionOnSave: ACTION_SAVE, loading: false, }; -const UserAuthorityPageForm = withFormik({ - enableReinitialize: true, - mapPropsToValues: ({ initialValues }) => initialValues, - validationSchema: Yup.object().shape({ - groupRolesCombo: Yup.array().of(Yup.object().shape({ - group: Yup.string(), - role: Yup.string(), - })), - }), - handleSubmit: ( - values, - { - props: { onSubmit, actionOnSave }, - setSubmitting, - }, - ) => { - onSubmit(values, actionOnSave || ACTION_SAVE).then(() => ( - setSubmitting(false) - )); - }, - displayName: 'autorityForm', +const UserAuthorityPageForm = reduxForm({ + form: 'autorityForm', })(UserAuthorityPageFormBody); export default UserAuthorityPageForm; diff --git a/src/ui/users/common/UserAuthorityPageFormContainer.js b/src/ui/users/common/UserAuthorityPageFormContainer.js index 56c54fdee..a9eb73528 100644 --- a/src/ui/users/common/UserAuthorityPageFormContainer.js +++ b/src/ui/users/common/UserAuthorityPageFormContainer.js @@ -2,28 +2,27 @@ import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; import { fetchAllGroupEntries } from 'state/groups/actions'; import { getLoading } from 'state/loading/selectors'; -import { getGroupsList, getGroupsMap } from 'state/groups/selectors'; -import { getRolesList, getRolesMap } from 'state/roles/selectors'; +import { getGroupsList } from 'state/groups/selectors'; +import { getRolesList } from 'state/roles/selectors'; import { fetchRoles } from 'state/roles/actions'; import UserAuthorityPageForm from 'ui/users/common/UserAuthorityPageForm'; import { ACTION_UPDATE } from 'state/users/const'; import { fetchUserAuthorities, sendPostUserAuthorities, sendPutUserAuthorities, sendDeleteUserAuthorities } from 'state/users/actions'; -import { getSelectedUserActionAuthorities, getSelectedUserAuthoritiesList } from 'state/users/selectors'; +import { getGroupRolesCombo, getSelectedUserActionAuthorities } from 'state/users/selectors'; import { setVisibleModal } from 'state/modal/actions'; -export const mapStateToProps = state => ({ - loading: getLoading(state).users, - groups: getGroupsList(state), - roles: getRolesList(state), - groupsMap: getGroupsMap(state), - rolesMap: getRolesMap(state), - initialValues: { groupRolesCombo: getSelectedUserAuthoritiesList(state) }, - actionOnSave: getSelectedUserActionAuthorities(state), -}); +export const mapStateToProps = state => + ({ + loading: getLoading(state).users, + groups: getGroupsList(state), + roles: getRolesList(state), + groupRolesCombo: getGroupRolesCombo(state), + actionOnSave: getSelectedUserActionAuthorities(state), + }); export const mapDispatchToProps = (dispatch, { match: { params } }) => ({ - onDidMount: () => { + onWillMount: () => { dispatch(fetchAllGroupEntries({ page: 1, pageSize: 0 })); dispatch(fetchRoles({ page: 1, pageSize: 0 })); dispatch(fetchUserAuthorities(params.username)); diff --git a/test/state/users/actions.test.js b/test/state/users/actions.test.js index eb40d200e..619590f93 100644 --- a/test/state/users/actions.test.js +++ b/test/state/users/actions.test.js @@ -248,7 +248,8 @@ describe('state/users/actions', () => { const actions = store.getActions(); expect(actions[0]).toHaveProperty('type', TOGGLE_LOADING); expect(actions[1]).toHaveProperty('type', SET_SELECTED_USER_AUTHORITIES); - expect(actions[2]).toHaveProperty('type', TOGGLE_LOADING); + expect(actions[2]).toHaveProperty('type', '@@redux-form/INITIALIZE'); + expect(actions[3]).toHaveProperty('type', TOGGLE_LOADING); done(); }).catch(done.fail); }); diff --git a/test/ui/users/common/UserAuthorityPageForm.test.js b/test/ui/users/common/UserAuthorityPageForm.test.js index 0a0e45be5..00441308f 100644 --- a/test/ui/users/common/UserAuthorityPageForm.test.js +++ b/test/ui/users/common/UserAuthorityPageForm.test.js @@ -1,38 +1,34 @@ import React from 'react'; -import '@testing-library/jest-dom/extend-expect'; -import { render, screen } from '@testing-library/react'; +import 'test/enzyme-init'; -import UserAuthorityPageForm from 'ui/users/common/UserAuthorityPageForm'; -import { mockRenderWithIntlAndStore } from 'test/legacyTestUtils'; +import { shallow } from 'enzyme'; +import { UserAuthorityPageFormBody } from 'ui/users/common/UserAuthorityPageForm'; const props = { - onAddNewClicked: jest.fn(), - onCloseModal: jest.fn(), - loading: false, - onDidMount: jest.fn(), - initialValues: { groupRolesCombo: [] }, + handleSubmit: jest.fn(), + onSubmit: jest.fn(), + onWillMount: jest.fn(), }; -jest.unmock('react-redux'); - describe('UserAuthorityPageForm', () => { - const renderForm = (initialValues = props.initialValues, addProps = {}) => { - const formProps = { ...props, ...addProps, initialValues }; - render(mockRenderWithIntlAndStore( - , - { modal: { visibleModal: '', info: {} } }, - )); - }; + let component; beforeEach(() => { - renderForm(); + component = shallow(); + }); + + it('renders without crashing', () => { + expect(component.exists()).toBe(true); }); +}); - it('has class PageTemplateForm', () => { - expect(screen.getByTestId('common_UserAuthorityPageForm_Form')).toBeInTheDocument(); +describe('with onWillMount callback', () => { + beforeEach(() => { + shallow(( + + )); }); - it('calls onDidMount', () => { - expect(props.onDidMount).toHaveBeenCalled(); + it('calls onWillMount', () => { + expect(props.onWillMount).toHaveBeenCalled(); }); }); - diff --git a/test/ui/users/common/UserAuthorityPageFormContainer.test.js b/test/ui/users/common/UserAuthorityPageFormContainer.test.js index 949b4ff9f..5a880eb2f 100644 --- a/test/ui/users/common/UserAuthorityPageFormContainer.test.js +++ b/test/ui/users/common/UserAuthorityPageFormContainer.test.js @@ -12,15 +12,13 @@ import { LIST_ROLES_OK } from 'test/mocks/roles'; jest.mock('state/groups/selectors', () => ({ getGroupsList: jest.fn(), - getGroupsMap: jest.fn(), })); jest.mock('state/roles/selectors', () => ({ getRolesList: jest.fn(), - getRolesMap: jest.fn(), })); jest.mock('state/users/selectors', () => ({ - getSelectedUserAuthoritiesList: jest.fn(), + getGroupRolesCombo: jest.fn(), getSelectedUserActionAuthorities: jest.fn(), })); @@ -46,17 +44,15 @@ describe('UserAuthorityPageFormContainer', () => { expect(props).toHaveProperty('loading'); expect(props).toHaveProperty('groups'); expect(props).toHaveProperty('roles'); - expect(props).toHaveProperty('groupsMap'); - expect(props).toHaveProperty('rolesMap'); - expect(props).toHaveProperty('initialValues'); + expect(props).toHaveProperty('groupRolesCombo'); expect(props).toHaveProperty('actionOnSave'); }); it('verify that onWillMount and onSubmit are defined in mapDispatchToProps', () => { const dispatchMock = jest.fn(); const result = mapDispatchToProps(dispatchMock, ownProps); - expect(result.onDidMount).toBeDefined(); - result.onDidMount(); + expect(result.onWillMount).toBeDefined(); + result.onWillMount(); expect(dispatchMock).toHaveBeenCalled(); expect(result.onSubmit).toBeDefined(); }); From 279e229ea5c7d773cc4c9611e00aaa424109f699 Mon Sep 17 00:00:00 2001 From: Jeff Go Date: Thu, 20 Jan 2022 21:04:31 +0800 Subject: [PATCH 010/122] Revert "Revert "ENG-2821 convert User Authority Form with Formik"" This reverts commit f24a2c5605fdebb917965fb1b1d031b07d7cd959. --- src/helpers/formikUtils.js | 33 +++- src/locales/en.js | 1 + src/locales/it.js | 1 + src/locales/pt.js | 1 + src/state/users/actions.js | 1 - src/state/users/reducer.js | 12 +- src/state/users/selectors.js | 29 ++-- src/ui/users/authority/UserAuthorityTable.js | 156 ++++++++--------- src/ui/users/common/UserAuthorityPageForm.js | 162 ++++++++++-------- .../common/UserAuthorityPageFormContainer.js | 25 +-- test/state/users/actions.test.js | 3 +- .../common/UserAuthorityPageForm.test.js | 44 ++--- .../UserAuthorityPageFormContainer.test.js | 12 +- 13 files changed, 261 insertions(+), 219 deletions(-) diff --git a/src/helpers/formikUtils.js b/src/helpers/formikUtils.js index b0ab59301..fb2786118 100644 --- a/src/helpers/formikUtils.js +++ b/src/helpers/formikUtils.js @@ -1,5 +1,36 @@ -// eslint-disable-next-line import/prefer-default-export +import React from 'react'; +import PropTypes from 'prop-types'; +import { FieldArray } from 'formik'; + export const getTouchErrorByField = (fieldName, { touched, errors }) => ({ touched: touched[fieldName], error: errors[fieldName], }); + +export const MultiField = ({ + name, + component: Component, + validateOnChange, + ...otherProps +}) => ( + ( + + )} + /> +); + +MultiField.propTypes = { + name: PropTypes.string.isRequired, + component: PropTypes.elementType.isRequired, + validateOnChange: PropTypes.bool, +}; + +MultiField.defaultProps = { + validateOnChange: true, +}; diff --git a/src/locales/en.js b/src/locales/en.js index 1b28feb5a..955e8c4fb 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -601,6 +601,7 @@ export default { 'user.authority.groups': 'User Group', 'user.authority.roles': 'User Role', 'user.authority.new': 'New authorizations', + 'user.authority.addNew': 'Add new Authorization', 'user.authority.noAuthYet': 'No authorizations yet', 'user.username': 'Username', 'user.password': 'Password', diff --git a/src/locales/it.js b/src/locales/it.js index 3e0ddd6b6..71b0830e7 100644 --- a/src/locales/it.js +++ b/src/locales/it.js @@ -601,6 +601,7 @@ export default { 'user.authority.groups': 'Gruppo utenti', 'user.authority.roles': 'Ruolo utenti', 'user.authority.new': 'Nuove autorizzazioni', + 'user.authority.addNew': 'Aggiungi nuova autorizzazione', 'user.authority.noAuthYet': 'Non ci sono autorizzazione presenti', 'user.username': 'Username', 'user.password': 'Password', diff --git a/src/locales/pt.js b/src/locales/pt.js index 5c9fd69bb..02a2171ac 100644 --- a/src/locales/pt.js +++ b/src/locales/pt.js @@ -572,6 +572,7 @@ export default { 'user.authority.groups': 'Grupo de Usuários', 'user.authority.roles': 'Papéis de Usuários', 'user.authority.new': 'Novas autorizações', + 'user.authority.addNew': 'Adicionar nova autorização', 'user.authority.noAuthYet': 'Nenhuma autorização ainda', 'user.username': 'Username', 'user.password': 'Senha', diff --git a/src/state/users/actions.js b/src/state/users/actions.js index a9a16d292..6e423dcc7 100644 --- a/src/state/users/actions.js +++ b/src/state/users/actions.js @@ -202,7 +202,6 @@ export const fetchUserAuthorities = username => async (dispatch) => { const json = await response.json(); if (response.ok) { dispatch(setSelectedUserAuthorities(username, json.payload)); - dispatch(initialize('autorityForm', { groupRolesCombo: json.payload })); } else { dispatch(addErrors(json.errors.map(e => e.message))); json.errors.forEach(err => dispatch(addToast(err.message, TOAST_ERROR))); diff --git a/src/state/users/reducer.js b/src/state/users/reducer.js index 16e09903a..b6cfb2fd4 100644 --- a/src/state/users/reducer.js +++ b/src/state/users/reducer.js @@ -39,13 +39,11 @@ export const selected = (state = {}, action = {}) => { export const authorities = (state = [], action = {}) => { switch (action.type) { case SET_SELECTED_USER_AUTHORITIES: { - let result = { username: action.payload.username, list: action.payload.authorities }; - if (action.payload.authorities.length > 0) { - result = { ...result, action: ACTION_UPDATE }; - } else { - result = { ...result, action: ACTION_SAVE }; - } - return result; + return { + username: action.payload.username, + list: action.payload.authorities, + action: action.payload.authorities.length > 0 ? ACTION_UPDATE : ACTION_SAVE, + }; } default: return state; } diff --git a/src/state/users/selectors.js b/src/state/users/selectors.js index 45625604e..4e2dfc695 100644 --- a/src/state/users/selectors.js +++ b/src/state/users/selectors.js @@ -1,5 +1,4 @@ import { createSelector } from 'reselect'; -import { formValueSelector } from 'redux-form'; import { getGroupsMap } from 'state/groups/selectors'; import { getRolesMap } from 'state/roles/selectors'; import { isEmpty } from 'lodash'; @@ -17,19 +16,17 @@ export const getUserList = createSelector( (UsersMap, idList) => idList.map(id => (UsersMap[id])), ); -const getGroupRolesComboValue = state => formValueSelector('autorityForm')(state, 'groupRolesCombo'); +export const makeGroupRolesCombo = (groupRoleCombo, groups, roles) => { + if (!isEmpty(groupRoleCombo) && !isEmpty(groups) && !isEmpty(roles)) { + return groupRoleCombo.map(item => ({ + group: item.group ? { code: item.group, name: groups[item.group].name } : {}, + role: item.role ? { code: item.role, name: roles[item.role].name } : {}, + })); + } + return []; +}; -export const getGroupRolesCombo = - createSelector( - [getGroupRolesComboValue, getGroupsMap, getRolesMap], - (groupRoleCombo, groups, roles) => { - if (!isEmpty(groupRoleCombo) && !isEmpty(groups) && !isEmpty(roles)) { - return groupRoleCombo.map(item => ({ - group: item.group ? { code: item.group, name: groups[item.group].name } : {}, - role: item.role ? { code: item.role, name: roles[item.role].name } : {}, - })); - } - return []; - }, - - ); +export const getGroupRolesCombo = createSelector( + [getSelectedUserAuthoritiesList, getGroupsMap, getRolesMap], + makeGroupRolesCombo, +); diff --git a/src/ui/users/authority/UserAuthorityTable.js b/src/ui/users/authority/UserAuthorityTable.js index 136b89492..694fa52e6 100644 --- a/src/ui/users/authority/UserAuthorityTable.js +++ b/src/ui/users/authority/UserAuthorityTable.js @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import React, { useRef } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage, defineMessages, injectIntl, intlShape } from 'react-intl'; import { Row, Col, Button, Alert } from 'patternfly-react'; @@ -13,40 +13,31 @@ const msgs = defineMessages({ }, }); -class UserAuthorityTable extends Component { - constructor(props) { - super(props); - this.onClickAdd = this.onClickAdd.bind(this); - this.group = null; - this.role = null; - } - - onClickAdd() { - const { fields, groupRolesCombo, onCloseModal } = this.props; +const UserAuthorityTable = ({ + push, remove, + groupRolesCombo, onCloseModal, + intl, groups, roles, onAddNewClicked, +}) => { + const setGroupRef = useRef(null); + const setRoleRef = useRef(null); + const onClickAdd = () => { + const group = setGroupRef.current; + const role = setRoleRef.current; const isPresent = Boolean(groupRolesCombo - .find(item => (this.group.value === '' || item.group.code === this.group.value) && - (this.role.value === '' || item.role.code === this.role.value))); - + .find(item => (group.value === '' || item.group.code === group.value) && + (role.value === '' || item.role.code === role.value))); if (!isPresent) { - fields.push({ - group: this.group.value || null, - role: this.role.value || null, + push({ + group: group.value || null, + role: role.value || null, }); } onCloseModal(); - } - - setGroupRef = (group) => { - this.group = group; - } + }; - setRoleRef = (role) => { - this.role = role; - } - - renderTable(renderRow) { - if (this.props.groupRolesCombo.length === 0) { + const renderTable = (renderRow) => { + if (groupRolesCombo.length === 0) { return (
@@ -76,65 +67,60 @@ class UserAuthorityTable extends Component { ); - } + }; + + const groupsWithEmpty = + [{ code: '', name: intl.formatMessage(msgs.chooseOption) }].concat(groups); + const rolesWithEmpty = + [{ code: '', name: intl.formatMessage(msgs.chooseOption) }].concat(roles); + const groupOptions = + groupsWithEmpty.map(gr => ()); + const rolesOptions = + rolesWithEmpty.map(rl => ()); - render() { - const { - intl, groupRolesCombo, groups, roles, fields, onAddNewClicked, - } = this.props; - const groupsWithEmpty = - [{ code: '', name: intl.formatMessage(msgs.chooseOption) }].concat(groups); - const rolesWithEmpty = - [{ code: '', name: intl.formatMessage(msgs.chooseOption) }].concat(roles); - const groupOptions = - groupsWithEmpty.map(gr => ()); - const rolesOptions = - rolesWithEmpty.map(rl => ()); + const renderRow = groupRolesCombo.map((item, index) => ( + + {item.group.name || } + {item.role.name || } + + + + + )); - const renderRow = groupRolesCombo.map((item, index) => ( - - {item.group.name || } - {item.role.name || } - + return ( +
+ + - - - )); - - return ( -
- - - - - - {this.renderTable(renderRow)} - -
- ); - } -} + +
+ {renderTable(renderRow)} + +
+ ); +}; UserAuthorityTable.propTypes = { intl: intlShape.isRequired, @@ -146,16 +132,14 @@ UserAuthorityTable.propTypes = { name: PropTypes.string, code: PropTypes.string, })), - fields: PropTypes.shape({ - push: PropTypes.func, - remove: PropTypes.func, - }).isRequired, groupRolesCombo: PropTypes.arrayOf(PropTypes.shape({ group: PropTypes.shape({ code: PropTypes.string, name: PropTypes.string }), role: PropTypes.shape({ code: PropTypes.string, name: PropTypes.string }), })), onAddNewClicked: PropTypes.func.isRequired, onCloseModal: PropTypes.func.isRequired, + push: PropTypes.func.isRequired, + remove: PropTypes.func.isRequired, }; UserAuthorityTable.defaultProps = { diff --git a/src/ui/users/common/UserAuthorityPageForm.js b/src/ui/users/common/UserAuthorityPageForm.js index 9c02acf40..d58ca644e 100644 --- a/src/ui/users/common/UserAuthorityPageForm.js +++ b/src/ui/users/common/UserAuthorityPageForm.js @@ -1,77 +1,73 @@ -import React, { Component } from 'react'; +import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import { Grid, Row, Col, Button, Spinner } from 'patternfly-react'; -import { reduxForm, FieldArray } from 'redux-form'; +import { withFormik, Form } from 'formik'; +import { MultiField } from 'helpers/formikUtils'; +import { makeGroupRolesCombo } from 'state/users/selectors'; +import * as Yup from 'yup'; import { FormattedMessage } from 'react-intl'; -import { ACTION_SAVE, ACTION_UPDATE } from 'state/users/const'; +import { ACTION_SAVE } from 'state/users/const'; import UserAuthorityTable from 'ui/users/authority/UserAuthorityTable'; import { TEST_ID_USER_AUTHORITY_PAGE_FORM } from 'ui/test-const/user-test-const'; -export class UserAuthorityPageFormBody extends Component { - constructor(props) { - super(props); - this.group = null; - this.role = null; - } +export const UserAuthorityPageFormBody = ({ + groups, roles, values, loading, + groupsMap, rolesMap, + onDidMount, isValid, isSubmitting: submitting, + onAddNewClicked, onCloseModal, +}) => { + useEffect(() => { + onDidMount(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - componentWillMount() { - this.props.onWillMount(); - } + const invalid = !isValid; + const groupRolesCombo = makeGroupRolesCombo(values.groupRolesCombo, groupsMap, rolesMap); - render() { - const { - invalid, submitting, handleSubmit, onAddNewClicked, onCloseModal, - } = this.props; - - return ( - -
this.props.onSubmit(values, this.props.actionOnSave))} - className="UserAuthorityPageForm form-horizontal" - > + return ( + + + + + + + + + + - - - - - - - - - - + - -
- ); - } -} + + +
+ ); +}; UserAuthorityPageFormBody.propTypes = { - handleSubmit: PropTypes.func.isRequired, - onSubmit: PropTypes.func.isRequired, - onWillMount: PropTypes.func.isRequired, + onDidMount: PropTypes.func.isRequired, onAddNewClicked: PropTypes.func.isRequired, onCloseModal: PropTypes.func.isRequired, - actionOnSave: PropTypes.oneOf([ACTION_SAVE, ACTION_UPDATE]), - invalid: PropTypes.bool, - submitting: PropTypes.bool, + isValid: PropTypes.bool, + isSubmitting: PropTypes.bool, groups: PropTypes.arrayOf(PropTypes.shape({ name: PropTypes.string, code: PropTypes.string, @@ -80,26 +76,52 @@ UserAuthorityPageFormBody.propTypes = { name: PropTypes.string, code: PropTypes.string, })), - groupRolesCombo: PropTypes.arrayOf(PropTypes.shape({ - group: PropTypes.shape({ code: PropTypes.string, name: PropTypes.string }), - role: PropTypes.shape({ code: PropTypes.string, name: PropTypes.string }), - })), + groupsMap: PropTypes.shape({}), + rolesMap: PropTypes.shape({}), + values: PropTypes.shape({ + groupRolesCombo: PropTypes.arrayOf(PropTypes.shape({ + group: PropTypes.string, + role: PropTypes.string, + })), + }), loading: PropTypes.bool, }; UserAuthorityPageFormBody.defaultProps = { - invalid: false, - submitting: false, + isValid: false, + isSubmitting: false, groups: [], roles: [], - groupRolesCombo: [], - actionOnSave: ACTION_SAVE, + groupsMap: {}, + rolesMap: {}, + values: { + groupRolesCombo: [], + }, loading: false, }; -const UserAuthorityPageForm = reduxForm({ - form: 'autorityForm', +const UserAuthorityPageForm = withFormik({ + enableReinitialize: true, + mapPropsToValues: ({ initialValues }) => initialValues, + validationSchema: Yup.object().shape({ + groupRolesCombo: Yup.array().of(Yup.object().shape({ + group: Yup.string(), + role: Yup.string(), + })), + }), + handleSubmit: ( + values, + { + props: { onSubmit, actionOnSave }, + setSubmitting, + }, + ) => { + onSubmit(values, actionOnSave || ACTION_SAVE).then(() => ( + setSubmitting(false) + )); + }, + displayName: 'autorityForm', })(UserAuthorityPageFormBody); export default UserAuthorityPageForm; diff --git a/src/ui/users/common/UserAuthorityPageFormContainer.js b/src/ui/users/common/UserAuthorityPageFormContainer.js index a9eb73528..56c54fdee 100644 --- a/src/ui/users/common/UserAuthorityPageFormContainer.js +++ b/src/ui/users/common/UserAuthorityPageFormContainer.js @@ -2,27 +2,28 @@ import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; import { fetchAllGroupEntries } from 'state/groups/actions'; import { getLoading } from 'state/loading/selectors'; -import { getGroupsList } from 'state/groups/selectors'; -import { getRolesList } from 'state/roles/selectors'; +import { getGroupsList, getGroupsMap } from 'state/groups/selectors'; +import { getRolesList, getRolesMap } from 'state/roles/selectors'; import { fetchRoles } from 'state/roles/actions'; import UserAuthorityPageForm from 'ui/users/common/UserAuthorityPageForm'; import { ACTION_UPDATE } from 'state/users/const'; import { fetchUserAuthorities, sendPostUserAuthorities, sendPutUserAuthorities, sendDeleteUserAuthorities } from 'state/users/actions'; -import { getGroupRolesCombo, getSelectedUserActionAuthorities } from 'state/users/selectors'; +import { getSelectedUserActionAuthorities, getSelectedUserAuthoritiesList } from 'state/users/selectors'; import { setVisibleModal } from 'state/modal/actions'; -export const mapStateToProps = state => - ({ - loading: getLoading(state).users, - groups: getGroupsList(state), - roles: getRolesList(state), - groupRolesCombo: getGroupRolesCombo(state), - actionOnSave: getSelectedUserActionAuthorities(state), - }); +export const mapStateToProps = state => ({ + loading: getLoading(state).users, + groups: getGroupsList(state), + roles: getRolesList(state), + groupsMap: getGroupsMap(state), + rolesMap: getRolesMap(state), + initialValues: { groupRolesCombo: getSelectedUserAuthoritiesList(state) }, + actionOnSave: getSelectedUserActionAuthorities(state), +}); export const mapDispatchToProps = (dispatch, { match: { params } }) => ({ - onWillMount: () => { + onDidMount: () => { dispatch(fetchAllGroupEntries({ page: 1, pageSize: 0 })); dispatch(fetchRoles({ page: 1, pageSize: 0 })); dispatch(fetchUserAuthorities(params.username)); diff --git a/test/state/users/actions.test.js b/test/state/users/actions.test.js index 619590f93..eb40d200e 100644 --- a/test/state/users/actions.test.js +++ b/test/state/users/actions.test.js @@ -248,8 +248,7 @@ describe('state/users/actions', () => { const actions = store.getActions(); expect(actions[0]).toHaveProperty('type', TOGGLE_LOADING); expect(actions[1]).toHaveProperty('type', SET_SELECTED_USER_AUTHORITIES); - expect(actions[2]).toHaveProperty('type', '@@redux-form/INITIALIZE'); - expect(actions[3]).toHaveProperty('type', TOGGLE_LOADING); + expect(actions[2]).toHaveProperty('type', TOGGLE_LOADING); done(); }).catch(done.fail); }); diff --git a/test/ui/users/common/UserAuthorityPageForm.test.js b/test/ui/users/common/UserAuthorityPageForm.test.js index 00441308f..0a0e45be5 100644 --- a/test/ui/users/common/UserAuthorityPageForm.test.js +++ b/test/ui/users/common/UserAuthorityPageForm.test.js @@ -1,34 +1,38 @@ import React from 'react'; -import 'test/enzyme-init'; +import '@testing-library/jest-dom/extend-expect'; +import { render, screen } from '@testing-library/react'; -import { shallow } from 'enzyme'; -import { UserAuthorityPageFormBody } from 'ui/users/common/UserAuthorityPageForm'; +import UserAuthorityPageForm from 'ui/users/common/UserAuthorityPageForm'; +import { mockRenderWithIntlAndStore } from 'test/legacyTestUtils'; const props = { - handleSubmit: jest.fn(), - onSubmit: jest.fn(), - onWillMount: jest.fn(), + onAddNewClicked: jest.fn(), + onCloseModal: jest.fn(), + loading: false, + onDidMount: jest.fn(), + initialValues: { groupRolesCombo: [] }, }; +jest.unmock('react-redux'); + describe('UserAuthorityPageForm', () => { - let component; + const renderForm = (initialValues = props.initialValues, addProps = {}) => { + const formProps = { ...props, ...addProps, initialValues }; + render(mockRenderWithIntlAndStore( + , + { modal: { visibleModal: '', info: {} } }, + )); + }; beforeEach(() => { - component = shallow(); - }); - - it('renders without crashing', () => { - expect(component.exists()).toBe(true); + renderForm(); }); -}); -describe('with onWillMount callback', () => { - beforeEach(() => { - shallow(( - - )); + it('has class PageTemplateForm', () => { + expect(screen.getByTestId('common_UserAuthorityPageForm_Form')).toBeInTheDocument(); }); - it('calls onWillMount', () => { - expect(props.onWillMount).toHaveBeenCalled(); + it('calls onDidMount', () => { + expect(props.onDidMount).toHaveBeenCalled(); }); }); + diff --git a/test/ui/users/common/UserAuthorityPageFormContainer.test.js b/test/ui/users/common/UserAuthorityPageFormContainer.test.js index 5a880eb2f..949b4ff9f 100644 --- a/test/ui/users/common/UserAuthorityPageFormContainer.test.js +++ b/test/ui/users/common/UserAuthorityPageFormContainer.test.js @@ -12,13 +12,15 @@ import { LIST_ROLES_OK } from 'test/mocks/roles'; jest.mock('state/groups/selectors', () => ({ getGroupsList: jest.fn(), + getGroupsMap: jest.fn(), })); jest.mock('state/roles/selectors', () => ({ getRolesList: jest.fn(), + getRolesMap: jest.fn(), })); jest.mock('state/users/selectors', () => ({ - getGroupRolesCombo: jest.fn(), + getSelectedUserAuthoritiesList: jest.fn(), getSelectedUserActionAuthorities: jest.fn(), })); @@ -44,15 +46,17 @@ describe('UserAuthorityPageFormContainer', () => { expect(props).toHaveProperty('loading'); expect(props).toHaveProperty('groups'); expect(props).toHaveProperty('roles'); - expect(props).toHaveProperty('groupRolesCombo'); + expect(props).toHaveProperty('groupsMap'); + expect(props).toHaveProperty('rolesMap'); + expect(props).toHaveProperty('initialValues'); expect(props).toHaveProperty('actionOnSave'); }); it('verify that onWillMount and onSubmit are defined in mapDispatchToProps', () => { const dispatchMock = jest.fn(); const result = mapDispatchToProps(dispatchMock, ownProps); - expect(result.onWillMount).toBeDefined(); - result.onWillMount(); + expect(result.onDidMount).toBeDefined(); + result.onDidMount(); expect(dispatchMock).toHaveBeenCalled(); expect(result.onSubmit).toBeDefined(); }); From 5819412ae6d9481ef5f5e715a148c7fa845aff33 Mon Sep 17 00:00:00 2001 From: Rax Canaan Layumas Date: Thu, 20 Jan 2022 20:41:37 +0800 Subject: [PATCH 011/122] Revert "Revert "ENG-2811 Re-implement user form to use formik instead of redux-form"" This reverts commit cc06925fcf8b769ed8e77b5ed733b2c54ec0cc1a. --- src/helpers/formikValidations.js | 3 + src/ui/common/form/RenderSelectInput.js | 1 + src/ui/common/formik-field/RenderTextInput.js | 2 +- src/ui/common/formik-field/SelectInput.js | 134 +++++ src/ui/common/formik-field/SwitchInput.js | 97 ++++ src/ui/users/add/AddFormContainer.js | 62 ++- src/ui/users/common/UserForm.js | 446 ++++++++-------- src/ui/users/edit/EditFormContainer.js | 88 +++- test/ui/users/add/AddFormContainer.test.js | 138 +++-- test/ui/users/common/UserForm.test.js | 489 ++++++++++++------ test/ui/users/edit/EditFormContainer.test.js | 162 ++++-- 11 files changed, 1117 insertions(+), 505 deletions(-) create mode 100644 src/ui/common/formik-field/SelectInput.js create mode 100644 src/ui/common/formik-field/SwitchInput.js diff --git a/src/helpers/formikValidations.js b/src/helpers/formikValidations.js index 1add44edc..9d1783895 100644 --- a/src/helpers/formikValidations.js +++ b/src/helpers/formikValidations.js @@ -1,3 +1,6 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + export const formatMessageRequired = { id: 'validateForm.required', defaultMessage: 'Required', diff --git a/src/ui/common/form/RenderSelectInput.js b/src/ui/common/form/RenderSelectInput.js index 708ddef0a..b93e3af60 100644 --- a/src/ui/common/form/RenderSelectInput.js +++ b/src/ui/common/form/RenderSelectInput.js @@ -57,6 +57,7 @@ const RenderSelectInputBody = ({ + {defaultOption} + {optionsList} + + {errorBox} + +
+ ); +}; + +SelectInput.propTypes = { + intl: intlShape.isRequired, + field: PropTypes.shape({ + name: PropTypes.string.isRequired, + }).isRequired, + form: PropTypes.shape({ + touched: PropTypes.shape({}), + errors: PropTypes.shape({}), + }), + forwardedRef: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape({ current: PropTypes.instanceOf(Element) }), + ]), + defaultOptionId: PropTypes.string, + options: PropTypes.arrayOf(PropTypes.shape({ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + text: PropTypes.string, + })), + label: PropTypes.node, + labelSize: PropTypes.number, + alignClass: PropTypes.string, + xsClass: PropTypes.string, + help: PropTypes.node, + optionReducer: PropTypes.func, + optionValue: PropTypes.string, + optionDisplayName: PropTypes.string, + size: PropTypes.number, + inputSize: PropTypes.number, + disabled: PropTypes.bool, + hasLabel: PropTypes.bool, +}; + +SelectInput.defaultProps = { + form: {}, + defaultOptionId: '', + options: [], + label: null, + labelSize: 2, + alignClass: 'text-right', + xsClass: 'mobile-left', + help: null, + optionReducer: null, + optionValue: 'value', + optionDisplayName: 'text', + size: null, + inputSize: null, + disabled: false, + hasLabel: true, + forwardedRef: null, +}; + +const IntlWrappedSelectInput = injectIntl(SelectInput); + +export default React.forwardRef((props, ref) => ( + +)); diff --git a/src/ui/common/formik-field/SwitchInput.js b/src/ui/common/formik-field/SwitchInput.js new file mode 100644 index 000000000..75393c099 --- /dev/null +++ b/src/ui/common/formik-field/SwitchInput.js @@ -0,0 +1,97 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Switch, Col, ControlLabel } from 'patternfly-react'; +import { getTouchErrorByField } from 'helpers/formikUtils'; + +const SwitchInput = ({ + field, form, append, label, labelSize, inputSize, alignClass, + help, trueValue, falseValue, disabled, onToggleValue, +}) => { + const switchValue = field.value === 'true' || field.value === true || field.value === trueValue; + const dataTestId = `${field.name}-switchField`; + const { touched, error } = getTouchErrorByField(field.name, form); + + const handleChange = (el, val) => { + const returnVal = val ? trueValue : falseValue; + form.setFieldValue(field.name, returnVal); + if (onToggleValue) { + onToggleValue(returnVal); + } + }; + + if (label) { + return ( +
+ + + {label} {help} + + + +
+ +
+ {append && {append}} + {touched && ((error && {error}))} + +
); + } + + return ( +
+ +
+ ); +}; + +SwitchInput.propTypes = { + trueValue: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + falseValue: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + field: PropTypes.shape({ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + onChange: PropTypes.func, + name: PropTypes.string, + }).isRequired, + form: PropTypes.shape({ + touched: PropTypes.shape({}), + errors: PropTypes.shape({}), + setFieldValue: PropTypes.func.isRequired, + }).isRequired, + label: PropTypes.node, + meta: PropTypes.shape({}), + help: PropTypes.node, + disabled: PropTypes.bool, + type: PropTypes.string, + labelSize: PropTypes.number, + inputSize: PropTypes.number, + append: PropTypes.string, + alignClass: PropTypes.string, + onToggleValue: PropTypes.func, +}; + +SwitchInput.defaultProps = { + trueValue: true, + falseValue: false, + label: '', + meta: {}, + help: null, + disabled: false, + type: 'text', + labelSize: 2, + inputSize: null, + append: '', + alignClass: 'text-right', + onToggleValue: null, +}; + +export default SwitchInput; diff --git a/src/ui/users/add/AddFormContainer.js b/src/ui/users/add/AddFormContainer.js index 399588b95..0bdd2e102 100644 --- a/src/ui/users/add/AddFormContainer.js +++ b/src/ui/users/add/AddFormContainer.js @@ -1,6 +1,6 @@ -import { connect } from 'react-redux'; -import { destroy, submit } from 'redux-form'; -import { withRouter } from 'react-router-dom'; +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; import { routeConverter } from '@entando/utils'; import { fetchProfileTypes } from 'state/profile-types/actions'; @@ -12,26 +12,42 @@ import { ROUTE_USER_LIST } from 'app-init/router'; import UserForm from 'ui/users/common/UserForm'; -export const mapStateToProps = state => ({ - profileTypes: getProfileTypesOptions(state), -}); - -export const mapDispatchToProps = (dispatch, { history }) => ({ - onSubmit: (user) => { - const { saveType } = user; - dispatch(sendPostUser(user, saveType === 'editProfile')); - }, - onWillMount: () => { - dispatch(destroy('user')); +const AddFormContainer = () => { + const dispatch = useDispatch(); + const history = useHistory(); + const profileTypes = useSelector(getProfileTypesOptions); + + const handleMount = useCallback(() => { dispatch(fetchProfileTypes({ page: 1, pageSize: 0 })); - }, - onSave: () => { dispatch(setVisibleModal('')); dispatch(submit('user')); }, - onCancel: () => dispatch(setVisibleModal(ConfirmCancelModalID)), - onDiscard: () => { dispatch(setVisibleModal('')); history.push(routeConverter(ROUTE_USER_LIST)); }, -}); + }, [dispatch]); + + const handleSubmit = useCallback((user, submitType) => { + dispatch(sendPostUser(user, submitType === 'saveAndEditProfile')); + }, [dispatch]); + + const handleCancel = useCallback(() => { + dispatch(setVisibleModal(ConfirmCancelModalID)); + }, [dispatch]); + + const handleDiscard = useCallback(() => { + dispatch(setVisibleModal('')); + history.push(routeConverter(ROUTE_USER_LIST)); + }, [dispatch, history]); + + const handleModalSave = useCallback(() => { + dispatch(setVisibleModal('')); + }, [dispatch]); -const AddFormContainer = connect(mapStateToProps, mapDispatchToProps, null, { - pure: false, -})(UserForm); + return ( + + ); +}; -export default withRouter(AddFormContainer); +export default AddFormContainer; diff --git a/src/ui/users/common/UserForm.js b/src/ui/users/common/UserForm.js index 9639157fc..5db48ac97 100644 --- a/src/ui/users/common/UserForm.js +++ b/src/ui/users/common/UserForm.js @@ -1,36 +1,36 @@ -import React, { Component } from 'react'; +import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; -import { Field, reduxForm } from 'redux-form'; -import { Button, Row, Col, FormGroup } from 'patternfly-react'; -import { - required, - maxLength, - minLength, - matchPassword, - userFormText, - formatDate, -} from '@entando/utils'; +import { Formik, Form, Field } from 'formik'; +import * as Yup from 'yup'; +import { Button, Row, Col } from 'patternfly-react'; +import { formatDate } from '@entando/utils'; import { FormattedMessage, defineMessages, injectIntl, intlShape } from 'react-intl'; -import RenderTextInput from 'ui/common/form/RenderTextInput'; -import SwitchRenderer from 'ui/common/form/SwitchRenderer'; -import RenderSelectInput from 'ui/common/form/RenderSelectInput'; +import RenderTextInput from 'ui/common/formik-field/RenderTextInput'; +import SelectInput from 'ui/common/formik-field/SelectInput'; +import SwitchInput from 'ui/common/formik-field/SwitchInput'; + import FormLabel from 'ui/common/form/FormLabel'; import FormSectionTitle from 'ui/common/form/FormSectionTitle'; import ConfirmCancelModalContainer from 'ui/common/cancel-modal/ConfirmCancelModalContainer'; import { TEST_ID_USER_FORM } from 'ui/test-const/user-test-const'; +import { userPassCharsValid } from 'helpers/formikValidations'; -const EDIT_MODE = 'edit'; -const NEW_MODE = 'new'; - -const minLength4 = minLength(4); -const minLength8 = minLength(8); -const maxLength20 = maxLength(20); -const maxLength80 = maxLength(80); +const msgs = defineMessages({ + username: { + id: 'user.username', + }, + password: { + id: 'user.password', + }, + passwordConfirm: { + id: 'user.passwordConfirm', + }, +}); -export const renderStaticField = (field) => { - const { input, label, name } = field; - let fieldValue = input.value.title || input.value; - if (!input.value) { +const renderStaticField = (fieldProps) => { + const { field, label, name } = fieldProps; + let fieldValue = field.value && (field.value.title || field.value); + if (!field.value) { fieldValue = ; } else if (!Number.isNaN(Date.parse(fieldValue))) { fieldValue = formatDate(fieldValue); @@ -48,227 +48,225 @@ export const renderStaticField = (field) => { ); }; -const msgs = defineMessages({ - username: { - id: 'user.table.username', - defaultMessage: 'Username', - }, - password: { - id: 'user.password', - defaultMessage: 'Password', - }, - passwordConfirm: { - id: 'user.passwordConfirm', - defaultMessage: 'Confirm Password', - }, +const addFormSchema = Yup.object().shape({ + username: Yup.string() + .required() + .min(4, ) + .max(80, ) + .test('usernameCharsValid', userPassCharsValid), + password: Yup.string() + .required() + .min(8, ) + .max(20, ) + .test('passwordCharsValid', userPassCharsValid), + passwordConfirm: Yup.string() + .required() + .oneOf([Yup.ref('password')], ), + profileType: Yup.string() + .required(), + status: Yup.string(), }); -export class UserFormBody extends Component { - componentWillMount() { - this.props.onWillMount(this.props); - } +const editFormSchema = Yup.object().shape({ + username: Yup.string(), + password: Yup.string() + .min(8, ) + .max(20, ) + .test('passwordCharsValid', userPassCharsValid), + passwordConfirm: Yup.string() + .when('password', (password, field) => (password ? (field + .required() + .oneOf([Yup.ref('password')], ) + ) : field)), + registration: Yup.string(), + lastLogin: Yup.string().nullable(), + lastPasswordChange: Yup.string().nullable(), + reset: Yup.boolean(), + status: Yup.string(), +}); - render() { - const { - intl, onSubmit, handleSubmit, invalid, submitting, mode, profileTypes, - password, dirty, onCancel, onDiscard, onSave, - } = this.props; +const getFormSchema = editing => (editing ? editFormSchema : addFormSchema); - const handleCancelClick = () => { - if (dirty) { - onCancel(); - } else { - onDiscard(); - } - }; +const UserForm = ({ + intl, initialValues, profileTypes, onMount, onSubmit, + onCancel, onDiscard, onModalSave, editing, +}) => { + useEffect(() => { + onMount(); + }, [onMount]); - const showUsername = ( - } - placeholder={intl.formatMessage(msgs.username)} - validate={mode !== EDIT_MODE ? - [required, minLength4, maxLength80, userFormText] : undefined} - disabled={mode === EDIT_MODE} - disallowedInput={/[^0-9a-zA-Z_.]/g} - forceLowerCase - /> - ); - const showEdit = () => { - if (mode === NEW_MODE) { - return null; - } - return ( -
- } - /> - } - /> - } - /> - - - - - - -
- ); - }; + const handleSubmit = ({ submitType, ...values }) => onSubmit(values, submitType); - const showProfileType = ( - mode !== EDIT_MODE ? - (} - name="profileType" - validate={required} - />) : null - ); + const handleCancelClick = ({ dirty }) => { + if (dirty) { + onCancel(); + } else { + onDiscard(); + } + }; - return ( -
- - -
- - {showUsername} - } - placeholder={intl.formatMessage(msgs.password)} - validate={[ - ...(mode === NEW_MODE ? [required] : []), - ...(password ? [userFormText, minLength8, maxLength20] : []), - ]} - /> - } - placeholder={intl.formatMessage(msgs.passwordConfirm)} - validate={[ - ...(mode === NEW_MODE ? [required] : []), - ...(password ? [matchPassword] : []), - ]} - /> - {/* Insert user info and reset button on EDIT */} - {showEdit()} - {showProfileType} - - - + const handleModalSave = ({ submitForm }) => { + onModalSave(); + submitForm(); + }; + + return ( + + {formik => ( + + + +
+ + } + placeholder={intl.formatMessage(msgs.username)} + disabled={editing} + /> + } + placeholder={intl.formatMessage(msgs.password)} + /> + } + placeholder={intl.formatMessage(msgs.passwordConfirm)} + /> + {editing ? ( +
+ } + /> + } + /> + } + /> + } + /> +
+ ) : ( } + options={profileTypes} + defaultOptionId="form.select.chooseOne" /> - - -
- -
-
- - - - - { - mode !== EDIT_MODE && ( + )} + } + trueValue="active" + falseValue="inactive" + /> +
+ +
+ + + handleModalSave(formik)} + onDiscard={onDiscard} + /> + + {!editing && ( - ) - } - - - -
- ); - } -} + )} + + + + + )} + + ); +}; -UserFormBody.propTypes = { +UserForm.propTypes = { intl: intlShape.isRequired, - onWillMount: PropTypes.func, - handleSubmit: PropTypes.func.isRequired, - onSubmit: PropTypes.func.isRequired, - invalid: PropTypes.bool, - submitting: PropTypes.bool, - mode: PropTypes.string, + initialValues: PropTypes.shape({ + username: PropTypes.string, + password: PropTypes.string, + passwordConfirm: PropTypes.string, + profileType: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + status: PropTypes.string, + registration: PropTypes.string, + lastLogin: PropTypes.string, + lastPasswordChange: PropTypes.string, + reset: PropTypes.bool, + }), profileTypes: PropTypes.arrayOf(PropTypes.shape({ - value: PropTypes.string, - text: PropTypes.string, + value: PropTypes.string.isRequired, + text: PropTypes.string.isRequired, })), - password: PropTypes.string, - dirty: PropTypes.bool, - onSave: PropTypes.func.isRequired, - onDiscard: PropTypes.func.isRequired, + onMount: PropTypes.func, + onSubmit: PropTypes.func.isRequired, onCancel: PropTypes.func.isRequired, + onDiscard: PropTypes.func.isRequired, + onModalSave: PropTypes.func.isRequired, + editing: PropTypes.bool, }; -UserFormBody.defaultProps = { - invalid: false, - submitting: false, - mode: NEW_MODE, - onWillMount: null, +UserForm.defaultProps = { + initialValues: { + username: '', + password: '', + passwordConfirm: '', + profileType: '', + status: 'inactive', + }, profileTypes: [], - password: '', - dirty: false, + onMount: () => {}, + editing: false, }; -const UserForm = reduxForm({ - form: 'user', -})(UserFormBody); - export default injectIntl(UserForm); diff --git a/src/ui/users/edit/EditFormContainer.js b/src/ui/users/edit/EditFormContainer.js index 9ecf17401..a44fb43e7 100644 --- a/src/ui/users/edit/EditFormContainer.js +++ b/src/ui/users/edit/EditFormContainer.js @@ -1,34 +1,68 @@ -import { connect } from 'react-redux'; -import { withRouter } from 'react-router-dom'; -import { formValueSelector, submit } from 'redux-form'; +import React, { useCallback, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory, useParams } from 'react-router-dom'; import { routeConverter } from '@entando/utils'; -import { fetchUserForm, sendPutUser } from 'state/users/actions'; +import { fetchCurrentPageUserDetail, sendPutUser } from 'state/users/actions'; import { setVisibleModal } from 'state/modal/actions'; import { ConfirmCancelModalID } from 'ui/common/cancel-modal/ConfirmCancelModal'; import { ROUTE_USER_LIST } from 'app-init/router'; import UserForm from 'ui/users/common/UserForm'; +import { getSelectedUser } from 'state/users/selectors'; -const EDIT_MODE = 'edit'; - -export const mapStateToProps = (state, { match: { params } }) => ({ - mode: EDIT_MODE, - username: params.username, - password: formValueSelector('user')(state, 'password'), -}); - -export const mapDispatchToProps = (dispatch, { history }) => ({ - onWillMount: ({ username }) => { dispatch(fetchUserForm(username)); }, - onSubmit: (user) => { - const editUser = { ...user, profileType: (user.profileType || {}).typeCode || '' }; - dispatch(sendPutUser(editUser)); - }, - onSave: () => { dispatch(setVisibleModal('')); dispatch(submit('user')); }, - onCancel: () => dispatch(setVisibleModal(ConfirmCancelModalID)), - onDiscard: () => { dispatch(setVisibleModal('')); history.push(routeConverter(ROUTE_USER_LIST)); }, -}); - - -export default withRouter(connect(mapStateToProps, mapDispatchToProps, null, { - pure: false, -})(UserForm)); + +const EditFormContainer = () => { + const dispatch = useDispatch(); + const history = useHistory(); + const { username } = useParams(); + const selectedUser = useSelector(getSelectedUser); + + const initialValues = useMemo(() => ({ + ...selectedUser, + username, + password: '', + passwordConfirm: '', + reset: false, + }), [selectedUser, username]); + + const handleMount = useCallback(() => { + dispatch(fetchCurrentPageUserDetail(username)); + }, [dispatch, username]); + + const handleSubmit = useCallback((user) => { + const updatedUser = { + ...user, + profileType: (user.profileType || {}).typeCode || '', + password: user.password || undefined, + passwordConfirm: user.passwordConfirm || undefined, + }; + dispatch(sendPutUser(updatedUser)); + }, [dispatch]); + + const handleCancel = useCallback(() => { + dispatch(setVisibleModal(ConfirmCancelModalID)); + }, [dispatch]); + + const handleDiscard = useCallback(() => { + dispatch(setVisibleModal('')); + history.push(routeConverter(ROUTE_USER_LIST)); + }, [dispatch, history]); + + const handleModalSave = useCallback(() => { + dispatch(setVisibleModal('')); + }, [dispatch]); + + return ( + + ); +}; + +export default EditFormContainer; diff --git a/test/ui/users/add/AddFormContainer.test.js b/test/ui/users/add/AddFormContainer.test.js index db06c24e4..5546c1838 100644 --- a/test/ui/users/add/AddFormContainer.test.js +++ b/test/ui/users/add/AddFormContainer.test.js @@ -1,43 +1,123 @@ -import 'test/enzyme-init'; +import React from 'react'; +import * as reactRedux from 'react-redux'; + +import { renderWithIntlRouterState } from 'test/testUtils'; +import AddFormContainer from 'ui/users/add/AddFormContainer'; +import UserForm from 'ui/users/common/UserForm'; import { PROFILE_TYPES_NORMALIZED, PROFILE_TYPES_OPTIONS } from 'test/mocks/profileTypes'; -import { mapStateToProps, mapDispatchToProps } from 'ui/users/add/AddFormContainer'; -import { sendPostUser } from 'state/users/actions'; -import { fetchProfileTypes } from 'state/profile-types/actions'; -const dispatchMock = jest.fn(); +jest.unmock('react-redux'); + +const useDispatchSpy = jest.spyOn(reactRedux, 'useDispatch'); +const mockDispatch = jest.fn(); +useDispatchSpy.mockReturnValue(mockDispatch); + +const mockHistoryPush = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: mockHistoryPush, + }), +})); jest.mock('state/users/actions', () => ({ - sendPostUser: jest.fn(), + sendPostUser: jest.fn(payload => ({ type: 'sendPostUser_test', payload })), })); jest.mock('state/profile-types/actions', () => ({ - fetchProfileTypes: jest.fn(), + fetchProfileTypes: jest.fn(() => ({ type: 'fetchProfileTypes_test' })), })); +jest.mock('state/modal/actions', () => ({ + setVisibleModal: jest.fn(payload => ({ type: 'setVisibleModal_test', payload })), +})); + +jest.mock('ui/users/common/UserForm', () => jest.fn(mockProps => ( +
+ + + + + +
+))); + +const setupAddFormContainer = () => { + const state = { + ...PROFILE_TYPES_NORMALIZED, + }; + const utils = renderWithIntlRouterState(, { state }); + const simulateMount = () => utils.getByText('onMount').click(); + const simulateSubmit = () => utils.getByText('onSubmit').click(); + const simulateCancel = () => utils.getByText('onCancel').click(); + const simulateDiscard = () => utils.getByText('onDiscard').click(); + const simulateModalSave = () => utils.getByText('onModalSave').click(); + + return { + ...utils, + simulateMount, + simulateSubmit, + simulateCancel, + simulateDiscard, + simulateModalSave, + }; +}; + describe('AddFormContainer', () => { - describe('mapDispatchToProps', () => { - let result; - beforeEach(() => { - result = mapDispatchToProps(dispatchMock, {}); - }); - it('verify that onSubmit is defined by mapDispatchToProps', () => { - expect(result).toHaveProperty('onSubmit'); - result.onSubmit({}); - expect(sendPostUser).toHaveBeenCalled(); - }); - - it('verify that onWillMount is defined by mapDispatchToProps', () => { - expect(result).toHaveProperty('onWillMount'); - result.onWillMount(); - expect(fetchProfileTypes).toHaveBeenCalled(); - }); + afterEach(() => { + mockDispatch.mockClear(); + }); + + it('passes profileTypes to UserForm with the correct value', () => { + setupAddFormContainer(); + + expect(UserForm) + .toHaveBeenCalledWith(expect.objectContaining({ profileTypes: PROFILE_TYPES_OPTIONS }), {}); + }); + + it('fetches relevant data when UserForm mounts', () => { + const { simulateMount } = setupAddFormContainer(); + + simulateMount(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'fetchProfileTypes_test' }); + }); + + it('calls the correct user action when UserForm is submitted', () => { + const { simulateSubmit } = setupAddFormContainer(); + + simulateSubmit(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'sendPostUser_test', payload: 'testValue' }); }); - describe('mapStateToProps', () => { - it('verify that profileTypes prop is defined and properly valued', () => { - const props = mapStateToProps(PROFILE_TYPES_NORMALIZED); - expect(props.profileTypes).toBeDefined(); - expect(props.profileTypes).toEqual(PROFILE_TYPES_OPTIONS); - }); + it('calls the correct modal action when UserForm is cancelled', () => { + const { simulateCancel } = setupAddFormContainer(); + + simulateCancel(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'setVisibleModal_test', payload: 'ConfirmCancelModal' }); + }); + + it('calls the correct modal and history actions when UserForm is discarded', () => { + const { simulateDiscard } = setupAddFormContainer(); + + simulateDiscard(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'setVisibleModal_test', payload: '' }); + expect(mockHistoryPush).toHaveBeenCalledWith('/user'); + }); + + it('calls the correct modal action when UserForm modal is saved', () => { + const { simulateModalSave } = setupAddFormContainer(); + + simulateModalSave(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'setVisibleModal_test', payload: '' }); }); }); diff --git a/test/ui/users/common/UserForm.test.js b/test/ui/users/common/UserForm.test.js index d547cc28d..67a37f45a 100644 --- a/test/ui/users/common/UserForm.test.js +++ b/test/ui/users/common/UserForm.test.js @@ -1,210 +1,375 @@ import React from 'react'; -import 'test/enzyme-init'; -import { shallow } from 'enzyme'; -import { UserFormBody, renderStaticField } from 'ui/users/common/UserForm'; -import { runValidators, mockIntl } from 'test/legacyTestUtils'; +import { screen, within, waitFor, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom/extend-expect'; -const handleSubmit = jest.fn(); -const onSubmit = jest.fn(); -const onWillMount = jest.fn(); -const EDIT_MODE = 'edit'; +import { renderWithIntl } from 'test/testUtils'; +import UserForm from 'ui/users/common/UserForm'; +import ConfirmCancelModalContainer from 'ui/common/cancel-modal/ConfirmCancelModalContainer'; -describe('UserForm', () => { - let userForm; - let submitting; - let invalid; - let profileTypes; - - beforeEach(() => { - submitting = false; - invalid = false; - }); - const buildUserForm = (mode) => { - const props = { - profileTypes, - submitting, - invalid, - handleSubmit, - onWillMount, - onSubmit, - mode, - msgs: { - username: { id: 'username', defaultMessage: 'username' }, - }, - password: 'test', - intl: mockIntl, - }; +jest.mock('ui/common/cancel-modal/ConfirmCancelModalContainer', () => jest.fn(() => null)); + +const setupUserForm = (initialValues, editing = false) => { + const mockHandleMount = jest.fn(); + const mockHandleSubmit = jest.fn(); + const mockHandleCancel = jest.fn(); + const mockHandleDiscard = jest.fn(); + const mockHandleModalSave = jest.fn(); + const profileTypes = [{ value: 'PFL', text: 'Default' }]; + const utils = renderWithIntl(( + + )); + const formView = within(screen.getByRole('form')); + + const getUsernameTextInput = () => formView.getByRole('textbox', { name: /username/i }); + const getPasswordTextInput = () => formView.getByPlaceholderText(/^password$/i); + const getPasswordConfirmTextInput = () => formView.getByLabelText(/confirm password/i); + const getProfileTypeSelectInput = () => formView.getByRole('combobox', { name: /profile type/i }); + const getStatusSwitchInput = () => formView.getByLabelText(/status/i).children[0]; + const getSaveButton = () => formView.getByRole('button', { name: /^save$/i }); + const getSaveAndEditProfileButton = () => formView.getByRole('button', { name: /save and edit profile/i }); + const getCancelButton = () => formView.getByRole('button', { name: /cancel/i }); + const getErrorMessage = () => formView.getByRole('alert'); + const queryProfileTypeSelectInput = () => formView.queryByRole('combobox', { name: /profile type/i }); + const queryResetSwitchInput = () => formView.queryByLabelText(/reset/i).children[0]; + const queryErrorMessage = () => formView.queryByRole('alert'); + const querySaveAndEditProfileButton = () => formView.queryByRole('button', { name: /save and edit profile/i }); + + const typeUsername = + value => userEvent.type(getUsernameTextInput(), value); + const typePassword = + value => userEvent.type(getPasswordTextInput(), value); + const typePasswordConfirm = + value => userEvent.type(getPasswordConfirmTextInput(), value); + const selectProfileType = + value => userEvent.selectOptions(getProfileTypeSelectInput(), value); + const clearPassword = () => userEvent.clear(getPasswordTextInput()); + + const toggleStatus = () => userEvent.click(getStatusSwitchInput()); - return shallow(); + const clickSave = () => userEvent.click(getSaveButton()); + const clickSaveAndEditProfile = () => userEvent.click(getSaveAndEditProfileButton()); + const clickCancel = () => userEvent.click(getCancelButton()); + + return { + ...utils, + mockHandleMount, + mockHandleSubmit, + mockHandleCancel, + mockHandleDiscard, + getUsernameTextInput, + getPasswordTextInput, + getPasswordConfirmTextInput, + getProfileTypeSelectInput, + getStatusSwitchInput, + getSaveButton, + getSaveAndEditProfileButton, + getErrorMessage, + queryProfileTypeSelectInput, + queryResetSwitchInput, + queryErrorMessage, + querySaveAndEditProfileButton, + typeUsername, + typePassword, + typePasswordConfirm, + selectProfileType, + toggleStatus, + clearPassword, + clickSave, + clickSaveAndEditProfile, + clickCancel, }; +}; + +const setupUserFormAndFillValues = ({ + username, password, passwordConfirm, profileType, +}) => { + const utils = setupUserForm(); + utils.typeUsername(username); + utils.typePassword(password); + utils.typePasswordConfirm(passwordConfirm); + utils.selectProfileType(profileType); + + fireEvent.blur(utils.getProfileTypeSelectInput()); - it('root component renders without crashing', () => { - userForm = buildUserForm(); - expect(userForm.exists()).toEqual(true); + return utils; +}; + +describe('UserForm', () => { + const user = { + username: 'testuser', + password: 'testpass', + passwordConfirm: 'testpass', + profileType: 'PFL', + status: 'inactive', + }; + + it('calls onMount when form has been rendered', () => { + const { mockHandleMount } = setupUserForm(); + + expect(mockHandleMount).toHaveBeenCalledTimes(1); }); - it('root component render minus icon if staticField value is null', () => { - const input = { name: 'registration', value: '' }; - const name = 'registration'; - const label = ; - const element = shallow(renderStaticField({ input, label, name })); - expect(element.find('.icon')).toExist(); - expect(element.find('.icon').hasClass('fa-minus')).toBe(true); + it('calls onSubmit with all the fields when save is clicked', async () => { + const { mockHandleSubmit, clickSave } = setupUserFormAndFillValues(user); + + clickSave(); + + await waitFor(() => { + expect(mockHandleSubmit).toHaveBeenCalledTimes(1); + expect(mockHandleSubmit).toHaveBeenCalledWith(user, 'save'); + }); }); - it('root component renders registration Field if its value is not null', () => { - const input = { name: 'registration', value: 'registration' }; - const name = 'registration'; - const label = ; - const element = renderStaticField({ input, label, name }); - const registration = shallow(element); - expect(registration.find('.form-group').exists()).toBe(true); + it('calls onSubmit with all the fields and a saveAndEditProfile submit type when save and edit profile button is clicked', async () => { + const { clickSaveAndEditProfile, mockHandleSubmit } = setupUserFormAndFillValues(user); + + clickSaveAndEditProfile(); + + await waitFor(() => { + expect(mockHandleSubmit).toHaveBeenCalledTimes(1); + expect(mockHandleSubmit).toHaveBeenCalledWith(user, 'saveAndEditProfile'); + }); }); - describe('test with mode = new', () => { - beforeEach(() => { - userForm = buildUserForm(); + it('calls onDiscard when cancel is clicked and form is not dirty', async () => { + const { mockHandleDiscard, clickCancel } = setupUserForm(); + + clickCancel(); + + await waitFor(() => { + expect(mockHandleDiscard).toHaveBeenCalledTimes(1); }); + }); + + it('calls onCancel when cancel is clicked and form is dirty', async () => { + const { mockHandleCancel, clickCancel } = setupUserFormAndFillValues(user); - it('root component renders username field', () => { - const username = userForm.find('[name="username"]'); - expect(username.exists()).toEqual(true); + clickCancel(); + + await waitFor(() => { + expect(mockHandleCancel).toHaveBeenCalledTimes(1); }); + }); - describe('username validation', () => { - let validatorArray; - beforeEach(() => { - validatorArray = userForm.find('[name="username"]').prop('validate'); - }); + it('renders ConfirmCancelModalContainer with the correct props', () => { + setupUserForm(); - it('is required', () => { - expect(runValidators(validatorArray, '').props.id).toBe('validateForm.required'); - }); + expect(ConfirmCancelModalContainer).toHaveBeenCalledWith(expect.objectContaining({ + contentText: expect.stringMatching(/save/i), + invalid: expect.any(Boolean), + submitting: expect.any(Boolean), + onSave: expect.any(Function), + onDiscard: expect.any(Function), + 'data-testid': expect.any(String), + }), {}); + }); - it('is invalid if input is shorter than 4 chars', () => { - expect(runValidators(validatorArray, '123').props.id).toBe('validateForm.minLength'); - expect(runValidators(validatorArray, '1234')).toBeFalsy(); - }); + it('disables save buttons and shows an error message when username is not provided', async () => { + const { getSaveButton, getSaveAndEditProfileButton, getErrorMessage } = setupUserFormAndFillValues({ ...user, username: '' }); - it('is invalid if input is longer than 80 chars', () => { - expect(runValidators(validatorArray, '123456789abcdefghijk')).toBeFalsy(); - expect(runValidators(validatorArray, '123456789abcdefghijk123456789abcdefghijk123456789abcdefghijk123456789abcdefghijkl').props.id) - .toBe('validateForm.maxLength'); - }); + await waitFor(() => { + expect(getSaveButton()).toHaveAttribute('disabled'); + expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); + expect(getErrorMessage()).toHaveTextContent(/field required/i); }); + }); + + it('disables save buttons and shows an error message when password is not provided', async () => { + const { + clearPassword, getPasswordTextInput, getSaveButton, + getSaveAndEditProfileButton, getErrorMessage, + } = setupUserForm({ ...user, passwordConfirm: '' }); + + clearPassword(); + fireEvent.blur(getPasswordTextInput()); - it('root component renders status field', () => { - const status = userForm.find('[name="status"]'); - expect(status.exists()).toEqual(true); + await waitFor(() => { + expect(getSaveButton()).toHaveAttribute('disabled'); + expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); + expect(getErrorMessage()).toHaveTextContent(/field required/i); }); + }); + + it('disables save buttons and shows an error message when confirm password is not provided', async () => { + const { getSaveButton, getSaveAndEditProfileButton, getErrorMessage } = setupUserFormAndFillValues({ ...user, passwordConfirm: '' }); - it('root component renders profileType field', () => { - const status = userForm.find('[name="profileType"]'); - expect(status.exists()).toEqual(true); + await waitFor(() => { + expect(getSaveButton()).toHaveAttribute('disabled'); + expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); + expect(getErrorMessage()).toHaveTextContent(/field required/i); }); + }); - describe('password field', () => { - let passwordField; - beforeEach(() => { - passwordField = userForm.find('[name="password"]'); - }); + it('disables save buttons and shows an error message when profile type is not provided', async () => { + const { getSaveButton, getSaveAndEditProfileButton, getErrorMessage } = setupUserFormAndFillValues({ ...user, profileType: '' }); - it('is rendered', () => { - expect(passwordField).toExist(); - }); + await waitFor(() => { + expect(getSaveButton()).toHaveAttribute('disabled'); + expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); + expect(getErrorMessage()).toHaveTextContent(/field required/i); + }); + }); - describe('validation', () => { - let validatorArray; - beforeEach(() => { - validatorArray = passwordField.prop('validate'); - }); - - it('is required', () => { - expect(runValidators(validatorArray, '').props.id).toBe('validateForm.required'); - }); - - it('is invalid if input is shorter than 8 chars', () => { - expect(runValidators(validatorArray, '1234567').props.id).toBe('validateForm.minLength'); - expect(runValidators(validatorArray, '12345678')).toBeFalsy(); - }); - - it('is invalid if input is longer than 20 chars', () => { - expect(runValidators(validatorArray, '123456789abcdefghijk')).toBeFalsy(); - expect(runValidators(validatorArray, '123456789abcdefghijkl').props.id) - .toBe('validateForm.maxLength'); - }); - }); + it('disables save buttons and shows an error message when username is too short', async () => { + const { getSaveButton, getSaveAndEditProfileButton, getErrorMessage } = setupUserFormAndFillValues({ ...user, username: 'abc' }); + + await waitFor(() => { + expect(getSaveButton()).toHaveAttribute('disabled'); + expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); + expect(getErrorMessage()).toHaveTextContent(/must be 4 characters or more/i); }); + }); - describe('passwordConfirm field', () => { - const ALL_VALUES = { password: '12345678' }; - let passwordConfirmField; - beforeEach(() => { - passwordConfirmField = userForm.find('[name="passwordConfirm"]'); + it('disables save buttons and shows an error message when username is too long', async () => { + const { getSaveButton, getSaveAndEditProfileButton, getErrorMessage } = + setupUserFormAndFillValues({ + ...user, username: 'thisisastringthathasmorethan80characters_thisisastringthathasmorethan80characters', }); - it('is rendered', () => { - expect(passwordConfirmField).toExist(); - }); + await waitFor(() => { + expect(getSaveButton()).toHaveAttribute('disabled'); + expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); + expect(getErrorMessage()).toHaveTextContent(/must be 80 characters or less/i); + }); + }); - describe('validation', () => { - let validatorArray; - beforeEach(() => { - validatorArray = passwordConfirmField.prop('validate'); - }); - - it('is required', () => { - expect(runValidators(validatorArray, '').props.id).toBe('validateForm.required'); - }); - - it('is invalid if input does not match the password', () => { - expect(runValidators(validatorArray, 'abcdefgh', ALL_VALUES).props.id) - .toBe('validateForm.passwordNotMatch'); - expect(runValidators(validatorArray, ALL_VALUES.password, ALL_VALUES)).toBeFalsy(); - }); - }); + it('disables save buttons and shows an error message when username contains invalid characters', async () => { + const { getSaveButton, getSaveAndEditProfileButton, getErrorMessage } = setupUserFormAndFillValues({ ...user, username: '-invalid-' }); + + await waitFor(() => { + expect(getSaveButton()).toHaveAttribute('disabled'); + expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); + expect(getErrorMessage()).toHaveTextContent(/contains invalid characters/i); + }); + }); + + it('disables save buttons and shows an error message when password is too short', async () => { + const shortPassword = 'abc'; + const { + getSaveButton, getSaveAndEditProfileButton, getErrorMessage, + } = setupUserFormAndFillValues({ + ...user, password: shortPassword, passwordConfirm: shortPassword, + }); + + await waitFor(() => { + expect(getSaveButton()).toHaveAttribute('disabled'); + expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); + expect(getErrorMessage()).toHaveTextContent(/must be 8 characters or more/i); + }); + }); + + it('disables save buttons and shows an error message when password is too long', async () => { + const longPassword = 'stringwithover20chars'; + const { + getSaveButton, getSaveAndEditProfileButton, getErrorMessage, + } = setupUserFormAndFillValues({ + ...user, password: longPassword, passwordConfirm: longPassword, + }); + + await waitFor(() => { + expect(getSaveButton()).toHaveAttribute('disabled'); + expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); + expect(getErrorMessage()).toHaveTextContent(/must be 20 characters or less/i); }); }); - describe('test with mode = edit', () => { - beforeEach(() => { - submitting = false; - invalid = false; - userForm = buildUserForm(EDIT_MODE); + it('disables save buttons and shows an error message when password contains invalid characters', async () => { + const invalidPassword = '-invalid-'; + const { + getSaveButton, getSaveAndEditProfileButton, getErrorMessage, + } = setupUserFormAndFillValues({ + ...user, password: invalidPassword, passwordConfirm: invalidPassword, }); - it('root component has class UserForm__content-edit', () => { - expect(userForm.find('.UserForm__content-edit').exists()).toBe(true); + + await waitFor(() => { + expect(getSaveButton()).toHaveAttribute('disabled'); + expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); + expect(getErrorMessage()).toHaveTextContent(/contains invalid characters/i); }); + }); - it('root component contains edit fields', () => { - expect(userForm.find('.UserForm__content-edit').find('Field')).toHaveLength(4); - expect(userForm.find('[name="registration"]').exists()).toBe(true); - expect(userForm.find('[name="lastLogin"]').exists()).toBe(true); - expect(userForm.find('[name="lastPasswordChange"]').exists()).toBe(true); - expect(userForm.find('[name="reset"]').exists()).toBe(true); + it('disables save buttons and shows an error message when password doesn\'t match confirm password', async () => { + const { getSaveButton, getSaveAndEditProfileButton, getErrorMessage } = + setupUserFormAndFillValues({ + ...user, password: 'password', passwordConfirm: 'differentpass', + }); + + await waitFor(() => { + expect(getSaveButton()).toHaveAttribute('disabled'); + expect(getSaveAndEditProfileButton()).toHaveAttribute('disabled'); + expect(getErrorMessage()).toHaveTextContent(/value doesn't match with password/i); }); }); - describe('test buttons and handlers', () => { - it('disables submit button while submitting', () => { - submitting = true; - userForm = buildUserForm(); - const submitButton = userForm.find('Button').first(); - expect(submitButton.prop('disabled')).toEqual(true); + describe('When editing', () => { + const detailedUser = { + ...user, + lastLogin: '2021-11-11 00:00:00', + lastPasswordChange: '2021-11-12 00:00:00', + registration: '2021-11-10 00:00:00', + reset: false, + }; + + it('calls onSubmit with all the fields when save is clicked', async () => { + const { mockHandleSubmit, clickSave } = setupUserForm(detailedUser, true); + + clickSave(); + + await waitFor(() => { + expect(mockHandleSubmit).toHaveBeenCalledTimes(1); + expect(mockHandleSubmit).toHaveBeenCalledWith(detailedUser, 'save'); + }); + }); + + it('disables username', () => { + const { getUsernameTextInput } = setupUserForm(detailedUser, true); + + expect(getUsernameTextInput()).toHaveAttribute('disabled'); }); - it('disables submit button if form is invalid', () => { - invalid = true; - userForm = buildUserForm(); - const submitButton = userForm.find('Button').first(); - expect(submitButton.prop('disabled')).toEqual(true); + it('doesn\'t show profile type field', () => { + const { queryProfileTypeSelectInput } = setupUserForm(detailedUser, true); + + expect(queryProfileTypeSelectInput()).not.toBeInTheDocument(); + }); + + it('shows the reset switch and static fields -- registration, last login, last password change', () => { + const { queryResetSwitchInput } = setupUserForm(detailedUser, true); + + expect(screen.getByText(detailedUser.lastLogin)).toBeInTheDocument(); + expect(screen.getByText(detailedUser.lastPasswordChange)).toBeInTheDocument(); + expect(screen.getByText(detailedUser.registration)).toBeInTheDocument(); + expect(queryResetSwitchInput()).toBeInTheDocument(); + }); + + it('doesn\'t show save and edit profile button', () => { + const { querySaveAndEditProfileButton } = setupUserForm(detailedUser, true); + + expect(querySaveAndEditProfileButton()).not.toBeInTheDocument(); }); - it('on form submit calls handleSubmit', () => { - userForm = buildUserForm(); - const preventDefault = jest.fn(); - userForm.find('form').simulate('submit', { preventDefault }); - expect(handleSubmit).toHaveBeenCalled(); + it('doesn\'t disable save button and doesn\'t show an error message when password is empty', async () => { + const { + clearPassword, getPasswordTextInput, queryErrorMessage, getSaveButton, + } = setupUserForm({ ...detailedUser, passwordConfirm: '' }, true); + + clearPassword(''); + fireEvent.blur(getPasswordTextInput()); + + await waitFor(() => { + expect(getSaveButton()).not.toHaveAttribute('disabled'); + expect(queryErrorMessage()).not.toBeInTheDocument(); + }); }); }); }); diff --git a/test/ui/users/edit/EditFormContainer.test.js b/test/ui/users/edit/EditFormContainer.test.js index a3433428c..ff0e3c617 100644 --- a/test/ui/users/edit/EditFormContainer.test.js +++ b/test/ui/users/edit/EditFormContainer.test.js @@ -1,46 +1,130 @@ -import { mapDispatchToProps, mapStateToProps } from 'ui/users/edit/EditFormContainer'; +import React from 'react'; +import * as reactRedux from 'react-redux'; -const ownProps = { - match: { - params: { - mode: 'edit', - username: 'test', +import { renderWithIntlRouterState } from 'test/testUtils'; +import EditFormContainer from 'ui/users/edit/EditFormContainer'; +import UserForm from 'ui/users/common/UserForm'; + +jest.unmock('react-redux'); + +const useDispatchSpy = jest.spyOn(reactRedux, 'useDispatch'); +const mockDispatch = jest.fn(); +useDispatchSpy.mockReturnValue(mockDispatch); + +const mockHistoryPush = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: mockHistoryPush, + }), +})); + +jest.mock('state/users/actions', () => ({ + sendPutUser: jest.fn(payload => ({ type: 'sendPutUser_test', payload })), + fetchCurrentPageUserDetail: jest.fn(payload => ({ type: 'fetchCurrentPageUserDetail_test', payload })), +})); + +jest.mock('state/profile-types/actions', () => ({ + fetchProfileTypes: jest.fn(() => ({ type: 'fetchProfileTypes_test' })), +})); + +jest.mock('state/modal/actions', () => ({ + setVisibleModal: jest.fn(payload => ({ type: 'setVisibleModal_test', payload })), +})); + +jest.mock('ui/users/common/UserForm', () => jest.fn(mockProps => ( +
+ + + + + +
+))); + +const setupEditFormContainer = () => { + const state = { + users: { + selected: {}, }, - }, -}; + }; + const utils = renderWithIntlRouterState(, { + state, initialRoute: '/user/edit/testuser', path: '/user/edit/:username', + }); + const simulateMount = () => utils.getByText('onMount').click(); + const simulateSubmit = () => utils.getByText('onSubmit').click(); + const simulateCancel = () => utils.getByText('onCancel').click(); + const simulateDiscard = () => utils.getByText('onDiscard').click(); + const simulateModalSave = () => utils.getByText('onModalSave').click(); -const dispatchProps = { - history: {}, + return { + ...utils, + simulateMount, + simulateSubmit, + simulateCancel, + simulateDiscard, + simulateModalSave, + }; }; -describe('EditFormContainer', () => { - const dispatchMock = jest.fn(); - let props; - beforeEach(() => { - jest.clearAllMocks(); - props = mapDispatchToProps(dispatchMock, dispatchProps); - }); - - describe('mapDispatchToProps', () => { - it('should map the correct function properties', () => { - expect(props.onWillMount).toBeDefined(); - expect(props.onSubmit).toBeDefined(); - }); - it('verify thant onWillMount is called', () => { - props.onWillMount('username'); - expect(dispatchMock).toHaveBeenCalled(); - }); - it('verify thant onSubmit is called', () => { - props.onSubmit({}); - expect(dispatchMock).toHaveBeenCalled(); - }); - }); - - describe('mapStateToProps', () => { - it('verify that username prop is defined and properly valued', () => { - props = mapStateToProps({}, ownProps); - expect(props.mode).toEqual('edit'); - expect(props.username).toEqual('test'); - }); +describe('AddFormContainer', () => { + afterEach(() => { + mockDispatch.mockClear(); + }); + + it('passes initialValues to UserForm with the correct values', () => { + setupEditFormContainer(); + + expect(UserForm).toHaveBeenCalledWith(expect.objectContaining({ + initialValues: { + username: 'testuser', password: '', passwordConfirm: '', reset: false, + }, + }), {}); + }); + + it('fetches user data when UserForm mounts', () => { + const { simulateMount } = setupEditFormContainer(); + + simulateMount(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'fetchCurrentPageUserDetail_test', payload: 'testuser' }); + }); + + it('calls the correct user action when UserForm is submitted', () => { + const { simulateSubmit } = setupEditFormContainer(); + + simulateSubmit(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'sendPutUser_test', payload: expect.any(Object) }); + }); + + it('calls the correct modal action when UserForm is cancelled', () => { + const { simulateCancel } = setupEditFormContainer(); + + simulateCancel(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'setVisibleModal_test', payload: 'ConfirmCancelModal' }); + }); + + it('calls the correct modal and history actions when UserForm is discarded', () => { + const { simulateDiscard } = setupEditFormContainer(); + + simulateDiscard(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'setVisibleModal_test', payload: '' }); + expect(mockHistoryPush).toHaveBeenCalledWith('/user'); + }); + + it('calls the correct modal action when UserForm modal is saved', () => { + const { simulateModalSave } = setupEditFormContainer(); + + simulateModalSave(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'setVisibleModal_test', payload: '' }); }); }); From 8d9ed2ebacbf063de17483d0c86fe402b99742b8 Mon Sep 17 00:00:00 2001 From: Jeff Go Date: Fri, 21 Jan 2022 20:25:00 +0800 Subject: [PATCH 012/122] ENG-2821 amend toast notifications when adding a new user authorization --- src/locales/en.js | 2 ++ src/locales/it.js | 2 ++ src/locales/pt.js | 2 ++ src/state/users/actions.js | 5 +++++ src/ui/users/authority/UserAuthorityTable.js | 3 +++ src/ui/users/common/UserAuthorityPageForm.js | 4 +++- src/ui/users/common/UserAuthorityPageFormContainer.js | 7 +++++++ test/ui/fragments/clone/CloneFormContainer.test.js | 1 + 8 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/locales/en.js b/src/locales/en.js index 955e8c4fb..115dabe66 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -603,6 +603,8 @@ export default { 'user.authority.new': 'New authorizations', 'user.authority.addNew': 'Add new Authorization', 'user.authority.noAuthYet': 'No authorizations yet', + 'user.authority.added': 'New authorization ({groupname}, {rolename}) has been added', + 'user.authority.success': 'Authorization has been updated', 'user.username': 'Username', 'user.password': 'Password', 'user.passwordConfirm': 'Confirm password', diff --git a/src/locales/it.js b/src/locales/it.js index 71b0830e7..9ac1bf73b 100644 --- a/src/locales/it.js +++ b/src/locales/it.js @@ -603,6 +603,8 @@ export default { 'user.authority.new': 'Nuove autorizzazioni', 'user.authority.addNew': 'Aggiungi nuova autorizzazione', 'user.authority.noAuthYet': 'Non ci sono autorizzazione presenti', + 'user.authority.added': 'È stata aggiunta una nuova autorizzazione ({groupname}, {rolename})', + 'user.authority.success': 'L\'autorizzazione è stata aggiornata', 'user.username': 'Username', 'user.password': 'Password', 'user.passwordConfirm': 'Conferma password', diff --git a/src/locales/pt.js b/src/locales/pt.js index 02a2171ac..869372dfa 100644 --- a/src/locales/pt.js +++ b/src/locales/pt.js @@ -574,6 +574,8 @@ export default { 'user.authority.new': 'Novas autorizações', 'user.authority.addNew': 'Adicionar nova autorização', 'user.authority.noAuthYet': 'Nenhuma autorização ainda', + 'user.authority.added': 'Nova autorização ({groupname}, {rolename}) foi adicionada', + 'user.authority.success': 'A autorização foi atualizada', 'user.username': 'Username', 'user.password': 'Senha', 'user.passwordConfirm': 'Confirme a senha', diff --git a/src/state/users/actions.js b/src/state/users/actions.js index 6e423dcc7..62aa58ce4 100644 --- a/src/state/users/actions.js +++ b/src/state/users/actions.js @@ -242,6 +242,11 @@ export const sendPutUserAuthorities = (authorities, username) => async (dispatch const json = await response.json(); if (response.ok) { history.push(ROUTE_USER_LIST); + dispatch(addToast( + { id: 'user.authority.success' }, + TOAST_SUCCESS, + )); + dispatch(clearErrors()); } else { dispatch(addErrors(json.errors.map(e => e.message))); json.errors.forEach(err => dispatch(addToast(err.message, TOAST_ERROR))); diff --git a/src/ui/users/authority/UserAuthorityTable.js b/src/ui/users/authority/UserAuthorityTable.js index 694fa52e6..ed332d658 100644 --- a/src/ui/users/authority/UserAuthorityTable.js +++ b/src/ui/users/authority/UserAuthorityTable.js @@ -17,6 +17,7 @@ const UserAuthorityTable = ({ push, remove, groupRolesCombo, onCloseModal, intl, groups, roles, onAddNewClicked, + onNewAuthAdded, }) => { const setGroupRef = useRef(null); const setRoleRef = useRef(null); @@ -32,6 +33,7 @@ const UserAuthorityTable = ({ group: group.value || null, role: role.value || null, }); + onNewAuthAdded({ group: group.value, role: role.value }); } onCloseModal(); }; @@ -137,6 +139,7 @@ UserAuthorityTable.propTypes = { role: PropTypes.shape({ code: PropTypes.string, name: PropTypes.string }), })), onAddNewClicked: PropTypes.func.isRequired, + onNewAuthAdded: PropTypes.func.isRequired, onCloseModal: PropTypes.func.isRequired, push: PropTypes.func.isRequired, remove: PropTypes.func.isRequired, diff --git a/src/ui/users/common/UserAuthorityPageForm.js b/src/ui/users/common/UserAuthorityPageForm.js index d58ca644e..7d0d2ab30 100644 --- a/src/ui/users/common/UserAuthorityPageForm.js +++ b/src/ui/users/common/UserAuthorityPageForm.js @@ -14,7 +14,7 @@ export const UserAuthorityPageFormBody = ({ groups, roles, values, loading, groupsMap, rolesMap, onDidMount, isValid, isSubmitting: submitting, - onAddNewClicked, onCloseModal, + onAddNewClicked, onCloseModal, onNewAuthAdded, }) => { useEffect(() => { onDidMount(); @@ -40,6 +40,7 @@ export const UserAuthorityPageFormBody = ({ roles={roles} groupRolesCombo={groupRolesCombo} onAddNewClicked={onAddNewClicked} + onNewAuthAdded={onNewAuthAdded} onCloseModal={onCloseModal} /> @@ -65,6 +66,7 @@ export const UserAuthorityPageFormBody = ({ UserAuthorityPageFormBody.propTypes = { onDidMount: PropTypes.func.isRequired, onAddNewClicked: PropTypes.func.isRequired, + onNewAuthAdded: PropTypes.func.isRequired, onCloseModal: PropTypes.func.isRequired, isValid: PropTypes.bool, isSubmitting: PropTypes.bool, diff --git a/src/ui/users/common/UserAuthorityPageFormContainer.js b/src/ui/users/common/UserAuthorityPageFormContainer.js index 56c54fdee..ac8e866f9 100644 --- a/src/ui/users/common/UserAuthorityPageFormContainer.js +++ b/src/ui/users/common/UserAuthorityPageFormContainer.js @@ -1,5 +1,6 @@ import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; +import { addToast, TOAST_SUCCESS } from '@entando/messages'; import { fetchAllGroupEntries } from 'state/groups/actions'; import { getLoading } from 'state/loading/selectors'; import { getGroupsList, getGroupsMap } from 'state/groups/selectors'; @@ -41,6 +42,12 @@ export const mapDispatchToProps = (dispatch, { match: { params } }) => ({ } }, onAddNewClicked: () => dispatch(setVisibleModal('AddAuthorityModal')), + onNewAuthAdded: ({ group, role }) => { + dispatch(addToast( + { id: 'user.authority.added', values: { groupname: group, rolename: role } }, + TOAST_SUCCESS, + )); + }, onCloseModal: () => dispatch(setVisibleModal('')), }); diff --git a/test/ui/fragments/clone/CloneFormContainer.test.js b/test/ui/fragments/clone/CloneFormContainer.test.js index 83f9c243d..b23461bca 100644 --- a/test/ui/fragments/clone/CloneFormContainer.test.js +++ b/test/ui/fragments/clone/CloneFormContainer.test.js @@ -5,6 +5,7 @@ import { getFragmentSelected } from 'state/fragments/selectors'; import { mapStateToProps, mapDispatchToProps } from 'ui/fragments/clone/CloneFormContainer'; const TEST_STATE = { + initialValues: { code: '', defaultGuiCode: '', guiCode: '' }, mode: FORM_MODE_CLONE, initialValues: DEFAULT_FORM_VALUES, }; From c4b9f2eb61904c80ee9f9fcde498915e64652d82 Mon Sep 17 00:00:00 2001 From: Jeff Go Date: Mon, 24 Jan 2022 17:45:27 +0800 Subject: [PATCH 013/122] ENG-3118 integration with formic --- src/ui/labels/list/LanguageForm.js | 113 +++++++++++++++-------------- 1 file changed, 57 insertions(+), 56 deletions(-) diff --git a/src/ui/labels/list/LanguageForm.js b/src/ui/labels/list/LanguageForm.js index ee9cf9653..d0ad35f18 100644 --- a/src/ui/labels/list/LanguageForm.js +++ b/src/ui/labels/list/LanguageForm.js @@ -1,6 +1,8 @@ -import React, { Component } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; -import { Field, reduxForm } from 'redux-form'; +import { Field, Form, withFormik } from 'formik'; +import * as Yup from 'yup'; +import { formatMessageRequired } from 'helpers/formikValidations'; import { Button, Col, FormGroup, InputGroup } from 'patternfly-react'; import { FormattedMessage, defineMessages, injectIntl, intlShape } from 'react-intl'; import FormLabel from 'ui/common/form/FormLabel'; @@ -24,60 +26,50 @@ const msgs = defineMessages({ }, }); -export class LanguageFormBody extends Component { - onSubmit = (ev) => { - ev.preventDefault(); - this.props.handleSubmit(); - }; - - render() { - const { - intl, invalid, submitting, languages, - } = this.props; - - return ( -
-
- - - - - } - className="form-control LanguageForm__language-field" +const LanguageFormBody = ({ + intl, isValid, isSubmitting: submitting, languages, ...otherProps +}) => { + const invalid = !isValid; + return ( +
+ + + + + + } + className="form-control LanguageForm__language-field" + > + + {renderSelectOptions(languages)} + + + - - - - - - -
- ); - } -} - + + + +
+ +
+ + +
+ ); +}; LanguageFormBody.propTypes = { intl: intlShape.isRequired, - handleSubmit: PropTypes.func.isRequired, - invalid: PropTypes.bool, - submitting: PropTypes.bool, + isValid: PropTypes.bool, + isSubmitting: PropTypes.bool, languages: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), text: PropTypes.string, @@ -85,13 +77,22 @@ LanguageFormBody.propTypes = { }; LanguageFormBody.defaultProps = { - invalid: false, - submitting: false, + isValid: false, + isSubmitting: false, languages: [], }; -const LanguageForm = reduxForm({ - form: 'language', +const LanguageForm = withFormik({ + mapPropsToValues: () => ({ language: '' }), + validationSchema: ({ intl }) => ( + Yup.object().shape({ + language: Yup.string().required(intl.formatMessage(formatMessageRequired)), + }) + ), + handleSubmit: (values, { setSubmitting, props: { onSubmit } }) => { + onSubmit(values).then(() => setSubmitting(false)); + }, + displayName: 'languageForm', })(LanguageFormBody); export default injectIntl(LanguageForm); From 35acf12decab14ace7ede9f6a85b70d64c4b8a77 Mon Sep 17 00:00:00 2001 From: Jeff Go Date: Tue, 25 Jan 2022 13:20:51 +0800 Subject: [PATCH 014/122] ENG-3118 updated unit tests --- test/ui/labels/list/LanguageForm.test.js | 49 ++++++++++-------------- 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/test/ui/labels/list/LanguageForm.test.js b/test/ui/labels/list/LanguageForm.test.js index 4148d9cfc..62624c902 100644 --- a/test/ui/labels/list/LanguageForm.test.js +++ b/test/ui/labels/list/LanguageForm.test.js @@ -1,55 +1,46 @@ import React from 'react'; -import 'test/enzyme-init'; -import { mount } from 'enzyme'; +import '@testing-library/jest-dom/extend-expect'; +import { screen, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { mockRenderWithIntlAndStore } from 'test/legacyTestUtils'; import LanguageForm, { renderSelectOptions } from 'ui/labels/list/LanguageForm'; import { LANGUAGES_LIST } from 'test/mocks/languages'; -jest.unmock('react-redux'); -jest.unmock('redux-form'); - const languages = LANGUAGES_LIST.filter(item => !item.isActive) .map(item => ( { value: item.code, text: item.description } )); -const handleSubmit = jest.fn(); -const onWillMount = jest.fn(); +jest.unmock('react-redux'); + +const onSubmit = jest.fn(); describe('LanguageFormBody', () => { - let languageForm; - let submitting; - let invalid; - beforeEach(() => { - submitting = false; - invalid = false; jest.clearAllMocks(); }); const buildLanguageForm = () => { const props = { - onWillMount, + onSubmit, languages, - submitting, - invalid, - handleSubmit, }; - - return mount(mockRenderWithIntlAndStore(, { modal: {} })); + render(mockRenderWithIntlAndStore( + , + { modal: { visibleModal: '', info: {} } }, + )); }; describe('basic render tests', () => { beforeEach(() => { - languageForm = buildLanguageForm(); + buildLanguageForm(); }); it('root component renders without crashing', () => { - expect(languageForm.exists()).toEqual(true); + expect(screen.getByTestId('list_LanguageForm_Form')).toBeInTheDocument(); }); it('root component renders language field', () => { - const language = languageForm.find('.LanguageForm__language-field'); - expect(language.exists()).toEqual(true); + expect(screen.getByTestId('list_LanguageForm_Field')).toBeInTheDocument(); }); it('root component renders options for select input', () => { @@ -59,14 +50,16 @@ describe('LanguageFormBody', () => { }); describe('event handlers test', () => { - const preventDefault = jest.fn(); beforeEach(() => { - languageForm = buildLanguageForm(); + buildLanguageForm(); }); - it('on form submit calls handleSubmit', () => { - languageForm.find('form').simulate('submit', { preventDefault }); - expect(handleSubmit).toHaveBeenCalled(); + it('on form submit calls handleSubmit', async () => { + userEvent.selectOptions(screen.getByTestId('list_LanguageForm_Field'), 'nl'); + userEvent.click(screen.getByTestId('list_LanguageForm_Button')); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalled(); + }); }); }); }); From 411489358f67721aec859b72812924077d1e759c Mon Sep 17 00:00:00 2001 From: Jeff Go Date: Tue, 25 Jan 2022 14:15:46 +0800 Subject: [PATCH 015/122] ENG-3118 amended action id for active language list table row for cypress purposes --- src/ui/labels/list/ActiveLangTable.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ui/labels/list/ActiveLangTable.js b/src/ui/labels/list/ActiveLangTable.js index abedadeed..7947ac03b 100644 --- a/src/ui/labels/list/ActiveLangTable.js +++ b/src/ui/labels/list/ActiveLangTable.js @@ -14,6 +14,7 @@ const renderRows = (rows, onDeactivateLang, defaultLanguage) => rows.map(item => {item.name}