Skip to content

Commit

Permalink
Merge pull request #2556 from bcgov/feature/DESENG-656-engagement-wiz…
Browse files Browse the repository at this point in the history
…ard-add-users

[To Main] DESENG-656 - Add user management to engagement authoring wizard
  • Loading branch information
NatSquared authored Jul 18, 2024
2 parents 9ce14ff + f10e241 commit 085451b
Show file tree
Hide file tree
Showing 12 changed files with 318 additions and 43 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
- **Hotfix** Fix MET-web CI build errors [🎟️ DESENG-665](https://citz-gdx.atlassian.net/browse/DESENG-665)
- Update the `npm install` command in CI for the web app to use `--legacy-peer-deps`, resolving a dependency issue that was causing the build to fail

## 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)
Expand Down
2 changes: 1 addition & 1 deletion met-api/src/met_api/services/engagement_slug_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 5 additions & 5 deletions met-api/src/met_api/services/membership_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
60 changes: 32 additions & 28 deletions met-web/src/components/common/Input/TextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,37 @@ type TextInputProps = {
disabled?: boolean;
} & Omit<InputProps, 'value' | 'onChange' | 'placeholder' | 'disabled'>;

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<TextInputProps> = ({
id,
value,
Expand All @@ -37,37 +68,10 @@ export const TextInput: React.FC<TextInputProps> = ({
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={{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const DateRangePickerWithCalculation = () => {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const EngagementVisibilityControl = () => {
}
});
return () => subscription.unsubscribe();
}, [watch]);
}, [watch, hasBeenEdited]);
return (
<>
<FormControl>
Expand Down Expand Up @@ -113,7 +113,14 @@ const EngagementVisibilityControl = () => {
<Unless condition={isConfirmed}>
<Grid item container spacing={2} flexDirection="row" alignItems="center">
<Grid item>
<Button variant="primary" onClick={() => setIsConfirmed(true)}>
<Button
variant="primary"
onClick={() => {
setIsConfirmed(true);
setHasBeenEdited(true);
setValue('slug', currentSlug);
}}
>
Confirm
</Button>
</Grid>
Expand Down
227 changes: 227 additions & 0 deletions met-web/src/components/engagement/new/create/UserManager.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { Autocomplete, Avatar, Box, Grid, IconButton, PaperProps, Popper, PopperProps, 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<User | null>(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 (
<Box width="100%">
<label htmlFor="team-members">
<BodyText bold size="small">
Add Team Members
</BodyText>
</label>
<Autocomplete
id="team-members"
openOnFocus
value={resultUser}
popupIcon={
<FontAwesomeIcon icon={faChevronDown} style={{ width: '16px', height: '16px', padding: '4px' }} />
}
PopperComponent={SearchPopper}
PaperComponent={SearchPaper}
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 (
<li {...props}>
<Grid container direction="row" spacing={2} alignItems={'center'}>
<Grid item>
<Avatar
sx={{
height: '30px',
width: '30px',
fontSize: '16px',
backgroundColor: props['aria-disabled']
? colors.notification.success.shade
: 'primary.light',
}}
>
{props['aria-disabled'] ? (
<FontAwesomeIcon icon={faCheck} />
) : (
`${option.first_name[0]}${option.last_name[0]}`
)}
</Avatar>
</Grid>
<Grid item>{`${option.first_name} ${option.last_name}`}</Grid>
</Grid>
</li>
);
}}
renderInput={(params) => {
return (
<TextField
value={searchTerm}
onBlur={() => 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',
},
},
}}
/>
);
}}
/>
<When condition={resultUser !== null}>
<Button
sx={{ mt: 2 }}
variant="primary"
icon={<FontAwesomeIcon icon={faPlus} />}
onClick={handleAddUser}
>
Add Team Member
</Button>
</When>
<When condition={selectedUsers.length}>
<Grid
container
spacing={2}
direction="column"
sx={{
mt: 2,
ml: 0,
pb: 2,
pr: 2,
border: '1px solid',
borderColor: 'primary.light',
borderRadius: '8px',
width: '300px',
}}
>
<Grid item>
<BodyText bold color="primary.light">
Team Member{selectedUsers.length > 1 && 's'} Added
</BodyText>
</Grid>
{selectedUsers.map((user) => (
<Grid item container key={user.id} direction="row" spacing={2} alignItems={'center'}>
<Grid item>
<Avatar
sx={{
height: '30px',
width: '30px',
fontSize: '16px',
backgroundColor: 'primary.light',
}}
>
{user.first_name[0]}
{user.last_name[0]}
</Avatar>
</Grid>
<Grid item>{`${user.first_name} ${user.last_name}`}</Grid>
<Grid item alignSelf="flex-end" marginLeft="auto">
<IconButton
sx={{ height: '16px', width: '16px' }}
onClick={() => handleRemoveUser(user)}
>
<FontAwesomeIcon fontSize={16} icon={faXmark} />
</IconButton>
</Grid>
</Grid>
))}
</Grid>
</When>
</Box>
);
};

const SearchPaper = (props: PaperProps) => <MetPaper {...props} children={props.children ?? []} />;
const SearchPopper = (props: PopperProps) => (
<Popper
{...props}
keepMounted
placement="bottom-end"
disablePortal
modifiers={[{ name: 'offset', options: { offset: [0, 8] } }]}
/>
);
Loading

0 comments on commit 085451b

Please sign in to comment.