- {user.handle}
-
-
- {showRadioButtons && (
-
e.target.checked && this.updatePermission(PROJECT_ROLES.READ)}
- />
-
-
)}
-
-
- {showRadioButtons && (
-
e.target.checked && this.updatePermission(PROJECT_ROLES.WRITE)}
- />
-
-
)}
-
-
- {showRadioButtons && (
-
e.target.checked && this.updatePermission(PROJECT_ROLES.MANAGER)}
- />
-
-
)}
-
-
- {showRadioButtons && (
-
e.target.checked && this.updatePermission(PROJECT_ROLES.COPILOT)}
- />
-
-
)}
+ {isInvite ? user.email : user.handle}
+ {!isInvite && (
+ <>
+
+ {showRadioButtons && (
+
e.target.checked && this.updatePermission(PROJECT_ROLES.READ)}
+ />
+
+
)}
+
+
+ {showRadioButtons && (
+
e.target.checked && this.updatePermission(PROJECT_ROLES.WRITE)}
+ />
+
+
)}
+
+
+ {showRadioButtons && (
+
e.target.checked && this.updatePermission(PROJECT_ROLES.MANAGER)}
+ />
+
+
)}
+
+
+ {showRadioButtons && (
+
e.target.checked && this.updatePermission(PROJECT_ROLES.COPILOT)}
+ />
+
+
)}
+
+ >
+ )}
+ {isInvite && (
+ <>
+
+
+ Invited {moment(user.createdAt).format('MMM D, YY')}
+
+
+
+ >
+ )}
{isEditable ? (
* {
+ width: 125px;
+ }
}
.addUserContentContainer {
diff --git a/src/components/Users/index.js b/src/components/Users/index.js
index 07d862dc..e328e230 100644
--- a/src/components/Users/index.js
+++ b/src/components/Users/index.js
@@ -6,12 +6,13 @@ import styles from './Users.module.scss'
import Select from '../Select'
import UserCard from '../UserCard'
import PrimaryButton from '../Buttons/PrimaryButton'
-import Modal from '../Modal'
-import SelectUserAutocomplete from '../SelectUserAutocomplete'
import { PROJECT_ROLES, AUTOCOMPLETE_DEBOUNCE_TIME_MS } from '../../config/constants'
-import { checkAdmin } from '../../util/tc'
-import { addUserToProject, removeUserFromProject } from '../../services/projects'
+import { checkAdmin, checkManager } from '../../util/tc'
+import { removeUserFromProject } from '../../services/projects'
+import { deleteProjectMemberInvite } from '../../services/projectMemberInvites'
import ConfirmationModal from '../Modal/ConfirmationModal'
+import UserAddModalContent from './user-add.modal'
+import InviteUserModalContent from './invite-user.modal' // Import the new component
const theme = {
container: styles.modalContainer
@@ -23,11 +24,7 @@ class Users extends Component {
this.state = {
projectOption: null,
showAddUserModal: false,
- userToAdd: null,
- userPermissionToAdd: PROJECT_ROLES.READ,
- showSelectUserError: false,
- isAdding: false,
- addUserError: false,
+ showInviteUserModal: false, // Add state for invite user modal
isRemoving: false,
removeError: null,
showRemoveConfirmationModal: false,
@@ -36,10 +33,9 @@ class Users extends Component {
}
this.setProjectOption = this.setProjectOption.bind(this)
this.onAddUserClick = this.onAddUserClick.bind(this)
+ this.onInviteUserClick = this.onInviteUserClick.bind(this) // Bind the new method
this.resetAddUserState = this.resetAddUserState.bind(this)
- this.onUpdateUserToAdd = this.onUpdateUserToAdd.bind(this)
- this.onAddUserConfirmClick = this.onAddUserConfirmClick.bind(this)
- this.updatePermission = this.updatePermission.bind(this)
+ this.resetInviteUserState = this.resetInviteUserState.bind(this) // Bind reset method
this.onRemoveClick = this.onRemoveClick.bind(this)
this.resetRemoveUserState = this.resetRemoveUserState.bind(this)
this.onRemoveConfirmClick = this.onRemoveConfirmClick.bind(this)
@@ -54,78 +50,24 @@ class Users extends Component {
loadProject(projectOption.value, false)
}
- updatePermission (newRole) {
- this.setState({
- userPermissionToAdd: newRole
- })
- }
-
onAddUserClick () {
this.setState({
showAddUserModal: true
})
}
- resetAddUserState () {
+ onInviteUserClick () {
this.setState({
- userToAdd: null,
- showSelectUserError: false,
- isAdding: false,
- showAddUserModal: false,
- userPermissionToAdd: PROJECT_ROLES.READ,
- addUserError: null
+ showInviteUserModal: true
})
}
- onUpdateUserToAdd (option) {
- let userToAdd = null
- if (option && option.value) {
- userToAdd = {
- handle: option.label,
- userId: parseInt(option.value, 10)
- }
- }
-
- this.setState({
- userToAdd,
- showSelectUserError: !userToAdd
- })
+ resetAddUserState () {
+ this.setState({ showAddUserModal: false })
}
- async onAddUserConfirmClick () {
- const { addNewProjectMember } = this.props
- if (this.state.isAdding) { return }
-
- this.setState({
- showSelectUserError: false,
- addUserError: null
- })
-
- if (!this.state.userToAdd) {
- this.setState({
- showSelectUserError: true
- })
- return
- }
-
- this.setState({
- isAdding: true
- })
-
- try {
- const newUserInfo = await addUserToProject(this.state.projectOption.value, this.state.userToAdd.userId, this.state.userPermissionToAdd)
- newUserInfo.handle = this.state.userToAdd.handle
- // wait for a second so that project's members are updated
- addNewProjectMember(newUserInfo)
- this.resetAddUserState()
- } catch (e) {
- const error = _.get(
- e,
- 'response.data.message',
- `Unable to add user`
- )
- this.setState({ isAdding: false, addUserError: error })
- }
+ resetInviteUserState () {
+ this.setState({ showInviteUserModal: false })
}
getHandle () {
@@ -167,11 +109,14 @@ class Users extends Component {
async onRemoveConfirmClick () {
if (this.state.isRemoving) { return }
- const { removeProjectNember } = this.props
+ const { removeProjectNember, invitedMembers } = this.props
const userToRemove = this.state.userToRemove
+ const isInvite = !!_.find(invitedMembers, { email: userToRemove.email })
try {
this.setState({ isRemoving: true })
- await removeUserFromProject(userToRemove.projectId, userToRemove.id)
+ await (
+ isInvite ? deleteProjectMemberInvite(userToRemove.projectId, userToRemove.id) : removeUserFromProject(userToRemove.projectId, userToRemove.id)
+ )
removeProjectNember(userToRemove)
this.resetRemoveUserState()
@@ -210,10 +155,12 @@ class Users extends Component {
const {
projects,
projectMembers,
+ invitedMembers,
updateProjectNember,
isEditable,
isSearchingUserProjects,
- resultSearchUserProjects
+ resultSearchUserProjects,
+ loadNextProjects
} = this.props
const {
searchKey
@@ -225,10 +172,11 @@ class Users extends Component {
}
})
const loggedInHandle = this.getHandle()
- const membersExist = projectMembers && projectMembers.length > 0
+ const membersExist = (projectMembers && projectMembers.length > 0) || (invitedMembers && invitedMembers.length > 0)
const isCopilotOrManager = this.checkIsCopilotOrManager(projectMembers, loggedInHandle)
const isAdmin = checkAdmin(this.props.auth.token)
- const showAddUser = isEditable && this.state.projectOption && (isCopilotOrManager || isAdmin)
+ const isManager = checkManager(this.props.auth.token)
+ const showAddUser = isEditable && this.state.projectOption && (isCopilotOrManager || isAdmin || isManager)
return (
@@ -246,6 +194,7 @@ class Users extends Component {
onChange={(e) => { this.setProjectOption(e) }}
onInputChange={this.debouncedOnInputChange}
isLoading={isSearchingUserProjects}
+ onMenuScrollBottom={loadNextProjects}
filterOption={() => true}
noOptionsMessage={() => isSearchingUserProjects ? 'Searching...' : 'No options'}
/>
@@ -260,140 +209,38 @@ class Users extends Component {
text={'Add User'}
type={'info'}
onClick={() => this.onAddUserClick()} />
+
this.onInviteUserClick()} />
)
}
{
this.state.showAddUserModal && (
- this.resetAddUserState()}>
-
-
Add User
-
-
- {
- this.state.showSelectUserError && (
-
-
Please select a member.
-
- )
- }
-
-
-
-
-
-
-
e.target.checked && this.updatePermission(PROJECT_ROLES.READ)}
- />
-
-
-
-
-
-
e.target.checked && this.updatePermission(PROJECT_ROLES.WRITE)}
- />
-
-
-
-
-
-
e.target.checked && this.updatePermission(PROJECT_ROLES.MANAGER)}
- />
-
-
-
-
-
-
e.target.checked && this.updatePermission(PROJECT_ROLES.COPILOT)}
- />
-
-
-
-
- {
- this.state.addUserError && (
-
- {this.state.addUserError}
-
- )
- }
-
-
-
-
-
this.resetAddUserState()}
- />
-
-
-
this.onAddUserConfirmClick()}
- />
-
-
-
-
+
+ )
+ }
+ {
+ this.state.showInviteUserModal && (
+
)
}
{
this.state.showRemoveConfirmationModal && (
+
+ {
+ _.map(invitedMembers, (member) => {
+ return (
+ -
+
+
+ )
+ })
+ }
+
>
)
}
@@ -450,14 +313,17 @@ Users.propTypes = {
loadProject: PropTypes.func.isRequired,
updateProjectNember: PropTypes.func.isRequired,
removeProjectNember: PropTypes.func.isRequired,
+ addNewProjectInvite: PropTypes.func.isRequired,
addNewProjectMember: PropTypes.func.isRequired,
auth: PropTypes.object,
isEditable: PropTypes.bool,
isSearchingUserProjects: PropTypes.bool,
projects: PropTypes.arrayOf(PropTypes.object),
projectMembers: PropTypes.arrayOf(PropTypes.object),
+ invitedMembers: PropTypes.arrayOf(PropTypes.object),
searchUserProjects: PropTypes.func.isRequired,
- resultSearchUserProjects: PropTypes.arrayOf(PropTypes.object)
+ resultSearchUserProjects: PropTypes.arrayOf(PropTypes.object),
+ loadNextProjects: PropTypes.func.isRequired
}
export default Users
diff --git a/src/components/Users/invite-user.modal.js b/src/components/Users/invite-user.modal.js
new file mode 100644
index 00000000..41b1dff4
--- /dev/null
+++ b/src/components/Users/invite-user.modal.js
@@ -0,0 +1,138 @@
+import React, { useState } from 'react'
+import PropTypes from 'prop-types'
+import cn from 'classnames'
+import { find, get } from 'lodash'
+import Modal from '../Modal'
+import PrimaryButton from '../Buttons/PrimaryButton'
+import { inviteUserToProject } from '../../services/projects'
+import { PROJECT_ROLES } from '../../config/constants'
+
+import styles from './Users.module.scss'
+
+const theme = {
+ container: styles.modalContainer
+}
+
+const validateEmail = (email) => {
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+ return emailRegex.test(email)
+}
+
+const InviteUserModalContent = ({ projectId, onClose, onMemberInvited, projectMembers, invitedMembers }) => {
+ const [emailToInvite, setEmailToInvite] = useState('')
+ const [showEmailError, setShowEmailError] = useState(false)
+ const [inviteUserError, setInviteUserError] = useState(null)
+ const [isInviting, setIsInviting] = useState(false)
+
+ const checkEmail = () => {
+ if (!validateEmail(emailToInvite)) {
+ setShowEmailError(true)
+ return false
+ }
+
+ if (find(invitedMembers, { email: emailToInvite })) {
+ setInviteUserError('Email is already invited!')
+ return false
+ }
+
+ if (find(projectMembers, { email: emailToInvite })) {
+ setInviteUserError('Member already part of the project!')
+ return false
+ }
+
+ return true
+ }
+
+ const onInviteUserConfirmClick = async () => {
+ if (isInviting) return
+
+ if (!checkEmail()) {
+ return
+ }
+
+ setIsInviting(true)
+ setInviteUserError(null)
+
+ try {
+ // api restriction: ONLY "customer" role can be invited via email
+ const { success: invitations = [], failed } = await inviteUserToProject(projectId, emailToInvite, PROJECT_ROLES.CUSTOMER)
+
+ if (failed) {
+ const error = get(failed, '0.message', 'Unable to invite user')
+ setInviteUserError(error)
+ setIsInviting(false)
+ } else {
+ onMemberInvited(invitations[0] || {})
+ onClose()
+ }
+ } catch (e) {
+ const error = get(e, 'response.data.message', 'Unable to invite user')
+ setInviteUserError(error)
+ setIsInviting(false)
+ }
+ }
+
+ return (
+
+
+
Invite User
+
+
+
+ Email* :
+
+
+ {
+ setEmailToInvite(e.target.value)
+ setShowEmailError(false)
+ setInviteUserError(null)
+ }}
+ onBlur={checkEmail}
+ />
+
+
+ {showEmailError && (
+
+
Please enter a valid email address.
+
+ )}
+ {inviteUserError && (
+
+ )}
+
+
+
+
+ )
+}
+
+InviteUserModalContent.propTypes = {
+ projectId: PropTypes.number.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onMemberInvited: PropTypes.func.isRequired,
+ projectMembers: PropTypes.arrayOf(PropTypes.object),
+ invitedMembers: PropTypes.arrayOf(PropTypes.object)
+}
+
+export default InviteUserModalContent
diff --git a/src/components/Users/user-add.modal.js b/src/components/Users/user-add.modal.js
new file mode 100644
index 00000000..79f08f93
--- /dev/null
+++ b/src/components/Users/user-add.modal.js
@@ -0,0 +1,175 @@
+import React, { useState } from 'react'
+import PropTypes from 'prop-types'
+import cn from 'classnames'
+import { get } from 'lodash'
+import Modal from '../Modal'
+import SelectUserAutocomplete from '../SelectUserAutocomplete'
+import { PROJECT_ROLES } from '../../config/constants'
+import PrimaryButton from '../Buttons/PrimaryButton'
+import { addUserToProject } from '../../services/projects'
+
+import styles from './Users.module.scss'
+
+const theme = {
+ container: styles.modalContainer
+}
+
+const UserAddModalContent = ({ projectId, addNewProjectMember, onClose }) => {
+ const [userToAdd, setUserToAdd] = useState(null)
+ const [userPermissionToAdd, setUserPermissionToAdd] = useState(PROJECT_ROLES.READ)
+ const [showSelectUserError, setShowSelectUserError] = useState(false)
+ const [addUserError, setAddUserError] = useState(null)
+ const [isAdding, setIsAdding] = useState(false)
+
+ const onUpdateUserToAdd = (option) => {
+ if (option && option.value) {
+ setUserToAdd({
+ handle: option.label,
+ userId: parseInt(option.value, 10)
+ })
+ setShowSelectUserError(false)
+ } else {
+ setUserToAdd(null)
+ }
+ }
+
+ const onAddUserConfirmClick = async () => {
+ if (isAdding) return
+
+ if (!userToAdd) {
+ setShowSelectUserError(true)
+ return
+ }
+
+ setIsAdding(true)
+ setAddUserError(null)
+
+ try {
+ const newUserInfo = await addUserToProject(projectId, userToAdd.userId, userPermissionToAdd)
+ newUserInfo.handle = userToAdd.handle
+ addNewProjectMember(newUserInfo)
+ onClose()
+ } catch (e) {
+ const error = get(e, 'response.data.message', 'Unable to add user')
+ setAddUserError(error)
+ setIsAdding(false)
+ }
+ }
+
+ return (
+
+
+
Add User
+
+
+ {showSelectUserError && (
+
+
Please select a member.
+
+ )}
+
+
+
+
+
+
+
setUserPermissionToAdd(PROJECT_ROLES.READ)}
+ />
+
+
+
+
+
+
setUserPermissionToAdd(PROJECT_ROLES.WRITE)}
+ />
+
+
+
+
+
+
setUserPermissionToAdd(PROJECT_ROLES.MANAGER)}
+ />
+
+
+
+
+
+
setUserPermissionToAdd(PROJECT_ROLES.COPILOT)}
+ />
+
+
+
+
+ {addUserError && (
+
{addUserError}
+ )}
+
+
+
+
+ )
+}
+UserAddModalContent.propTypes = {
+ projectId: PropTypes.number.isRequired,
+ addNewProjectMember: PropTypes.func.isRequired,
+ onClose: PropTypes.func.isRequired
+}
+
+export default UserAddModalContent
diff --git a/src/config/constants.js b/src/config/constants.js
index 6aa99441..befe6681 100644
--- a/src/config/constants.js
+++ b/src/config/constants.js
@@ -33,7 +33,9 @@ export const {
TYPEFORM_URL,
PROFILE_URL
} = process.env
+
export const CREATE_FORUM_TYPE_IDS = typeof process.env.CREATE_FORUM_TYPE_IDS === 'string' ? process.env.CREATE_FORUM_TYPE_IDS.split(',') : process.env.CREATE_FORUM_TYPE_IDS
+export const PROJECTS_API_URL = process.env.PROJECTS_API_URL || process.env.PROJECT_API_URL
/**
* Filepicker config
@@ -193,6 +195,14 @@ export const UPDATE_PROJECT_PENDING = 'UPDATE_PROJECT_PENDING'
export const UPDATE_PROJECT_SUCCESS = 'UPDATE_PROJECT_SUCCESS'
export const UPDATE_PROJECT_FAILURE = 'UPDATE_PROJECT_FAILURE'
+export const PROJECT_MEMBER_INVITE_STATUS_ACCEPTED = 'accepted'
+export const PROJECT_MEMBER_INVITE_STATUS_REFUSED = 'refused'
+export const PROJECT_MEMBER_INVITE_STATUS_CANCELED = 'canceled'
+export const PROJECT_MEMBER_INVITE_STATUS_PENDING = 'pending'
+export const PROJECT_MEMBER_INVITE_STATUS_REQUESTED = 'requested'
+export const PROJECT_MEMBER_INVITE_STATUS_REQUEST_APPROVED = 'request_approved'
+export const PROJECT_MEMBER_INVITE_STATUS_REQUEST_REJECTED = 'request_rejected'
+
// Name of challenge tracks
export const CHALLENGE_TRACKS = {
DESIGN: DES_TRACK_ID,
@@ -242,6 +252,7 @@ export const MARATHON_MATCH_SUBTRACKS = [
export const PROJECT_ROLES = {
READ: 'observer',
+ CUSTOMER: 'customer',
WRITE: 'customer',
MANAGER: 'manager',
COPILOT: 'copilot'
@@ -308,6 +319,10 @@ export const COPILOT_ROLES = [
'copilot'
]
+export const MANAGER_ROLES = [
+ 'project manager'
+]
+
export const downloadAttachmentURL = (challengeId, attachmentId, token) =>
`${CHALLENGE_API_URL}/${challengeId}/attachments/${attachmentId}/download?token=${token}`
diff --git a/src/containers/Challenges/index.js b/src/containers/Challenges/index.js
index ca7759f5..1a7d3ba1 100644
--- a/src/containers/Challenges/index.js
+++ b/src/containers/Challenges/index.js
@@ -14,13 +14,14 @@ import {
deleteChallenge,
loadChallengeTypes
} from '../../actions/challenges'
-import { loadProject, updateProject } from '../../actions/projects'
+import { loadProject, loadProjects, updateProject } from '../../actions/projects'
import {
loadNextProjects,
setActiveProject,
resetSidebarActiveParams
} from '../../actions/sidebar'
-import { checkAdmin } from '../../util/tc'
+import { checkAdmin, checkIsUserInvited } from '../../util/tc'
+import { withRouter } from 'react-router-dom'
class Challenges extends Component {
constructor (props) {
@@ -42,6 +43,7 @@ class Challenges extends Component {
} = this.props
loadChallengeTypes()
if (dashboard) {
+ this.props.loadProjects('', {})
this.reloadChallenges(this.props, true, true)
}
if (menu === 'NULL' && activeProjectId !== -1) {
@@ -55,6 +57,14 @@ class Challenges extends Component {
}
}
+ componentDidUpdate () {
+ const { auth } = this.props
+
+ if (checkIsUserInvited(auth.token, this.props.projectDetail)) {
+ this.props.history.push(`/projects/${this.props.projectDetail.id}/invitation`)
+ }
+ }
+
componentWillReceiveProps (nextProps) {
if (
(nextProps.dashboard && this.props.dashboard !== nextProps.dashboard) ||
@@ -194,6 +204,7 @@ Challenges.defaultProps = {
}
Challenges.propTypes = {
+ history: PropTypes.object,
projects: PropTypes.arrayOf(PropTypes.shape()),
menu: PropTypes.string,
challenges: PropTypes.arrayOf(PropTypes.object),
@@ -234,7 +245,8 @@ Challenges.propTypes = {
fetchNextProjects: PropTypes.func.isRequired,
metadata: PropTypes.shape({
challengeTypes: PropTypes.array
- })
+ }),
+ loadProjects: PropTypes.func.isRequired
}
const mapStateToProps = ({ challenges, sidebar, projects, auth }) => ({
@@ -265,7 +277,10 @@ const mapDispatchToProps = {
loadChallengeTypes,
setActiveProject,
partiallyUpdateChallengeDetails,
- deleteChallenge
+ deleteChallenge,
+ loadProjects
}
-export default connect(mapStateToProps, mapDispatchToProps)(Challenges)
+export default withRouter(
+ connect(mapStateToProps, mapDispatchToProps)(Challenges)
+)
diff --git a/src/containers/ProjectEditor/index.js b/src/containers/ProjectEditor/index.js
index 653b02b5..241562b2 100644
--- a/src/containers/ProjectEditor/index.js
+++ b/src/containers/ProjectEditor/index.js
@@ -15,7 +15,7 @@ import {
updateProject
} from '../../actions/projects'
import { setActiveProject } from '../../actions/sidebar'
-import { checkAdminOrCopilot, checkAdmin } from '../../util/tc'
+import { checkAdminOrCopilot, checkAdmin, checkIsUserInvited } from '../../util/tc'
import { PROJECT_ROLES } from '../../config/constants'
import Loader from '../../components/Loader'
@@ -37,6 +37,11 @@ class ProjectEditor extends Component {
componentDidUpdate () {
const { auth } = this.props
+
+ if (checkIsUserInvited(auth.token, this.props.projectDetail)) {
+ this.props.history.push(`/projects/${this.props.projectDetail.id}/invitation`)
+ }
+
if (!checkAdminOrCopilot(auth.token, this.props.projectDetail)) {
this.props.history.push('/projects')
}
diff --git a/src/containers/ProjectInvitations/ProjectInvitations.module.scss b/src/containers/ProjectInvitations/ProjectInvitations.module.scss
new file mode 100644
index 00000000..865f95f9
--- /dev/null
+++ b/src/containers/ProjectInvitations/ProjectInvitations.module.scss
@@ -0,0 +1,120 @@
+@import '../../styles/includes';
+
+.modalContainer {
+ padding: 0;
+ position: fixed;
+ overflow: auto;
+ z-index: 10000;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ box-sizing: border-box;
+ width: auto;
+ max-width: none;
+ transform: none;
+ background: transparent;
+ color: $text-color;
+ opacity: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ :global {
+ button.close {
+ margin-right: 5px;
+ margin-top: 5px;
+ }
+ }
+
+ .contentContainer {
+ box-sizing: border-box;
+ background: $white;
+ opacity: 1;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: center;
+ border-radius: 6px;
+ margin: 0 auto;
+ width: 852px;
+ padding: 30px;
+
+ .content {
+ padding: 30px;
+ width: 100%;
+ height: 100%;
+ }
+
+ .title {
+ @include roboto-bold();
+
+ font-size: 30px;
+ line-height: 36px;
+ margin-bottom: 30px;
+ margin-top: 0;
+ }
+
+ span {
+ @include roboto;
+
+ font-size: 22px;
+ font-weight: 400;
+ line-height: 26px;
+ }
+
+ &.confirm {
+ width: 999px;
+
+ .buttonGroup {
+ display: flex;
+ justify-content: space-between;
+ margin-top: 30px;
+
+ .buttonSizeA {
+ width: 193px;
+ height: 40px;
+ margin-right: 33px;
+
+ span {
+ font-size: 18px;
+ font-weight: 500;
+ }
+ }
+
+ .buttonSizeB {
+ width: 160px;
+ height: 40px;
+
+ span {
+ font-size: 18px;
+ font-weight: 500;
+ line-height: 22px;
+ }
+ }
+ }
+ }
+
+ .buttonGroup {
+ display: flex;
+ justify-content: space-between;
+ margin-top: 30px;
+
+ .button {
+ width: 135px;
+ height: 40px;
+ margin-right: 66px;
+
+ span {
+ font-size: 18px;
+ font-weight: 500;
+ }
+ }
+
+ .button:last-child {
+ margin-right: 0;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/containers/ProjectInvitations/index.js b/src/containers/ProjectInvitations/index.js
new file mode 100644
index 00000000..89048b82
--- /dev/null
+++ b/src/containers/ProjectInvitations/index.js
@@ -0,0 +1,119 @@
+import PropTypes from 'prop-types'
+import React, { useCallback, useEffect, useMemo, useState } from 'react'
+import { connect } from 'react-redux'
+import { withRouter } from 'react-router-dom'
+import { toastr } from 'react-redux-toastr'
+import { checkIsUserInvited } from '../../util/tc'
+import { isEmpty } from 'lodash'
+import { loadProject } from '../../actions/projects'
+import ConfirmationModal from '../../components/Modal/ConfirmationModal'
+
+import styles from './ProjectInvitations.module.scss'
+import { updateProjectMemberInvite } from '../../services/projectMemberInvites'
+import { PROJECT_MEMBER_INVITE_STATUS_ACCEPTED, PROJECT_MEMBER_INVITE_STATUS_REFUSED } from '../../config/constants'
+
+const theme = {
+ container: styles.modalContainer
+}
+
+const ProjectInvitations = ({ match, auth, isProjectLoading, history, projectDetail, loadProject }) => {
+ const automaticAction = useMemo(() => [PROJECT_MEMBER_INVITE_STATUS_ACCEPTED, PROJECT_MEMBER_INVITE_STATUS_REFUSED].includes(match.params.action) ? match.params.action : undefined, [match.params])
+ const projectId = useMemo(() => parseInt(match.params.projectId), [match.params])
+ const invitation = useMemo(() => checkIsUserInvited(auth.token, projectDetail), [auth.token, projectDetail])
+ const [isUpdating, setIsUpdating] = useState(automaticAction || false)
+ const isAccepting = isUpdating === PROJECT_MEMBER_INVITE_STATUS_ACCEPTED
+ const isDeclining = isUpdating === PROJECT_MEMBER_INVITE_STATUS_REFUSED
+
+ useEffect(() => {
+ if (!projectId) {
+ return
+ }
+
+ if (isProjectLoading || isEmpty(projectDetail)) {
+ if (!isProjectLoading) {
+ loadProject(projectId)
+ }
+ return
+ }
+
+ if (!invitation) {
+ history.push(`/projects`)
+ }
+ }, [projectId, auth, projectDetail, isProjectLoading, history])
+
+ const updateInvite = useCallback(async (status) => {
+ setIsUpdating(status)
+ await updateProjectMemberInvite(projectId, invitation.id, status)
+ toastr.success('Success', `Successfully ${status} the invitation.`)
+ history.push(status === PROJECT_MEMBER_INVITE_STATUS_ACCEPTED ? `/projects/${projectId}/challenges` : '/projects')
+ }, [invitation])
+
+ const acceptInvite = useCallback(() => updateInvite(PROJECT_MEMBER_INVITE_STATUS_ACCEPTED), [updateInvite])
+ const declineInvite = useCallback(() => updateInvite(PROJECT_MEMBER_INVITE_STATUS_REFUSED), [updateInvite])
+
+ useEffect(() => {
+ if (!invitation || !automaticAction) {
+ return
+ }
+
+ setTimeout(() => {
+ if (automaticAction === PROJECT_MEMBER_INVITE_STATUS_ACCEPTED) {
+ acceptInvite()
+ } else if (automaticAction === PROJECT_MEMBER_INVITE_STATUS_REFUSED) {
+ declineInvite()
+ }
+ }, [1500])
+ }, [invitation, automaticAction])
+
+ return (
+ <>
+ {invitation && (
+
+ )}
+ >
+ )
+}
+
+ProjectInvitations.propTypes = {
+ match: PropTypes.shape({
+ params: PropTypes.shape({
+ projectId: PropTypes.string
+ })
+ }).isRequired,
+ auth: PropTypes.object.isRequired,
+ isProjectLoading: PropTypes.bool,
+ history: PropTypes.object,
+ loadProject: PropTypes.func.isRequired,
+ projectDetail: PropTypes.object
+}
+
+const mapStateToProps = ({ projects, auth }) => {
+ return {
+ projectDetail: projects.projectDetail,
+ isProjectLoading: projects.isLoading,
+ auth
+ }
+}
+
+const mapDispatchToProps = {
+ loadProject
+}
+
+export default withRouter(
+ connect(mapStateToProps, mapDispatchToProps)(ProjectInvitations)
+)
diff --git a/src/containers/Projects/index.js b/src/containers/Projects/index.js
index 733c044c..e3dd4167 100644
--- a/src/containers/Projects/index.js
+++ b/src/containers/Projects/index.js
@@ -5,7 +5,7 @@ import { withRouter, Link } from 'react-router-dom'
import { connect } from 'react-redux'
import PropTypes from 'prop-types'
import Loader from '../../components/Loader'
-import { checkAdminOrCopilot } from '../../util/tc'
+import { checkAdminOrCopilot, checkManager } from '../../util/tc'
import { PrimaryButton } from '../../components/Buttons'
import Select from '../../components/Select'
import ProjectCard from '../../components/ProjectCard'
@@ -18,11 +18,21 @@ import styles from './styles.module.scss'
const Projects = ({ projects, auth, isLoading, projectsCount, loadProjects, loadMoreProjects, unloadProjects }) => {
const [search, setSearch] = useState()
const [projectStatus, setProjectStatus] = useState('')
+ const [showOnlyMyProjects, setOnlyMyProjects] = useState(false)
const selectedStatus = useMemo(() => PROJECT_STATUSES.find(s => s.value === projectStatus))
+ const isProjectManager = checkManager(auth.token)
useEffect(() => {
- loadProjects(search, projectStatus ? { status: projectStatus } : {})
- }, [search, projectStatus])
+ const params = {}
+ if (projectStatus) {
+ params.status = projectStatus
+ }
+
+ if (isProjectManager) {
+ params.memberOnly = showOnlyMyProjects
+ }
+ loadProjects(search, params)
+ }, [search, projectStatus, showOnlyMyProjects, isProjectManager])
// unload projects on dismount
useEffect(() => () => unloadProjects, [])
@@ -46,7 +56,7 @@ const Projects = ({ projects, auth, isLoading, projectsCount, loadProjects, load
)}
-
+
@@ -61,7 +71,7 @@ const Projects = ({ projects, auth, isLoading, projectsCount, loadProjects, load
/>
-
+
@@ -76,6 +86,25 @@ const Projects = ({ projects, auth, isLoading, projectsCount, loadProjects, load
/>
+
+ {
+ checkManager(auth.token) && (
+
+
setOnlyMyProjects(!showOnlyMyProjects)}
+ />
+
+
+ )
+ }
+
{projects.length > 0 ? (
<>
diff --git a/src/containers/Projects/styles.module.scss b/src/containers/Projects/styles.module.scss
index d80341cc..ef18461d 100644
--- a/src/containers/Projects/styles.module.scss
+++ b/src/containers/Projects/styles.module.scss
@@ -43,6 +43,7 @@
display: flex;
gap: 10px;
margin-bottom: 20px;
+ align-items: end;
.searchInput {
width: 100%;
height: 40px;
@@ -51,4 +52,85 @@
border: 1px solid $light-gray;
background-color: $lighter-gray;
}
+
+ .tcCheckbox {
+ @include tc-checkbox;
+
+ .tc-checkbox-label {
+ @include roboto-light();
+
+ line-height: 17px;
+ font-weight: 300;
+ margin-left: 21px;
+ user-select: none;
+ cursor: pointer;
+ width: 195px;
+ font-size: 14px;
+ color: #3d3d3d;
+ }
+
+ height: 18px;
+ width: 210px;
+ margin: 0;
+ padding: 0;
+ vertical-align: bottom;
+ position: relative;
+ display: inline-block;
+ margin-bottom: 4px;
+ margin-left: 8px;
+
+ input[type=checkbox] {
+ display: none;
+ }
+
+ label {
+ @include roboto-light();
+
+ line-height: 17px;
+ font-weight: 300;
+ cursor: pointer;
+ position: absolute;
+ display: inline-block;
+ width: 18px;
+ height: 18px;
+ top: 0;
+ left: 0;
+ border: none;
+ box-shadow: none;
+ background: $tc-gray-30;
+ transition: all 0.15s ease-in-out;
+
+ &::after {
+ opacity: 0;
+ content: '';
+ position: absolute;
+ width: 9px;
+ height: 5px;
+ background: transparent;
+ top: 5px;
+ left: 5px;
+ border-top: none;
+ border-right: none;
+ transform: rotate(-45deg);
+ transition: all 0.15s ease-in-out;
+ }
+
+ &:hover::after {
+ opacity: 0.3;
+ }
+
+ div {
+ margin-left: 24px;
+ width: 300px;
+ }
+ }
+
+ input[type=checkbox]:checked ~ label {
+ background: $tc-blue-20;
+ }
+
+ input[type=checkbox]:checked + label::after {
+ border-color: $white;
+ }
+ }
}
\ No newline at end of file
diff --git a/src/containers/Users/index.js b/src/containers/Users/index.js
index 7a1ecf3f..dabaa0b5 100644
--- a/src/containers/Users/index.js
+++ b/src/containers/Users/index.js
@@ -5,10 +5,11 @@ import PT from 'prop-types'
import UsersComponent from '../../components/Users'
import { PROJECT_ROLES } from '../../config/constants'
import { fetchProjectById } from '../../services/projects'
-import { checkAdmin } from '../../util/tc'
+import { checkAdmin, checkManager } from '../../util/tc'
import {
loadAllUserProjects,
+ loadNextProjects,
searchUserProjects
} from '../../actions/users'
@@ -19,25 +20,40 @@ class Users extends Component {
this.state = {
loginUserRoleInProject: '',
projectMembers: null,
+ invitedMembers: null,
isAdmin: false
}
this.loadProject = this.loadProject.bind(this)
this.updateProjectNember = this.updateProjectNember.bind(this)
this.removeProjectNember = this.removeProjectNember.bind(this)
+ this.addNewProjectInvite = this.addNewProjectInvite.bind(this)
this.addNewProjectMember = this.addNewProjectMember.bind(this)
+ this.loadNextProjects = this.loadNextProjects.bind(this)
}
componentDidMount () {
- const { token, isLoading, loadAllUserProjects } = this.props
+ const { token, isLoading, loadAllUserProjects, page } = this.props
if (!isLoading) {
const isAdmin = checkAdmin(token)
- loadAllUserProjects(isAdmin)
+ const isManager = checkManager(token)
+ const params = {
+ page
+ }
+ loadAllUserProjects(params, isAdmin, isManager)
this.setState({
isAdmin
})
}
}
+ loadNextProjects () {
+ const { loadNextProjects: nextProjectsHandler, token } = this.props
+ const isAdmin = checkAdmin(token)
+ const isManager = checkManager(token)
+
+ nextProjectsHandler(isAdmin, isManager)
+ }
+
isEditable () {
const { loginUserRoleInProject } = this.state
if (loginUserRoleInProject === PROJECT_ROLES.READ) {
@@ -66,8 +82,10 @@ class Users extends Component {
loadProject (projectId) {
fetchProjectById(projectId).then((project) => {
const projectMembers = _.get(project, 'members')
+ const invitedMembers = _.get(project, 'invites')
this.setState({
- projectMembers
+ projectMembers,
+ invitedMembers
})
const { loggedInUser } = this.props
this.updateLoginUserRoleInProject(projectMembers, loggedInUser)
@@ -88,11 +106,13 @@ class Users extends Component {
}
removeProjectNember (projectMember) {
- const { projectMembers } = this.state
+ const { projectMembers, invitedMembers } = this.state
const newProjectMembers = _.filter(projectMembers, pm => pm.id !== projectMember.id)
+ const newInvitedMembers = _.filter(invitedMembers, pm => pm.id !== projectMember.id)
const { loggedInUser } = this.props
this.setState({
- projectMembers: newProjectMembers
+ projectMembers: newProjectMembers,
+ invitedMembers: newInvitedMembers
})
this.updateLoginUserRoleInProject(newProjectMembers, loggedInUser)
}
@@ -110,6 +130,15 @@ class Users extends Component {
this.updateLoginUserRoleInProject(newProjectMembers, loggedInUser)
}
+ addNewProjectInvite (invitedMember) {
+ this.setState(() => ({
+ invitedMembers: [
+ ...(this.state.invitedMembers || []),
+ invitedMember
+ ]
+ }))
+ }
+
render () {
const {
projects,
@@ -120,6 +149,7 @@ class Users extends Component {
} = this.props
const {
projectMembers,
+ invitedMembers,
isAdmin
} = this.state
return (
@@ -129,7 +159,10 @@ class Users extends Component {
updateProjectNember={this.updateProjectNember}
removeProjectNember={this.removeProjectNember}
addNewProjectMember={this.addNewProjectMember}
+ addNewProjectInvite={this.addNewProjectInvite}
+ loadNextProjects={this.loadNextProjects}
projectMembers={projectMembers}
+ invitedMembers={invitedMembers}
auth={auth}
isAdmin={isAdmin}
isEditable={this.isEditable()}
@@ -146,6 +179,7 @@ class Users extends Component {
const mapStateToProps = ({ users, auth }) => {
return {
projects: users.allUserProjects,
+ page: users.page,
isLoading: users.isLoadingAllUserProjects,
resultSearchUserProjects: users.searchUserProjects,
isSearchingUserProjects: users.isSearchingUserProjects,
@@ -164,12 +198,15 @@ Users.propTypes = {
isLoading: PT.bool,
isSearchingUserProjects: PT.bool,
loadAllUserProjects: PT.func.isRequired,
- searchUserProjects: PT.func.isRequired
+ searchUserProjects: PT.func.isRequired,
+ loadNextProjects: PT.func.isRequired,
+ page: PT.number
}
const mapDispatchToProps = {
loadAllUserProjects,
- searchUserProjects
+ searchUserProjects,
+ loadNextProjects
}
export default connect(mapStateToProps, mapDispatchToProps)(Users)
diff --git a/src/reducers/users.js b/src/reducers/users.js
index 8dc18247..e293c154 100644
--- a/src/reducers/users.js
+++ b/src/reducers/users.js
@@ -14,7 +14,9 @@ const initialState = {
allUserProjects: [],
isLoadingAllUserProjects: false,
searchUserProjects: [],
- isSearchingUserProjects: false
+ isSearchingUserProjects: false,
+ page: 1,
+ total: null
}
export default function (state = initialState, action) {
@@ -23,7 +25,9 @@ export default function (state = initialState, action) {
return {
...state,
allUserProjects: action.projects,
- isLoadingAllUserProjects: false
+ isLoadingAllUserProjects: false,
+ page: action.page,
+ total: action.total
}
case LOAD_ALL_USER_PROJECTS_PENDING:
return { ...state, isLoadingAllUserProjects: true }
diff --git a/src/routes.js b/src/routes.js
index aa87a527..fed56ded 100644
--- a/src/routes.js
+++ b/src/routes.js
@@ -33,6 +33,7 @@ import ConfirmationModal from './components/Modal/ConfirmationModal'
import Users from './containers/Users'
import { isBetaMode, removeFromLocalStorage, saveToLocalStorage } from './util/localstorage'
import ProjectEditor from './containers/ProjectEditor'
+import ProjectInvitations from './containers/ProjectInvitations'
const { ACCOUNTS_APP_LOGIN_URL, IDLE_TIMEOUT_MINUTES, IDLE_TIMEOUT_GRACE_MINUTES, COMMUNITY_APP_URL } = process.env
@@ -210,6 +211,14 @@ class Routes extends React.Component {
)()}
/>
+
renderApp(
+ ,
+ ,
+ ,
+
+ )()}
+ />
renderApp(
,
diff --git a/src/services/projectMemberInvites.js b/src/services/projectMemberInvites.js
new file mode 100644
index 00000000..783155d5
--- /dev/null
+++ b/src/services/projectMemberInvites.js
@@ -0,0 +1,66 @@
+import { axiosInstance as axios } from './axiosWithAuth'
+import { PROJECTS_API_URL } from '../config/constants'
+
+/**
+ * Update project member invite based on project's id & given member
+ * @param {integer} projectId unique identifier of the project
+ * @param {integer} inviteId unique identifier of the invite
+ * @param {string} status the new status for invitation
+ * @return {object} project member invite returned by api
+ */
+export function updateProjectMemberInvite (projectId, inviteId, status) {
+ const url = `${PROJECTS_API_URL}/${projectId}/invites/${inviteId}`
+ return axios.patch(url, { status })
+ .then(resp => resp.data)
+}
+
+/**
+ * Delete project member invite based on project's id & given invite's id
+ * @param {integer} projectId unique identifier of the project
+ * @param {integer} inviteId unique identifier of the invite
+ * @return {object} project member invite returned by api
+ */
+export function deleteProjectMemberInvite (projectId, inviteId) {
+ const url = `${PROJECTS_API_URL}/${projectId}/invites/${inviteId}`
+ return axios.delete(url)
+}
+
+/**
+ * Create a project member invite based on project's id & given member
+ * @param {integer} projectId unique identifier of the project
+ * @param {object} member invite
+ * @return {object} project member invite returned by api
+ */
+export function createProjectMemberInvite (projectId, member) {
+ const fields = 'id,projectId,userId,email,role,status,createdAt,updatedAt,createdBy,updatedBy,handle'
+ const url = `${PROJECTS_API_URL}/${projectId}/invites/?fields=` + encodeURIComponent(fields)
+ return axios({
+ method: 'post',
+ url,
+ data: member,
+ validateStatus (status) {
+ return (status >= 200 && status < 300) || status === 403
+ }
+ })
+ .then(resp => resp.data)
+}
+
+export function getProjectMemberInvites (projectId) {
+ const fields = 'id,projectId,userId,email,role,status,createdAt,updatedAt,createdBy,updatedBy,handle'
+ const url = `${PROJECTS_API_URL}/${projectId}/invites/?fields=` +
+ encodeURIComponent(fields)
+ return axios.get(url)
+ .then(resp => {
+ return resp.data
+ })
+}
+
+/**
+ * Get a project member invite based on project's id
+ * @param {integer} projectId unique identifier of the project
+ * @return {object} project member invite returned by api
+ */
+export function getProjectInviteById (projectId) {
+ return axios.get(`${PROJECTS_API_URL}/${projectId}/invites`)
+ .then(resp => resp.data)
+}
diff --git a/src/services/projects.js b/src/services/projects.js
index e749ea11..e61a0f70 100644
--- a/src/services/projects.js
+++ b/src/services/projects.js
@@ -7,11 +7,11 @@ import {
GENERIC_PROJECT_MILESTONE_PRODUCT_NAME,
GENERIC_PROJECT_MILESTONE_PRODUCT_TYPE,
PHASE_PRODUCT_CHALLENGE_ID_FIELD,
- PHASE_PRODUCT_TEMPLATE_ID
+ PHASE_PRODUCT_TEMPLATE_ID,
+ PROJECTS_API_URL
} from '../config/constants'
import { paginationHeaders } from '../util/pagination'
-
-const { PROJECT_API_URL } = process.env
+import { createProjectMemberInvite } from './projectMemberInvites'
/**
* Get billing accounts based on project id
@@ -21,7 +21,7 @@ const { PROJECT_API_URL } = process.env
* @returns {Promise