From 59ec85bd0fc4573518ae747570c4b4ed1fd2907e Mon Sep 17 00:00:00 2001 From: maxceem Date: Tue, 15 Sep 2020 13:15:40 +0300 Subject: [PATCH] feat: separate invitation dialog for copilots ref issue #4102 --- .../TeamManagement/CopilotManagementDialog.js | 238 ++++++++++++++++++ .../TeamManagement/ProjectManagementDialog.js | 3 +- .../TeamManagement/TeamManagement.jsx | 112 ++++++++- .../TeamManagement/TeamManagement.scss | 3 +- .../TopcoderManagementDialog.js | 17 +- src/config/permissions.js | 27 ++ .../containers/TeamManagementContainer.jsx | 13 +- 7 files changed, 392 insertions(+), 21 deletions(-) create mode 100644 src/components/TeamManagement/CopilotManagementDialog.js diff --git a/src/components/TeamManagement/CopilotManagementDialog.js b/src/components/TeamManagement/CopilotManagementDialog.js new file mode 100644 index 000000000..b76cb11a6 --- /dev/null +++ b/src/components/TeamManagement/CopilotManagementDialog.js @@ -0,0 +1,238 @@ +import _ from 'lodash' +import React from 'react' +import PT from 'prop-types' +import moment from 'moment' +import Modal from 'react-modal' +import XMarkIcon from '../../assets/icons/icon-x-mark.svg' +import Avatar from 'appirio-tech-react-components/components/Avatar/Avatar' +import PERMISSIONS from '../../config/permissions' + +import { hasPermission } from '../../helpers/permissions' +import {getAvatarResized, getFullNameWithFallback} from '../../helpers/tcHelpers' +import { compareEmail, compareHandles } from '../../helpers/utils' +import AutocompleteInputContainer from './AutocompleteInputContainer' + +class ProjectManagementDialog extends React.Component { + constructor(props) { + super(props) + this.state = { + showAlreadyMemberError: false, + errorMessage: null + } + this.onChange = this.onChange.bind(this) + this.showIndividualErrors = this.showIndividualErrors.bind(this) + } + + componentWillReceiveProps(nextProps) { + const { processingInvites, selectedMembers } = this.props + + if (processingInvites && !nextProps.processingInvites ) { + const notInvitedSelectedMembers = _.reject(selectedMembers, (selectedMember) => ( + this.isSelectedMemberAlreadyInvited(nextProps.copilotTeamInvites, selectedMember) + )) + + this.props.onSelectedMembersUpdate(notInvitedSelectedMembers) + + if (nextProps.error) { + this.showIndividualErrors(nextProps.error, notInvitedSelectedMembers) + } + } + } + + onChange(selectedMembers) { + const { projectTeamInvites, members, topcoderTeamInvites, copilotTeamInvites } = this.props + + const present = _.some(selectedMembers, (selectedMember) => ( + this.isSelectedMemberAlreadyInvited(members, selectedMember) + || this.isSelectedMemberAlreadyInvited(topcoderTeamInvites, selectedMember) + || this.isSelectedMemberAlreadyInvited(projectTeamInvites, selectedMember) + || this.isSelectedMemberAlreadyInvited(copilotTeamInvites, selectedMember) + )) + + this.setState({ + validUserText: !present, + showAlreadyMemberError: present, + errorMessage: null, + }) + + this.props.onSelectedMembersUpdate(selectedMembers) + } + + isSelectedMemberAlreadyInvited(copilotTeamInvites = [], selectedMember) { + return !!copilotTeamInvites.find((invite) => ( + (invite.email && compareEmail(invite.email, selectedMember.label)) || + (invite.userId && compareHandles(invite.handle, selectedMember.label)) + )) + } + + showIndividualErrors(error) { + const uniqueMessages = _.groupBy(error.failed, 'message') + + const msgs = _.keys(uniqueMessages).map((message) => { + const users = uniqueMessages[message].map((failed) => ( + failed.email ? failed.email : failed.handle + )) + + return ({ + message, + users, + }) + }) + + const listMessages = msgs.map((m) => `${m.users.join(', ')}: ${m.message}`) + + this.setState({ + errorMessage: listMessages.length > 0 ? listMessages.join('\n') : null + }) + } + + render() { + const { + members, currentUser, isMember, removeMember, removeInvite, + onCancel, copilotTeamInvites = [], selectedMembers, processingInvites, + } = this.props + const showRemove = currentUser.isAdmin || (!currentUser.isCopilot && isMember) + const showSuggestions = hasPermission(PERMISSIONS.SEE_MEMBER_SUGGESTIONS) + let i = 0 + return ( + + +
+
+ Copilots + +
+ +
+ {(members.map((member) => { + if (!member.isCopilot) { + return null + } + i++ + const remove = () => { + removeMember(member) + } + const userFullName = getFullNameWithFallback(member) + return ( +
+ +
+ +
+ {userFullName} + + @{member.handle || 'ConnectUser'} + +
+
+ {showRemove &&
+ {(currentUser.userId === member.userId) ? 'Leave' : 'Remove'} +
} +
+ ) + }))} + {(copilotTeamInvites.map((invite) => { + const remove = () => { + removeInvite(invite) + } + i++ + const hasUserId = !_.isNil(invite.userId) + const handle = invite.handle + const userFullName = getFullNameWithFallback(invite) + return ( +
+ +
+ {hasUserId && {userFullName}} + + {hasUserId && handle && @{handle}} + { (!hasUserId) && {invite.email}} + +
+ {showRemove &&
+ Remove + + Invited {moment(invite.createdAt).format('MMM D, YY')} + +
} +
+ ) + }))} +
+ +
+
invite more copilots
+ + {this.state.showAlreadyMemberError &&
+ Project Member(s) can't be invited again. Please remove them from list. +
} + { this.state.errorMessage &&
+ {this.state.errorMessage} +
} + +
+
+ +
+ ) + } +} + +ProjectManagementDialog.defaultProps = { + projectTeamInvites: [], + topcoderTeamInvites: [], + members: [] +} + +ProjectManagementDialog.propTypes = { + error: PT.oneOfType([PT.object, PT.bool]), + currentUser: PT.object.isRequired, + members: PT.arrayOf(PT.object).isRequired, + allMembers: PT.arrayOf(PT.object).isRequired, + isMember: PT.bool.isRequired, + onCancel: PT.func.isRequired, + removeMember: PT.func.isRequired, + projectTeamInvites: PT.arrayOf(PT.object), + topcoderTeamInvites: PT.arrayOf(PT.object), + sendInvite: PT.func.isRequired, + removeInvite: PT.func.isRequired, + onSelectedMembersUpdate: PT.func.isRequired, + selectedMembers: PT.arrayOf(PT.object), + processingInvites: PT.bool.isRequired, +} + +export default ProjectManagementDialog diff --git a/src/components/TeamManagement/ProjectManagementDialog.js b/src/components/TeamManagement/ProjectManagementDialog.js index 6cc49abea..537c1038f 100644 --- a/src/components/TeamManagement/ProjectManagementDialog.js +++ b/src/components/TeamManagement/ProjectManagementDialog.js @@ -40,12 +40,13 @@ class ProjectManagementDialog extends React.Component { } onChange(selectedMembers) { - const { projectTeamInvites, members, topcoderTeamInvites } = this.props + const { projectTeamInvites, members, topcoderTeamInvites, copilotTeamInvites } = this.props const present = _.some(selectedMembers, (selectedMember) => ( this.isSelectedMemberAlreadyInvited(members, selectedMember) || this.isSelectedMemberAlreadyInvited(topcoderTeamInvites, selectedMember) || this.isSelectedMemberAlreadyInvited(projectTeamInvites, selectedMember) + || this.isSelectedMemberAlreadyInvited(copilotTeamInvites, selectedMember) )) this.setState({ diff --git a/src/components/TeamManagement/TeamManagement.jsx b/src/components/TeamManagement/TeamManagement.jsx index 31ca1b9e0..0697d360e 100644 --- a/src/components/TeamManagement/TeamManagement.jsx +++ b/src/components/TeamManagement/TeamManagement.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types' import uncontrollable from 'uncontrollable' import './TeamManagement.scss' import ProjectDialog from './ProjectManagementDialog' +import CopilotDialog from './CopilotManagementDialog' import TopcoderDialog from './TopcoderManagementDialog' import MemberItem from './MemberItem' import AddIcon from '../../assets/icons/icon-ui-bold-add.svg' @@ -46,9 +47,11 @@ class TeamManagement extends React.Component { this.state = { topcoderTeamInviteButtonExpanded: false, projectTeamInviteButtonExpanded: false, + copilotTeamInviteButtonExpanded: false, } this.projectTeamInviteButtonClick = this.projectTeamInviteButtonClick.bind(this) this.topcoderTeamInviteButtonClick = this.topcoderTeamInviteButtonClick.bind(this) + this.copilotTeamInviteButtonClick = this.copilotTeamInviteButtonClick.bind(this) } topcoderTeamInviteButtonClick() { @@ -61,6 +64,11 @@ class TeamManagement extends React.Component { this.setState({projectTeamInviteButtonExpanded: !this.state.projectTeamInviteButtonExpanded}) } + copilotTeamInviteButtonClick() { + this.refreshStickyComp() + this.setState({copilotTeamInviteButtonExpanded: !this.state.copilotTeamInviteButtonExpanded}) + } + refreshStickyComp() { const event = document.createEvent('Event') event.initEvent('refreshsticky', true, true) @@ -84,19 +92,22 @@ class TeamManagement extends React.Component { projectTeamInvites, onProjectInviteDeleteConfirm, onProjectInviteSend, deletingInvite, changeRole, onDeleteInvite, isShowTopcoderDialog, onShowTopcoderDialog, processingInvites, processingMembers, onTopcoderInviteSend, onTopcoderInviteDeleteConfirm, topcoderTeamInvites, onAcceptOrRefuse, error, - onSelectedMembersUpdate, selectedMembers, allMembers, updatingMemberIds + onSelectedMembersUpdate, selectedMembers, allMembers, updatingMemberIds, onShowCopilotDialog, copilotTeamInvites, + isShowCopilotDialog, onCopilotInviteSend, } = this.props - const { projectTeamInviteButtonExpanded, topcoderTeamInviteButtonExpanded, + copilotTeamInviteButtonExpanded, } = this.state const currentMember = members.filter((member) => member.userId === currentUser.userId)[0] const modalActive = isAddingTeamMember || deletingMember || isShowJoin || showNewMemberConfirmation || deletingInvite const customerTeamManageAction = (currentUser.isAdmin || currentUser.isManager) && !currentMember const topcoderTeamManageAction = hasPermission(PERMISSIONS.MANAGE_TOPCODER_TEAM) + const copilotTeamManageAction = hasPermission(PERMISSIONS.MANAGE_COPILOTS) + const canRequestCopilot = hasPermission(PERMISSIONS.REQUEST_COPILOTS) const canJoinAsCopilot = !currentMember && currentUser.isCopilot const canJoinAsManager = !currentMember && (currentUser.isManager || currentUser.isAccountManager) const canShowInvite = currentMember && (currentMember.isCustomer || currentMember.isCopilot || currentMember.isManager) @@ -104,6 +115,7 @@ class TeamManagement extends React.Component { const sortedMembers = members let projectTeamInviteCount = 0 let topcoderTeamInviteCount = 0 + let copilotTeamInviteCount = 0 return (
@@ -173,6 +185,67 @@ class TeamManagement extends React.Component {
+
+
+
+ Copilot + {copilotTeamManageAction && + onShowCopilotDialog(true)}> + Manage + + } +
+
+ {sortedMembers.map((member, i) => { + if (!member.isCopilot) { + return + } + + copilotTeamInviteCount++ + if (!copilotTeamInviteButtonExpanded && copilotTeamInviteCount > 3) { + return null + } + + return ( + + ) + })} + {copilotTeamInvites.map((invite, i) => { + copilotTeamInviteCount++ + if(!copilotTeamInviteButtonExpanded && copilotTeamInviteCount > 3) { + return null + } + + return ( + + ) + })} + {copilotTeamInviteCount > 3 && +
+
+ {!copilotTeamInviteButtonExpanded ? 'Show All': 'Show Less'} +
+
+ } + {canRequestCopilot && + + } +
+
+
@@ -183,7 +256,7 @@ class TeamManagement extends React.Component {
{sortedMembers.map((member, i) => { - if (member.isCustomer) { + if (member.isCustomer || member.isCopilot) { return } @@ -197,9 +270,6 @@ class TeamManagement extends React.Component { ) })} {topcoderTeamInvites.map((invite, i) => { - if (invite.isCustomer) { - return - } topcoderTeamInviteCount++ if(!topcoderTeamInviteButtonExpanded &&topcoderTeamInviteCount > 3) { return null @@ -284,6 +354,35 @@ class TeamManagement extends React.Component { /> ) })())} + {(!modalActive && isShowCopilotDialog) && ((() => { + const onClickCancel = () => onShowCopilotDialog(false) + const removeMember = (member) => { + onMemberDelete(member) + } + const removeInvite = (item) => { + onDeleteInvite({item, type: 'copilot'}) + } + return ( + + ) + })())} {(!modalActive && (isShowTopcoderDialog || this.props.history.location.hash === '#manageTopcoderTeam')) && ((() => { const onClickCancel = () => { this.props.history.push(this.props.history.location.pathname) @@ -538,6 +637,7 @@ export default uncontrollable(TeamManagement, { deletingMember: 'onMemberDelete', isShowJoin: 'onJoin', isShowProjectDialog: 'onShowProjectDialog', + isShowCopilotDialog: 'onShowCopilotDialog', isShowTopcoderDialog: 'onShowTopcoderDialog', deletingInvite: 'onDeleteInvite', isInvited: 'onInviteAcceptShow' diff --git a/src/components/TeamManagement/TeamManagement.scss b/src/components/TeamManagement/TeamManagement.scss index e1f48debb..ec80e7b4f 100644 --- a/src/components/TeamManagement/TeamManagement.scss +++ b/src/components/TeamManagement/TeamManagement.scss @@ -504,7 +504,8 @@ } } - .join-btn { + .join-btn, + a.join-btn { @include roboto; cursor: pointer; padding: $base-unit*2 $base-unit*3; diff --git a/src/components/TeamManagement/TopcoderManagementDialog.js b/src/components/TeamManagement/TopcoderManagementDialog.js index 03ae312a8..2c06521e8 100644 --- a/src/components/TeamManagement/TopcoderManagementDialog.js +++ b/src/components/TeamManagement/TopcoderManagementDialog.js @@ -42,10 +42,6 @@ class TopcoderManagementDialog extends React.Component { }, { title: 'Observer', value: 'observer', - }, { - title: 'Copilot', - value: 'copilot', - canAddDirectly: true, }, { title: 'Account Manager', value: 'account_manager', @@ -82,12 +78,13 @@ class TopcoderManagementDialog extends React.Component { } onChange(selectedMembers) { - const { projectTeamInvites, members, topcoderTeamInvites } = this.props + const { projectTeamInvites, members, topcoderTeamInvites, copilotTeamInvites } = this.props const present = _.some(selectedMembers, (selectedMember) => ( this.isSelectedMemberAlreadyInvited(members, selectedMember) || this.isSelectedMemberAlreadyInvited(topcoderTeamInvites, selectedMember) || this.isSelectedMemberAlreadyInvited(projectTeamInvites, selectedMember) + || this.isSelectedMemberAlreadyInvited(copilotTeamInvites, selectedMember) )) this.setState({ @@ -170,7 +167,7 @@ class TopcoderManagementDialog extends React.Component {
{(members.map((member) => { - if (member.isCustomer) { + if (member.isCustomer || member.isCopilot) { return null } i++ @@ -216,9 +213,9 @@ class TopcoderManagementDialog extends React.Component {
) } - let types = ['Copilot', 'Manager', 'Account Manager', 'Account Executive', 'Program Manager', 'Solution Architect', 'Project Manager'] + let types = ['Manager', 'Account Manager', 'Account Executive', 'Program Manager', 'Solution Architect', 'Project Manager'] const currentType = role - types = currentType === 'Observer'? ['Observer', ...types] : [...types] + types = currentType === 'Observer'? ['Observer', ...types] : [...types] const onClick = (type) => { this.onUserRoleChange(member.userId, member.id, type) } @@ -379,9 +376,7 @@ class TopcoderManagementDialog extends React.Component { disabled={processingInvites || this.state.showAlreadyMemberError || selectedMembers.length === 0} onClick={this.addUsers} > - {_.find(this.roles, {value:this.state.userRole}).canAddDirectly && !showApproveDecline - ?'Request invite' - :'Invite users'} + Invite users
} diff --git a/src/config/permissions.js b/src/config/permissions.js index cf5c0ff54..1840289a1 100644 --- a/src/config/permissions.js +++ b/src/config/permissions.js @@ -196,6 +196,33 @@ export default { ] }, + MANAGE_COPILOTS: { + _meta: { + group: 'Project Members', + title: 'Manage copilots', + description: 'Directly invite copilots to the project.', + }, + topcoderRoles: [ + ...TOPCODER_ADMINS, + ROLE_CONNECT_COPILOT_MANAGER + ] + }, + + REQUEST_COPILOTS: { + _meta: { + group: 'Project Members', + title: 'Request copilots', + description: 'Request copilots to the project.', + }, + projectRoles: [ + ..._.difference(PROJECT_ALL, [PROJECT_ROLE_COPILOT, PROJECT_ROLE_CUSTOMER]) + ], + topcoderRoles: [ + ...TOPCODER_ADMINS, + ROLE_CONNECT_COPILOT_MANAGER + ] + }, + ACCESS_PRIVATE_POST: { _meta: { group: 'Topics & Posts', diff --git a/src/projects/detail/containers/TeamManagementContainer.jsx b/src/projects/detail/containers/TeamManagementContainer.jsx index 88b2805bb..7a3e20035 100644 --- a/src/projects/detail/containers/TeamManagementContainer.jsx +++ b/src/projects/detail/containers/TeamManagementContainer.jsx @@ -47,6 +47,7 @@ class TeamManagementContainer extends Component { } this.onProjectInviteSend = this.onProjectInviteSend.bind(this) + this.onCopilotInviteSend = this.onCopilotInviteSend.bind(this) this.onProjectInviteDelete = this.onProjectInviteDelete.bind(this) this.onTopcoderInviteDelete = this.onTopcoderInviteDelete.bind(this) this.onTopcoderInviteSend = this.onTopcoderInviteSend.bind(this) @@ -116,6 +117,11 @@ class TeamManagementContainer extends Component { this.props.inviteProjectMembers(this.props.projectId, emails, handles) } + onCopilotInviteSend() { + const {handles, emails} = this.getEmailsAndHandles() + this.props.inviteTopcoderMembers(this.props.projectId, { role: 'copilot', handles, emails }) + } + onAcceptOrRefuse(invite) { return this.props.acceptOrRefuseInvite(this.props.projectId, invite) } @@ -182,7 +188,7 @@ class TeamManagementContainer extends Component { render() { const projectMembers = this.anontateMemberProps() - const {projectTeamInvites, topcoderTeamInvites } = this.props + const {projectTeamInvites, topcoderTeamInvites, copilotTeamInvites } = this.props return (
{ processingMembers: projectState.processingMembers, updatingMemberIds: projectState.updatingMemberIds, error: projectState.error, - topcoderTeamInvites: _.filter(projectState.project.invites, i => i.role !== 'customer'), + topcoderTeamInvites: _.filter(projectState.project.invites, i => i.role !== 'customer' && i.role !== 'copilot'), + copilotTeamInvites: _.filter(projectState.project.invites, i => i.role === 'copilot'), projectTeamInvites: _.filter(projectState.project.invites, i => i.role === 'customer') } }