diff --git a/package-lock.json b/package-lock.json index 0a6e9146..85a59a66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16422,6 +16422,13 @@ } } }, + "tc-auth-lib": { + "version": "github:topcoder-platform/tc-auth-lib#68fdc22464810c51b703a33e529cdbd6d09437de", + "from": "github:topcoder-platform/tc-auth-lib#1.0.4", + "requires": { + "lodash": "^4.17.19" + } + }, "terminal-link": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", diff --git a/package.json b/package.json index 0d65bb8e..b3f1e105 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,8 @@ "redux": "^4.0.5", "redux-logger": "^3.0.6", "redux-promise-middleware": "^6.1.2", - "redux-thunk": "^2.3.0" + "redux-thunk": "^2.3.0", + "tc-auth-lib": "topcoder-platform/tc-auth-lib#1.0.4" }, "browserslist": [ "last 1 version", diff --git a/src/components/BaseModal/index.jsx b/src/components/BaseModal/index.jsx index 314e9d2c..e0bf5a83 100644 --- a/src/components/BaseModal/index.jsx +++ b/src/components/BaseModal/index.jsx @@ -27,14 +27,22 @@ const containerStyle = { padding: "10px", }; -function BaseModal({ open, onClose, children, title, button, disabled }) { +function BaseModal({ + open, + onClose, + children, + title, + button, + disabled, + extraModalStyle, +}) { return ( } styles={{ - modal: modalStyle, + modal: { ...modalStyle, ...extraModalStyle }, modalContainer: containerStyle, }} center={true} @@ -63,6 +71,7 @@ BaseModal.propTypes = { title: PT.string, button: PT.element, disabled: PT.bool, + extraModalStyle: PT.object, }; export default BaseModal; diff --git a/src/components/DateInput/index.jsx b/src/components/DateInput/index.jsx index 9f63bc7e..3bb3c89e 100644 --- a/src/components/DateInput/index.jsx +++ b/src/components/DateInput/index.jsx @@ -31,7 +31,7 @@ DateInput.propTypes = { placeholder: PT.string, onBlur: PT.func, onFocus: PT.func, - className: PT.string + className: PT.string, }; export default DateInput; diff --git a/src/components/DateInput/styles.module.scss b/src/components/DateInput/styles.module.scss index 33623161..10670205 100644 --- a/src/components/DateInput/styles.module.scss +++ b/src/components/DateInput/styles.module.scss @@ -5,10 +5,9 @@ width: 100%; } &.error { - input{ + input { border-color: #fe665d; } - } } diff --git a/src/components/FormField/index.jsx b/src/components/FormField/index.jsx index 938f3bce..2af5c131 100644 --- a/src/components/FormField/index.jsx +++ b/src/components/FormField/index.jsx @@ -18,7 +18,7 @@ const FormField = ({ field }) => { {({ input, meta }) => (
- { !field.readonly && ( + {!field.readonly && (
)}
diff --git a/src/components/ReactSelect/index.jsx b/src/components/ReactSelect/index.jsx index e2e64a17..cb6c3bec 100644 --- a/src/components/ReactSelect/index.jsx +++ b/src/components/ReactSelect/index.jsx @@ -6,6 +6,7 @@ import React from "react"; import PT from "prop-types"; import Select from "react-select"; +import CreatableSelect from "react-select/creatable"; import "./styles.module.scss"; const ReactSelect = (props) => { @@ -69,18 +70,36 @@ const ReactSelect = (props) => { return (
- props.noOptionsText} + /> + )}
); }; @@ -100,6 +119,8 @@ ReactSelect.propTypes = { label: PT.string.isRequired, }).isRequired ), + isCreatable: PT.bool, + noOptionsText: PT.string, }; export default ReactSelect; diff --git a/src/components/TCForm/index.jsx b/src/components/TCForm/index.jsx index 84eb577a..e66614d8 100644 --- a/src/components/TCForm/index.jsx +++ b/src/components/TCForm/index.jsx @@ -58,9 +58,7 @@ const TCForm = ({
{row.fields.map((field) => (
- +
))}
diff --git a/src/components/TCForm/styles.module.scss b/src/components/TCForm/styles.module.scss index 88c248a1..336470de 100644 --- a/src/components/TCForm/styles.module.scss +++ b/src/components/TCForm/styles.module.scss @@ -14,7 +14,7 @@ } } -.job-form-fields-wrapper{ +.job-form-fields-wrapper { width: 100%; max-width: 640px; margin: 0 auto; diff --git a/src/components/TextInput/index.jsx b/src/components/TextInput/index.jsx index ff702bb8..58d92d7c 100644 --- a/src/components/TextInput/index.jsx +++ b/src/components/TextInput/index.jsx @@ -11,7 +11,7 @@ import "./styles.module.scss"; function TextInput(props) { return ( { diff --git a/src/constants/index.js b/src/constants/index.js index fb911d46..d0261753 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -222,3 +222,30 @@ export const STATUS_OPTIONS = [ { value: "closed", label: "closed" }, { value: "cancelled", label: "cancelled" }, ]; + +/* + * TopCoder user roles + */ +export const ROLE_TOPCODER_USER = "Topcoder User"; +export const ROLE_CONNECT_COPILOT = "Connect Copilot"; +export const ROLE_CONNECT_MANAGER = "Connect Manager"; +export const ROLE_CONNECT_ACCOUNT_MANAGER = "Connect Account Manager"; +export const ROLE_CONNECT_ADMIN = "Connect Admin"; +export const ROLE_ADMINISTRATOR = "administrator"; +export const ROLE_CONNECT_COPILOT_MANAGER = "Connect Copilot Manager"; +export const ROLE_BUSINESS_DEVELOPMENT_REPRESENTATIVE = + "Business Development Representative"; +export const ROLE_PRESALES = "Presales"; +export const ROLE_ACCOUNT_EXECUTIVE = "Account Executive"; +export const ROLE_PROGRAM_MANAGER = "Program Manager"; +export const ROLE_SOLUTION_ARCHITECT = "Solution Architect"; +export const ROLE_PROJECT_MANAGER = "Project Manager"; + +// User roles that can see suggestions when adding new members to project +export const SEE_SUGGESTION_ROLES = [ + ROLE_ADMINISTRATOR, + ROLE_CONNECT_ADMIN, + ROLE_CONNECT_MANAGER, + ROLE_CONNECT_ACCOUNT_MANAGER, + ROLE_CONNECT_COPILOT_MANAGER, +]; diff --git a/src/routes/JobDetails/index.jsx b/src/routes/JobDetails/index.jsx index e86adccd..1f6146b0 100644 --- a/src/routes/JobDetails/index.jsx +++ b/src/routes/JobDetails/index.jsx @@ -36,8 +36,10 @@ const JobDetails = ({ teamId, jobId }) => { const skill = _.find(skills, { id: skillId }); if (!skill) { - console.warn(`Couldn't find name for skill id "${skillId}" of the job "${job.id}".`) - return null + console.warn( + `Couldn't find name for skill id "${skillId}" of the job "${job.id}".` + ); + return null; } return skill.name; @@ -79,7 +81,10 @@ const JobDetails = ({ teamId, jobId }) => { }> {job.resourceType} - }> + } + > {job.rateType} }> diff --git a/src/routes/JobForm/index.jsx b/src/routes/JobForm/index.jsx index a861f758..61f4cf1a 100644 --- a/src/routes/JobForm/index.jsx +++ b/src/routes/JobForm/index.jsx @@ -61,20 +61,21 @@ const JobForm = ({ teamId, jobId }) => { // as we are using `PUT` method (not `PATCH`) we have send ALL the fields // fields which we don't send would become `null` otherwise - const getRequestData = (values) => _.pick(values, [ - 'projectId', - 'externalId', - 'description', - 'title', - 'startDate', - 'duration', - 'numPositions', - 'resourceType', - 'rateType', - 'workload', - 'skills', - 'status', - ]); + const getRequestData = (values) => + _.pick(values, [ + "projectId", + "externalId", + "description", + "title", + "startDate", + "duration", + "numPositions", + "resourceType", + "rateType", + "workload", + "skills", + "status", + ]); useEffect(() => { if (skills && job && !options) { diff --git a/src/routes/PositionDetails/index.jsx b/src/routes/PositionDetails/index.jsx index e9e659b0..e9256d86 100644 --- a/src/routes/PositionDetails/index.jsx +++ b/src/routes/PositionDetails/index.jsx @@ -17,7 +17,9 @@ import "./styles.module.scss"; const PositionDetails = ({ teamId, positionId }) => { // be dafault show "Interested" tab - const [candidateStatus, setCandidateStatus] = useState(CANDIDATE_STATUS.SHORTLIST); + const [candidateStatus, setCandidateStatus] = useState( + CANDIDATE_STATUS.SHORTLIST + ); const { state: { position, error }, updateCandidate, @@ -32,10 +34,14 @@ const PositionDetails = ({ teamId, positionId }) => { // if there are some candidates to review, then show "To Review" tab by default useEffect(() => { - if (position && _.filter(position.candidates, { status: CANDIDATE_STATUS.OPEN }).length > 0) { - setCandidateStatus(CANDIDATE_STATUS.OPEN) + if ( + position && + _.filter(position.candidates, { status: CANDIDATE_STATUS.OPEN }).length > + 0 + ) { + setCandidateStatus(CANDIDATE_STATUS.OPEN); } - }, [position]) + }, [position]); return ( diff --git a/src/routes/ResourceBookingDetails/index.jsx b/src/routes/ResourceBookingDetails/index.jsx index a029873c..b2bc7828 100644 --- a/src/routes/ResourceBookingDetails/index.jsx +++ b/src/routes/ResourceBookingDetails/index.jsx @@ -20,26 +20,30 @@ import ResourceDetails from "./ResourceDetails"; import "./styles.module.scss"; const ResourceBookingDetails = ({ teamId, resourceBookingId }) => { - const [resource, loadingError] = useData(getReourceBookingById, resourceBookingId); + const [resource, loadingError] = useData( + getReourceBookingById, + resourceBookingId + ); const [team, loadingTeamError] = useData(getTeamById, teamId); - const [jobTitle, setJobTitle] = useState("") - const [member, setMember] = useState("") + const [jobTitle, setJobTitle] = useState(""); + const [member, setMember] = useState(""); useEffect(() => { if (team) { - const resourceWithMemberDetails = _.find( - team.resources, - { id: resourceBookingId } - ); + const resourceWithMemberDetails = _.find(team.resources, { + id: resourceBookingId, + }); // resource inside Team object has all the member details we need setMember(resourceWithMemberDetails); if (resourceWithMemberDetails.jobId) { const job = _.find(team.jobs, { id: resourceWithMemberDetails.jobId }); - setJobTitle(_.get(job, "title", ` ${resourceWithMemberDetails.jobId}`)); + setJobTitle( + _.get(job, "title", ` ${resourceWithMemberDetails.jobId}`) + ); } else { - setJobTitle("") + setJobTitle(""); } } }, [team, resourceBookingId]); @@ -49,25 +53,25 @@ const ResourceBookingDetails = ({ teamId, resourceBookingId }) => { {!(member && resource) ? ( ) : ( - <> - -
- - -
- -
- - )} + + + )}
); }; diff --git a/src/routes/ResourceBookingForm/index.jsx b/src/routes/ResourceBookingForm/index.jsx index ea475ea4..a7de9335 100644 --- a/src/routes/ResourceBookingForm/index.jsx +++ b/src/routes/ResourceBookingForm/index.jsx @@ -29,10 +29,7 @@ const ResourceBookingDetails = ({ teamId, resourceBookingId }) => { const formData = useMemo(() => { if (team && rb) { - const resource = _.find( - team.resources, - { id: resourceBookingId } - ); + const resource = _.find(team.resources, { id: resourceBookingId }); const data = { ...rb, @@ -69,40 +66,41 @@ const ResourceBookingDetails = ({ teamId, resourceBookingId }) => { // as we are using `PUT` method (not `PATCH`) we have send ALL the fields // fields which we don't send would become `null` otherwise - const getRequestData = (values) => _.pick(values, [ - 'projectId', - 'userId', - 'jobId', - 'status', - 'startDate', - 'endDate', - 'memberRate', - 'customerRate', - 'rateType', - ]); + const getRequestData = (values) => + _.pick(values, [ + "projectId", + "userId", + "jobId", + "status", + "startDate", + "endDate", + "memberRate", + "customerRate", + "rateType", + ]); return ( {!formData ? ( ) : ( - <> - + +
+ -
- -
- - )} +
+ + )}
); }; diff --git a/src/routes/TeamAccess/actions/index.js b/src/routes/TeamAccess/actions/index.js index 43e374b7..32970278 100644 --- a/src/routes/TeamAccess/actions/index.js +++ b/src/routes/TeamAccess/actions/index.js @@ -6,9 +6,8 @@ import { getTeamMembers, getTeamInvitees, deleteTeamMember, - deleteInvite, getMemberSuggestions, - postInvites, + postMembers, } from "services/teams"; export const ACTION_TYPE = { @@ -28,14 +27,10 @@ export const ACTION_TYPE = { REMOVE_MEMBER_PENDING: "REMOVE_MEMBER_PENDING", REMOVE_MEMBER_SUCCESS: "REMOVE_MEMBER_SUCCESS", REMOVE_MEMBER_ERROR: "REMOVE_MEMBER_ERROR", - REMOVE_INVITE: "REMOVE_INVITE", - REMOVE_INVITE_PENDING: "REMOVE_INVITE_PENDING", - REMOVE_INVITE_SUCCESS: "REMOVE_INVITE_SUCCESS", - REMOVE_INVITE_ERROR: "REMOVE_INVITE_ERROR", - ADD_INVITES: "ADD_INVITES", - ADD_INVITES_PENDING: "ADD_INVITES_PENDING", - ADD_INVITES_SUCCESS: "ADD_INVITES_SUCCESS", - ADD_INVITES_ERROR: "ADD_INVITES_ERROR", + ADD_MEMBERS: "ADD_MEMBERS", + ADD_MEMBERS_PENDING: "ADD_MEMBERS_PENDING", + ADD_MEMBERS_SUCCESS: "ADD_MEMBERS_SUCCESS", + ADD_MEMBERS_ERROR: "ADD_MEMBERS_ERROR", CLEAR_ALL: "CLEAR_ALL", CLEAR_SUGGESTIONS: "CLEAR_SUGGESTIONS", }; @@ -103,26 +98,6 @@ export const removeTeamMember = (teamId, memberId) => ({ }, }); -/** - * Removes an invite - * - * @param {string|number} teamId - * @param {string|number} REMOVE_INVITE_PENDING - * - * @returns {Promise} deleted invite id or error - */ -export const removeInvite = (teamId, inviteId) => ({ - type: ACTION_TYPE.REMOVE_INVITE, - payload: async () => { - const res = await deleteInvite(teamId, inviteId); - return res.data; - }, - meta: { - teamId, - inviteId, - }, -}); - /** * Loads suggestions for invites * @@ -149,7 +124,7 @@ export const clearSuggestions = () => ({ }); /** - * Adds invites to team + * Adds members to team * * @param {string|number} teamId * @param {string[]} handles @@ -157,10 +132,10 @@ export const clearSuggestions = () => ({ * * @returns {Promise} list of successes and failures, or error */ -export const addInvites = (teamId, handles, emails) => ({ - type: ACTION_TYPE.ADD_INVITES, +export const addMembers = (teamId, handles, emails) => ({ + type: ACTION_TYPE.ADD_MEMBERS, payload: async () => { - const res = await postInvites(teamId, handles, emails, "customer"); + const res = await postMembers(teamId, handles, emails); return res.data; }, }); diff --git a/src/routes/TeamAccess/components/AddModal/index.jsx b/src/routes/TeamAccess/components/AddModal/index.jsx index c046f7d2..0b1bfac2 100644 --- a/src/routes/TeamAccess/components/AddModal/index.jsx +++ b/src/routes/TeamAccess/components/AddModal/index.jsx @@ -1,17 +1,62 @@ import React, { useCallback, useState } from "react"; import _ from "lodash"; +import PT from "prop-types"; import { useDispatch, useSelector } from "react-redux"; import { toastr } from "react-redux-toastr"; -import { loadSuggestions, clearSuggestions, addInvites } from "../../actions"; +import { loadSuggestions, clearSuggestions, addMembers } from "../../actions"; import Button from "components/Button"; import BaseModal from "components/BaseModal"; import ReactSelect from "components/ReactSelect"; +import "./styles.module.scss"; +// Minimum length of input for suggestions to trigger const SUGGESTION_TRIGGER_LENGTH = 3; -function AddModal({ open, onClose, teamId, validateInvites }) { +/** + * Filters selected members, keeping those who could not be added to team + * @param {Object[]} members The list of selected members + * @param {Object[]} failedList The list of members who could not be added + * + * @returns {Object[]} The filtered list + */ +const filterFailed = (members, failedList) => { + return members.filter((member) => { + return _.some(failedList, (failedMem) => { + if (failedMem.email) { + return failedMem.email === member.label; + } + return failedMem.handle === member.label; + }); + }); +}; + +/** + * Groups users by error message so they can be displayed together + * @param {Object[]} errorList A list of errors returned from server + * + * @returns {string[]} A list of messages, ready to be displayed + */ +const groupErrors = (errorList) => { + const grouped = _.groupBy(errorList, "error"); + + const messages = Object.keys(grouped).map((error) => { + const labels = grouped[error].map((failure) => + failure.email ? failure.email : failure.handle + ); + + return { + message: error, + users: labels, + }; + }); + + return messages.map((msg) => `${msg.users.join(", ")}: ${msg.message}`); +}; + +const AddModal = ({ open, onClose, teamId, validateAdds, showSuggestions }) => { const [loading, setLoading] = useState(false); - const [error, setError] = useState(); + const [validationError, setValidationError] = useState(false); + const [responseErrors, setResponseErrors] = useState([]); const [selectedMembers, setSelectedMembers] = useState([]); const options = useSelector((state) => state.teamMembers.suggestions.map((sugg) => ({ @@ -29,30 +74,20 @@ function AddModal({ open, onClose, teamId, validateInvites }) { { leading: true } ); - const validateSelection = () => { - if (validateInvites(selectedMembers)) { - setError( - new Error( - "Project members can't be invited again. Please remove them from list" - ) - ); - } else { - setError(undefined); - } - }; - const handleClose = useCallback(() => { setSelectedMembers([]); + setValidationError(false); + setResponseErrors([]); onClose(); }, [onClose]); - const submitInvites = useCallback(() => { + const submitAdds = useCallback(() => { const handles = []; const emails = []; selectedMembers.forEach((member) => { const val = member.label; if (member.isEmail) { - emails.push(val); + emails.push(val.toLowerCase()); } else { handles.push(val); } @@ -60,18 +95,39 @@ function AddModal({ open, onClose, teamId, validateInvites }) { setLoading(true); - dispatch(addInvites(teamId, handles, emails)).then((res) => { - setLoading(false); - if (!res.value.failed) { - const numInvites = res.value.success.length; - const plural = numInvites !== 1 ? "s" : ""; - handleClose(); - toastr.success( - "Invites Added", - `Successfully added ${numInvites} invite${plural}` - ); - } - }); + dispatch(addMembers(teamId, handles, emails)) + .then((res) => { + setLoading(false); + const { success, failed } = res.value; + if (success.length) { + const numAdds = success.length; + const plural = numAdds !== 1 ? "s" : ""; + toastr.success( + "Members Added", + `Successfully added ${numAdds} member${plural}` + ); + } + + if (failed.length) { + const remaining = filterFailed(selectedMembers, failed); + const errors = groupErrors(failed); + + setSelectedMembers(remaining); + setResponseErrors(errors); + } else { + handleClose(); + } + }) + .catch((err) => { + setLoading(false); + + // Display message from server error, else display generic message + if (!!err.response) { + setResponseErrors([err.message]); + } else { + setResponseErrors(["Error occured when adding members"]); + } + }); }, [dispatch, selectedMembers, teamId]); const onInputChange = useCallback( @@ -87,14 +143,16 @@ function AddModal({ open, onClose, teamId, validateInvites }) { return ""; } - // load suggestions - if (val.length >= SUGGESTION_TRIGGER_LENGTH) { - debouncedLoadSuggestions(val); - } else { - dispatch(clearSuggestions()); + // load suggestions if role allows + if (showSuggestions) { + if (val.length >= SUGGESTION_TRIGGER_LENGTH) { + debouncedLoadSuggestions(val); + } else { + dispatch(clearSuggestions()); + } } }, - [dispatch] + [dispatch, selectedMembers, showSuggestions] ); const onUpdate = useCallback( @@ -106,21 +164,26 @@ function AddModal({ open, onClose, teamId, validateInvites }) { setSelectedMembers(normalizedArr); - validateSelection(); + const isAlreadySelected = validateAdds(normalizedArr); + + if (isAlreadySelected) setValidationError(true); + else setValidationError(false); + + setResponseErrors([]); dispatch(clearSuggestions()); }, - [dispatch] + [dispatch, validateAdds] ); - const inviteButton = ( + const addButton = ( ); @@ -128,9 +191,10 @@ function AddModal({ open, onClose, teamId, validateInvites }) { - {error && error.message} + {validationError && ( +
+ Project member(s) can't be added again. Please remove them from list +
+ )} + {responseErrors.length > 0 && ( +
+ {responseErrors.map((err) => ( +

{err}

+ ))} +
+ )}
); -} +}; + +AddModal.propTypes = { + open: PT.bool, + onClose: PT.func, + teamId: PT.string, + validateAdds: PT.func, + showSuggestions: PT.bool, +}; export default AddModal; diff --git a/src/routes/TeamAccess/components/AddModal/styles.module.scss b/src/routes/TeamAccess/components/AddModal/styles.module.scss new file mode 100644 index 00000000..debcd2fd --- /dev/null +++ b/src/routes/TeamAccess/components/AddModal/styles.module.scss @@ -0,0 +1,15 @@ +@import "styles/include"; + +.error-message { + @include font-roboto; + font-style: italic; + font-size: 13px; + color: #ff5b52; + display: block; + width: fit-content; + margin: 24px auto; + padding: 10px; + border: 1px solid #ffd4d1; + border-radius: 2px; + background: #fff4f4; +} diff --git a/src/routes/TeamAccess/components/AddModalContainer/index.jsx b/src/routes/TeamAccess/components/AddModalContainer/index.jsx new file mode 100644 index 00000000..e79d2c03 --- /dev/null +++ b/src/routes/TeamAccess/components/AddModalContainer/index.jsx @@ -0,0 +1,123 @@ +/** + * Component containing additional logic for AddModal + */ + +import React, { useCallback } from "react"; +import _ from "lodash"; +import PT from "prop-types"; +import { SEE_SUGGESTION_ROLES } from "constants"; +import AddModal from "../AddModal"; +import { useTCRoles } from "../../hooks/useTCRoles"; + +/** + * Checks if a member to be added is already on the team + * @param {Object} newMember The new member to be added + * @param {Object[]} memberList An array of members on the team + * + * @returns {boolean} true if member already on team, false otherwise + */ +const checkForMatches = (newMember, memberList) => { + const label = newMember.label; + + if (newMember.isEmail) { + const lowered = label.toLowerCase(); + return memberList.find((member) => { + return member.email === lowered; + }); + } + return memberList.find((member) => member.handle === label); +}; + +/** + * Checks if member has any of the allowed roles + * @param {string[]} memberRoles A list of the member's roles + * @param {string[]} neededRoles A list of allowed roles + * + * @returns {boolean} true if member has at least one allowed role, false otherwise + */ +const hasRequiredRole = (memberRoles, neededRoles) => { + return _.some(memberRoles, (role) => { + const loweredRole = role.toLowerCase(); + return neededRoles.find((needed) => { + const lowNeeded = needed.toLowerCase(); + console.log(loweredRole, lowNeeded); + return loweredRole === lowNeeded; + }); + }); +}; + +const AddModalContainer = ({ + members, + invitees, + teamId, + addOpen, + setAddOpen, +}) => { + const roles = useTCRoles(); + + const validateAdds = useCallback( + (newMembers) => { + return _.some(newMembers, (newMember) => { + return ( + checkForMatches(newMember, members) || + checkForMatches(newMember, invitees) + ); + }); + }, + [members, invitees] + ); + + const shouldShowSuggestions = useCallback(() => { + return hasRequiredRole(roles, SEE_SUGGESTION_ROLES); + }, [roles]); + + return ( + setAddOpen(false)} + teamId={teamId} + validateAdds={validateAdds} + showSuggestions={shouldShowSuggestions()} + /> + ); +}; + +AddModalContainer.propTypes = { + teamId: PT.string, + addOpen: PT.bool, + setAddOpen: PT.func, + members: PT.arrayOf( + PT.shape({ + id: PT.number, + userId: PT.number, + role: PT.string, + createdAt: PT.string, + updatedAt: PT.string, + createdBy: PT.number, + updatedBy: PT.number, + handle: PT.string, + photoURL: PT.string, + workingHourStart: PT.string, + workingHourEnd: PT.string, + timeZone: PT.string, + email: PT.string, + }) + ), + invitees: PT.arrayOf( + PT.shape({ + createdAt: PT.string, + createdBy: PT.number, + email: PT.string, + handle: PT.string, + id: PT.number, + projectId: PT.number, + role: PT.string, + status: PT.string, + updatedAt: PT.string, + updatedBy: PT.number, + userId: PT.number, + }) + ), +}; + +export default AddModalContainer; diff --git a/src/routes/TeamAccess/components/DeleteModal/index.jsx b/src/routes/TeamAccess/components/DeleteModal/index.jsx index bb9d76ca..cf3246b9 100644 --- a/src/routes/TeamAccess/components/DeleteModal/index.jsx +++ b/src/routes/TeamAccess/components/DeleteModal/index.jsx @@ -3,17 +3,14 @@ import { useDispatch } from "react-redux"; import { toastr } from "react-redux-toastr"; import BaseModal from "components/BaseModal"; import Button from "components/Button"; -import { removeTeamMember, removeInvite } from "../../actions"; -import "./styles.module.scss"; +import { removeTeamMember } from "../../actions"; import CenteredSpinner from "components/CenteredSpinner"; const MEMBER_TITLE = "You're about to delete a member from the team"; -const INVITE_TITLE = "You're about to remove an invitation"; const DELETE_MEMBER_TITLE = "Deleting Member..."; -const DELETE_INVITE_TITLE = "Deleting Invite..."; -function DeleteModal({ selected, open, onClose, teamId, isInvite }) { +function DeleteModal({ selected, open, onClose, teamId }) { const [loading, setLoading] = useState(false); let handle; @@ -25,53 +22,26 @@ function DeleteModal({ selected, open, onClose, teamId, isInvite }) { } } - let deleteTitle = DELETE_MEMBER_TITLE; - if (isInvite) deleteTitle = DELETE_INVITE_TITLE; - const dispatch = useDispatch(); const deleteMember = useCallback(() => { setLoading(true); - if (!isInvite) { - dispatch(removeTeamMember(teamId, selected.id)) - .then(() => { - setLoading(false); - toastr.success( - "Member Removed", - `You have successfully removed ${handle} from the team` - ); - onClose(); - }) - .catch((err) => { - setLoading(false); - toastr.error("Failed to Remove Member", err.message); - }); - } else { - dispatch(removeInvite(teamId, selected.id)) - .then(() => { - setLoading(false); - toastr.success( - "Invite Removed", - `You have successfully removed invite for ${handle}` - ); - onClose(); - }) - .catch((err) => { - setLoading(false); - toastr.error("Failed to Remove Invite", err.message); - }); - } - }, [dispatch, selected, isInvite]); + dispatch(removeTeamMember(teamId, selected.id)) + .then(() => { + setLoading(false); + toastr.success( + "Member Removed", + `You have successfully removed ${handle} from the team` + ); + onClose(); + }) + .catch((err) => { + setLoading(false); + toastr.error("Failed to Remove Member", err.message); + }); + }, [dispatch, selected]); const displayText = useCallback(() => { - if (isInvite) { - return ( - "Once you cancel the invitation for " + - handle + - " they won't be able to access the project. " + - "You will have to invite them again in order for them to gain access" - ); - } return ( "You are about to remove " + handle + @@ -79,7 +49,7 @@ function DeleteModal({ selected, open, onClose, teamId, isInvite }) { "and can't see or interact with it anymore. Do you still " + "want to remove the member?" ); - }, [selected, isInvite]); + }, [selected]); const button = ( ); @@ -96,7 +66,7 @@ function DeleteModal({ selected, open, onClose, teamId, isInvite }) { diff --git a/src/routes/TeamAccess/components/DeleteModal/styles.module.scss b/src/routes/TeamAccess/components/DeleteModal/styles.module.scss deleted file mode 100644 index c4914bcc..00000000 --- a/src/routes/TeamAccess/components/DeleteModal/styles.module.scss +++ /dev/null @@ -1,5 +0,0 @@ -.loader-container { - display: flex; - align-items: center; - flex-direction: column; -} diff --git a/src/routes/TeamAccess/components/MemberList/index.jsx b/src/routes/TeamAccess/components/MemberList/index.jsx index e627ce13..0c85b5d1 100644 --- a/src/routes/TeamAccess/components/MemberList/index.jsx +++ b/src/routes/TeamAccess/components/MemberList/index.jsx @@ -3,35 +3,24 @@ */ import React, { useState } from "react"; -import _ from "lodash"; import PT from "prop-types"; import CardHeader from "components/CardHeader"; import Button from "components/Button"; import "./styles.module.scss"; import Avatar from "components/Avatar"; import { Link } from "@reach/router"; - import TimeSection from "../TimeSection"; import { formatInviteTime } from "utils/format"; import IconDirectArrow from "../../../../assets/images/icon-direct-arrow.svg"; -import AddModal from "../AddModal"; import DeleteModal from "../DeleteModal"; +import AddModalContainer from "../AddModalContainer"; -function MemberList({ teamId, members, invitees }) { +const MemberList = ({ teamId, members, invitees }) => { const [selectedToDelete, setSelectedToDelete] = useState(null); - const [inviteOpen, setInviteOpen] = useState(false); - const [isInvite, setIsInvite] = useState(false); + const [addOpen, setAddOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); - const validateInvites = (newInvites) => { - return _.some(newInvites, (newInvite) => { - members.find((member) => newInvite.label === member.handle) || - invitees.find((invite) => newInvite.label === invite.handle); - }); - }; - - const openDeleteModal = (member, isInvite = false) => { - setIsInvite(isInvite); + const openDeleteModal = (member) => { setSelectedToDelete(member); setDeleteOpen(true); }; @@ -42,7 +31,7 @@ function MemberList({ teamId, members, invitees }) {
- +
{members.length > 0 || invitees.length > 0 ? ( @@ -101,12 +90,6 @@ function MemberList({ teamId, members, invitees }) { Invited {formatInviteTime(invitee.createdAt)} - ))} @@ -120,17 +103,17 @@ function MemberList({ teamId, members, invitees }) { open={deleteOpen} onClose={() => setDeleteOpen(false)} teamId={teamId} - isInvite={isInvite} /> - setInviteOpen(false)} + ); -} +}; MemberList.propTypes = { teamId: PT.string, diff --git a/src/routes/TeamAccess/hooks/useTCRoles.js b/src/routes/TeamAccess/hooks/useTCRoles.js new file mode 100644 index 00000000..954dcd75 --- /dev/null +++ b/src/routes/TeamAccess/hooks/useTCRoles.js @@ -0,0 +1,32 @@ +/** + * useTCRoles hook + */ +import { useEffect, useState } from "react"; +import { decodeToken } from "tc-auth-lib"; +import { getAuthUserTokens } from "@topcoder/micro-frontends-navbar-app"; + +/** + * Hook which decodes token of logged in user and gives access to user's roles + * + * @returns {string[]} roles The user's roles + */ +export const useTCRoles = () => { + const [roles, setRoles] = useState([]); + + useEffect(() => { + getAuthUserTokens() + .then(({ tokenV3 }) => { + if (!!tokenV3) { + const decoded = decodeToken(tokenV3); + setRoles(decoded.roles); + } else { + throw new Error("unable to get token"); + } + }) + .catch((err) => { + console.warn("Unable to get user roles"); + }); + }, []); + + return roles; +}; diff --git a/src/routes/TeamAccess/reducers/index.js b/src/routes/TeamAccess/reducers/index.js index d37e1927..4571caf2 100644 --- a/src/routes/TeamAccess/reducers/index.js +++ b/src/routes/TeamAccess/reducers/index.js @@ -10,7 +10,7 @@ const initialState = { loading: false, error: undefined, updating: false, - inviteError: undefined, + addError: undefined, }; const reducer = (state = initialState, action) => { @@ -84,28 +84,6 @@ const reducer = (state = initialState, action) => { error: action.payload, }; - case ACTION_TYPE.REMOVE_INVITE_PENDING: - return { - ...state, - updating: true, - error: undefined, - }; - - case ACTION_TYPE.REMOVE_INVITE_SUCCESS: - return { - ...state, - invites: state.invites.filter((invite) => invite.id !== action.payload), - updating: false, - error: undefined, - }; - - case ACTION_TYPE.REMOVE_INVITE_ERROR: - return { - ...state, - updating: false, - error: action.payload, - }; - case ACTION_TYPE.LOAD_SUGGESTIONS_PENDING: return { ...state, @@ -134,19 +112,19 @@ const reducer = (state = initialState, action) => { suggestions: [], }; - case ACTION_TYPE.ADD_INVITES_PENDING: + case ACTION_TYPE.ADD_MEMBERS_PENDING: return { ...state, updating: true, - inviteError: undefined, + addError: undefined, }; - case ACTION_TYPE.ADD_INVITES_SUCCESS: + case ACTION_TYPE.ADD_MEMBERS_SUCCESS: return { ...state, - invites: [...state.invites, ...action.payload.success], + members: [...state.members, ...action.payload.success], updating: false, - inviteError: action.payload.failed + addError: action.payload.failed ? { type: "SOME_FAILED", failed: action.payload.failed, @@ -154,11 +132,11 @@ const reducer = (state = initialState, action) => { : undefined, }; - case ACTION_TYPE.ADD_INVITES_ERROR: + case ACTION_TYPE.ADD_MEMBERS_ERROR: return { ...state, updating: false, - inviteError: action.payload, + addError: action.payload, }; default: diff --git a/src/services/teams.js b/src/services/teams.js index 67f0773f..269a5d5f 100644 --- a/src/services/teams.js +++ b/src/services/teams.js @@ -106,24 +106,6 @@ export const deleteTeamMember = (teamId, memberId) => { }); }; -/** - * Delete Invite - * - * @param {string|number} teamId team id - * @param {string|number} inviteId invite id - * - * @returns {Promise} inviteId or error - */ -export const deleteInvite = (teamId, inviteId) => { - const url = `${config.API.V5}/projects/${teamId}/invites/${inviteId}`; - return new Promise((resolve, reject) => { - axios - .delete(url) - .then(() => resolve({ data: inviteId })) - .catch((ex) => reject(ex)); - }); -}; - /** * Get member suggestions * @@ -136,38 +118,6 @@ export const getMemberSuggestions = (fragment) => { return axios.get(url); }; -/** - * Post new team invites - * - * @param {string|number} teamId team id - * @param {string[]} handles user handles to add - * @param {string[]} emails user emails to add - * @param {string} role role to assign to users - * - * @returns {Promise} object with successfully added invites, and failed invites - */ -export const postInvites = (teamId, handles, emails, role) => { - const url = `${config.API.V5}/projects/${teamId}/invites/?fields=id,projectId,userId,email,role,status,createdAt,updatedAt,createdBy,updatedBy,handle`; - const bodyObj = {}; - if (handles && handles.length > 0) { - bodyObj.handles = handles; - } - if (emails && emails.length > 0) { - bodyObj.emails = emails; - } - bodyObj.role = role; - - return new Promise((resolve, reject) => { - axios - .post(url, bodyObj, { - validateStatus: (status) => - (status >= 200 && status < 300) || status === 403, - }) - .then((res) => resolve(res)) - .catch((ex) => reject(ex)); - }); -}; - /** * Post an issue report * @@ -193,3 +143,26 @@ export const postReport = (teamName, teamId, reportText, memberHandle) => { return axios.post(url, bodyObj); }; + +/** + * Post new team members + * + * @param {string|number} teamId team id + * @param {string[]} handles user handles to add + * @param {string[]} emails user emails to add + * + * @returns {Promise} object with successfully added members and failed adds + */ +export const postMembers = (teamId, handles, emails) => { + const url = `${config.API.V5}/taas-teams/${teamId}/members`; + const bodyObj = {}; + + if (handles && handles.length > 0) { + bodyObj.handles = handles; + } + if (emails && emails.length > 0) { + bodyObj.emails = emails; + } + + return axios.post(url, bodyObj); +};