From 99b90c150d80e386be6dfeb2e759233d3da803da Mon Sep 17 00:00:00 2001 From: NatSquared Date: Tue, 16 Jul 2024 12:17:13 -0700 Subject: [PATCH 01/10] DESENG-656 - Add user management to engagement authoring wizard --- CHANGELOG.MD | 7 + .../services/engagement_slug_service.py | 2 +- .../met_api/services/membership_service.py | 10 +- .../src/components/common/Input/TextInput.tsx | 60 ++--- .../engagement/new/create/DatesCalculator.tsx | 2 +- .../new/create/EngagmentVisibilityControl.tsx | 12 +- .../engagement/new/create/UserManager.tsx | 222 ++++++++++++++++++ .../new/create/engagmentCreateAction.tsx | 8 + .../engagement/new/create/index.tsx | 11 +- .../userManagement/userSearchLoader.tsx | 21 ++ met-web/src/routes/AuthenticatedRoutes.tsx | 2 + .../src/services/userService/api/index.tsx | 2 +- 12 files changed, 318 insertions(+), 41 deletions(-) create mode 100644 met-web/src/components/engagement/new/create/UserManager.tsx create mode 100644 met-web/src/components/userManagement/userSearchLoader.tsx diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 8e5f72292..5ae757101 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,3 +1,10 @@ +## July 16, 2024 + +- **Feature** Add user management to engagement authoring wizard[🎟️ DESENG-656](https://citz-gdx.atlassian.net/browse/DESENG-656) + - Added new UI component to handle searching, selecting, and displaying users + - Minor tweaks to existing wizard functionality + - Minor backend change: don't throw error when setting an engagement's slug to its current value + ## July 11, 2024 - **Feature** Add the existing engagement form fields to the engagement authoring wizard [🎟️ DESENG-655](https://citz-gdx.atlassian.net/browse/DESENG-655) diff --git a/met-api/src/met_api/services/engagement_slug_service.py b/met-api/src/met_api/services/engagement_slug_service.py index 2849354da..ceab4b9b3 100644 --- a/met-api/src/met_api/services/engagement_slug_service.py +++ b/met-api/src/met_api/services/engagement_slug_service.py @@ -86,7 +86,7 @@ def update_engagement_slug(cls, slug: str, engagement_id: int) -> EngagementSlug """Update an engagement slug.""" cls._verify_engagement(engagement_id) existing_slug = EngagementSlugModel.find_by_slug(slug) - if existing_slug: + if existing_slug and existing_slug.engagement_id != engagement_id: raise ValueError(f'{slug} is already used by another engagement') engagement_slug = EngagementSlugModel.find_by_engagement_id(engagement_id) diff --git a/met-api/src/met_api/services/membership_service.py b/met-api/src/met_api/services/membership_service.py index 8852e4b44..3b7dc3b7e 100644 --- a/met-api/src/met_api/services/membership_service.py +++ b/met-api/src/met_api/services/membership_service.py @@ -13,7 +13,7 @@ from met_api.services.staff_user_service import StaffUserService from met_api.services.user_group_membership_service import UserGroupMembershipService from met_api.utils.constants import CompositeRoles -from met_api.utils.enums import CompositeRoleId, CompositeRoleNames, MembershipStatus +from met_api.utils.enums import CompositeRoleId, MembershipStatus from met_api.utils.roles import Role from met_api.utils.token_info import TokenInfo @@ -57,8 +57,8 @@ def _validate_create_membership(engagement_id, user_details): user_id = user_details.get('id') - roles = user_details.get('main_role') - if CompositeRoleNames.ADMIN.value in roles: + role = user_details.get('main_role') + if role == CompositeRoles.ADMIN.value: raise BusinessException( error='This user is already a Administrator.', status_code=HTTPStatus.CONFLICT.value) @@ -86,8 +86,8 @@ def _get_membership_details(user_details): default_role = CompositeRoles.TEAM_MEMBER.name default_membership_type = MembershipType.TEAM_MEMBER - is_reviewer = CompositeRoles.REVIEWER.value in user_details.get('main_role') - is_team_member = CompositeRoles.TEAM_MEMBER.value in user_details.get('main_role') + is_reviewer = user_details.get('main_role') == CompositeRoles.REVIEWER.value + is_team_member = user_details.get('main_role') == CompositeRoles.TEAM_MEMBER.value if is_reviewer: # If the user is assigned to the REVIEWER role, set the role name and membership type accordingly diff --git a/met-web/src/components/common/Input/TextInput.tsx b/met-web/src/components/common/Input/TextInput.tsx index 0759c54a9..e94bdb265 100644 --- a/met-web/src/components/common/Input/TextInput.tsx +++ b/met-web/src/components/common/Input/TextInput.tsx @@ -15,6 +15,37 @@ type TextInputProps = { disabled?: boolean; } & Omit; +export const textInputStyles = { + display: 'flex', + height: '48px', + padding: '8px 16px', + alignItems: 'center', + justifyContent: 'center', + gap: '10px', + alignSelf: 'stretch', + borderRadius: '8px', + caretColor: colors.surface.blue[90], + '&:hover': { + boxShadow: `0 0 0 2px ${colors.surface.gray[90]} inset`, + '&:has(:disabled)': { + boxShadow: `0 0 0 1px ${colors.surface.gray[80]} inset`, + }, + }, + '&.Mui-focused': { + boxShadow: `0 0 0 4px ${colors.focus.regular.outer}`, + '&:has(:disabled)': { + // make sure disabled state doesn't override focus state + boxShadow: `0 0 0 1px ${colors.surface.gray[80]} inset`, + }, + }, + '&:has(:disabled)': { + background: colors.surface.gray[10], + color: colors.type.regular.secondary, + userSelect: 'none', + cursor: 'not-allowed', + }, +}; + export const TextInput: React.FC = ({ id, value, @@ -37,37 +68,10 @@ export const TextInput: React.FC = ({ placeholder={placeholder} disabled={disabled} sx={{ - display: 'flex', - height: '48px', - padding: '8px 16px', - alignItems: 'center', - justifyContent: 'center', - gap: '10px', - alignSelf: 'stretch', - borderRadius: '8px', + ...textInputStyles, boxShadow: error ? `0 0 0 2px ${colors.notification.error.shade} inset` : `0 0 0 1px ${colors.surface.gray[80]} inset`, - caretColor: colors.surface.blue[90], - '&:hover': { - boxShadow: `0 0 0 2px ${colors.surface.gray[90]} inset`, - '&:has(:disabled)': { - boxShadow: `0 0 0 1px ${colors.surface.gray[80]} inset`, - }, - }, - '&.Mui-focused': { - boxShadow: `0 0 0 4px ${colors.focus.regular.outer}`, - '&:has(:disabled)': { - // make sure disabled state doesn't override focus state - boxShadow: `0 0 0 1px ${colors.surface.gray[80]} inset`, - }, - }, - '&:has(:disabled)': { - background: colors.surface.gray[10], - color: colors.type.regular.secondary, - userSelect: 'none', - cursor: 'not-allowed', - }, ...sx, }} inputProps={{ diff --git a/met-web/src/components/engagement/new/create/DatesCalculator.tsx b/met-web/src/components/engagement/new/create/DatesCalculator.tsx index 5e1d9a2d4..ac177881b 100644 --- a/met-web/src/components/engagement/new/create/DatesCalculator.tsx +++ b/met-web/src/components/engagement/new/create/DatesCalculator.tsx @@ -25,7 +25,7 @@ export const DatesCalculator = () => { useEffect(() => { const subscription = watch((value, { name, type }) => { if (name === 'start_date') { - setDisableDatesBefore(value.start_date.clone().add(1, 'day')); + setDisableDatesBefore(value.start_date.clone()); if (value.start_date.isAfter(value.end_date) || !value.end_date) { reset({ ...value, diff --git a/met-web/src/components/engagement/new/create/EngagmentVisibilityControl.tsx b/met-web/src/components/engagement/new/create/EngagmentVisibilityControl.tsx index dbbee3935..fe542c129 100644 --- a/met-web/src/components/engagement/new/create/EngagmentVisibilityControl.tsx +++ b/met-web/src/components/engagement/new/create/EngagmentVisibilityControl.tsx @@ -39,10 +39,11 @@ const EngagementVisibilityControl = () => { .join('') .toLowerCase(); setValue('slug', newSlug); + setCurrentSlug(newSlug); } }); return () => subscription.unsubscribe(); - }, [watch]); + }, [watch, hasBeenEdited]); return ( <> @@ -111,7 +112,14 @@ const EngagementVisibilityControl = () => { - diff --git a/met-web/src/components/engagement/new/create/UserManager.tsx b/met-web/src/components/engagement/new/create/UserManager.tsx new file mode 100644 index 000000000..0b22071c2 --- /dev/null +++ b/met-web/src/components/engagement/new/create/UserManager.tsx @@ -0,0 +1,222 @@ +import { Autocomplete, Avatar, Box, Grid, IconButton, Popper } from '@mui/material'; +import { TextField } from '@mui/material'; +import { textInputStyles } from 'components/common/Input/TextInput'; +import { User, USER_COMPOSITE_ROLE } from 'models/user'; +import React, { useEffect, useRef } from 'react'; +import { useFetcher } from 'react-router-dom'; +import { BodyText } from 'components/common/Typography'; +import { When } from 'react-if'; +import { Button } from 'components/common/Input'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCheck, faChevronDown, faPlus, faXmark } from '@fortawesome/pro-regular-svg-icons'; +import { colors } from 'styles/Theme'; +import { MetPaper } from 'components/common'; +import { useFormContext } from 'react-hook-form'; +import { useAppSelector } from 'hooks'; +import { debounce } from 'lodash'; + +export const UserManager = () => { + const currentUser = useAppSelector((state) => state.user); + const fetcher = useFetcher(); + + const engagementForm = useFormContext(); + const { setValue, watch } = engagementForm; + const selectedUsers = watch('users') as User[]; + + const [searchTerm, setSearchTerm] = React.useState(''); + const [resultUser, setResultUser] = React.useState(null); + + const defaultSearchOptions = new URLSearchParams({ + searchTerm: '', + includeRoles: 'true', + sortKey: 'last_name', + sortOrder: 'asc', + page: '1', + size: '100', + }); + + const debouncedSearch = useRef( + debounce( + (q) => { + defaultSearchOptions.set('searchTerm', q); + fetcher.load(`/usermanagement/search?${defaultSearchOptions.toString()}`); + }, + 500, + { maxWait: 1000, leading: true, trailing: true }, + ), + ); + + useEffect(() => { + debouncedSearch.current(searchTerm); + }, [searchTerm]); + + const users = (fetcher.data?.users?.items as User[] | undefined)?.filter( + (user) => + // Remove ourselves from the list + user.id !== currentUser.userDetail.user?.id && + // Remove admins from the list - they can already see everything and will cause an error if specified + user.main_role !== USER_COMPOSITE_ROLE.ADMIN.label, + ); + + const handleAddUser = () => { + if (resultUser && !selectedUsers.filter((u) => u.id === resultUser.id).length) { + setValue('users', [...selectedUsers, resultUser]); + } + setResultUser(null); + setSearchTerm(''); + }; + + const handleRemoveUser = (user: User) => { + setValue( + 'users', + selectedUsers.filter((u) => u !== user), + ); + }; + + return ( + + + Add Team Members + + + } + PopperComponent={(props) => ( + + )} + PaperComponent={(props) => } + onChange={(_, newValue) => setResultUser(newValue)} + options={users ?? []} + loading={fetcher.state === 'loading'} + getOptionLabel={(option: User) => `${option.first_name} ${option.last_name}`} + groupBy={(option: User) => option.last_name[0]} + getOptionDisabled={(option) => selectedUsers.filter((u) => u.id === option.id).length > 0} + sx={{ maxWidth: '320px' }} + renderOption={(props, option, state) => { + return ( +
  • + + + + {props['aria-disabled'] ? ( + + ) : ( + `${option.first_name[0]}${option.last_name[0]}` + )} + + + {`${option.first_name} ${option.last_name}`} + +
  • + ); + }} + renderInput={(params) => { + return ( + setSearchTerm('')} + onChange={(e) => setSearchTerm(e.target.value)} + {...params} + InputProps={{ + ...params.InputProps, + placeholder: 'Select Team Member', + sx: textInputStyles, + }} + inputProps={{ + ...params.inputProps, + sx: { + fontSize: '16px', + lineHeight: '24px', + color: colors.type.regular.primary, + '&::placeholder': { + color: colors.type.regular.secondary, + }, + '&:disabled': { + cursor: 'not-allowed', + }, + }, + }} + /> + ); + }} + /> + + + + + + + + Team Member{selectedUsers.length > 1 && 's'} Added + + + {selectedUsers.map((user) => ( + + + + {user.first_name[0]} + {user.last_name[0]} + + + {`${user.first_name} ${user.last_name}`} + + handleRemoveUser(user)} + > + + + + + ))} + + +
    + ); +}; diff --git a/met-web/src/components/engagement/new/create/engagmentCreateAction.tsx b/met-web/src/components/engagement/new/create/engagmentCreateAction.tsx index d29adf088..227dcc0e7 100644 --- a/met-web/src/components/engagement/new/create/engagmentCreateAction.tsx +++ b/met-web/src/components/engagement/new/create/engagmentCreateAction.tsx @@ -1,6 +1,7 @@ import { ActionFunction, redirect } from 'react-router-dom'; import { postEngagement as createEngagement } from 'services/engagementService'; import { patchEngagementSlug } from 'services/engagementSlugService'; +import { addTeamMemberToEngagement } from 'services/membershipService'; export const engagementCreateAction: ActionFunction = async ({ request }) => { const formData = (await request.formData()) as FormData; @@ -22,6 +23,13 @@ export const engagementCreateAction: ActionFunction = async ({ request }) => { } catch (e) { console.error(e); } + formData + .get('users') + ?.toString() + .split(',') + .forEach(async (user_id) => { + await addTeamMemberToEngagement({ user_id, engagement_id: engagement.id }); + }); return redirect(`/engagements/${engagement.id}/form`); }; diff --git a/met-web/src/components/engagement/new/create/index.tsx b/met-web/src/components/engagement/new/create/index.tsx index 1dd29bfb2..eb42fc999 100644 --- a/met-web/src/components/engagement/new/create/index.tsx +++ b/met-web/src/components/engagement/new/create/index.tsx @@ -14,6 +14,8 @@ import { colors } from 'styles/Theme'; import EngagementVisibilityControl from './EngagmentVisibilityControl'; import UnsavedWorkConfirmation from 'components/common/Navigation/UnsavedWorkConfirmation'; import { AutoBreadcrumbs } from 'components/common/Navigation/Breadcrumb'; +import { UserManager } from './UserManager'; +import { User } from 'models/user'; interface EngagementCreationData { name: string; @@ -21,6 +23,7 @@ interface EngagementCreationData { end_date: Dayjs; is_internal: boolean; slug: string; + users: User[]; } const _TemporaryConstructionNotice = ( @@ -54,6 +57,7 @@ const EngagementCreationWizard = () => { end_date: undefined, is_internal: undefined, slug: '', + users: [], }, mode: 'onBlur', }); @@ -66,6 +70,7 @@ const EngagementCreationWizard = () => { end_date: data.end_date.format('YYYY-MM-DD'), is_internal: data.is_internal, slug: data.slug, + users: data.users.map((u) => u.external_id), }, { method: 'post', @@ -162,11 +167,11 @@ const EngagementCreationWizard = () => { In addition to yourself, please add the team members that you would like to have access - to this engagement. You can only add individuals that already have a MET account. + to this engagement. You can only add individuals that have already signed into MET. - {_TemporaryConstructionNotice} + - +