-
-
-
-
+ >
+ )}
);
};
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 = (
- Invite
+ Add
);
@@ -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 = (
deleteMember()}
disabled={loading}
>
- Remove {isInvite ? "invitation" : "member"}
+ Remove member
);
@@ -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 }) {
- setInviteOpen(true)}>+Add
+ setAddOpen(true)}>+Add
{members.length > 0 || invitees.length > 0 ? (
@@ -101,12 +90,6 @@ function MemberList({ teamId, members, invitees }) {
Invited {formatInviteTime(invitee.createdAt)}
-