Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 2aca828

Browse files
authoredFeb 22, 2021
Merge pull request #107 from mbaghel/feature/member-management
Feature/member management Add Members Directly
2 parents 1be375a + e3a73cb commit 2aca828

File tree

26 files changed

+552
-347
lines changed

26 files changed

+552
-347
lines changed
 

‎package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@
8181
"redux": "^4.0.5",
8282
"redux-logger": "^3.0.6",
8383
"redux-promise-middleware": "^6.1.2",
84-
"redux-thunk": "^2.3.0"
84+
"redux-thunk": "^2.3.0",
85+
"tc-auth-lib": "topcoder-platform/tc-auth-lib#1.0.4"
8586
},
8687
"browserslist": [
8788
"last 1 version",

‎src/components/BaseModal/index.jsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,22 @@ const containerStyle = {
2727
padding: "10px",
2828
};
2929

30-
function BaseModal({ open, onClose, children, title, button, disabled }) {
30+
function BaseModal({
31+
open,
32+
onClose,
33+
children,
34+
title,
35+
button,
36+
disabled,
37+
extraModalStyle,
38+
}) {
3139
return (
3240
<Modal
3341
open={open}
3442
onClose={onClose}
3543
closeIcon={<IconCross width="15px" height="15px" />}
3644
styles={{
37-
modal: modalStyle,
45+
modal: { ...modalStyle, ...extraModalStyle },
3846
modalContainer: containerStyle,
3947
}}
4048
center={true}
@@ -63,6 +71,7 @@ BaseModal.propTypes = {
6371
title: PT.string,
6472
button: PT.element,
6573
disabled: PT.bool,
74+
extraModalStyle: PT.object,
6675
};
6776

6877
export default BaseModal;

‎src/components/DateInput/index.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ DateInput.propTypes = {
3131
placeholder: PT.string,
3232
onBlur: PT.func,
3333
onFocus: PT.func,
34-
className: PT.string
34+
className: PT.string,
3535
};
3636

3737
export default DateInput;

‎src/components/DateInput/styles.module.scss

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@
55
width: 100%;
66
}
77
&.error {
8-
input{
8+
input {
99
border-color: #fe665d;
1010
}
11-
1211
}
1312
}
1413

‎src/components/FormField/index.jsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const FormField = ({ field }) => {
1818
<Field name={field.name}>
1919
{({ input, meta }) => (
2020
<div>
21-
{ !field.readonly && (
21+
{!field.readonly && (
2222
<label
2323
styleName={
2424
(input.value != "undefined" &&
@@ -89,9 +89,9 @@ const FormField = ({ field }) => {
8989
onFocus={input.onFocus}
9090
/>
9191
)}
92-
{(field.isRequired || field.customValidator) && meta.error && meta.touched && (
93-
<div styleName="field-error">{meta.error}</div>
94-
)}
92+
{(field.isRequired || field.customValidator) &&
93+
meta.error &&
94+
meta.touched && <div styleName="field-error">{meta.error}</div>}
9595
</div>
9696
)}
9797
</Field>

‎src/components/ReactSelect/index.jsx

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import React from "react";
77
import PT from "prop-types";
88
import Select from "react-select";
9+
import CreatableSelect from "react-select/creatable";
910
import "./styles.module.scss";
1011

1112
const ReactSelect = (props) => {
@@ -69,18 +70,36 @@ const ReactSelect = (props) => {
6970

7071
return (
7172
<div styleName="select-wrapper">
72-
<Select
73-
value={props.value}
74-
styles={customStyles}
75-
onChange={props.onChange}
76-
options={props.options}
77-
styleName={props.error ? "error" : ""}
78-
isMulti={props.isMulti}
79-
onBlur={props.onBlur}
80-
onFocus={props.onFocus}
81-
placeholder={props.placeholder}
82-
onInputChange={props.onInputChange}
83-
/>
73+
{props.isCreatable ? (
74+
<CreatableSelect
75+
value={props.value}
76+
styles={customStyles}
77+
onChange={props.onChange}
78+
options={props.options}
79+
styleName={props.error ? "error" : ""}
80+
isMulti={props.isMulti}
81+
onBlur={props.onBlur}
82+
onFocus={props.onFocus}
83+
placeholder={props.placeholder}
84+
onInputChange={props.onInputChange}
85+
noOptionsMessage={() => props.noOptionsText}
86+
createOptionPosition="first"
87+
/>
88+
) : (
89+
<Select
90+
value={props.value}
91+
styles={customStyles}
92+
onChange={props.onChange}
93+
options={props.options}
94+
styleName={props.error ? "error" : ""}
95+
isMulti={props.isMulti}
96+
onBlur={props.onBlur}
97+
onFocus={props.onFocus}
98+
placeholder={props.placeholder}
99+
onInputChange={props.onInputChange}
100+
noOptionsMessage={() => props.noOptionsText}
101+
/>
102+
)}
84103
</div>
85104
);
86105
};
@@ -100,6 +119,8 @@ ReactSelect.propTypes = {
100119
label: PT.string.isRequired,
101120
}).isRequired
102121
),
122+
isCreatable: PT.bool,
123+
noOptionsText: PT.string,
103124
};
104125

105126
export default ReactSelect;

‎src/components/TCForm/index.jsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,7 @@ const TCForm = ({
5858
<div styleName="field-group">
5959
{row.fields.map((field) => (
6060
<div styleName="field-group-field" key={field.name}>
61-
<FormField
62-
field={fields[field]}
63-
/>
61+
<FormField field={fields[field]} />
6462
</div>
6563
))}
6664
</div>

‎src/components/TCForm/styles.module.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
}
1515
}
1616

17-
.job-form-fields-wrapper{
17+
.job-form-fields-wrapper {
1818
width: 100%;
1919
max-width: 640px;
2020
margin: 0 auto;

‎src/components/TextInput/index.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import "./styles.module.scss";
1111
function TextInput(props) {
1212
return (
1313
<input
14-
styleName={cn("TextInput", props.className, {"readonly": props.readonly})}
14+
styleName={cn("TextInput", props.className, { readonly: props.readonly })}
1515
maxLength={props.maxLength}
1616
min={props.minValue}
1717
onChange={(event) => {

‎src/constants/index.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,3 +222,30 @@ export const STATUS_OPTIONS = [
222222
{ value: "closed", label: "closed" },
223223
{ value: "cancelled", label: "cancelled" },
224224
];
225+
226+
/*
227+
* TopCoder user roles
228+
*/
229+
export const ROLE_TOPCODER_USER = "Topcoder User";
230+
export const ROLE_CONNECT_COPILOT = "Connect Copilot";
231+
export const ROLE_CONNECT_MANAGER = "Connect Manager";
232+
export const ROLE_CONNECT_ACCOUNT_MANAGER = "Connect Account Manager";
233+
export const ROLE_CONNECT_ADMIN = "Connect Admin";
234+
export const ROLE_ADMINISTRATOR = "administrator";
235+
export const ROLE_CONNECT_COPILOT_MANAGER = "Connect Copilot Manager";
236+
export const ROLE_BUSINESS_DEVELOPMENT_REPRESENTATIVE =
237+
"Business Development Representative";
238+
export const ROLE_PRESALES = "Presales";
239+
export const ROLE_ACCOUNT_EXECUTIVE = "Account Executive";
240+
export const ROLE_PROGRAM_MANAGER = "Program Manager";
241+
export const ROLE_SOLUTION_ARCHITECT = "Solution Architect";
242+
export const ROLE_PROJECT_MANAGER = "Project Manager";
243+
244+
// User roles that can see suggestions when adding new members to project
245+
export const SEE_SUGGESTION_ROLES = [
246+
ROLE_ADMINISTRATOR,
247+
ROLE_CONNECT_ADMIN,
248+
ROLE_CONNECT_MANAGER,
249+
ROLE_CONNECT_ACCOUNT_MANAGER,
250+
ROLE_CONNECT_COPILOT_MANAGER,
251+
];

‎src/routes/JobDetails/index.jsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,10 @@ const JobDetails = ({ teamId, jobId }) => {
3636
const skill = _.find(skills, { id: skillId });
3737

3838
if (!skill) {
39-
console.warn(`Couldn't find name for skill id "${skillId}" of the job "${job.id}".`)
40-
return null
39+
console.warn(
40+
`Couldn't find name for skill id "${skillId}" of the job "${job.id}".`
41+
);
42+
return null;
4143
}
4244

4345
return skill.name;
@@ -79,7 +81,10 @@ const JobDetails = ({ teamId, jobId }) => {
7981
<DataItem title="Resource Type" icon={<IconDescription />}>
8082
{job.resourceType}
8183
</DataItem>
82-
<DataItem title="Resource Rate Frequency" icon={<IconDescription />}>
84+
<DataItem
85+
title="Resource Rate Frequency"
86+
icon={<IconDescription />}
87+
>
8388
{job.rateType}
8489
</DataItem>
8590
<DataItem title="Workload" icon={<IconDescription />}>

‎src/routes/JobForm/index.jsx

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -61,20 +61,21 @@ const JobForm = ({ teamId, jobId }) => {
6161

6262
// as we are using `PUT` method (not `PATCH`) we have send ALL the fields
6363
// fields which we don't send would become `null` otherwise
64-
const getRequestData = (values) => _.pick(values, [
65-
'projectId',
66-
'externalId',
67-
'description',
68-
'title',
69-
'startDate',
70-
'duration',
71-
'numPositions',
72-
'resourceType',
73-
'rateType',
74-
'workload',
75-
'skills',
76-
'status',
77-
]);
64+
const getRequestData = (values) =>
65+
_.pick(values, [
66+
"projectId",
67+
"externalId",
68+
"description",
69+
"title",
70+
"startDate",
71+
"duration",
72+
"numPositions",
73+
"resourceType",
74+
"rateType",
75+
"workload",
76+
"skills",
77+
"status",
78+
]);
7879

7980
useEffect(() => {
8081
if (skills && job && !options) {

‎src/routes/PositionDetails/index.jsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import "./styles.module.scss";
1717

1818
const PositionDetails = ({ teamId, positionId }) => {
1919
// be dafault show "Interested" tab
20-
const [candidateStatus, setCandidateStatus] = useState(CANDIDATE_STATUS.SHORTLIST);
20+
const [candidateStatus, setCandidateStatus] = useState(
21+
CANDIDATE_STATUS.SHORTLIST
22+
);
2123
const {
2224
state: { position, error },
2325
updateCandidate,
@@ -32,10 +34,14 @@ const PositionDetails = ({ teamId, positionId }) => {
3234

3335
// if there are some candidates to review, then show "To Review" tab by default
3436
useEffect(() => {
35-
if (position && _.filter(position.candidates, { status: CANDIDATE_STATUS.OPEN }).length > 0) {
36-
setCandidateStatus(CANDIDATE_STATUS.OPEN)
37+
if (
38+
position &&
39+
_.filter(position.candidates, { status: CANDIDATE_STATUS.OPEN }).length >
40+
0
41+
) {
42+
setCandidateStatus(CANDIDATE_STATUS.OPEN);
3743
}
38-
}, [position])
44+
}, [position]);
3945

4046
return (
4147
<Page title="Job Details">

‎src/routes/ResourceBookingDetails/index.jsx

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -20,26 +20,30 @@ import ResourceDetails from "./ResourceDetails";
2020
import "./styles.module.scss";
2121

2222
const ResourceBookingDetails = ({ teamId, resourceBookingId }) => {
23-
const [resource, loadingError] = useData(getReourceBookingById, resourceBookingId);
23+
const [resource, loadingError] = useData(
24+
getReourceBookingById,
25+
resourceBookingId
26+
);
2427
const [team, loadingTeamError] = useData(getTeamById, teamId);
25-
const [jobTitle, setJobTitle] = useState("")
26-
const [member, setMember] = useState("")
28+
const [jobTitle, setJobTitle] = useState("");
29+
const [member, setMember] = useState("");
2730

2831
useEffect(() => {
2932
if (team) {
30-
const resourceWithMemberDetails = _.find(
31-
team.resources,
32-
{ id: resourceBookingId }
33-
);
33+
const resourceWithMemberDetails = _.find(team.resources, {
34+
id: resourceBookingId,
35+
});
3436

3537
// resource inside Team object has all the member details we need
3638
setMember(resourceWithMemberDetails);
3739

3840
if (resourceWithMemberDetails.jobId) {
3941
const job = _.find(team.jobs, { id: resourceWithMemberDetails.jobId });
40-
setJobTitle(_.get(job, "title", `<Not Found> ${resourceWithMemberDetails.jobId}`));
42+
setJobTitle(
43+
_.get(job, "title", `<Not Found> ${resourceWithMemberDetails.jobId}`)
44+
);
4145
} else {
42-
setJobTitle("<Not Assigned>")
46+
setJobTitle("<Not Assigned>");
4347
}
4448
}
4549
}, [team, resourceBookingId]);
@@ -49,25 +53,25 @@ const ResourceBookingDetails = ({ teamId, resourceBookingId }) => {
4953
{!(member && resource) ? (
5054
<LoadingIndicator error={loadingError || loadingTeamError} />
5155
) : (
52-
<>
53-
<PageHeader
54-
title="Member Details"
55-
backTo={`/taas/myteams/${teamId}`}
56-
/>
57-
<div styleName="content-wrapper">
58-
<ResourceSummary member={member} />
59-
<ResourceDetails resource={resource} jobTitle={jobTitle} />
60-
<div styleName="actions">
61-
<Button
62-
size="medium"
63-
routeTo={`/taas/myteams/${teamId}/rb/${resource.id}/edit`}
64-
>
65-
Edit Member Details
56+
<>
57+
<PageHeader
58+
title="Member Details"
59+
backTo={`/taas/myteams/${teamId}`}
60+
/>
61+
<div styleName="content-wrapper">
62+
<ResourceSummary member={member} />
63+
<ResourceDetails resource={resource} jobTitle={jobTitle} />
64+
<div styleName="actions">
65+
<Button
66+
size="medium"
67+
routeTo={`/taas/myteams/${teamId}/rb/${resource.id}/edit`}
68+
>
69+
Edit Member Details
6670
</Button>
67-
</div>
6871
</div>
69-
</>
70-
)}
72+
</div>
73+
</>
74+
)}
7175
</Page>
7276
);
7377
};

‎src/routes/ResourceBookingForm/index.jsx

Lines changed: 29 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,7 @@ const ResourceBookingDetails = ({ teamId, resourceBookingId }) => {
2929

3030
const formData = useMemo(() => {
3131
if (team && rb) {
32-
const resource = _.find(
33-
team.resources,
34-
{ id: resourceBookingId }
35-
);
32+
const resource = _.find(team.resources, { id: resourceBookingId });
3633

3734
const data = {
3835
...rb,
@@ -69,40 +66,41 @@ const ResourceBookingDetails = ({ teamId, resourceBookingId }) => {
6966

7067
// as we are using `PUT` method (not `PATCH`) we have send ALL the fields
7168
// fields which we don't send would become `null` otherwise
72-
const getRequestData = (values) => _.pick(values, [
73-
'projectId',
74-
'userId',
75-
'jobId',
76-
'status',
77-
'startDate',
78-
'endDate',
79-
'memberRate',
80-
'customerRate',
81-
'rateType',
82-
]);
69+
const getRequestData = (values) =>
70+
_.pick(values, [
71+
"projectId",
72+
"userId",
73+
"jobId",
74+
"status",
75+
"startDate",
76+
"endDate",
77+
"memberRate",
78+
"customerRate",
79+
"rateType",
80+
]);
8381

8482
return (
8583
<Page title="Edit Member Details">
8684
{!formData ? (
8785
<LoadingIndicator error={loadingError || loadingTeamError} />
8886
) : (
89-
<>
90-
<PageHeader
91-
title="Edit Member Details"
92-
backTo={`/taas/myteams/${teamId}/rb/${resourceBookingId}`}
87+
<>
88+
<PageHeader
89+
title="Edit Member Details"
90+
backTo={`/taas/myteams/${teamId}/rb/${resourceBookingId}`}
91+
/>
92+
<div styleName="rb-modification-details">
93+
<TCForm
94+
configuration={getEditResourceBookingConfig(onSubmit)}
95+
initialValue={formData}
96+
submitButton={{ text: "Save" }}
97+
backButton={{ text: "Cancel", backTo: `/taas/myteams/${teamId}` }}
98+
submitting={submitting}
99+
setSubmitting={setSubmitting}
93100
/>
94-
<div styleName="rb-modification-details">
95-
<TCForm
96-
configuration={getEditResourceBookingConfig(onSubmit)}
97-
initialValue={formData}
98-
submitButton={{ text: "Save" }}
99-
backButton={{ text: "Cancel", backTo: `/taas/myteams/${teamId}` }}
100-
submitting={submitting}
101-
setSubmitting={setSubmitting}
102-
/>
103-
</div>
104-
</>
105-
)}
101+
</div>
102+
</>
103+
)}
106104
</Page>
107105
);
108106
};

‎src/routes/TeamAccess/actions/index.js

Lines changed: 9 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,8 @@ import {
66
getTeamMembers,
77
getTeamInvitees,
88
deleteTeamMember,
9-
deleteInvite,
109
getMemberSuggestions,
11-
postInvites,
10+
postMembers,
1211
} from "services/teams";
1312

1413
export const ACTION_TYPE = {
@@ -28,14 +27,10 @@ export const ACTION_TYPE = {
2827
REMOVE_MEMBER_PENDING: "REMOVE_MEMBER_PENDING",
2928
REMOVE_MEMBER_SUCCESS: "REMOVE_MEMBER_SUCCESS",
3029
REMOVE_MEMBER_ERROR: "REMOVE_MEMBER_ERROR",
31-
REMOVE_INVITE: "REMOVE_INVITE",
32-
REMOVE_INVITE_PENDING: "REMOVE_INVITE_PENDING",
33-
REMOVE_INVITE_SUCCESS: "REMOVE_INVITE_SUCCESS",
34-
REMOVE_INVITE_ERROR: "REMOVE_INVITE_ERROR",
35-
ADD_INVITES: "ADD_INVITES",
36-
ADD_INVITES_PENDING: "ADD_INVITES_PENDING",
37-
ADD_INVITES_SUCCESS: "ADD_INVITES_SUCCESS",
38-
ADD_INVITES_ERROR: "ADD_INVITES_ERROR",
30+
ADD_MEMBERS: "ADD_MEMBERS",
31+
ADD_MEMBERS_PENDING: "ADD_MEMBERS_PENDING",
32+
ADD_MEMBERS_SUCCESS: "ADD_MEMBERS_SUCCESS",
33+
ADD_MEMBERS_ERROR: "ADD_MEMBERS_ERROR",
3934
CLEAR_ALL: "CLEAR_ALL",
4035
CLEAR_SUGGESTIONS: "CLEAR_SUGGESTIONS",
4136
};
@@ -103,26 +98,6 @@ export const removeTeamMember = (teamId, memberId) => ({
10398
},
10499
});
105100

106-
/**
107-
* Removes an invite
108-
*
109-
* @param {string|number} teamId
110-
* @param {string|number} REMOVE_INVITE_PENDING
111-
*
112-
* @returns {Promise} deleted invite id or error
113-
*/
114-
export const removeInvite = (teamId, inviteId) => ({
115-
type: ACTION_TYPE.REMOVE_INVITE,
116-
payload: async () => {
117-
const res = await deleteInvite(teamId, inviteId);
118-
return res.data;
119-
},
120-
meta: {
121-
teamId,
122-
inviteId,
123-
},
124-
});
125-
126101
/**
127102
* Loads suggestions for invites
128103
*
@@ -149,18 +124,18 @@ export const clearSuggestions = () => ({
149124
});
150125

151126
/**
152-
* Adds invites to team
127+
* Adds members to team
153128
*
154129
* @param {string|number} teamId
155130
* @param {string[]} handles
156131
* @param {string[]} emails
157132
*
158133
* @returns {Promise} list of successes and failures, or error
159134
*/
160-
export const addInvites = (teamId, handles, emails) => ({
161-
type: ACTION_TYPE.ADD_INVITES,
135+
export const addMembers = (teamId, handles, emails) => ({
136+
type: ACTION_TYPE.ADD_MEMBERS,
162137
payload: async () => {
163-
const res = await postInvites(teamId, handles, emails, "customer");
138+
const res = await postMembers(teamId, handles, emails);
164139
return res.data;
165140
},
166141
});
Lines changed: 131 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,62 @@
11
import React, { useCallback, useState } from "react";
22
import _ from "lodash";
3+
import PT from "prop-types";
34
import { useDispatch, useSelector } from "react-redux";
45
import { toastr } from "react-redux-toastr";
5-
import { loadSuggestions, clearSuggestions, addInvites } from "../../actions";
6+
import { loadSuggestions, clearSuggestions, addMembers } from "../../actions";
67
import Button from "components/Button";
78
import BaseModal from "components/BaseModal";
89
import ReactSelect from "components/ReactSelect";
10+
import "./styles.module.scss";
911

12+
// Minimum length of input for suggestions to trigger
1013
const SUGGESTION_TRIGGER_LENGTH = 3;
1114

12-
function AddModal({ open, onClose, teamId, validateInvites }) {
15+
/**
16+
* Filters selected members, keeping those who could not be added to team
17+
* @param {Object[]} members The list of selected members
18+
* @param {Object[]} failedList The list of members who could not be added
19+
*
20+
* @returns {Object[]} The filtered list
21+
*/
22+
const filterFailed = (members, failedList) => {
23+
return members.filter((member) => {
24+
return _.some(failedList, (failedMem) => {
25+
if (failedMem.email) {
26+
return failedMem.email === member.label;
27+
}
28+
return failedMem.handle === member.label;
29+
});
30+
});
31+
};
32+
33+
/**
34+
* Groups users by error message so they can be displayed together
35+
* @param {Object[]} errorList A list of errors returned from server
36+
*
37+
* @returns {string[]} A list of messages, ready to be displayed
38+
*/
39+
const groupErrors = (errorList) => {
40+
const grouped = _.groupBy(errorList, "error");
41+
42+
const messages = Object.keys(grouped).map((error) => {
43+
const labels = grouped[error].map((failure) =>
44+
failure.email ? failure.email : failure.handle
45+
);
46+
47+
return {
48+
message: error,
49+
users: labels,
50+
};
51+
});
52+
53+
return messages.map((msg) => `${msg.users.join(", ")}: ${msg.message}`);
54+
};
55+
56+
const AddModal = ({ open, onClose, teamId, validateAdds, showSuggestions }) => {
1357
const [loading, setLoading] = useState(false);
14-
const [error, setError] = useState();
58+
const [validationError, setValidationError] = useState(false);
59+
const [responseErrors, setResponseErrors] = useState([]);
1560
const [selectedMembers, setSelectedMembers] = useState([]);
1661
const options = useSelector((state) =>
1762
state.teamMembers.suggestions.map((sugg) => ({
@@ -29,49 +74,60 @@ function AddModal({ open, onClose, teamId, validateInvites }) {
2974
{ leading: true }
3075
);
3176

32-
const validateSelection = () => {
33-
if (validateInvites(selectedMembers)) {
34-
setError(
35-
new Error(
36-
"Project members can't be invited again. Please remove them from list"
37-
)
38-
);
39-
} else {
40-
setError(undefined);
41-
}
42-
};
43-
4477
const handleClose = useCallback(() => {
4578
setSelectedMembers([]);
79+
setValidationError(false);
80+
setResponseErrors([]);
4681
onClose();
4782
}, [onClose]);
4883

49-
const submitInvites = useCallback(() => {
84+
const submitAdds = useCallback(() => {
5085
const handles = [];
5186
const emails = [];
5287
selectedMembers.forEach((member) => {
5388
const val = member.label;
5489
if (member.isEmail) {
55-
emails.push(val);
90+
emails.push(val.toLowerCase());
5691
} else {
5792
handles.push(val);
5893
}
5994
});
6095

6196
setLoading(true);
6297

63-
dispatch(addInvites(teamId, handles, emails)).then((res) => {
64-
setLoading(false);
65-
if (!res.value.failed) {
66-
const numInvites = res.value.success.length;
67-
const plural = numInvites !== 1 ? "s" : "";
68-
handleClose();
69-
toastr.success(
70-
"Invites Added",
71-
`Successfully added ${numInvites} invite${plural}`
72-
);
73-
}
74-
});
98+
dispatch(addMembers(teamId, handles, emails))
99+
.then((res) => {
100+
setLoading(false);
101+
const { success, failed } = res.value;
102+
if (success.length) {
103+
const numAdds = success.length;
104+
const plural = numAdds !== 1 ? "s" : "";
105+
toastr.success(
106+
"Members Added",
107+
`Successfully added ${numAdds} member${plural}`
108+
);
109+
}
110+
111+
if (failed.length) {
112+
const remaining = filterFailed(selectedMembers, failed);
113+
const errors = groupErrors(failed);
114+
115+
setSelectedMembers(remaining);
116+
setResponseErrors(errors);
117+
} else {
118+
handleClose();
119+
}
120+
})
121+
.catch((err) => {
122+
setLoading(false);
123+
124+
// Display message from server error, else display generic message
125+
if (!!err.response) {
126+
setResponseErrors([err.message]);
127+
} else {
128+
setResponseErrors(["Error occured when adding members"]);
129+
}
130+
});
75131
}, [dispatch, selectedMembers, teamId]);
76132

77133
const onInputChange = useCallback(
@@ -87,14 +143,16 @@ function AddModal({ open, onClose, teamId, validateInvites }) {
87143
return "";
88144
}
89145

90-
// load suggestions
91-
if (val.length >= SUGGESTION_TRIGGER_LENGTH) {
92-
debouncedLoadSuggestions(val);
93-
} else {
94-
dispatch(clearSuggestions());
146+
// load suggestions if role allows
147+
if (showSuggestions) {
148+
if (val.length >= SUGGESTION_TRIGGER_LENGTH) {
149+
debouncedLoadSuggestions(val);
150+
} else {
151+
dispatch(clearSuggestions());
152+
}
95153
}
96154
},
97-
[dispatch]
155+
[dispatch, selectedMembers, showSuggestions]
98156
);
99157

100158
const onUpdate = useCallback(
@@ -106,43 +164,70 @@ function AddModal({ open, onClose, teamId, validateInvites }) {
106164

107165
setSelectedMembers(normalizedArr);
108166

109-
validateSelection();
167+
const isAlreadySelected = validateAdds(normalizedArr);
168+
169+
if (isAlreadySelected) setValidationError(true);
170+
else setValidationError(false);
171+
172+
setResponseErrors([]);
110173

111174
dispatch(clearSuggestions());
112175
},
113-
[dispatch]
176+
[dispatch, validateAdds]
114177
);
115178

116-
const inviteButton = (
179+
const addButton = (
117180
<Button
118181
type="primary"
119182
size="medium"
120-
onClick={submitInvites}
121-
disabled={loading || selectedMembers.length < 1}
183+
onClick={submitAdds}
184+
disabled={loading || selectedMembers.length < 1 || validationError}
122185
>
123-
Invite
186+
Add
124187
</Button>
125188
);
126189

127190
return (
128191
<BaseModal
129192
open={open}
130193
onClose={handleClose}
131-
button={inviteButton}
132-
title="Invite more people"
194+
button={addButton}
195+
title="Add more people"
133196
disabled={loading}
197+
extraModalStyle={{ overflowY: "visible" }}
134198
>
135199
<ReactSelect
136200
value={selectedMembers}
137201
onChange={onUpdate}
138202
options={options}
139203
onInputChange={onInputChange}
140204
isMulti
141-
placeholder="Enter one or more user handles"
205+
placeholder="Enter email address(es) or user handles"
206+
isCreatable
207+
noOptionsText="Type to search"
142208
/>
143-
{error && error.message}
209+
{validationError && (
210+
<div styleName="error-message">
211+
Project member(s) can't be added again. Please remove them from list
212+
</div>
213+
)}
214+
{responseErrors.length > 0 && (
215+
<div styleName="error-message">
216+
{responseErrors.map((err) => (
217+
<p>{err}</p>
218+
))}
219+
</div>
220+
)}
144221
</BaseModal>
145222
);
146-
}
223+
};
224+
225+
AddModal.propTypes = {
226+
open: PT.bool,
227+
onClose: PT.func,
228+
teamId: PT.string,
229+
validateAdds: PT.func,
230+
showSuggestions: PT.bool,
231+
};
147232

148233
export default AddModal;
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
@import "styles/include";
2+
3+
.error-message {
4+
@include font-roboto;
5+
font-style: italic;
6+
font-size: 13px;
7+
color: #ff5b52;
8+
display: block;
9+
width: fit-content;
10+
margin: 24px auto;
11+
padding: 10px;
12+
border: 1px solid #ffd4d1;
13+
border-radius: 2px;
14+
background: #fff4f4;
15+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* Component containing additional logic for AddModal
3+
*/
4+
5+
import React, { useCallback } from "react";
6+
import _ from "lodash";
7+
import PT from "prop-types";
8+
import { SEE_SUGGESTION_ROLES } from "constants";
9+
import AddModal from "../AddModal";
10+
import { useTCRoles } from "../../hooks/useTCRoles";
11+
12+
/**
13+
* Checks if a member to be added is already on the team
14+
* @param {Object} newMember The new member to be added
15+
* @param {Object[]} memberList An array of members on the team
16+
*
17+
* @returns {boolean} true if member already on team, false otherwise
18+
*/
19+
const checkForMatches = (newMember, memberList) => {
20+
const label = newMember.label;
21+
22+
if (newMember.isEmail) {
23+
const lowered = label.toLowerCase();
24+
return memberList.find((member) => {
25+
return member.email === lowered;
26+
});
27+
}
28+
return memberList.find((member) => member.handle === label);
29+
};
30+
31+
/**
32+
* Checks if member has any of the allowed roles
33+
* @param {string[]} memberRoles A list of the member's roles
34+
* @param {string[]} neededRoles A list of allowed roles
35+
*
36+
* @returns {boolean} true if member has at least one allowed role, false otherwise
37+
*/
38+
const hasRequiredRole = (memberRoles, neededRoles) => {
39+
return _.some(memberRoles, (role) => {
40+
const loweredRole = role.toLowerCase();
41+
return neededRoles.find((needed) => {
42+
const lowNeeded = needed.toLowerCase();
43+
console.log(loweredRole, lowNeeded);
44+
return loweredRole === lowNeeded;
45+
});
46+
});
47+
};
48+
49+
const AddModalContainer = ({
50+
members,
51+
invitees,
52+
teamId,
53+
addOpen,
54+
setAddOpen,
55+
}) => {
56+
const roles = useTCRoles();
57+
58+
const validateAdds = useCallback(
59+
(newMembers) => {
60+
return _.some(newMembers, (newMember) => {
61+
return (
62+
checkForMatches(newMember, members) ||
63+
checkForMatches(newMember, invitees)
64+
);
65+
});
66+
},
67+
[members, invitees]
68+
);
69+
70+
const shouldShowSuggestions = useCallback(() => {
71+
return hasRequiredRole(roles, SEE_SUGGESTION_ROLES);
72+
}, [roles]);
73+
74+
return (
75+
<AddModal
76+
open={addOpen}
77+
onClose={() => setAddOpen(false)}
78+
teamId={teamId}
79+
validateAdds={validateAdds}
80+
showSuggestions={shouldShowSuggestions()}
81+
/>
82+
);
83+
};
84+
85+
AddModalContainer.propTypes = {
86+
teamId: PT.string,
87+
addOpen: PT.bool,
88+
setAddOpen: PT.func,
89+
members: PT.arrayOf(
90+
PT.shape({
91+
id: PT.number,
92+
userId: PT.number,
93+
role: PT.string,
94+
createdAt: PT.string,
95+
updatedAt: PT.string,
96+
createdBy: PT.number,
97+
updatedBy: PT.number,
98+
handle: PT.string,
99+
photoURL: PT.string,
100+
workingHourStart: PT.string,
101+
workingHourEnd: PT.string,
102+
timeZone: PT.string,
103+
email: PT.string,
104+
})
105+
),
106+
invitees: PT.arrayOf(
107+
PT.shape({
108+
createdAt: PT.string,
109+
createdBy: PT.number,
110+
email: PT.string,
111+
handle: PT.string,
112+
id: PT.number,
113+
projectId: PT.number,
114+
role: PT.string,
115+
status: PT.string,
116+
updatedAt: PT.string,
117+
updatedBy: PT.number,
118+
userId: PT.number,
119+
})
120+
),
121+
};
122+
123+
export default AddModalContainer;

‎src/routes/TeamAccess/components/DeleteModal/index.jsx

Lines changed: 19 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,14 @@ import { useDispatch } from "react-redux";
33
import { toastr } from "react-redux-toastr";
44
import BaseModal from "components/BaseModal";
55
import Button from "components/Button";
6-
import { removeTeamMember, removeInvite } from "../../actions";
7-
import "./styles.module.scss";
6+
import { removeTeamMember } from "../../actions";
87
import CenteredSpinner from "components/CenteredSpinner";
98

109
const MEMBER_TITLE = "You're about to delete a member from the team";
11-
const INVITE_TITLE = "You're about to remove an invitation";
1210

1311
const DELETE_MEMBER_TITLE = "Deleting Member...";
14-
const DELETE_INVITE_TITLE = "Deleting Invite...";
1512

16-
function DeleteModal({ selected, open, onClose, teamId, isInvite }) {
13+
function DeleteModal({ selected, open, onClose, teamId }) {
1714
const [loading, setLoading] = useState(false);
1815

1916
let handle;
@@ -25,61 +22,34 @@ function DeleteModal({ selected, open, onClose, teamId, isInvite }) {
2522
}
2623
}
2724

28-
let deleteTitle = DELETE_MEMBER_TITLE;
29-
if (isInvite) deleteTitle = DELETE_INVITE_TITLE;
30-
3125
const dispatch = useDispatch();
3226

3327
const deleteMember = useCallback(() => {
3428
setLoading(true);
35-
if (!isInvite) {
36-
dispatch(removeTeamMember(teamId, selected.id))
37-
.then(() => {
38-
setLoading(false);
39-
toastr.success(
40-
"Member Removed",
41-
`You have successfully removed ${handle} from the team`
42-
);
43-
onClose();
44-
})
45-
.catch((err) => {
46-
setLoading(false);
47-
toastr.error("Failed to Remove Member", err.message);
48-
});
49-
} else {
50-
dispatch(removeInvite(teamId, selected.id))
51-
.then(() => {
52-
setLoading(false);
53-
toastr.success(
54-
"Invite Removed",
55-
`You have successfully removed invite for ${handle}`
56-
);
57-
onClose();
58-
})
59-
.catch((err) => {
60-
setLoading(false);
61-
toastr.error("Failed to Remove Invite", err.message);
62-
});
63-
}
64-
}, [dispatch, selected, isInvite]);
29+
dispatch(removeTeamMember(teamId, selected.id))
30+
.then(() => {
31+
setLoading(false);
32+
toastr.success(
33+
"Member Removed",
34+
`You have successfully removed ${handle} from the team`
35+
);
36+
onClose();
37+
})
38+
.catch((err) => {
39+
setLoading(false);
40+
toastr.error("Failed to Remove Member", err.message);
41+
});
42+
}, [dispatch, selected]);
6543

6644
const displayText = useCallback(() => {
67-
if (isInvite) {
68-
return (
69-
"Once you cancel the invitation for " +
70-
handle +
71-
" they won't be able to access the project. " +
72-
"You will have to invite them again in order for them to gain access"
73-
);
74-
}
7545
return (
7646
"You are about to remove " +
7747
handle +
7848
" from your team. They will lose all rights to the project " +
7949
"and can't see or interact with it anymore. Do you still " +
8050
"want to remove the member?"
8151
);
82-
}, [selected, isInvite]);
52+
}, [selected]);
8353

8454
const button = (
8555
<Button
@@ -88,15 +58,15 @@ function DeleteModal({ selected, open, onClose, teamId, isInvite }) {
8858
onClick={() => deleteMember()}
8959
disabled={loading}
9060
>
91-
Remove {isInvite ? "invitation" : "member"}
61+
Remove member
9262
</Button>
9363
);
9464

9565
return (
9666
<BaseModal
9767
open={open}
9868
onClose={onClose}
99-
title={loading ? deleteTitle : isInvite ? INVITE_TITLE : MEMBER_TITLE}
69+
title={loading ? DELETE_MEMBER_TITLE : MEMBER_TITLE}
10070
button={button}
10171
disabled={loading}
10272
>

‎src/routes/TeamAccess/components/DeleteModal/styles.module.scss

Lines changed: 0 additions & 5 deletions
This file was deleted.

‎src/routes/TeamAccess/components/MemberList/index.jsx

Lines changed: 11 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,35 +3,24 @@
33
*/
44

55
import React, { useState } from "react";
6-
import _ from "lodash";
76
import PT from "prop-types";
87
import CardHeader from "components/CardHeader";
98
import Button from "components/Button";
109
import "./styles.module.scss";
1110
import Avatar from "components/Avatar";
1211
import { Link } from "@reach/router";
13-
1412
import TimeSection from "../TimeSection";
1513
import { formatInviteTime } from "utils/format";
1614
import IconDirectArrow from "../../../../assets/images/icon-direct-arrow.svg";
17-
import AddModal from "../AddModal";
1815
import DeleteModal from "../DeleteModal";
16+
import AddModalContainer from "../AddModalContainer";
1917

20-
function MemberList({ teamId, members, invitees }) {
18+
const MemberList = ({ teamId, members, invitees }) => {
2119
const [selectedToDelete, setSelectedToDelete] = useState(null);
22-
const [inviteOpen, setInviteOpen] = useState(false);
23-
const [isInvite, setIsInvite] = useState(false);
20+
const [addOpen, setAddOpen] = useState(false);
2421
const [deleteOpen, setDeleteOpen] = useState(false);
2522

26-
const validateInvites = (newInvites) => {
27-
return _.some(newInvites, (newInvite) => {
28-
members.find((member) => newInvite.label === member.handle) ||
29-
invitees.find((invite) => newInvite.label === invite.handle);
30-
});
31-
};
32-
33-
const openDeleteModal = (member, isInvite = false) => {
34-
setIsInvite(isInvite);
23+
const openDeleteModal = (member) => {
3524
setSelectedToDelete(member);
3625
setDeleteOpen(true);
3726
};
@@ -42,7 +31,7 @@ function MemberList({ teamId, members, invitees }) {
4231
<div styleName="list-header">
4332
<CardHeader title="Project Access" />
4433
<div styleName="actions">
45-
<Button onClick={() => setInviteOpen(true)}>+Add</Button>
34+
<Button onClick={() => setAddOpen(true)}>+Add</Button>
4635
</div>
4736
</div>
4837
{members.length > 0 || invitees.length > 0 ? (
@@ -101,12 +90,6 @@ function MemberList({ teamId, members, invitees }) {
10190
Invited {formatInviteTime(invitee.createdAt)}
10291
</div>
10392
</div>
104-
<button
105-
onClick={() => openDeleteModal(invitee, true)}
106-
styleName="delete"
107-
>
108-
&times;
109-
</button>
11093
</div>
11194
</div>
11295
))}
@@ -120,17 +103,17 @@ function MemberList({ teamId, members, invitees }) {
120103
open={deleteOpen}
121104
onClose={() => setDeleteOpen(false)}
122105
teamId={teamId}
123-
isInvite={isInvite}
124106
/>
125-
<AddModal
126-
open={inviteOpen}
127-
onClose={() => setInviteOpen(false)}
107+
<AddModalContainer
108+
members={members}
109+
invitees={invitees}
128110
teamId={teamId}
129-
validateInvites={validateInvites}
111+
addOpen={addOpen}
112+
setAddOpen={setAddOpen}
130113
/>
131114
</>
132115
);
133-
}
116+
};
134117

135118
MemberList.propTypes = {
136119
teamId: PT.string,
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* useTCRoles hook
3+
*/
4+
import { useEffect, useState } from "react";
5+
import { decodeToken } from "tc-auth-lib";
6+
import { getAuthUserTokens } from "@topcoder/micro-frontends-navbar-app";
7+
8+
/**
9+
* Hook which decodes token of logged in user and gives access to user's roles
10+
*
11+
* @returns {string[]} roles The user's roles
12+
*/
13+
export const useTCRoles = () => {
14+
const [roles, setRoles] = useState([]);
15+
16+
useEffect(() => {
17+
getAuthUserTokens()
18+
.then(({ tokenV3 }) => {
19+
if (!!tokenV3) {
20+
const decoded = decodeToken(tokenV3);
21+
setRoles(decoded.roles);
22+
} else {
23+
throw new Error("unable to get token");
24+
}
25+
})
26+
.catch((err) => {
27+
console.warn("Unable to get user roles");
28+
});
29+
}, []);
30+
31+
return roles;
32+
};

‎src/routes/TeamAccess/reducers/index.js

Lines changed: 8 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const initialState = {
1010
loading: false,
1111
error: undefined,
1212
updating: false,
13-
inviteError: undefined,
13+
addError: undefined,
1414
};
1515

1616
const reducer = (state = initialState, action) => {
@@ -84,28 +84,6 @@ const reducer = (state = initialState, action) => {
8484
error: action.payload,
8585
};
8686

87-
case ACTION_TYPE.REMOVE_INVITE_PENDING:
88-
return {
89-
...state,
90-
updating: true,
91-
error: undefined,
92-
};
93-
94-
case ACTION_TYPE.REMOVE_INVITE_SUCCESS:
95-
return {
96-
...state,
97-
invites: state.invites.filter((invite) => invite.id !== action.payload),
98-
updating: false,
99-
error: undefined,
100-
};
101-
102-
case ACTION_TYPE.REMOVE_INVITE_ERROR:
103-
return {
104-
...state,
105-
updating: false,
106-
error: action.payload,
107-
};
108-
10987
case ACTION_TYPE.LOAD_SUGGESTIONS_PENDING:
11088
return {
11189
...state,
@@ -134,31 +112,31 @@ const reducer = (state = initialState, action) => {
134112
suggestions: [],
135113
};
136114

137-
case ACTION_TYPE.ADD_INVITES_PENDING:
115+
case ACTION_TYPE.ADD_MEMBERS_PENDING:
138116
return {
139117
...state,
140118
updating: true,
141-
inviteError: undefined,
119+
addError: undefined,
142120
};
143121

144-
case ACTION_TYPE.ADD_INVITES_SUCCESS:
122+
case ACTION_TYPE.ADD_MEMBERS_SUCCESS:
145123
return {
146124
...state,
147-
invites: [...state.invites, ...action.payload.success],
125+
members: [...state.members, ...action.payload.success],
148126
updating: false,
149-
inviteError: action.payload.failed
127+
addError: action.payload.failed
150128
? {
151129
type: "SOME_FAILED",
152130
failed: action.payload.failed,
153131
}
154132
: undefined,
155133
};
156134

157-
case ACTION_TYPE.ADD_INVITES_ERROR:
135+
case ACTION_TYPE.ADD_MEMBERS_ERROR:
158136
return {
159137
...state,
160138
updating: false,
161-
inviteError: action.payload,
139+
addError: action.payload,
162140
};
163141

164142
default:

‎src/services/teams.js

Lines changed: 23 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -106,24 +106,6 @@ export const deleteTeamMember = (teamId, memberId) => {
106106
});
107107
};
108108

109-
/**
110-
* Delete Invite
111-
*
112-
* @param {string|number} teamId team id
113-
* @param {string|number} inviteId invite id
114-
*
115-
* @returns {Promise} inviteId or error
116-
*/
117-
export const deleteInvite = (teamId, inviteId) => {
118-
const url = `${config.API.V5}/projects/${teamId}/invites/${inviteId}`;
119-
return new Promise((resolve, reject) => {
120-
axios
121-
.delete(url)
122-
.then(() => resolve({ data: inviteId }))
123-
.catch((ex) => reject(ex));
124-
});
125-
};
126-
127109
/**
128110
* Get member suggestions
129111
*
@@ -136,38 +118,6 @@ export const getMemberSuggestions = (fragment) => {
136118
return axios.get(url);
137119
};
138120

139-
/**
140-
* Post new team invites
141-
*
142-
* @param {string|number} teamId team id
143-
* @param {string[]} handles user handles to add
144-
* @param {string[]} emails user emails to add
145-
* @param {string} role role to assign to users
146-
*
147-
* @returns {Promise<object>} object with successfully added invites, and failed invites
148-
*/
149-
export const postInvites = (teamId, handles, emails, role) => {
150-
const url = `${config.API.V5}/projects/${teamId}/invites/?fields=id,projectId,userId,email,role,status,createdAt,updatedAt,createdBy,updatedBy,handle`;
151-
const bodyObj = {};
152-
if (handles && handles.length > 0) {
153-
bodyObj.handles = handles;
154-
}
155-
if (emails && emails.length > 0) {
156-
bodyObj.emails = emails;
157-
}
158-
bodyObj.role = role;
159-
160-
return new Promise((resolve, reject) => {
161-
axios
162-
.post(url, bodyObj, {
163-
validateStatus: (status) =>
164-
(status >= 200 && status < 300) || status === 403,
165-
})
166-
.then((res) => resolve(res))
167-
.catch((ex) => reject(ex));
168-
});
169-
};
170-
171121
/**
172122
* Post an issue report
173123
*
@@ -193,3 +143,26 @@ export const postReport = (teamName, teamId, reportText, memberHandle) => {
193143

194144
return axios.post(url, bodyObj);
195145
};
146+
147+
/**
148+
* Post new team members
149+
*
150+
* @param {string|number} teamId team id
151+
* @param {string[]} handles user handles to add
152+
* @param {string[]} emails user emails to add
153+
*
154+
* @returns {Promise<object>} object with successfully added members and failed adds
155+
*/
156+
export const postMembers = (teamId, handles, emails) => {
157+
const url = `${config.API.V5}/taas-teams/${teamId}/members`;
158+
const bodyObj = {};
159+
160+
if (handles && handles.length > 0) {
161+
bodyObj.handles = handles;
162+
}
163+
if (emails && emails.length > 0) {
164+
bodyObj.emails = emails;
165+
}
166+
167+
return axios.post(url, bodyObj);
168+
};

0 commit comments

Comments
 (0)
This repository has been archived.