diff --git a/src/components/Groups/AddStudent/AddStudent.js b/src/components/Groups/AddStudent/AddStudent.js index aff66b6fe..11537705b 100644 --- a/src/components/Groups/AddStudent/AddStudent.js +++ b/src/components/Groups/AddStudent/AddStudent.js @@ -1,20 +1,90 @@ -import React from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { Modal } from 'react-bootstrap'; +import { defaultMemoize } from 'reselect'; +import InviteUserForm from '../../forms/InviteUserForm'; import LeaveJoinGroupButtonContainer from '../../../containers/LeaveJoinGroupButtonContainer'; import AddUserContainer from '../../../containers/AddUserContainer'; +import Button from '../../widgets/TheButton'; +import InsetPanel from '../../widgets/InsetPanel'; +import Icon from '../../icons'; -const AddStudent = ({ groupId, instanceId }) => ( - } - /> +const inviteUserInitialValues = { + titlesBeforeName: '', + firstName: '', + lastName: '', + titlesAfterName: '', + email: '', +}; + +const prepareInviteOnSubmitHandler = defaultMemoize( + (inviteUser, setDialogOpen, instanceId) => + ({ email, titlesBeforeName, firstName, lastName, titlesAfterName }) => { + email = email.trim(); + firstName = firstName.trim(); + lastName = lastName.trim(); + titlesBeforeName = titlesBeforeName.trim() || undefined; + titlesAfterName = titlesAfterName.trim() || undefined; + return inviteUser({ email, titlesBeforeName, firstName, lastName, titlesAfterName, instanceId }).then(() => + setDialogOpen(false) + ); + } ); +const AddStudent = ({ groupId, instanceId, inviteUser = null }) => { + const [dialogOpen, setDialogOpen] = useState(false); + return ( + <> + } + /> + + {inviteUser && ( + <> +
+
+ +
+ + setDialogOpen(false)} size="xl"> + + + + + + + + + + + + + + + + )} + + ); +}; + AddStudent.propTypes = { instanceId: PropTypes.string.isRequired, groupId: PropTypes.string.isRequired, + inviteUser: PropTypes.func, }; export default AddStudent; diff --git a/src/components/forms/CreateUserForm/CreateUserForm.js b/src/components/forms/CreateUserForm/CreateUserForm.js index 65f76286f..528d2ba38 100644 --- a/src/components/forms/CreateUserForm/CreateUserForm.js +++ b/src/components/forms/CreateUserForm/CreateUserForm.js @@ -193,7 +193,7 @@ const validate = ({ firstName, lastName, email, password, passwordConfirm }) => const asyncValidate = ({ email, password = '' }, dispatch) => { if (password === '') { - dispatch(change('edit-user-profile', 'passwordStrength', null)); + dispatch(change('create-user', 'passwordStrength', null)); return Promise.resolve(); } @@ -211,7 +211,7 @@ const asyncValidate = ({ email, password = '' }, dispatch) => { ); } - dispatch(change('edit-user-profile', 'passwordStrength', passwordScore)); + dispatch(change('create-user', 'passwordStrength', passwordScore)); if (Object.keys(errors).length > 0) { throw errors; @@ -223,7 +223,7 @@ const asyncValidate = ({ email, password = '' }, dispatch) => { }; export default reduxForm({ - form: 'edit-user-profile', + form: 'create-user', validate, asyncValidate, asyncBlurFields: ['email', 'password', 'passwordConfirm'], diff --git a/src/components/forms/InviteUserForm/InviteUserForm.js b/src/components/forms/InviteUserForm/InviteUserForm.js new file mode 100644 index 000000000..4422db77c --- /dev/null +++ b/src/components/forms/InviteUserForm/InviteUserForm.js @@ -0,0 +1,193 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { reduxForm, Field } from 'redux-form'; +import isEmail from 'validator/lib/isEmail'; + +import SubmitButton from '../SubmitButton'; +import Callout from '../../widgets/Callout'; +import { validateRegistrationData } from '../../../redux/modules/users'; +import { TextField } from '../Fields'; + +const InviteUserForm = ({ + submitting, + handleSubmit, + onSubmit, + dirty, + submitFailed = false, + submitSucceeded = false, + asyncValidating, + invalid, + reset, +}) => ( +
+ } + /> + +
+ + } + /> + + } + /> + + } + /> + + } + /> + + {submitFailed && ( + + + + )} + +
+ onSubmit(data).then(reset))} + submitting={submitting} + dirty={dirty} + invalid={invalid} + hasSucceeded={submitSucceeded} + hasFailed={submitFailed} + asyncValidating={asyncValidating} + messages={{ + submit: , + submitting: , + success: , + }} + /> +
+
+); + +InviteUserForm.propTypes = { + handleSubmit: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + asyncValidate: PropTypes.func.isRequired, + submitFailed: PropTypes.bool, + submitSucceeded: PropTypes.bool, + dirty: PropTypes.bool, + submitting: PropTypes.bool, + asyncValidating: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), + invalid: PropTypes.bool, + pristine: PropTypes.bool, + reset: PropTypes.func, +}; + +const validate = ({ firstName, lastName, email, password, passwordConfirm }) => { + const errors = {}; + + if (!firstName) { + errors.firstName = ( + + ); + } + + if (firstName && firstName.length < 2) { + errors.firstName = ( + + ); + } + + if (!lastName) { + errors.lastName = ( + + ); + } + + if (lastName && lastName.length < 2) { + errors.lastName = ( + + ); + } + + if (email && isEmail(email) === false) { + errors.email = ( + + ); + } else if (!email) { + errors.email = ( + + ); + } + + return errors; +}; + +const asyncValidate = ({ email }, dispatch) => { + return new Promise((resolve, reject) => + dispatch(validateRegistrationData(email)) + .then(res => res.value) + .then(({ usernameIsFree }) => { + if (!usernameIsFree) { + const errors = { + email: ( + + ), + }; + throw errors; + } + }) + .then(resolve()) + .catch(errors => reject(errors)) + ); +}; + +export default reduxForm({ + form: 'invite-user', + validate, + asyncValidate, + asyncBlurFields: ['email'], + enableReinitialize: true, + keepDirtyOnReinitialize: false, +})(InviteUserForm); diff --git a/src/components/forms/InviteUserForm/index.js b/src/components/forms/InviteUserForm/index.js new file mode 100644 index 000000000..85474f3c2 --- /dev/null +++ b/src/components/forms/InviteUserForm/index.js @@ -0,0 +1,2 @@ +import InviteUserForm from './InviteUserForm'; +export default InviteUserForm; diff --git a/src/locales/cs.json b/src/locales/cs.json index 1557479e3..436bf5bb3 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -38,6 +38,9 @@ "app.addSisTermForm.title": "Přidat nový semestr", "app.addSisTermForm.winter": "Zimní semestr", "app.addSisTermForm.year": "Rok:", + "app.addStudent.inviteButton": "Poslat pozvánku", + "app.addStudent.inviteDialog.explain": "Pozvánka bude zaslána uživateli na danou mailovou adresu. Uživatel obdrží odkaz pro registraci lokálním účtem. Detaily uživatelského profilu (jméno a email) vyplňte pečlivě, uživatel nebude mít možnost je změnit.", + "app.addStudent.inviteDialog.title": "Poslat pozvánku do ReCodExu", "app.addUserContainer.emptyQuery": "Žádné výsledky. Zadejte vyhledávací dotaz...", "app.allowUserButton.confirmAllow": "Uživatel mohl být zablokován z dobrého důvodu. Opravdu si přejete povolit účet?", "app.allowUserButton.confirmDisallow": "Pokud zakážete tento uživatelský účet, uživatel nebude moci provést žádnou operaci ani vidět žádná data. Opravdu si přejete účet zakázat?", @@ -1019,6 +1022,10 @@ "app.instances.title": "Instance", "app.instancesTable.admin": "Admin", "app.instancesTable.validLicence": "Má platnou licenci", + "app.inviteUserForm.emailAndLogin": "Email (a přihlašovací jméno):", + "app.inviteUserForm.invite": "Zaslat pozvánku", + "app.inviteUserForm.invited": "Pozvánka zaslána", + "app.inviteUserForm.inviting": "Zasílám pozvánku...", "app.leaveGroup.confirm": "Opravdu chcete opustit tuto skupinu?", "app.licencesTable.isValid": "Bez revokace", "app.licencesTable.noLicences": "Nejsou zde žádné licence.", diff --git a/src/locales/en.json b/src/locales/en.json index b8be23f35..48be3d7bf 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -38,6 +38,9 @@ "app.addSisTermForm.title": "Add new term", "app.addSisTermForm.winter": "Winter term", "app.addSisTermForm.year": "Year:", + "app.addStudent.inviteButton": "Invite to Register", + "app.addStudent.inviteDialog.explain": "An invitation will be sent to the user at given email address. The user will receive a link for registration as a local user. User profile details (name and email) must be filled in correctly, since the user will not be able to modify them.", + "app.addStudent.inviteDialog.title": "Send invitation to ReCodEx", "app.addUserContainer.emptyQuery": "No results. Enter a search query...", "app.allowUserButton.confirmAllow": "The user may have been disabled for a reason. Do you really wish to enable the account?", "app.allowUserButton.confirmDisallow": "If you disable the account, the user will not be able to perform any operation nor access any data. Do you wish to disable it?", @@ -1019,6 +1022,10 @@ "app.instances.title": "Instances", "app.instancesTable.admin": "Admin", "app.instancesTable.validLicence": "Has valid licence", + "app.inviteUserForm.emailAndLogin": "Email (and login name):", + "app.inviteUserForm.invite": "Invite", + "app.inviteUserForm.invited": "Invited", + "app.inviteUserForm.inviting": "Inviting...", "app.leaveGroup.confirm": "Are you sure you want to leave this group?", "app.licencesTable.isValid": "Without revocation", "app.licencesTable.noLicences": "There are no licences.", diff --git a/src/pages/GroupDetail/GroupDetail.js b/src/pages/GroupDetail/GroupDetail.js index b8758ebd1..c6f19e035 100644 --- a/src/pages/GroupDetail/GroupDetail.js +++ b/src/pages/GroupDetail/GroupDetail.js @@ -22,7 +22,7 @@ import ExercisesListContainer from '../../containers/ExercisesListContainer'; import { fetchGroupIfNeeded } from '../../redux/modules/groups'; import { fetchGroupStats, fetchGroupStatsIfNeeded } from '../../redux/modules/stats'; -import { fetchByIds } from '../../redux/modules/users'; +import { fetchByIds, inviteUser } from '../../redux/modules/users'; import { fetchAssignmentsForGroup } from '../../redux/modules/assignments'; import { fetchShadowAssignmentsForGroup, @@ -150,6 +150,7 @@ class GroupDetail extends Component { fetchUsersSolutions, setShadowPoints, removeShadowPoints, + inviteUser, links: { GROUP_DETAIL_URI_FACTORY }, } = this.props; @@ -363,7 +364,11 @@ class GroupDetail extends Component { /> } isOpen> - + @@ -449,6 +454,7 @@ GroupDetail.propTypes = { fetchUsersSolutions: PropTypes.func.isRequired, setShadowPoints: PropTypes.func.isRequired, removeShadowPoints: PropTypes.func.isRequired, + inviteUser: PropTypes.func.isRequired, links: PropTypes.object, }; @@ -493,6 +499,7 @@ const mapDispatchToProps = (dispatch, { match: { params } }) => ({ dispatch(setShadowAssignmentPoints(params.groupId, shadowId, awardeeId, pointsId, points, note, awardedAt)), removeShadowPoints: (shadowId, awardeeId, pointsId) => dispatch(removeShadowAssignmentPoints(params.groupId, shadowId, awardeeId, pointsId)), + inviteUser: data => dispatch(inviteUser(data)), }); export default withLinks(connect(mapStateToProps, mapDispatchToProps)(GroupDetail)); diff --git a/src/redux/modules/users.js b/src/redux/modules/users.js index d2ef0c9e8..0de9425b8 100644 --- a/src/redux/modules/users.js +++ b/src/redux/modules/users.js @@ -1,7 +1,12 @@ import { handleActions } from 'redux-actions'; import { fromJS } from 'immutable'; -import factory, { initialState, createRecord, resourceStatus } from '../helpers/resourceManager'; +import factory, { + initialState, + createRecord, + resourceStatus, + createActionsWithPostfixes, +} from '../helpers/resourceManager'; import { createApiAction } from '../middleware/apiMiddleware'; import { actionTypes as emailVerificationActionTypes } from './emailVerification'; @@ -13,26 +18,13 @@ import { actionTypes as authActionTypes } from './authTypes'; import { arrayToObject } from '../../helpers/common'; export const additionalActionTypes = { - VALIDATE_REGISTRATION_DATA: 'recodex/users/VALIDATE_REGISTRATION_DATA', - VALIDATE_REGISTRATION_DATA_PENDING: 'recodex/users/VALIDATE_REGISTRATION_DATA_PENDING', - VALIDATE_REGISTRATION_DATA_FULFILLED: 'recodex/users/VALIDATE_REGISTRATION_DATA_FULFILLED', - VALIDATE_REGISTRATION_DATA_REJECTED: 'recodex/users/VALIDATE_REGISTRATION_DATA_REJECTED', - FETCH_BY_IDS: 'recodex/users/FETCH_BY_IDS', - FETCH_BY_IDS_PENDING: 'recodex/users/FETCH_BY_IDS_PENDING', - FETCH_BY_IDS_FULFILLED: 'recodex/users/FETCH_BY_IDS_FULFILLED', - FETCH_BY_IDS_REJECTED: 'recodex/users/FETCH_BY_IDS_REJECTED', - CREATE_LOCAL_LOGIN: 'recodex/users/CREATE_LOCAL_LOGIN', - CREATE_LOCAL_LOGIN_PENDING: 'recodex/users/CREATE_LOCAL_LOGIN_PENDING', - CREATE_LOCAL_LOGIN_FULFILLED: 'recodex/users/CREATE_LOCAL_LOGIN_FULFILLED', - CREATE_LOCAL_LOGIN_REJECTED: 'recodex/users/CREATE_LOCAL_LOGIN_REJECTED', - SET_ROLE: 'recodex/users/SET_ROLE', - SET_ROLE_PENDING: 'recodex/users/SET_ROLE_PENDING', - SET_ROLE_FULFILLED: 'recodex/users/SET_ROLE_FULFILLED', - SET_ROLE_REJECTED: 'recodex/users/SET_ROLE_REJECTED', - SET_IS_ALLOWED: 'recodex/users/SET_IS_ALLOWED', - SET_IS_ALLOWED_PENDING: 'recodex/users/SET_IS_ALLOWED_PENDING', - SET_IS_ALLOWED_FULFILLED: 'recodex/users/SET_IS_ALLOWED_FULFILLED', - SET_IS_ALLOWED_REJECTED: 'recodex/users/SET_IS_ALLOWED_REJECTED', + // createActionsWithPostfixes generates all 4 constants for async operations + ...createActionsWithPostfixes('VALIDATE_REGISTRATION_DATA', 'recodex/users'), + ...createActionsWithPostfixes('FETCH_BY_IDS', 'recodex/users'), + ...createActionsWithPostfixes('CREATE_LOCAL_LOGIN', 'recodex/users'), + ...createActionsWithPostfixes('SET_ROLE', 'recodex/users'), + ...createActionsWithPostfixes('SET_IS_ALLOWED', 'recodex/users'), + ...createActionsWithPostfixes('INVITE_USER', 'recodex/users'), }; const resourceName = 'users'; @@ -49,12 +41,12 @@ export const fetchManyEndpoint = '/users'; export const loadUserData = actions.pushResource; export const fetchUser = actions.fetchResource; export const fetchUserIfNeeded = actions.fetchOneIfNeeded; -export const validateRegistrationData = (email, password) => +export const validateRegistrationData = (email, password = null) => createApiAction({ type: additionalActionTypes.VALIDATE_REGISTRATION_DATA, endpoint: '/users/validate-registration-data', method: 'POST', - body: { email, password }, + body: password === null ? { email } : { email, password }, }); export const updateProfile = actions.updateResource; @@ -102,6 +94,14 @@ export const setIsAllowed = (id, isAllowed = true) => body: { isAllowed }, }); +export const inviteUser = body => + createApiAction({ + type: additionalActionTypes.INVITE_USER, + endpoint: '/users/invite', + method: 'POST', + body, + }); + /** * Reducer */