From e4378e07e3db6a51e207fd839c6bd35d98bf6a72 Mon Sep 17 00:00:00 2001 From: jakeaturner Date: Tue, 5 Mar 2024 18:31:26 -0800 Subject: [PATCH] fix(Projects): redesign manage team modal --- .../components/projects/ManageTeamModal.tsx | 318 +++++++++++------- client/src/utils/misc.ts | 9 + server/api.js | 3 +- server/api/projects.js | 37 +- server/api/validators/projects.ts | 14 +- server/util/helpers.js | 9 + 6 files changed, 249 insertions(+), 141 deletions(-) diff --git a/client/src/components/projects/ManageTeamModal.tsx b/client/src/components/projects/ManageTeamModal.tsx index 6309cc74..388978eb 100644 --- a/client/src/components/projects/ManageTeamModal.tsx +++ b/client/src/components/projects/ManageTeamModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import axios from "axios"; import { Modal, @@ -12,6 +12,10 @@ import { Image, Dropdown, Popup, + Table, + Input, + TableProps, + Radio, } from "semantic-ui-react"; import { CentralIdentityOrg, Project, User } from "../../types"; import { @@ -22,6 +26,8 @@ import { import { projectRoleOptions } from "../util/ProjectHelpers"; import useGlobalError from "../error/ErrorHooks"; import useDebounce from "../../hooks/useDebounce"; +import { useTypedSelector } from "../../state/hooks"; +import { extractEmailDomain } from "../../utils/misc"; type ProjectDisplayMember = User & { roleValue: string; roleDisplay: string }; type AddableUser = Pick; @@ -33,6 +39,10 @@ interface ManageTeamModalProps extends ModalProps { onClose: () => void; } +interface RenderCurrentTeamTableProps extends TableProps { + project: Project; +} + const ManageTeamModal: React.FC = ({ show, project, @@ -41,19 +51,19 @@ const ManageTeamModal: React.FC = ({ }) => { const { handleGlobalError } = useGlobalError(); const { debounce } = useDebounce(); + const user = useTypedSelector((state) => state.user); const [loading, setLoading] = useState(false); + const [hasNotSearched, setHasNotSearched] = useState(true); + const [searchString, setSearchString] = useState(""); + const [includeOutsideOrg, setIncludeOutsideOrg] = useState(false); const [teamUserOptions, setTeamUserOptions] = useState([]); const [teamUserOptsLoading, setTeamUserOptsLoading] = useState(false); - const [teamUserUUIDToAdd, setTeamUserUUIDToAdd] = useState( - null - ); - useEffect(() => { - if (show) { - getTeamUserOptions(""); - } - }, [show]); + const userOrgDomain = useMemo(() => { + if (!user?.email) return ""; + return extractEmailDomain(user.email); + }, [user.email]); /** * Resets state before calling the provided onClose function. @@ -62,7 +72,7 @@ const ManageTeamModal: React.FC = ({ setLoading(false); setTeamUserOptions([]); setTeamUserOptsLoading(false); - setTeamUserUUIDToAdd(null); + setSearchString(""); if (onClose) { onClose(); @@ -77,9 +87,10 @@ const ManageTeamModal: React.FC = ({ try { if (!project.projectID) return; + setHasNotSearched(false); setTeamUserOptsLoading(true); const res = await axios.get( - `/project/${project.projectID}/team/addable?search=${searchString}` + `/project/${project.projectID}/team/addable?search=${searchString}&includeOutsideOrg=${includeOutsideOrg}&page=1&limit=5` ); if (res.data.err) { throw new Error(res.data.errMsg); @@ -183,13 +194,9 @@ const ManageTeamModal: React.FC = ({ * in state (teamUserToAdd) to the project's team, then * refreshes the project data and Addable Users options. */ - const submitAddTeamMember = async () => { + const submitAddTeamMember = async (uuid: string) => { try { - if ( - !teamUserUUIDToAdd || - isEmptyString(teamUserUUIDToAdd) || - isEmptyString(project.projectID) - ) { + if (!project.projectID || !uuid) { throw new Error( "Invalid user or project UUID. This may be caused by an internal error." ); @@ -197,7 +204,7 @@ const ManageTeamModal: React.FC = ({ setLoading(true); const res = await axios.post(`/project/${project.projectID}/team`, { - uuid: teamUserUUIDToAdd, + uuid }); if (res.data.err) { @@ -205,8 +212,7 @@ const ManageTeamModal: React.FC = ({ return; } - setTeamUserOptions([]); - setTeamUserUUIDToAdd(null); + getTeamUserOptions(searchString); // Refresh addable users list onDataChanged(); } catch (err) { handleGlobalError(err); @@ -215,16 +221,20 @@ const ManageTeamModal: React.FC = ({ } }; - const renderTeamModalList = (projData: Project) => { - if (!projData) return null; - let projTeam: ProjectDisplayMember[] = []; - if (projData.leads && Array.isArray(projData.leads)) { - projData.leads.forEach((item) => { + const RenderCurrentTeamTable: React.FC = ({ + project, + ...rest + }: { + project: Project; + }) => { + const projTeam: ProjectDisplayMember[] = []; + if (project.leads && Array.isArray(project.leads)) { + project.leads.forEach((item) => { projTeam.push({ ...item, roleValue: "lead", roleDisplay: "Lead" }); }); } - if (projData.liaisons && Array.isArray(projData.liaisons)) { - projData.liaisons.forEach((item) => { + if (project.liaisons && Array.isArray(project.liaisons)) { + project.liaisons.forEach((item) => { projTeam.push({ ...item, roleValue: "liaison", @@ -232,13 +242,13 @@ const ManageTeamModal: React.FC = ({ }); }); } - if (projData.members && Array.isArray(projData.members)) { - projData.members.forEach((item) => { + if (project.members && Array.isArray(project.members)) { + project.members.forEach((item) => { projTeam.push({ ...item, roleValue: "member", roleDisplay: "Member" }); }); } - if (projData.auditors && Array.isArray(projData.auditors)) { - projData.auditors.forEach((item) => { + if (project.auditors && Array.isArray(project.auditors)) { + project.auditors.forEach((item) => { projTeam.push({ ...item, roleValue: "auditor", @@ -246,106 +256,176 @@ const ManageTeamModal: React.FC = ({ }); }); } - projTeam = sortUsersByName(projTeam) as ProjectDisplayMember[]; + const sortedTeam = sortUsersByName(projTeam) as ProjectDisplayMember[]; + return ( - - {projTeam.map((item, idx) => { - return ( - -
-
- - - {item.firstName} {item.lastName} - -
-
- - submitChangeTeamMemberRole( - item.uuid, - value ? value.toString() : "" - ) - } - /> - { - submitRemoveTeamMember(item.uuid); - }} - icon - > - - - } - content="Remove from project" - /> -
-
-
- ); - })} -
+ + + + Name + Role + Actions + + + + {sortedTeam.map((item) => ( + + + + {item.firstName} {item.lastName} + + + + submitChangeTeamMemberRole( + item.uuid, + value ? value.toString() : "" + ) + } + /> + + + { + submitRemoveTeamMember(item.uuid); + }} + icon + > + + + } + content="Remove from project" + /> + + + ))} + +
); }; return ( Manage Project Team - -
e.preventDefault()}> - - - ({ - key: crypto.randomUUID(), - text: `${item.firstName} ${item.lastName}`, - value: item.uuid ?? "", - image: { - avatar: true, - src: item.avatar, - }, - }))} - onChange={(e, { value }) => { - setTeamUserUUIDToAdd(value?.toString() || ""); - }} - fluid - selection - search - onSearchChange={(e, { searchQuery }) => { - getTeamUserOptionsDebounced(searchQuery); - }} - loading={teamUserOptsLoading} - /> - - -
- + {!loading ? ( - renderTeamModalList(project) + <> +

Current Team Members

+ +
e.preventDefault()} className="mt-16 h-72"> + +
+

Add Team Members

+
+ + setIncludeOutsideOrg(!includeOutsideOrg)} + /> +
+
+ { + setSearchString(value); + getTeamUserOptionsDebounced(value); + }} + /> +
+ + + + Name + Actions + + + + {teamUserOptsLoading && } + {!teamUserOptsLoading && + teamUserOptions.map((item) => ( + + + + {item.firstName} {item.lastName} + + + { + submitAddTeamMember(item.uuid); + }} + icon + > + + Add to Project + + } + content="Add to project" + /> + + + ))} + {!teamUserOptsLoading && + !hasNotSearched && + teamUserOptions.length === 0 && ( + + +

+ No users found. Please try another search. +

+
+
+ )} + {!teamUserOptsLoading && hasNotSearched && ( + + +

+ Start typing to search for users to add to the + project. +

+
+
+ )} +
+
+
+ ) : ( )}
+ + +
); }; diff --git a/client/src/utils/misc.ts b/client/src/utils/misc.ts index c847d0df..7d2554bd 100644 --- a/client/src/utils/misc.ts +++ b/client/src/utils/misc.ts @@ -180,3 +180,12 @@ export function getAssetFilterText(key: string) { return ""; } } + +export function extractEmailDomain(email: string): string | null { + if(!email) return null; + const parts = email.split("@"); + if (parts.length === 2) { + return parts[1]; + } + return null; +} \ No newline at end of file diff --git a/server/api.js b/server/api.js index 16e25f96..23cea9fd 100644 --- a/server/api.js +++ b/server/api.js @@ -1178,8 +1178,7 @@ router.route('/project/:projectID/team') router.route('/project/:projectID/team/addable').get( authAPI.verifyRequest, authAPI.getUserAttributes, - projectsAPI.validate('getAddableMembers'), - middleware.checkValidationErrors, + middleware.validateZod(ProjectValidators.GetAddableTeamMembersSchema), projectsAPI.getAddableMembers, ); diff --git a/server/api/projects.js b/server/api/projects.js index f23a47f2..744ded8a 100644 --- a/server/api/projects.js +++ b/server/api/projects.js @@ -43,7 +43,7 @@ import { getBookTOCFromAPI, } from '../util/bookutils.js'; import { validateA11YReviewSectionItem } from '../util/a11yreviewutils.js'; -import { isEmptyString, assembleUrl, getPaginationOffset } from '../util/helpers.js'; +import { isEmptyString, assembleUrl, getPaginationOffset, extractEmailDomain } from '../util/helpers.js'; import { libraryNameKeys } from '../util/librariesmap.js'; import authAPI from './auth.js'; import mailAPI from './mail.js'; @@ -1424,7 +1424,11 @@ async function getPublicProjects(req, res) { async function getAddableMembers(req, res) { try { const { projectID } = req.params; - const { search } = req.query; + const { search, includeOutsideOrg, page, limit } = req.query; + + const parsedPage = parseInt(page) || 1; + const parsedLimit = parseInt(limit) || 10; + const project = await Project.findOne({ projectID }).lean(); if (!project) { return res.status(404).send({ @@ -1441,6 +1445,12 @@ async function getAddableMembers(req, res) { }); } + let userDomain; + if(["false", false].includes(includeOutsideOrg)){ + const user = await User.findOne({uuid: req.user.decoded.uuid}).lean().orFail(); + userDomain = extractEmailDomain(user.email); + } + const existing = constructProjectTeam(project); // don't include existing team members let searchObj = {}; @@ -1459,7 +1469,7 @@ async function getAddableMembers(req, res) { }; } - const sortObj = { $sort: { firstName: -1 } }; // sort by first name if no search + const sortObj = { $sort: { firstName: -1 } }; // sort by first name if no search (otherwise, results are sorted by text score) const users = await User.aggregate([ ...(search && [searchObj]), { @@ -1467,6 +1477,8 @@ async function getAddableMembers(req, res) { $and: [ { uuid: { $nin: existing } }, { $expr: { $not: '$isSystem' } }, + {centralID: {$exists: true}}, + ...(userDomain ? [{email: {$regex: new RegExp(userDomain, 'i')}}] : []), ], }, }, @@ -1481,19 +1493,12 @@ async function getAddableMembers(req, res) { }, }, ...(search ? [] : [sortObj]), - ]).limit(25); // limit to 25 results - - const filtered = users.filter((user) => user.centralID) // filter out users without a centralID - // const settled = await Promise.allSettled(filtered.map((user) => centralIdentity._getUserOrgsRaw(user.centralID))) - - // for(let i = 0; i < settled.length; i++) { - // if(settled[i].status === 'fulfilled') { - // filtered[i].orgs = settled[i].value ?? [] - // } - // } + { $skip: (parsedPage - 1) * parsedLimit }, + { $limit: parsedLimit }, + ]) return res.send({ - users: filtered, + users: users || [], err: false, }); } catch (e) { @@ -3204,10 +3209,6 @@ const validate = (method) => { return [ query('uuid', conductorErrors.err1).exists().isString().isUUID() ] - case 'getAddableMembers': - return [ - param('projectID', conductorErrors.err1).exists().isString().isLength({ min: 10, max: 10 }) - ] case 'addMemberToProject': return [ param('projectID', conductorErrors.err1).exists().isString().isLength({ min: 10, max: 10 }), diff --git a/server/api/validators/projects.ts b/server/api/validators/projects.ts index ad536252..99a33c90 100644 --- a/server/api/validators/projects.ts +++ b/server/api/validators/projects.ts @@ -1,6 +1,16 @@ import { z } from "zod"; -import { PaginationSchema, isMongoIDValidator } from "./misc.js"; +import { PaginationSchema } from "./misc.js"; export const getPublicProjectsSchema = z.object({ query: PaginationSchema, -}); \ No newline at end of file +}); + +export const GetAddableTeamMembersSchema = z.object({ + params: z.object({ + projectID: z.string().length(10), + }), + query: z.object({ + search: z.string().min(1).max(50).or(z.literal("")).optional(), + includeOutsideOrg: z.coerce.boolean().optional(), + }).merge(PaginationSchema) +}); diff --git a/server/util/helpers.js b/server/util/helpers.js index 531c51b9..b5345d09 100644 --- a/server/util/helpers.js +++ b/server/util/helpers.js @@ -353,4 +353,13 @@ export function getSubdomainFromUrl(url){ return parts[0]; } return null; +} + +export function extractEmailDomain(email) { + if(!email) return null; + const parts = email.split("@"); + if (parts.length === 2) { + return parts[1]; + } + return null; } \ No newline at end of file