mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	Feat/change request overview applied state (#2322)
* feat: review button * feat: add review button * fix: add to box * fix: separate function calls * fix: comment out reviewers * fix: type
This commit is contained in:
		
							parent
							
								
									147408045b
								
							
						
					
					
						commit
						d8db33ac7f
					
				| @ -22,7 +22,7 @@ export const ChangeRequestHeader: FC<{ changeRequest: IChangeRequest }> = ({ | ||||
|                 <StyledHeader variant="h1"> | ||||
|                     Change request #{changeRequest.id} | ||||
|                 </StyledHeader> | ||||
|                 <ChangeRequestStatusBadge state={changeRequest.state} />; | ||||
|                 <ChangeRequestStatusBadge state={changeRequest.state} /> | ||||
|             </StyledContainer> | ||||
|             <StyledInnerContainer> | ||||
|                 <Typography variant="body2" sx={{ margin: 'auto 0' }}> | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| import { styled } from '@mui/material'; | ||||
| import { FC } from 'react'; | ||||
| import { Box, Button, Paper } from '@mui/material'; | ||||
| import { Box } from '@mui/material'; | ||||
| import { useChangeRequest } from 'hooks/api/getters/useChangeRequest/useChangeRequest'; | ||||
| import { ChangeRequestHeader } from './ChangeRequestHeader/ChangeRequestHeader'; | ||||
| import { ChangeRequestTimeline } from './ChangeRequestTimeline/ChangeRequestTimeline'; | ||||
| @ -10,11 +11,42 @@ import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useCh | ||||
| import { ChangeRequestReviewStatus } from './ChangeRequestReviewStatus/ChangeRequestReviewStatus'; | ||||
| import useToast from 'hooks/useToast'; | ||||
| import { formatUnknownError } from 'utils/formatUnknownError'; | ||||
| import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; | ||||
| import Button from '@mui/material/Button'; | ||||
| import Paper from '@mui/material/Paper'; | ||||
| import { ReviewButton } from './ReviewButton/ReviewButton'; | ||||
| 
 | ||||
| const StyledAsideBox = styled(Box)(({ theme }) => ({ | ||||
|     width: '30%', | ||||
|     display: 'flex', | ||||
|     flexDirection: 'column', | ||||
| })); | ||||
| 
 | ||||
| const StyledPaper = styled(Paper)(({ theme }) => ({ | ||||
|     marginTop: theme.spacing(2), | ||||
|     marginLeft: theme.spacing(2), | ||||
|     width: '70%', | ||||
|     padding: theme.spacing(1, 2), | ||||
|     borderRadius: theme.shape.borderRadiusLarge, | ||||
| })); | ||||
| 
 | ||||
| const StyledButtonBox = styled(Box)(({ theme }) => ({ | ||||
|     marginTop: theme.spacing(2), | ||||
|     display: 'flex', | ||||
|     justifyContent: 'flex-end', | ||||
| })); | ||||
| 
 | ||||
| const StyledInnerContainer = styled(Box)(({ theme }) => ({ | ||||
|     padding: theme.spacing(2), | ||||
| })); | ||||
| 
 | ||||
| export const ChangeRequestOverview: FC = () => { | ||||
|     const projectId = useRequiredPathParam('projectId'); | ||||
|     const id = useRequiredPathParam('id'); | ||||
|     const { data: changeRequest } = useChangeRequest(projectId, id); | ||||
|     const { data: changeRequest, refetchChangeRequest } = useChangeRequest( | ||||
|         projectId, | ||||
|         id | ||||
|     ); | ||||
|     const { applyChanges } = useChangeRequestApi(); | ||||
|     const { setToastData, setToastApiError } = useToast(); | ||||
| 
 | ||||
| @ -25,10 +57,11 @@ export const ChangeRequestOverview: FC = () => { | ||||
|     const onApplyChanges = async () => { | ||||
|         try { | ||||
|             await applyChanges(projectId, id); | ||||
|             refetchChangeRequest(); | ||||
|             setToastData({ | ||||
|                 type: 'success', | ||||
|                 title: 'Success', | ||||
|                 text: 'Changes appplied', | ||||
|                 text: 'Changes applied', | ||||
|             }); | ||||
|         } catch (error: unknown) { | ||||
|             setToastApiError(formatUnknownError(error)); | ||||
| @ -39,49 +72,36 @@ export const ChangeRequestOverview: FC = () => { | ||||
|         <> | ||||
|             <ChangeRequestHeader changeRequest={changeRequest} /> | ||||
|             <Box sx={{ display: 'flex' }}> | ||||
|                 <Box | ||||
|                     sx={{ | ||||
|                         width: '30%', | ||||
|                         display: 'flex', | ||||
|                         flexDirection: 'column', | ||||
|                     }} | ||||
|                 > | ||||
|                 <StyledAsideBox> | ||||
|                     <ChangeRequestTimeline state={changeRequest.state} /> | ||||
|                     <ChangeRequestReviewers /> | ||||
|                 </Box> | ||||
|                 <Paper | ||||
|                     elevation={0} | ||||
|                     sx={theme => ({ | ||||
|                         marginTop: theme.spacing(2), | ||||
|                         marginLeft: theme.spacing(2), | ||||
|                         width: '70%', | ||||
|                         padding: 2, | ||||
|                         borderRadius: theme => | ||||
|                             `${theme.shape.borderRadiusLarge}px`, | ||||
|                     })} | ||||
|                 > | ||||
|                     <Box | ||||
|                         sx={theme => ({ | ||||
|                             padding: theme.spacing(2), | ||||
|                         })} | ||||
|                     > | ||||
|                     {/* <ChangeRequestReviewers /> */} | ||||
|                 </StyledAsideBox> | ||||
|                 <StyledPaper elevation={0}> | ||||
|                     <StyledInnerContainer> | ||||
|                         Changes | ||||
|                         <ChangeRequest changeRequest={changeRequest} /> | ||||
|                         <ChangeRequestReviewStatus | ||||
|                             approved={ | ||||
|                                 changeRequest.state === 'Approved' || | ||||
|                                 changeRequest.state === 'Applied' | ||||
|                             } | ||||
|                             state={changeRequest.state} | ||||
|                         /> | ||||
|                         <Button | ||||
|                             variant="contained" | ||||
|                             sx={{ marginTop: 2 }} | ||||
|                             onClick={onApplyChanges} | ||||
|                         > | ||||
|                             Apply changes | ||||
|                         </Button> | ||||
|                     </Box> | ||||
|                 </Paper> | ||||
|                         <StyledButtonBox> | ||||
|                             <ConditionallyRender | ||||
|                                 condition={changeRequest.state === 'In review'} | ||||
|                                 show={<ReviewButton />} | ||||
|                             /> | ||||
|                             <ConditionallyRender | ||||
|                                 condition={changeRequest.state === 'Approved'} | ||||
|                                 show={ | ||||
|                                     <Button | ||||
|                                         variant="contained" | ||||
|                                         onClick={onApplyChanges} | ||||
|                                     > | ||||
|                                         Apply changes | ||||
|                                     </Button> | ||||
|                                 } | ||||
|                             /> | ||||
|                         </StyledButtonBox> | ||||
|                     </StyledInnerContainer> | ||||
|                 </StyledPaper> | ||||
|             </Box> | ||||
|         </> | ||||
|     ); | ||||
|  | ||||
| @ -3,7 +3,12 @@ import { Cancel, CheckCircle } from '@mui/icons-material'; | ||||
| import { Box, Typography, Divider } from '@mui/material'; | ||||
| 
 | ||||
| const styledComponentPropCheck = () => (prop: string) => | ||||
|     prop !== 'color' && prop !== 'sx' && prop !== 'approved'; | ||||
|     prop !== 'color' && | ||||
|     prop !== 'sx' && | ||||
|     prop !== 'approved' && | ||||
|     prop !== 'border' && | ||||
|     prop !== 'bgColor' && | ||||
|     prop !== 'svgColor'; | ||||
| 
 | ||||
| export const StyledFlexAlignCenterBox = styled(Box)(({ theme }) => ({ | ||||
|     display: 'flex', | ||||
| @ -31,11 +36,9 @@ export const StyledOuterContainer = styled(Box)(({ theme }) => ({ | ||||
| 
 | ||||
| export const StyledButtonContainer = styled(Box, { | ||||
|     shouldForwardProp: styledComponentPropCheck(), | ||||
| })<{ approved: boolean }>(({ theme, approved }) => ({ | ||||
| })<{ bgColor: string; svgColor: string }>(({ theme, bgColor, svgColor }) => ({ | ||||
|     borderRadius: `${theme.shape.borderRadiusMedium}px`, | ||||
|     backgroundColor: approved | ||||
|         ? theme.palette.success.main | ||||
|         : theme.palette.tableHeaderBackground, | ||||
|     backgroundColor: bgColor, | ||||
|     padding: theme.spacing(1, 2), | ||||
|     marginRight: theme.spacing(2), | ||||
|     height: '45px', | ||||
| @ -44,9 +47,7 @@ export const StyledButtonContainer = styled(Box, { | ||||
|     alignItems: 'center', | ||||
|     justifyContent: 'center', | ||||
|     ['svg']: { | ||||
|         color: approved | ||||
|             ? theme.palette.tertiary.background | ||||
|             : theme.palette.neutral.main, | ||||
|         color: svgColor, | ||||
|     }, | ||||
| })); | ||||
| 
 | ||||
| @ -56,18 +57,16 @@ export const StyledDivider = styled(Divider)(({ theme }) => ({ | ||||
| 
 | ||||
| export const StyledReviewStatusContainer = styled(Box, { | ||||
|     shouldForwardProp: styledComponentPropCheck(), | ||||
| })<{ approved: boolean }>(({ theme, approved }) => ({ | ||||
| })<{ border: string }>(({ theme, border }) => ({ | ||||
|     borderRadius: `${theme.shape.borderRadiusLarge}px`, | ||||
|     border: approved | ||||
|         ? `2px solid ${theme.palette.success.main}` | ||||
|         : `1px solid ${theme.palette.tertiary.main}`, | ||||
|     border: border, | ||||
|     padding: theme.spacing(3), | ||||
|     width: '100%', | ||||
| })); | ||||
| 
 | ||||
| export const StyledReviewTitle = styled(Typography, { | ||||
|     shouldForwardProp: styledComponentPropCheck(), | ||||
| })<{ approved: boolean }>(({ theme, approved }) => ({ | ||||
| })<{ color: string }>(({ theme, color }) => ({ | ||||
|     fontWeight: 'bold', | ||||
|     color: approved ? theme.palette.success.main : theme.palette.error.main, | ||||
|     color, | ||||
| })); | ||||
|  | ||||
| @ -1,7 +1,6 @@ | ||||
| import { FC } from 'react'; | ||||
| import { Box, Typography } from '@mui/material'; | ||||
| import { Box, Theme, Typography, useTheme } from '@mui/material'; | ||||
| import { ReactComponent as ChangesAppliedIcon } from 'assets/icons/merge.svg'; | ||||
| import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; | ||||
| import { | ||||
|     StyledOuterContainer, | ||||
|     StyledButtonContainer, | ||||
| @ -12,40 +11,98 @@ import { | ||||
|     StyledReviewTitle, | ||||
|     StyledDivider, | ||||
| } from './ChangeRequestReviewStatus.styles'; | ||||
| import { ChangeRequestState } from 'component/changeRequest/changeRequest.types'; | ||||
| interface ISuggestChangeReviewsStatusProps { | ||||
|     approved: boolean; | ||||
|     state: ChangeRequestState; | ||||
| } | ||||
| 
 | ||||
| const resolveBorder = (state: ChangeRequestState, theme: Theme) => { | ||||
|     if (state === 'Approved') { | ||||
|         return `2px solid ${theme.palette.success.main}`; | ||||
|     } | ||||
| 
 | ||||
|     if (state === 'Applied') { | ||||
|         return `2px solid ${theme.palette.primary.main}`; | ||||
|     } | ||||
| 
 | ||||
|     return `1px solid ${theme.palette.tertiary.main}`; | ||||
| }; | ||||
| 
 | ||||
| const resolveIconColors = (state: ChangeRequestState, theme: Theme) => { | ||||
|     if (state === 'Approved') { | ||||
|         return { | ||||
|             bgColor: theme.palette.success.main!, | ||||
|             svgColor: theme.palette.tertiary.background, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     if (state === 'Applied') { | ||||
|         return { | ||||
|             bgColor: theme.palette.primary.main!, | ||||
|             svgColor: theme.palette.tertiary.background, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     return { | ||||
|         bgColor: theme.palette.tableHeaderBackground, | ||||
|         svgColor: theme.palette.neutral.main!, | ||||
|     }; | ||||
| }; | ||||
| 
 | ||||
| export const ChangeRequestReviewStatus: FC< | ||||
|     ISuggestChangeReviewsStatusProps | ||||
| > = ({ approved }) => { | ||||
| > = ({ state }) => { | ||||
|     const theme = useTheme(); | ||||
| 
 | ||||
|     return ( | ||||
|         <StyledOuterContainer> | ||||
|             <StyledButtonContainer approved={approved}> | ||||
|             <StyledButtonContainer {...resolveIconColors(state, theme)}> | ||||
|                 <ChangesAppliedIcon | ||||
|                     style={{ | ||||
|                         transform: `scale(1.5)`, | ||||
|                     }} | ||||
|                 /> | ||||
|             </StyledButtonContainer> | ||||
|             <StyledReviewStatusContainer approved={approved}> | ||||
|                 <ConditionallyRender | ||||
|                     condition={approved} | ||||
|                     show={<Approved approved={approved} />} | ||||
|                     elseShow={<ReviewRequired approved={approved} />} | ||||
|                 /> | ||||
|             <StyledReviewStatusContainer border={resolveBorder(state, theme)}> | ||||
|                 <ResolveComponent state={state} /> | ||||
|             </StyledReviewStatusContainer> | ||||
|         </StyledOuterContainer> | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| const Approved = ({ approved }: ISuggestChangeReviewsStatusProps) => { | ||||
| interface IResolveComponentProps { | ||||
|     state: ChangeRequestState; | ||||
| } | ||||
| 
 | ||||
| const ResolveComponent = ({ state }: IResolveComponentProps) => { | ||||
|     if (!state) { | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     if (state === 'Approved') { | ||||
|         return <Approved />; | ||||
|     } | ||||
| 
 | ||||
|     if (state === 'Applied') { | ||||
|         return <Applied />; | ||||
|     } | ||||
| 
 | ||||
|     if (state === 'Cancelled') { | ||||
|         return <Cancelled />; | ||||
|     } | ||||
| 
 | ||||
|     return <ReviewRequired />; | ||||
| }; | ||||
| 
 | ||||
| const Approved = () => { | ||||
|     const theme = useTheme(); | ||||
| 
 | ||||
|     return ( | ||||
|         <> | ||||
|             <StyledFlexAlignCenterBox> | ||||
|                 <StyledSuccessIcon /> | ||||
|                 <Box> | ||||
|                     <StyledReviewTitle approved={approved}> | ||||
|                     <StyledReviewTitle color={theme.palette.success.main}> | ||||
|                         Changed approved | ||||
|                     </StyledReviewTitle> | ||||
|                     <Typography> | ||||
| @ -59,7 +116,7 @@ const Approved = ({ approved }: ISuggestChangeReviewsStatusProps) => { | ||||
|             <StyledFlexAlignCenterBox> | ||||
|                 <StyledSuccessIcon /> | ||||
|                 <Box> | ||||
|                     <StyledReviewTitle approved={approved}> | ||||
|                     <StyledReviewTitle color={theme.palette.success.main}> | ||||
|                         Changes are ready to be applied | ||||
|                     </StyledReviewTitle> | ||||
|                 </Box> | ||||
| @ -68,13 +125,15 @@ const Approved = ({ approved }: ISuggestChangeReviewsStatusProps) => { | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| const ReviewRequired = ({ approved }: ISuggestChangeReviewsStatusProps) => { | ||||
| const ReviewRequired = () => { | ||||
|     const theme = useTheme(); | ||||
| 
 | ||||
|     return ( | ||||
|         <> | ||||
|             <StyledFlexAlignCenterBox> | ||||
|                 <StyledErrorIcon /> | ||||
|                 <Box> | ||||
|                     <StyledReviewTitle approved={approved}> | ||||
|                     <StyledReviewTitle color={theme.palette.error.main}> | ||||
|                         Review required | ||||
|                     </StyledReviewTitle> | ||||
|                     <Typography> | ||||
| @ -88,10 +147,44 @@ const ReviewRequired = ({ approved }: ISuggestChangeReviewsStatusProps) => { | ||||
| 
 | ||||
|             <StyledFlexAlignCenterBox> | ||||
|                 <StyledErrorIcon /> | ||||
|                 <StyledReviewTitle approved={approved}> | ||||
|                 <StyledReviewTitle color={theme.palette.error.main}> | ||||
|                     Apply changes is blocked | ||||
|                 </StyledReviewTitle> | ||||
|             </StyledFlexAlignCenterBox> | ||||
|         </> | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| const Applied = () => { | ||||
|     const theme = useTheme(); | ||||
| 
 | ||||
|     return ( | ||||
|         <> | ||||
|             <StyledFlexAlignCenterBox> | ||||
|                 <StyledSuccessIcon sx={{ color: theme.palette.primary.main }} /> | ||||
|                 <Box> | ||||
|                     <StyledReviewTitle color={theme.palette.primary.main}> | ||||
|                         Changes applied | ||||
|                     </StyledReviewTitle> | ||||
|                 </Box> | ||||
|             </StyledFlexAlignCenterBox> | ||||
|         </> | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| const Cancelled = () => { | ||||
|     const theme = useTheme(); | ||||
| 
 | ||||
|     return ( | ||||
|         <> | ||||
|             <StyledFlexAlignCenterBox> | ||||
|                 <StyledErrorIcon /> | ||||
|                 <Box> | ||||
|                     <StyledReviewTitle color={theme.palette.error.main}> | ||||
|                         Changes cancelled | ||||
|                     </StyledReviewTitle> | ||||
|                 </Box> | ||||
|             </StyledFlexAlignCenterBox> | ||||
|         </> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| @ -0,0 +1,132 @@ | ||||
| import React from 'react'; | ||||
| import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; | ||||
| import { useChangeRequest } from 'hooks/api/getters/useChangeRequest/useChangeRequest'; | ||||
| import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi'; | ||||
| import { formatUnknownError } from 'utils/formatUnknownError'; | ||||
| import useToast from 'hooks/useToast'; | ||||
| 
 | ||||
| import { | ||||
|     Button, | ||||
|     Grow, | ||||
|     Paper, | ||||
|     Popper, | ||||
|     MenuItem, | ||||
|     MenuList, | ||||
|     ClickAwayListener, | ||||
| } from '@mui/material'; | ||||
| 
 | ||||
| import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; | ||||
| 
 | ||||
| export const ReviewButton = () => { | ||||
|     const projectId = useRequiredPathParam('projectId'); | ||||
|     const id = useRequiredPathParam('id'); | ||||
|     const { refetchChangeRequest } = useChangeRequest(projectId, id); | ||||
|     const { setToastApiError, setToastData } = useToast(); | ||||
| 
 | ||||
|     const { changeState } = useChangeRequestApi(); | ||||
| 
 | ||||
|     const [open, setOpen] = React.useState(false); | ||||
|     const anchorRef = React.useRef<HTMLButtonElement>(null); | ||||
| 
 | ||||
|     const onApprove = async () => { | ||||
|         try { | ||||
|             await changeState(projectId, Number(id), { | ||||
|                 state: 'Approved', | ||||
|             }); | ||||
|             refetchChangeRequest(); | ||||
|             setToastData({ | ||||
|                 type: 'success', | ||||
|                 title: 'Success', | ||||
|                 text: 'Changes approved', | ||||
|             }); | ||||
|         } catch (error: unknown) { | ||||
|             setToastApiError(formatUnknownError(error)); | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     const onReject = async () => { | ||||
|         try { | ||||
|             await changeState(projectId, Number(id), { | ||||
|                 state: 'Cancelled', | ||||
|             }); | ||||
|             refetchChangeRequest(); | ||||
|             setToastData({ | ||||
|                 type: 'success', | ||||
|                 title: 'Success', | ||||
|                 text: 'Changes rejected', | ||||
|             }); | ||||
|         } catch (error: unknown) { | ||||
|             setToastApiError(formatUnknownError(error)); | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     const onToggle = () => { | ||||
|         setOpen(prevOpen => !prevOpen); | ||||
|     }; | ||||
| 
 | ||||
|     const onClose = (event: Event) => { | ||||
|         if ( | ||||
|             anchorRef.current && | ||||
|             anchorRef.current.contains(event.target as HTMLElement) | ||||
|         ) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         setOpen(false); | ||||
|     }; | ||||
| 
 | ||||
|     return ( | ||||
|         <React.Fragment> | ||||
|             <Button | ||||
|                 variant="contained" | ||||
|                 aria-controls={open ? 'review-options-menu' : undefined} | ||||
|                 aria-expanded={open ? 'true' : undefined} | ||||
|                 aria-label="review changes" | ||||
|                 aria-haspopup="menu" | ||||
|                 onClick={onToggle} | ||||
|                 ref={anchorRef} | ||||
|                 endIcon={<ArrowDropDownIcon />} | ||||
|             > | ||||
|                 Review changes | ||||
|             </Button> | ||||
|             <Popper | ||||
|                 sx={{ | ||||
|                     zIndex: 1, | ||||
|                 }} | ||||
|                 open={open} | ||||
|                 anchorEl={anchorRef.current} | ||||
|                 role={undefined} | ||||
|                 transition | ||||
|                 disablePortal | ||||
|             > | ||||
|                 {({ TransitionProps, placement }) => ( | ||||
|                     <Grow | ||||
|                         {...TransitionProps} | ||||
|                         style={{ | ||||
|                             transformOrigin: | ||||
|                                 placement === 'bottom' | ||||
|                                     ? 'center top' | ||||
|                                     : 'center bottom', | ||||
|                         }} | ||||
|                     > | ||||
|                         <Paper> | ||||
|                             <ClickAwayListener onClickAway={onClose}> | ||||
|                                 <MenuList | ||||
|                                     id="review-options-menu" | ||||
|                                     autoFocusItem | ||||
|                                 > | ||||
|                                     <MenuItem onClick={onApprove}> | ||||
|                                         Approve changes | ||||
|                                     </MenuItem> | ||||
|                                     <MenuItem onClick={onReject}> | ||||
|                                         Reject changes | ||||
|                                     </MenuItem> | ||||
|                                 </MenuList> | ||||
|                             </ClickAwayListener> | ||||
|                         </Paper> | ||||
|                     </Grow> | ||||
|                 )} | ||||
|             </Popper> | ||||
|         </React.Fragment> | ||||
|     ); | ||||
| }; | ||||
| @ -35,10 +35,7 @@ export const ChangeRequestStatusBadge: VFC<IChangeRequestStatusBadgeProps> = ({ | ||||
|             ); | ||||
|         case 'Cancelled': | ||||
|             return ( | ||||
|                 <Badge | ||||
|                     color="error" | ||||
|                     icon={<Close fontSize={'small'} sx={{ mr: 8 }} />} | ||||
|                 > | ||||
|                 <Badge color="error" icon={<Close fontSize={'small'} />}> | ||||
|                     Cancelled | ||||
|                 </Badge> | ||||
|             ); | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user