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:
parent
e2717ab8d3
commit
3227e30f12
@ -12,6 +12,7 @@ const changeRequestWithDefaultChange = (
|
||||
) => {
|
||||
const changeRequest: IChangeRequest = {
|
||||
approvals: [],
|
||||
rejections: [],
|
||||
comments: [],
|
||||
createdAt: new Date(),
|
||||
createdBy: {
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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();
|
||||
});
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -18,6 +18,7 @@ const changeRequest = {
|
||||
createdAt: new Date(),
|
||||
features: [],
|
||||
approvals: [],
|
||||
rejections: [],
|
||||
comments: [],
|
||||
segments: [],
|
||||
};
|
||||
|
@ -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 />;
|
||||
}
|
||||
|
@ -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'
|
||||
);
|
||||
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
) => {
|
||||
|
@ -56,6 +56,7 @@ export interface IFlags {
|
||||
configurableFeatureTypeLifetimes?: boolean;
|
||||
frontendNavigationUpdate?: boolean;
|
||||
segmentChangeRequests?: boolean;
|
||||
changeRequestReject?: boolean;
|
||||
lastSeenByEnvironment?: boolean;
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user