diff --git a/.github/workflows/code_reviewer.yml b/.github/workflows/code_reviewer.yml new file mode 100644 index 00000000..a7a7e865 --- /dev/null +++ b/.github/workflows/code_reviewer.yml @@ -0,0 +1,22 @@ +name: AI PR Reviewer + +on: + pull_request: + types: + - opened + - synchronize +permissions: + pull-requests: write +jobs: + tc-ai-pr-review: + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@v3 + + - name: TC AI PR Reviewer + uses: topcoder-platform/tc-ai-pr-reviewer@master + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # The GITHUB_TOKEN is there by default so you just need to keep it like it is and not necessarily need to add it as secret as it will throw an error. [More Details](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#about-the-github_token-secret) + LAB45_API_KEY: ${{ secrets.LAB45_API_KEY }} + exclude: "**/*.json, **/*.md" # Optional: exclude patterns separated by commas diff --git a/public/static/comment.jpg b/public/static/comment.jpg new file mode 100644 index 00000000..52a7ce58 Binary files /dev/null and b/public/static/comment.jpg differ diff --git a/public/static/logo.jpg b/public/static/logo.jpg new file mode 100644 index 00000000..44cf1a5f Binary files /dev/null and b/public/static/logo.jpg differ diff --git a/src/actions/challenges.js b/src/actions/challenges.js index f1dabb7e..60eb631f 100644 --- a/src/actions/challenges.js +++ b/src/actions/challenges.js @@ -57,7 +57,7 @@ import { } from '../config/constants' import { loadProject } from './projects' import { removeChallengeFromPhaseProduct, saveChallengeAsPhaseProduct } from '../services/projects' -import { checkAdmin } from '../util/tc' +import { checkAdmin, checkManager } from '../util/tc' /** * Member challenges related redux actions @@ -159,7 +159,11 @@ export function loadChallengesByPage ( filters['projectId'] = projectId } else if (_.isObject(projectId) && projectId.value > 0) { filters['projectId'] = projectId.value - } else if (!checkAdmin(getState().auth.token) && userId) { + } else if ( + !checkAdmin(getState().auth.token) && + !checkManager(getState().auth.token) && + userId + ) { // Note that we only add the memberId field if *no* project ID is given, // so that the list of *all challenges shows only those that the member is on filters['memberId'] = userId diff --git a/src/actions/projects.js b/src/actions/projects.js index 2d88e519..56f803ee 100644 --- a/src/actions/projects.js +++ b/src/actions/projects.js @@ -32,7 +32,7 @@ import { fetchMemberProjects, updateProjectApi } from '../services/projects' -import { checkAdmin } from '../util/tc' +import { checkAdmin, checkManager } from '../util/tc' function _loadProjects (projectNameOrIdFilter = '', paramFilters = {}) { return (dispatch, getState) => { @@ -54,7 +54,7 @@ function _loadProjects (projectNameOrIdFilter = '', paramFilters = {}) { } } - if (!checkAdmin(getState().auth.token)) { + if (!checkAdmin(getState().auth.token) && !checkManager(getState().auth.token)) { filters['memberOnly'] = true } diff --git a/src/actions/sidebar.js b/src/actions/sidebar.js index 4fd02fd1..0c02d8f4 100644 --- a/src/actions/sidebar.js +++ b/src/actions/sidebar.js @@ -11,7 +11,7 @@ import { UNLOAD_PROJECTS_SUCCESS, PROJECTS_PAGE_SIZE } from '../config/constants' -import { checkAdmin } from '../util/tc' +import { checkAdmin, checkManager } from '../util/tc' import _ from 'lodash' /** @@ -50,7 +50,7 @@ export function loadProjects (filterProjectName = '', paramFilters = {}) { } } - if (!checkAdmin(getState().auth.token)) { + if (!checkAdmin(getState().auth.token) && !checkManager(getState().auth.token)) { filters['memberOnly'] = true } diff --git a/src/actions/users.js b/src/actions/users.js index 46a9a56f..31a65ab2 100644 --- a/src/actions/users.js +++ b/src/actions/users.js @@ -10,33 +10,55 @@ import { SEARCH_USER_PROJECTS_SUCCESS, SEARCH_USER_PROJECTS_FAILURE } from '../config/constants' +import _ from 'lodash' /** * Loads projects of the authenticated user */ -export function loadAllUserProjects (isAdmin = true) { - return (dispatch) => { +export function loadAllUserProjects (params, isAdmin = true, isManager = true) { + return (dispatch, getState) => { dispatch({ type: LOAD_ALL_USER_PROJECTS_PENDING }) + const state = getState().users + const filters = { status: 'active', - sort: 'lastActivityAt desc' + sort: 'lastActivityAt desc', + perPage: 20, + ...params } - if (!isAdmin) { + + if (!isAdmin && !isManager) { filters['memberOnly'] = true } - fetchMemberProjects(filters).then(({ projects }) => dispatch({ + fetchMemberProjects(filters).then(({ projects, pagination }) => dispatch({ type: LOAD_ALL_USER_PROJECTS_SUCCESS, - projects + projects: _.uniqBy((filters.page ? state.allUserProjects || [] : []).concat(projects), 'id'), + total: pagination.xTotal, + page: pagination.xPage })).catch(() => dispatch({ type: LOAD_ALL_USER_PROJECTS_FAILURE })) } } +export function loadNextProjects (isAdmin = true, isManager = true) { + return (dispatch, getState) => { + const { page, total, allUserProjects } = getState().users + if (allUserProjects.length >= total) { + return + } + + loadAllUserProjects(_.assign({}, { + perPage: 20, + page: page + 1 + }), isAdmin, isManager)(dispatch, getState) + } +} + /** * Filter projects of the authenticated user * diff --git a/src/components/UserCard/index.js b/src/components/UserCard/index.js index 87387714..67f1cc08 100644 --- a/src/components/UserCard/index.js +++ b/src/components/UserCard/index.js @@ -1,3 +1,5 @@ +import _ from 'lodash' +import moment from 'moment' import React, { Component } from 'react' import PropTypes from 'prop-types' import cn from 'classnames' @@ -6,7 +8,6 @@ import { PROJECT_ROLES } from '../../config/constants' import PrimaryButton from '../Buttons/PrimaryButton' import AlertModal from '../Modal/AlertModal' import { updateProjectMemberRole } from '../../services/projects' -import _ from 'lodash' const theme = { container: styles.modalContainer @@ -58,7 +59,7 @@ class UserCard extends Component { } render () { - const { user, onRemoveClick, isEditable } = this.props + const { isInvite, user, onRemoveClick, isEditable } = this.props const showRadioButtons = _.includes(_.values(PROJECT_ROLES), user.role) return (
@@ -90,76 +91,90 @@ class UserCard extends Component { )}
- {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
-
-
-
- Member* : -
-
- -
-
- { - 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 && ( +
+
{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
+
+
+
+ Member* : +
+
+ +
+
+ {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} Billing accounts data */ export async function fetchBillingAccounts (projectId) { - const response = await axiosInstance.get(`${PROJECT_API_URL}/${projectId}/billingAccounts`) + const response = await axiosInstance.get(`${PROJECTS_API_URL}/${projectId}/billingAccounts`) return _.get(response, 'data') } @@ -33,7 +33,7 @@ export async function fetchBillingAccounts (projectId) { * @returns {Promise} Billing account data */ export async function fetchBillingAccount (projectId) { - const response = await axiosInstance.get(`${PROJECT_API_URL}/${projectId}/billingAccount`) + const response = await axiosInstance.get(`${PROJECTS_API_URL}/${projectId}/billingAccount`) return _.get(response, 'data') } @@ -53,7 +53,7 @@ export function fetchMemberProjects (filters) { } } - return axiosInstance.get(`${PROJECT_API_URL}?${queryString.stringify(params)}`).then(response => { + return axiosInstance.get(`${PROJECTS_API_URL}?${queryString.stringify(params)}`).then(response => { return { projects: _.get(response, 'data'), pagination: paginationHeaders(response) } }) } @@ -64,7 +64,7 @@ export function fetchMemberProjects (filters) { * @returns {Promise<*>} */ export async function fetchProjectById (id) { - const response = await axiosInstance.get(`${PROJECT_API_URL}/${id}`) + const response = await axiosInstance.get(`${PROJECTS_API_URL}/${id}`) return _.get(response, 'data') } @@ -74,7 +74,7 @@ export async function fetchProjectById (id) { * @returns {Promise<*>} */ export async function fetchProjectPhases (id) { - const response = await axiosInstance.get(`${PROJECT_API_URL}/${id}/phases`, { + const response = await axiosInstance.get(`${PROJECTS_API_URL}/${id}/phases`, { params: { fields: 'id,name,products,status' } @@ -90,7 +90,7 @@ export async function fetchProjectPhases (id) { * @returns {Promise<*>} */ export async function updateProjectMemberRole (projectId, memberRecordId, newRole) { - const response = await axiosInstance.patch(`${PROJECT_API_URL}/${projectId}/members/${memberRecordId}`, { + const response = await axiosInstance.patch(`${PROJECTS_API_URL}/${projectId}/members/${memberRecordId}`, { role: newRole }) return _.get(response, 'data') @@ -104,13 +104,27 @@ export async function updateProjectMemberRole (projectId, memberRecordId, newRol * @returns {Promise<*>} */ export async function addUserToProject (projectId, userId, role) { - const response = await axiosInstance.post(`${PROJECT_API_URL}/${projectId}/members`, { + const response = await axiosInstance.post(`${PROJECTS_API_URL}/${projectId}/members`, { userId, role }) return _.get(response, 'data') } +/** + * adds the given user to the given project with the specified role + * @param projectId project id + * @param userId user id + * @param role + * @returns {Promise<*>} + */ +export async function inviteUserToProject (projectId, email, role) { + return createProjectMemberInvite(projectId, { + emails: [email], + role: role + }) +} + /** * removes the given member record from the project * @param projectId project id @@ -118,7 +132,7 @@ export async function addUserToProject (projectId, userId, role) { * @returns {Promise<*>} */ export async function removeUserFromProject (projectId, memberRecordId) { - const response = await axiosInstance.delete(`${PROJECT_API_URL}/${projectId}/members/${memberRecordId}`) + const response = await axiosInstance.delete(`${PROJECTS_API_URL}/${projectId}/members/${memberRecordId}`) return response } @@ -145,7 +159,7 @@ export async function saveChallengeAsPhaseProduct (projectId, phaseId, challenge estimatedPrice: 1 } - return axiosInstance.post(`${PROJECT_API_URL}/${projectId}/phases/${phaseId}/products`, + return axiosInstance.post(`${PROJECTS_API_URL}/${projectId}/phases/${phaseId}/products`, _.set(payload, PHASE_PRODUCT_CHALLENGE_ID_FIELD, challengeId) ) } @@ -176,7 +190,7 @@ export async function removeChallengeFromPhaseProduct (projectId, challengeId) { if (selectedMilestoneProduct) { // If its the only challenge in product and product doesn't contain any other detail just delete it - return axiosInstance.delete(`${PROJECT_API_URL}/${projectId}/phases/${selectedMilestoneProduct.phaseId}/products/${selectedMilestoneProduct.productId}`) + return axiosInstance.delete(`${PROJECTS_API_URL}/${projectId}/phases/${selectedMilestoneProduct.phaseId}/products/${selectedMilestoneProduct.productId}`) } } @@ -186,7 +200,7 @@ export async function removeChallengeFromPhaseProduct (projectId, challengeId) { * @returns {Promise<*>} */ export async function createProjectApi (project) { - const response = await axiosInstance.post(`${PROJECT_API_URL}`, project) + const response = await axiosInstance.post(`${PROJECTS_API_URL}`, project) return _.get(response, 'data') } @@ -197,7 +211,7 @@ export async function createProjectApi (project) { * @returns {Promise<*>} */ export async function updateProjectApi (projectId, project) { - const response = await axiosInstance.patch(`${PROJECT_API_URL}/${projectId}`, project) + const response = await axiosInstance.patch(`${PROJECTS_API_URL}/${projectId}`, project) return _.get(response, 'data') } @@ -206,7 +220,7 @@ export async function updateProjectApi (projectId, project) { * @returns {Promise<*>} */ export async function getProjectTypes () { - const response = await axiosInstance.get(`${PROJECT_API_URL}/metadata/projectTypes`) + const response = await axiosInstance.get(`${PROJECTS_API_URL}/metadata/projectTypes`) return _.get(response, 'data') } @@ -218,7 +232,7 @@ export async function getProjectTypes () { */ export async function getProjectAttachment (projectId, attachmentId) { const response = await axiosInstance.get( - `${PROJECT_API_URL}/${projectId}/attachments/${attachmentId}` + `${PROJECTS_API_URL}/${projectId}/attachments/${attachmentId}` ) return _.get(response, 'data') } @@ -241,7 +255,7 @@ export async function addProjectAttachmentApi (projectId, data) { } const response = await axiosInstance.post( - `${PROJECT_API_URL}/${projectId}/attachments`, + `${PROJECTS_API_URL}/${projectId}/attachments`, data ) return _.get(response, 'data') @@ -272,7 +286,7 @@ export async function updateProjectAttachmentApi ( } const response = await axiosInstance.patch( - `${PROJECT_API_URL}/${projectId}/attachments/${attachmentId}`, + `${PROJECTS_API_URL}/${projectId}/attachments/${attachmentId}`, data ) return _.get(response, 'data') @@ -285,6 +299,6 @@ export async function updateProjectAttachmentApi ( */ export async function removeProjectAttachmentApi (projectId, attachmentId) { await axiosInstance.delete( - `${PROJECT_API_URL}/${projectId}/attachments/${attachmentId}` + `${PROJECTS_API_URL}/${projectId}/attachments/${attachmentId}` ) } diff --git a/src/util/tc.js b/src/util/tc.js index 576e9df4..a9941596 100644 --- a/src/util/tc.js +++ b/src/util/tc.js @@ -10,7 +10,8 @@ import { SUBMITTER_ROLE_UUID, READ_ONLY_ROLES, ALLOWED_DOWNLOAD_SUBMISSIONS_ROLES, - ALLOWED_EDIT_RESOURCE_ROLES + ALLOWED_EDIT_RESOURCE_ROLES, + MANAGER_ROLES } from '../config/constants' import _ from 'lodash' import { decodeToken } from 'tc-auth-lib' @@ -200,6 +201,11 @@ export const checkAdmin = (token) => { return roles.some(val => ADMIN_ROLES.indexOf(val.toLowerCase()) > -1) } +export const checkManager = (token) => { + const tokenData = decodeToken(token) + const roles = _.get(tokenData, 'roles') + return roles.some(val => MANAGER_ROLES.indexOf(val.toLowerCase()) > -1) +} /** * Checks if token has any of the copilot roles * @param token @@ -227,6 +233,15 @@ export const checkAdminOrCopilot = (token, project) => { return isAdmin || (isCopilot && canManageProject) } +export const checkIsUserInvited = (token, project) => { + if (!token) { + return + } + + const tokenData = decodeToken(token) + return project && !_.isEmpty(project) && _.find(project.invites, { userId: tokenData.userId }) +} + /** * Get resource role by name * diff --git a/src/util/validation.js b/src/util/validation.js index 7f7e7c70..9d6cb6ac 100644 --- a/src/util/validation.js +++ b/src/util/validation.js @@ -58,7 +58,7 @@ export const taaSProjectFormValidationSchema = Yup.object({ /** * regex for url validation */ -const urlRegex = /((https?):\/\/)?(www.)?[a-z0-9]+(\.[a-z]{2,}){1,3}(#?\/?(?:[a-zA-Z0-9#]+))*\/?(\?[a-zA-Z0-9-_]+=[a-zA-Z0-9-%]+&?)?$/ +const urlRegex = /((https?):\/\/)?(www\.)?[\w-]+(\.[a-z]{2,}){1,3}(#?\/?(?:[a-zA-Z0-9#-]+))*\/?(\?[a-zA-Z0-9-_]+=[a-zA-Z0-9-%]+&?)?$/ /** * validation schema for add link form in assets library