Skip to content

Commit 81a1173

Browse files
committed
PM-973 - invite user modal
1 parent 4fd1543 commit 81a1173

File tree

5 files changed

+222
-20
lines changed

5 files changed

+222
-20
lines changed

src/components/Users/Users.module.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,12 @@
118118
text-decoration: none;
119119
font-size: 12px;
120120
}
121+
122+
&.inviteEmailInput {
123+
input {
124+
width: 250px;
125+
}
126+
}
121127
}
122128
}
123129

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/* eslint-disable no-unused-vars */
2+
import React, { useState } from 'react'
3+
import PropTypes from 'prop-types'
4+
import cn from 'classnames'
5+
import { get } from 'lodash'
6+
import Modal from '../Modal'
7+
import PrimaryButton from '../Buttons/PrimaryButton'
8+
import { inviteUserToProject } from '../../services/projects'
9+
import { PROJECT_ROLES } from '../../config/constants'
10+
11+
import styles from './Users.module.scss'
12+
13+
const theme = {
14+
container: styles.modalContainer
15+
}
16+
17+
const validateEmail = (email) => {
18+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
19+
return emailRegex.test(email)
20+
}
21+
22+
const InviteUserModalContent = ({ projectId, onClose }) => {
23+
const [emailToInvite, setEmailToInvite] = useState('')
24+
const [showEmailError, setShowEmailError] = useState(false)
25+
const [inviteUserError, setInviteUserError] = useState(null)
26+
const [isInviting, setIsInviting] = useState(false)
27+
28+
const handleEmailBlur = () => {
29+
if (!validateEmail(emailToInvite)) {
30+
setShowEmailError(true)
31+
}
32+
}
33+
34+
const onInviteUserConfirmClick = async () => {
35+
if (isInviting) return
36+
37+
if (!emailToInvite || !validateEmail(emailToInvite)) {
38+
setShowEmailError(true)
39+
return
40+
}
41+
42+
setIsInviting(true)
43+
setInviteUserError(null)
44+
45+
try {
46+
// api restriction: ONLY "customer" role can be invited via email
47+
await inviteUserToProject(projectId, emailToInvite, PROJECT_ROLES.CUSTOMER)
48+
onClose()
49+
} catch (e) {
50+
const error = get(e, 'response.data.message', 'Unable to invite user')
51+
setInviteUserError(error)
52+
setIsInviting(false)
53+
}
54+
}
55+
56+
return (
57+
<Modal theme={theme} onCancel={onClose}>
58+
<div className={cn(styles.contentContainer, styles.confirm)}>
59+
<div className={styles.title}>Invite User</div>
60+
<div className={styles.addUserContentContainer}>
61+
<div className={styles.row}>
62+
<div className={cn(styles.field, styles.col1, styles.addUserTitle)}>
63+
Email<span className={styles.required}>*</span> :
64+
</div>
65+
<div className={cn(styles.field, styles.col2, styles.inviteEmailInput)}>
66+
<input
67+
type='email'
68+
name='email'
69+
placeholder='Enter Email'
70+
onChange={(e) => {
71+
setEmailToInvite(e.target.value)
72+
setShowEmailError(false)
73+
}}
74+
onBlur={handleEmailBlur}
75+
/>
76+
</div>
77+
</div>
78+
{showEmailError && (
79+
<div className={styles.row}>
80+
<div className={styles.errorMesssage}>Please enter a valid email address.</div>
81+
</div>
82+
)}
83+
{inviteUserError && (
84+
<div className={styles.errorMesssage}>{inviteUserError}</div>
85+
)}
86+
</div>
87+
<div className={styles.buttonGroup}>
88+
<div className={styles.buttonSizeA}>
89+
<PrimaryButton
90+
text={'Close'}
91+
type={'info'}
92+
onClick={onClose}
93+
/>
94+
</div>
95+
<div className={styles.buttonSizeA}>
96+
<PrimaryButton
97+
text={isInviting ? 'Inviting user...' : 'Invite User'}
98+
type={'info'}
99+
onClick={onInviteUserConfirmClick}
100+
/>
101+
</div>
102+
</div>
103+
</div>
104+
</Modal>
105+
)
106+
}
107+
108+
InviteUserModalContent.propTypes = {
109+
projectId: PropTypes.number.isRequired,
110+
onClose: PropTypes.func.isRequired
111+
}
112+
113+
export default InviteUserModalContent

src/config/constants.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ export const {
3333
TYPEFORM_URL,
3434
PROFILE_URL
3535
} = process.env
36+
3637
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
38+
export const PROJECTS_API_URL = process.env.PROJECTS_API_URL || process.env.PROJECT_API_URL
3739

3840
/**
3941
* Filepicker config
@@ -242,6 +244,7 @@ export const MARATHON_MATCH_SUBTRACKS = [
242244

243245
export const PROJECT_ROLES = {
244246
READ: 'observer',
247+
CUSTOMER: 'customer',
245248
WRITE: 'customer',
246249
MANAGER: 'manager',
247250
COPILOT: 'copilot'

src/services/projectMemberInvites.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { axiosInstance as axios } from './axiosWithAuth'
2+
import { PROJECTS_API_URL } from '../config/constants'
3+
4+
/**
5+
* Update project member invite based on project's id & given member
6+
* @param {integer} projectId unique identifier of the project
7+
* @param {integer} inviteId unique identifier of the invite
8+
* @param {string} status the new status for invitation
9+
* @return {object} project member invite returned by api
10+
*/
11+
export function updateProjectMemberInvite (projectId, inviteId, status) {
12+
const url = `${PROJECTS_API_URL}/v5/projects/${projectId}/invites/${inviteId}`
13+
return axios.patch(url, { status })
14+
.then(resp => resp.data)
15+
}
16+
17+
/**
18+
* Delete project member invite based on project's id & given invite's id
19+
* @param {integer} projectId unique identifier of the project
20+
* @param {integer} inviteId unique identifier of the invite
21+
* @return {object} project member invite returned by api
22+
*/
23+
export function deleteProjectMemberInvite (projectId, inviteId) {
24+
const url = `${PROJECTS_API_URL}/v5/projects/${projectId}/invites/${inviteId}`
25+
return axios.delete(url)
26+
}
27+
28+
/**
29+
* Create a project member invite based on project's id & given member
30+
* @param {integer} projectId unique identifier of the project
31+
* @param {object} member invite
32+
* @return {object} project member invite returned by api
33+
*/
34+
export function createProjectMemberInvite (projectId, member) {
35+
const fields = 'id,projectId,userId,email,role,status,createdAt,updatedAt,createdBy,updatedBy,handle'
36+
const url = `${PROJECTS_API_URL}/${projectId}/invites/?fields=` + encodeURIComponent(fields)
37+
return axios({
38+
method: 'post',
39+
url,
40+
data: member,
41+
validateStatus (status) {
42+
return (status >= 200 && status < 300) || status === 403
43+
}
44+
})
45+
.then(resp => resp.data)
46+
}
47+
48+
export function getProjectMemberInvites (projectId) {
49+
const fields = 'id,projectId,userId,email,role,status,createdAt,updatedAt,createdBy,updatedBy,handle'
50+
const url = `${PROJECTS_API_URL}/v5/projects/${projectId}/invites/?fields=` +
51+
encodeURIComponent(fields)
52+
return axios.get(url)
53+
.then(resp => {
54+
return resp.data
55+
})
56+
}
57+
58+
/**
59+
* Get a project member invite based on project's id
60+
* @param {integer} projectId unique identifier of the project
61+
* @return {object} project member invite returned by api
62+
*/
63+
export function getProjectInviteById (projectId) {
64+
return axios.get(`${PROJECTS_API_URL}/v5/projects/${projectId}/invites`)
65+
.then(resp => resp.data)
66+
}

src/services/projects.js

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import {
77
GENERIC_PROJECT_MILESTONE_PRODUCT_NAME,
88
GENERIC_PROJECT_MILESTONE_PRODUCT_TYPE,
99
PHASE_PRODUCT_CHALLENGE_ID_FIELD,
10-
PHASE_PRODUCT_TEMPLATE_ID
10+
PHASE_PRODUCT_TEMPLATE_ID,
11+
PROJECTS_API_URL
1112
} from '../config/constants'
1213
import { paginationHeaders } from '../util/pagination'
13-
14-
const { PROJECT_API_URL } = process.env
14+
import { createProjectMemberInvite } from './projectMemberInvites'
1515

1616
/**
1717
* Get billing accounts based on project id
@@ -21,7 +21,7 @@ const { PROJECT_API_URL } = process.env
2121
* @returns {Promise<Object>} Billing accounts data
2222
*/
2323
export async function fetchBillingAccounts (projectId) {
24-
const response = await axiosInstance.get(`${PROJECT_API_URL}/${projectId}/billingAccounts`)
24+
const response = await axiosInstance.get(`${PROJECTS_API_URL}/${projectId}/billingAccounts`)
2525
return _.get(response, 'data')
2626
}
2727

@@ -33,7 +33,7 @@ export async function fetchBillingAccounts (projectId) {
3333
* @returns {Promise<Object>} Billing account data
3434
*/
3535
export async function fetchBillingAccount (projectId) {
36-
const response = await axiosInstance.get(`${PROJECT_API_URL}/${projectId}/billingAccount`)
36+
const response = await axiosInstance.get(`${PROJECTS_API_URL}/${projectId}/billingAccount`)
3737
return _.get(response, 'data')
3838
}
3939

@@ -53,7 +53,7 @@ export function fetchMemberProjects (filters) {
5353
}
5454
}
5555

56-
return axiosInstance.get(`${PROJECT_API_URL}?${queryString.stringify(params)}`).then(response => {
56+
return axiosInstance.get(`${PROJECTS_API_URL}?${queryString.stringify(params)}`).then(response => {
5757
return { projects: _.get(response, 'data'), pagination: paginationHeaders(response) }
5858
})
5959
}
@@ -64,7 +64,7 @@ export function fetchMemberProjects (filters) {
6464
* @returns {Promise<*>}
6565
*/
6666
export async function fetchProjectById (id) {
67-
const response = await axiosInstance.get(`${PROJECT_API_URL}/${id}`)
67+
const response = await axiosInstance.get(`${PROJECTS_API_URL}/${id}`)
6868
return _.get(response, 'data')
6969
}
7070

@@ -74,7 +74,7 @@ export async function fetchProjectById (id) {
7474
* @returns {Promise<*>}
7575
*/
7676
export async function fetchProjectPhases (id) {
77-
const response = await axiosInstance.get(`${PROJECT_API_URL}/${id}/phases`, {
77+
const response = await axiosInstance.get(`${PROJECTS_API_URL}/${id}/phases`, {
7878
params: {
7979
fields: 'id,name,products,status'
8080
}
@@ -90,7 +90,7 @@ export async function fetchProjectPhases (id) {
9090
* @returns {Promise<*>}
9191
*/
9292
export async function updateProjectMemberRole (projectId, memberRecordId, newRole) {
93-
const response = await axiosInstance.patch(`${PROJECT_API_URL}/${projectId}/members/${memberRecordId}`, {
93+
const response = await axiosInstance.patch(`${PROJECTS_API_URL}/${projectId}/members/${memberRecordId}`, {
9494
role: newRole
9595
})
9696
return _.get(response, 'data')
@@ -104,21 +104,35 @@ export async function updateProjectMemberRole (projectId, memberRecordId, newRol
104104
* @returns {Promise<*>}
105105
*/
106106
export async function addUserToProject (projectId, userId, role) {
107-
const response = await axiosInstance.post(`${PROJECT_API_URL}/${projectId}/members`, {
107+
const response = await axiosInstance.post(`${PROJECTS_API_URL}/${projectId}/members`, {
108108
userId,
109109
role
110110
})
111111
return _.get(response, 'data')
112112
}
113113

114+
/**
115+
* adds the given user to the given project with the specified role
116+
* @param projectId project id
117+
* @param userId user id
118+
* @param role
119+
* @returns {Promise<*>}
120+
*/
121+
export async function inviteUserToProject (projectId, email, role) {
122+
return createProjectMemberInvite(projectId, {
123+
emails: [email],
124+
role: role
125+
})
126+
}
127+
114128
/**
115129
* removes the given member record from the project
116130
* @param projectId project id
117131
* @param memberRecordId member record id
118132
* @returns {Promise<*>}
119133
*/
120134
export async function removeUserFromProject (projectId, memberRecordId) {
121-
const response = await axiosInstance.delete(`${PROJECT_API_URL}/${projectId}/members/${memberRecordId}`)
135+
const response = await axiosInstance.delete(`${PROJECTS_API_URL}/${projectId}/members/${memberRecordId}`)
122136
return response
123137
}
124138

@@ -145,7 +159,7 @@ export async function saveChallengeAsPhaseProduct (projectId, phaseId, challenge
145159
estimatedPrice: 1
146160
}
147161

148-
return axiosInstance.post(`${PROJECT_API_URL}/${projectId}/phases/${phaseId}/products`,
162+
return axiosInstance.post(`${PROJECTS_API_URL}/${projectId}/phases/${phaseId}/products`,
149163
_.set(payload, PHASE_PRODUCT_CHALLENGE_ID_FIELD, challengeId)
150164
)
151165
}
@@ -176,7 +190,7 @@ export async function removeChallengeFromPhaseProduct (projectId, challengeId) {
176190

177191
if (selectedMilestoneProduct) {
178192
// If its the only challenge in product and product doesn't contain any other detail just delete it
179-
return axiosInstance.delete(`${PROJECT_API_URL}/${projectId}/phases/${selectedMilestoneProduct.phaseId}/products/${selectedMilestoneProduct.productId}`)
193+
return axiosInstance.delete(`${PROJECTS_API_URL}/${projectId}/phases/${selectedMilestoneProduct.phaseId}/products/${selectedMilestoneProduct.productId}`)
180194
}
181195
}
182196

@@ -186,7 +200,7 @@ export async function removeChallengeFromPhaseProduct (projectId, challengeId) {
186200
* @returns {Promise<*>}
187201
*/
188202
export async function createProjectApi (project) {
189-
const response = await axiosInstance.post(`${PROJECT_API_URL}`, project)
203+
const response = await axiosInstance.post(`${PROJECTS_API_URL}`, project)
190204
return _.get(response, 'data')
191205
}
192206

@@ -197,7 +211,7 @@ export async function createProjectApi (project) {
197211
* @returns {Promise<*>}
198212
*/
199213
export async function updateProjectApi (projectId, project) {
200-
const response = await axiosInstance.patch(`${PROJECT_API_URL}/${projectId}`, project)
214+
const response = await axiosInstance.patch(`${PROJECTS_API_URL}/${projectId}`, project)
201215
return _.get(response, 'data')
202216
}
203217

@@ -206,7 +220,7 @@ export async function updateProjectApi (projectId, project) {
206220
* @returns {Promise<*>}
207221
*/
208222
export async function getProjectTypes () {
209-
const response = await axiosInstance.get(`${PROJECT_API_URL}/metadata/projectTypes`)
223+
const response = await axiosInstance.get(`${PROJECTS_API_URL}/metadata/projectTypes`)
210224
return _.get(response, 'data')
211225
}
212226

@@ -218,7 +232,7 @@ export async function getProjectTypes () {
218232
*/
219233
export async function getProjectAttachment (projectId, attachmentId) {
220234
const response = await axiosInstance.get(
221-
`${PROJECT_API_URL}/${projectId}/attachments/${attachmentId}`
235+
`${PROJECTS_API_URL}/${projectId}/attachments/${attachmentId}`
222236
)
223237
return _.get(response, 'data')
224238
}
@@ -241,7 +255,7 @@ export async function addProjectAttachmentApi (projectId, data) {
241255
}
242256

243257
const response = await axiosInstance.post(
244-
`${PROJECT_API_URL}/${projectId}/attachments`,
258+
`${PROJECTS_API_URL}/${projectId}/attachments`,
245259
data
246260
)
247261
return _.get(response, 'data')
@@ -272,7 +286,7 @@ export async function updateProjectAttachmentApi (
272286
}
273287

274288
const response = await axiosInstance.patch(
275-
`${PROJECT_API_URL}/${projectId}/attachments/${attachmentId}`,
289+
`${PROJECTS_API_URL}/${projectId}/attachments/${attachmentId}`,
276290
data
277291
)
278292
return _.get(response, 'data')
@@ -285,6 +299,6 @@ export async function updateProjectAttachmentApi (
285299
*/
286300
export async function removeProjectAttachmentApi (projectId, attachmentId) {
287301
await axiosInstance.delete(
288-
`${PROJECT_API_URL}/${projectId}/attachments/${attachmentId}`
302+
`${PROJECTS_API_URL}/${projectId}/attachments/${attachmentId}`
289303
)
290304
}

0 commit comments

Comments
 (0)