1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-24 01:18:01 +02:00

feat: Change request reject UI (#4489)

This commit is contained in:
Mateusz Kwasniewski 2023-08-15 09:08:26 +02:00 committed by GitHub
parent e2717ab8d3
commit 3227e30f12
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 263 additions and 50 deletions

View File

@ -12,6 +12,7 @@ const changeRequestWithDefaultChange = (
) => {
const changeRequest: IChangeRequest = {
approvals: [],
rejections: [],
comments: [],
createdAt: new Date(),
createdBy: {

View File

@ -1,13 +1,8 @@
import { Alert, Button, styled, Typography } from '@mui/material';
import { Alert, Box, Button, styled, Typography } from '@mui/material';
import { FC, useContext, useState } from 'react';
import { Box } from '@mui/material';
import { useChangeRequest } from 'hooks/api/getters/useChangeRequest/useChangeRequest';
import { ChangeRequestHeader } from './ChangeRequestHeader/ChangeRequestHeader';
import { ChangeRequestTimeline } from './ChangeRequestTimeline/ChangeRequestTimeline';
import {
ChangeRequestReviewers,
ChangeRequestReviewersHeader,
} from './ChangeRequestReviewers/ChangeRequestReviewers';
import { ChangeRequest } from '../ChangeRequest/ChangeRequest';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
@ -17,7 +12,6 @@ import { formatUnknownError } from 'utils/formatUnknownError';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import Paper from '@mui/material/Paper';
import { ReviewButton } from './ReviewButton/ReviewButton';
import { ChangeRequestReviewer } from './ChangeRequestReviewers/ChangeRequestReviewer';
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import { APPLY_CHANGE_REQUEST } from 'component/providers/AccessProvider/permissions';
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
@ -28,6 +22,7 @@ import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequ
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { changesCount } from '../changesCount';
import { ChangeRequestReviewers } from './ChangeRequestReviewers/ChangeRequestReviewers';
const StyledAsideBox = styled(Box)(({ theme }) => ({
width: '30%',
@ -161,25 +156,7 @@ export const ChangeRequestOverview: FC = () => {
<ChangeRequestBody>
<StyledAsideBox>
<ChangeRequestTimeline state={changeRequest.state} />
<ChangeRequestReviewers
header={
<ChangeRequestReviewersHeader
actualApprovals={changeRequest.approvals.length}
minApprovals={changeRequest.minApprovals}
/>
}
>
{changeRequest.approvals?.map(approver => (
<ChangeRequestReviewer
key={approver.createdBy.username}
name={
approver.createdBy.username ||
'Unknown user'
}
imageUrl={approver.createdBy.imageUrl}
/>
))}
</ChangeRequestReviewers>
<ChangeRequestReviewers changeRequest={changeRequest} />
</StyledAsideBox>
<StyledPaper elevation={0}>
<StyledInnerContainer>
@ -262,6 +239,7 @@ export const ChangeRequestOverview: FC = () => {
<ConditionallyRender
condition={
changeRequest.state !== 'Applied' &&
changeRequest.state !== 'Rejected' &&
changeRequest.state !== 'Cancelled' &&
(changeRequest.createdBy.id === user?.id ||
isAdmin)

View File

@ -106,6 +106,10 @@ const ResolveComponent = ({ changeRequest }: IResolveComponentProps) => {
return <Cancelled />;
}
if (state === 'Rejected') {
return <Rejected />;
}
return <ReviewRequired minApprovals={changeRequest.minApprovals} />;
};
@ -207,3 +211,20 @@ const Cancelled = () => {
</>
);
};
const Rejected = () => {
const theme = useTheme();
return (
<>
<StyledFlexAlignCenterBox>
<StyledErrorIcon />
<Box>
<StyledReviewTitle color={theme.palette.error.main}>
Changes rejected
</StyledReviewTitle>
</Box>
</StyledFlexAlignCenterBox>
</>
);
};

View File

@ -0,0 +1,30 @@
import React, { FC } from 'react';
import { Typography } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { ChangeRequestApprover } from './ChangeRequestReviewer';
import { IChangeRequestApproval } from '../../changeRequest.types';
interface ChangeRequestApprovalProps {
approvals: IChangeRequestApproval[];
}
export const ChangeRequestApprovals: FC<ChangeRequestApprovalProps> = ({
approvals = [],
}) => (
<>
<Typography variant="body1" color="text.secondary">
<ConditionallyRender
condition={approvals?.length > 0}
show={'Approved by'}
elseShow={'No approvals yet'}
/>
</Typography>
{approvals.map(approver => (
<ChangeRequestApprover
key={approver.createdBy.username}
name={approver.createdBy.username || 'Unknown user'}
imageUrl={approver.createdBy.imageUrl}
/>
))}
</>
);

View File

@ -0,0 +1,25 @@
import { IChangeRequestApproval } from '../../changeRequest.types';
import React, { FC } from 'react';
import { Typography } from '@mui/material';
import { ChangeRequestRejector } from './ChangeRequestReviewer';
interface ChangeRequestRejectionProps {
rejections: IChangeRequestApproval[];
}
export const ChangeRequestRejections: FC<ChangeRequestRejectionProps> = ({
rejections = [],
}) => (
<>
<Typography variant="body1" color="text.secondary">
Rejected by
</Typography>
{rejections.map(rejector => (
<ChangeRequestRejector
key={rejector.createdBy.username}
name={rejector.createdBy.username || 'Unknown user'}
imageUrl={rejector.createdBy.imageUrl}
/>
))}
</>
);

View File

@ -1,7 +1,7 @@
import { Box, styled, Typography } from '@mui/material';
import React, { FC } from 'react';
import { StyledAvatar } from '../ChangeRequestHeader/ChangeRequestHeader.styles';
import { CheckCircle } from '@mui/icons-material';
import { CheckCircle, Cancel } from '@mui/icons-material';
interface IChangeRequestReviewerProps {
name?: string;
@ -21,26 +21,41 @@ export const StyledSuccessIcon = styled(CheckCircle)(({ theme }) => ({
marginLeft: 'auto',
}));
export const ChangeRequestReviewer: FC<IChangeRequestReviewerProps> = ({
export const StyledErrorIcon = styled(Cancel)(({ theme }) => ({
color: theme.palette.error.main,
marginLeft: 'auto',
}));
export const ReviewerName = styled(Typography)({
maxWidth: '170px',
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
color: 'text.primary',
});
export const ChangeRequestApprover: FC<IChangeRequestReviewerProps> = ({
name,
imageUrl,
}) => {
return (
<StyledBox>
<StyledAvatar src={imageUrl} />
<Typography
variant="body1"
color="text.primary"
sx={{
maxWidth: '170px',
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
}}
>
{name}
</Typography>
<ReviewerName variant="body1">{name}</ReviewerName>
<StyledSuccessIcon />
</StyledBox>
);
};
export const ChangeRequestRejector: FC<IChangeRequestReviewerProps> = ({
name,
imageUrl,
}) => {
return (
<StyledBox>
<StyledAvatar src={imageUrl} />
<ReviewerName variant="body1">{name}</ReviewerName>
<StyledErrorIcon />
</StyledBox>
);
};

View File

@ -0,0 +1,65 @@
import { render } from 'utils/testRenderer';
import React from 'react';
import { screen } from '@testing-library/react';
import { ChangeRequestReviewers } from './ChangeRequestReviewers';
test('Show approvers', async () => {
render(
<ChangeRequestReviewers
changeRequest={{
state: 'Approved',
minApprovals: 2,
rejections: [],
approvals: [
{
createdBy: {
id: 1,
username: 'approver',
imageUrl: 'approverImg',
},
createdAt: new Date(),
},
],
}}
/>
);
expect(screen.getByText('Approved by')).toBeInTheDocument();
expect(screen.getByText('approver')).toBeInTheDocument();
});
test('Show rejectors', async () => {
render(
<ChangeRequestReviewers
changeRequest={{
state: 'Rejected',
minApprovals: 2,
rejections: [
{
createdBy: {
id: 2,
username: 'rejector',
imageUrl: 'rejectorImg',
},
createdAt: new Date(),
},
],
approvals: [
{
createdBy: {
id: 1,
username: 'approver',
imageUrl: 'approverImg',
},
createdAt: new Date(),
},
],
}}
/>
);
expect(screen.getByText('Rejected by')).toBeInTheDocument();
expect(screen.getByText('rejector')).toBeInTheDocument();
expect(screen.queryByText('Approved by')).not.toBeInTheDocument();
expect(screen.queryByText('approver')).not.toBeInTheDocument();
});

View File

@ -1,6 +1,9 @@
import { Box, Paper, styled, Typography } from '@mui/material';
import React, { FC, ReactNode } from 'react';
import { ConditionallyRender } from '../../../common/ConditionallyRender/ConditionallyRender';
import { ChangeRequestRejections } from './ChangeRequestRejections';
import { ChangeRequestApprovals } from './ChangeRequestApprovals';
import { IChangeRequest } from '../../changeRequest.types';
const StyledBox = styled(Box)(({ theme }) => ({
marginBottom: theme.spacing(2),
@ -20,7 +23,7 @@ export const ChangeRequestReviewersHeader: FC<{
);
};
export const ChangeRequestReviewers: FC<{ header: ReactNode }> = ({
export const ChangeRequestReviewersWrapper: FC<{ header: ReactNode }> = ({
header,
children,
}) => {
@ -34,14 +37,35 @@ export const ChangeRequestReviewers: FC<{ header: ReactNode }> = ({
})}
>
<StyledBox>{header}</StyledBox>
<Typography variant="body1" color="text.secondary">
<ConditionallyRender
condition={React.Children.count(children) > 0}
show={'Approved by'}
elseShow={'No approvals yet'}
/>
</Typography>
{children}
</Paper>
);
};
export const ChangeRequestReviewers: FC<{
changeRequest: Pick<
IChangeRequest,
'approvals' | 'rejections' | 'state' | 'minApprovals'
>;
}> = ({ changeRequest }) => (
<ChangeRequestReviewersWrapper
header={
<ChangeRequestReviewersHeader
actualApprovals={changeRequest.approvals.length}
minApprovals={changeRequest.minApprovals}
/>
}
>
<ConditionallyRender
condition={changeRequest.state === 'Rejected'}
show={
<ChangeRequestRejections
rejections={changeRequest.rejections}
/>
}
elseShow={
<ChangeRequestApprovals approvals={changeRequest.approvals} />
}
/>
</ChangeRequestReviewersWrapper>
);

View File

@ -20,8 +20,11 @@ import PermissionButton from 'component/common/PermissionButton/PermissionButton
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
import AccessContext from 'contexts/AccessContext';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
export const ReviewButton: FC<{ disabled: boolean }> = ({ disabled }) => {
const { uiConfig } = useUiConfig();
const { isAdmin } = useContext(AccessContext);
const projectId = useRequiredPathParam('projectId');
const id = useRequiredPathParam('id');
@ -53,6 +56,23 @@ export const ReviewButton: FC<{ disabled: boolean }> = ({ disabled }) => {
}
};
const onReject = async () => {
try {
await changeState(projectId, Number(id), {
state: 'Rejected',
});
refetchChangeRequest();
refetchChangeRequestOpen();
setToastData({
type: 'success',
title: 'Success',
text: 'Changes rejected',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
const onToggle = () => {
setOpen(prevOpen => !prevOpen);
};
@ -117,6 +137,16 @@ export const ReviewButton: FC<{ disabled: boolean }> = ({ disabled }) => {
<MenuItem onClick={onApprove}>
Approve changes
</MenuItem>
<ConditionallyRender
condition={Boolean(
uiConfig?.flags?.changeRequestReject
)}
show={
<MenuItem onClick={onReject}>
Reject changes
</MenuItem>
}
/>
</MenuList>
</ClickAwayListener>
</Paper>

View File

@ -18,6 +18,7 @@ const changeRequest = {
createdAt: new Date(),
features: [],
approvals: [],
rejections: [],
comments: [],
segments: [],
};

View File

@ -41,6 +41,12 @@ export const ChangeRequestStatusBadge: VFC<IChangeRequestStatusBadgeProps> = ({
Cancelled
</Badge>
);
case 'Rejected':
return (
<Badge color="error" icon={<Close fontSize={'small'} />}>
Rejected
</Badge>
);
default:
return <ReviewRequiredBadge />;
}

View File

@ -72,11 +72,13 @@ export const ChangeRequestsTabs = ({
const open = changeRequests.filter(
changeRequest =>
changeRequest.state !== 'Cancelled' &&
changeRequest.state !== 'Rejected' &&
changeRequest.state !== 'Applied'
);
const closed = changeRequests.filter(
changeRequest =>
changeRequest.state === 'Cancelled' ||
changeRequest.state === 'Rejected' ||
changeRequest.state === 'Applied'
);

View File

@ -15,6 +15,7 @@ export interface IChangeRequest {
features: IChangeRequestFeature[];
segments: ISegmentChange[];
approvals: IChangeRequestApproval[];
rejections: IChangeRequestApproval[];
comments: IChangeRequestComment[];
conflict?: string;
}
@ -66,7 +67,8 @@ export type ChangeRequestState =
| 'Approved'
| 'In review'
| 'Applied'
| 'Cancelled';
| 'Cancelled'
| 'Rejected';
type ChangeRequestPayload =
| ChangeRequestEnabled

View File

@ -55,7 +55,12 @@ export const useChangeRequestApi = () => {
project: string,
changeRequestId: number,
payload: {
state: 'Approved' | 'Applied' | 'Cancelled' | 'In review';
state:
| 'Approved'
| 'Applied'
| 'Cancelled'
| 'In review'
| 'Rejected';
comment?: string;
}
) => {

View File

@ -56,6 +56,7 @@ export interface IFlags {
configurableFeatureTypeLifetimes?: boolean;
frontendNavigationUpdate?: boolean;
segmentChangeRequests?: boolean;
changeRequestReject?: boolean;
lastSeenByEnvironment?: boolean;
}

View File

@ -69,6 +69,7 @@ exports[`should create default config 1`] = `
"flags": {
"anonymiseEventLog": false,
"caseInsensitiveInOperators": false,
"changeRequestReject": false,
"configurableFeatureTypeLifetimes": false,
"customRootRolesKillSwitch": false,
"demo": false,
@ -106,6 +107,7 @@ exports[`should create default config 1`] = `
"experiments": {
"anonymiseEventLog": false,
"caseInsensitiveInOperators": false,
"changeRequestReject": false,
"configurableFeatureTypeLifetimes": false,
"customRootRolesKillSwitch": false,
"demo": false,

View File

@ -29,6 +29,7 @@ export type IFlagKey =
| 'frontendNavigationUpdate'
| 'lastSeenByEnvironment'
| 'segmentChangeRequests'
| 'changeRequestReject'
| 'customRootRolesKillSwitch';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
@ -133,6 +134,10 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_SEGMENT_CHANGE_REQUESTS,
false,
),
changeRequestReject: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_CHANGE_REQUEST_REJECT,
false,
),
customRootRolesKillSwitch: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_CUSTOM_ROOT_ROLES_KILL_SWITCH,
false,