1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-11-10 01:19:53 +01:00
unleash.unleash/frontend/src/component/changeRequest/ChangeRequestOverview/ChangeRequestOverview.tsx

628 lines
26 KiB
TypeScript

import {
Alert,
Box,
Button,
styled,
Typography,
useTheme,
} from '@mui/material';
import { type FC, useContext, useState } from 'react';
import { useChangeRequest } from 'hooks/api/getters/useChangeRequest/useChangeRequest';
import { ChangeRequestHeader } from './ChangeRequestHeader/ChangeRequestHeader.tsx';
import { ReactComponent as ApprovedIcon } from 'assets/icons/merge.svg';
import {
ChangeRequestTimeline,
type ISuggestChangeTimelineProps,
} from './ChangeRequestTimeline/ChangeRequestTimeline.tsx';
import { ChangeRequest } from '../ChangeRequest/ChangeRequest.tsx';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { ChangeRequestReviewStatus } from './ChangeRequestReviewStatus/ChangeRequestReviewStatus.tsx';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import Paper from '@mui/material/Paper';
import { ReviewButton } from './ReviewButton/ReviewButton.tsx';
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
import AccessContext from 'contexts/AccessContext';
import { ChangeRequestComment } from './ChangeRequestComments/ChangeRequestComment.tsx';
import { AddCommentField } from './ChangeRequestComments/AddCommentField.tsx';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { changesCount } from '../changesCount.ts';
import { ChangeRequestRejectDialogue } from './ChangeRequestRejectDialog/ChangeRequestRejectDialog.tsx';
import { ApplyButton } from './ApplyButton/ApplyButton.tsx';
import {
ChangeRequestApplyScheduledDialogue,
ChangeRequestRejectScheduledDialogue,
} from './ChangeRequestScheduledDialogs/changeRequestScheduledDialogs.tsx';
import { ScheduleChangeRequestDialog } from './ChangeRequestScheduledDialogs/ScheduleChangeRequestDialog.tsx';
import type { PlausibleChangeRequestState } from '../changeRequest.types';
import { useNavigate } from 'react-router-dom';
import { useActionableChangeRequests } from 'hooks/api/getters/useActionableChangeRequests/useActionableChangeRequests';
import { ChangeRequestRequestedApprovers } from './ChangeRequestRequestedApprovers/ChangeRequestRequestedApprovers.tsx';
const breakpoint = 'md';
const StyledAsideBox = styled(Box)(({ theme }) => ({
width: '30%',
display: 'flex',
flexDirection: 'column',
[theme.breakpoints.down(breakpoint)]: {
width: '100%',
},
}));
const StyledPaper = styled(Paper)(({ theme }) => ({
padding: theme.spacing(1, 2),
borderRadius: theme.shape.borderRadiusLarge,
}));
const StyledApplyPaper = styled(Paper)(({ theme }) => ({
padding: theme.spacing(1, 2),
borderRadius: theme.shape.borderRadiusLarge,
marginTop: theme.spacing(2),
}));
const StyledDiv = styled('div')(({ theme }) => ({
marginTop: theme.spacing(2),
width: '70%',
borderRadius: theme.shape.borderRadiusLarge,
[theme.breakpoints.down(breakpoint)]: {
width: '100%',
},
}));
const StyledButtonBox = styled(Box)(({ theme }) => ({
marginTop: theme.spacing(3),
display: 'flex',
justifyContent: 'flex-end',
}));
const StyledInnerContainer = styled(Box)(({ theme }) => ({
padding: theme.spacing(2),
}));
const StyledApplyInnerContainer = styled(Box)(({ theme }) => ({
paddingBottom: theme.spacing(1.5),
}));
const StyledOuterContainer = styled(Box)(({ theme }) => ({
display: 'flex',
marginTop: theme.spacing(2),
}));
const StyledButton = styled(Button)(({ theme }) => ({
marginLeft: theme.spacing(2),
}));
const ChangeRequestBody = styled(Box)(({ theme }) => ({
display: 'flex',
columnGap: theme.spacing(2),
[theme.breakpoints.down(breakpoint)]: {
flexDirection: 'column',
},
}));
const StyledApplyContainer = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'row',
justifyContent: 'flex-end',
width: '100%',
}));
const StyledBox = styled(Box)(({ theme }) => ({
width: '100%',
}));
const StyledTypography = styled(Typography)(({ theme }) => ({
fontWeight: 'bold',
}));
const StyledButtonContainer = styled(Box)(({ theme }) => ({
borderRadius: `${theme.shape.borderRadiusMedium}px`,
backgroundColor: theme.palette.primary.main!,
padding: theme.spacing(1, 2),
marginRight: theme.spacing(2),
height: '45px',
width: '45px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
svg: {
color: theme.palette.background.paper,
},
}));
export const ChangeRequestOverview: FC = () => {
const projectId = useRequiredPathParam('projectId');
const [showCancelDialog, setShowCancelDialog] = useState(false);
const [showRejectDialog, setShowRejectDialog] = useState(false);
const [showScheduleChangesDialog, setShowScheduleChangeDialog] =
useState(false);
const [showApplyScheduledDialog, setShowApplyScheduledDialog] =
useState(false);
const [showRejectScheduledDialog, setShowRejectScheduledDialog] =
useState(false);
const { user } = useAuthUser();
const { isAdmin } = useContext(AccessContext);
const [commentText, setCommentText] = useState('');
const { refetch: refetchActionableChangeRequests } =
useActionableChangeRequests(projectId);
const id = useRequiredPathParam('id');
const { data: changeRequest, refetchChangeRequest } = useChangeRequest(
projectId,
id,
);
const { changeState, addComment } = useChangeRequestApi();
const { refetch: refetchChangeRequestOpen } =
usePendingChangeRequests(projectId);
const { setToastData, setToastApiError } = useToast();
const { isChangeRequestConfiguredForReview } =
useChangeRequestsEnabled(projectId);
const [disabled, setDisabled] = useState(false);
const navigate = useNavigate();
const theme = useTheme();
if (!changeRequest) {
return null;
}
const allowChangeRequestActions = isChangeRequestConfiguredForReview(
changeRequest.environment,
);
const getCurrentState = (): PlausibleChangeRequestState => {
switch (changeRequest.state) {
case 'Scheduled':
return `${changeRequest.state} ${changeRequest.schedule.status}`;
default:
return changeRequest.state;
}
};
const onApplyChanges = async () => {
try {
setDisabled(true);
await changeState(projectId, Number(id), getCurrentState(), {
state: 'Applied',
});
setShowApplyScheduledDialog(false);
await refetchChangeRequest();
refetchChangeRequestOpen();
refetchActionableChangeRequests();
setToastData({
type: 'success',
text: 'Changes applied',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
} finally {
setDisabled(false);
}
};
const onScheduleChangeRequest = async (scheduledDate: Date) => {
try {
setDisabled(true);
await changeState(projectId, Number(id), getCurrentState(), {
state: 'Scheduled',
scheduledAt: scheduledDate.toISOString(),
});
setShowScheduleChangeDialog(false);
refetchChangeRequest();
refetchChangeRequestOpen();
refetchActionableChangeRequests();
setToastData({
type: 'success',
text: 'Changes scheduled',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
} finally {
setDisabled(false);
}
};
const onAddComment = async () => {
try {
setDisabled(true);
await addComment(projectId, id, commentText);
setCommentText('');
await refetchChangeRequest();
setToastData({
type: 'success',
text: 'Comment added',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
} finally {
setDisabled(false);
}
};
const onCancelChanges = async () => {
try {
setDisabled(true);
await changeState(projectId, Number(id), getCurrentState(), {
state: 'Cancelled',
});
setShowCancelDialog(false);
await refetchChangeRequest();
refetchChangeRequestOpen();
refetchActionableChangeRequests();
setToastData({
type: 'success',
text: 'Changes cancelled',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
} finally {
setDisabled(false);
}
};
const onReject = async (comment?: string) => {
try {
setDisabled(true);
await changeState(projectId, Number(id), getCurrentState(), {
state: 'Rejected',
comment,
});
setShowRejectDialog(false);
await refetchChangeRequest();
setToastData({
type: 'success',
text: 'Changes rejected',
});
refetchChangeRequestOpen();
refetchActionableChangeRequests();
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
} finally {
setDisabled(false);
}
};
const onApprove = async () => {
try {
setDisabled(true);
await changeState(projectId, Number(id), getCurrentState(), {
state: 'Approved',
});
await refetchChangeRequest();
refetchActionableChangeRequests();
refetchChangeRequestOpen();
setToastData({
type: 'success',
text: 'Changes approved',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
} finally {
setDisabled(false);
}
};
const onCancel = () => setShowCancelDialog(true);
const onCancelAbort = () => setShowCancelDialog(false);
const onCancelReject = () => setShowRejectDialog(false);
const onApplyScheduledAbort = () => setShowApplyScheduledDialog(false);
const onScheduleChangeAbort = () => setShowScheduleChangeDialog(false);
const onRejectScheduledAbort = () => setShowRejectScheduledDialog(false);
const isSelfReview =
changeRequest?.createdBy.id === user?.id &&
changeRequest.state === 'In review' &&
!isAdmin;
const hasApprovedAlready = changeRequest.approvals?.some(
(approval) => approval.createdBy.id === user?.id,
);
const countOfChanges = changesCount(changeRequest);
const scheduledAt =
'schedule' in changeRequest
? changeRequest.schedule.scheduledAt
: undefined;
const timelineProps: ISuggestChangeTimelineProps =
changeRequest.state === 'Scheduled'
? {
state: 'Scheduled',
schedule: changeRequest.schedule,
}
: {
state: changeRequest.state,
schedule: undefined,
};
return (
<>
<ChangeRequestHeader changeRequest={changeRequest} />
<ChangeRequestBody>
<StyledAsideBox>
<ChangeRequestTimeline
{...timelineProps}
timestamps={changeRequest.stateTimestamps}
/>
<ChangeRequestRequestedApprovers
changeRequest={changeRequest}
/>
</StyledAsideBox>
<StyledDiv>
<StyledPaper elevation={0}>
<StyledInnerContainer>
Requested Changes ({countOfChanges})
<ChangeRequest
changeRequest={changeRequest}
onRefetch={refetchChangeRequest}
/>
{changeRequest.comments?.map((comment) => (
<ChangeRequestComment
key={comment.id}
comment={comment}
/>
))}
<AddCommentField
user={user}
commentText={commentText}
onTypeComment={setCommentText}
>
<Button
variant='outlined'
onClick={onAddComment}
disabled={
!allowChangeRequestActions ||
commentText.trim().length === 0 ||
commentText.trim().length > 1000 ||
disabled
}
>
Comment
</Button>
</AddCommentField>
<ConditionallyRender
condition={isSelfReview}
show={
<Alert
sx={(theme) => ({
marginTop: theme.spacing(1.5),
})}
severity='info'
>
You can not approve your own change
request
</Alert>
}
/>
<ChangeRequestReviewStatus
changeRequest={changeRequest}
onEditClick={() =>
setShowScheduleChangeDialog(true)
}
/>
<StyledButtonBox>
<ConditionallyRender
condition={
changeRequest.state === 'In review' &&
!hasApprovedAlready
}
show={
<ReviewButton
onReject={() =>
setShowRejectDialog(true)
}
onApprove={onApprove}
disabled={
!allowChangeRequestActions ||
disabled
}
>
Review changes ({countOfChanges})
</ReviewButton>
}
/>
<ConditionallyRender
condition={
changeRequest.state === 'Scheduled'
}
show={
<ApplyButton
onApply={() =>
setShowApplyScheduledDialog(
true,
)
}
disabled={
!allowChangeRequestActions ||
disabled
}
onSchedule={() =>
setShowScheduleChangeDialog(
true,
)
}
variant={'update'}
>
Apply or schedule changes
</ApplyButton>
}
/>
<ConditionallyRender
condition={
changeRequest.state === 'In review' ||
changeRequest.state === 'Approved' ||
changeRequest.state === 'Scheduled'
}
show={
<StyledButton
variant='outlined'
onClick={() => {
navigate(
`/playground?changeRequest=${changeRequest.id}&projects=${projectId}&environments=${changeRequest.environment}`,
);
}}
>
Preview changes
</StyledButton>
}
/>
<ConditionallyRender
condition={
changeRequest.state !== 'Applied' &&
changeRequest.state !== 'Rejected' &&
changeRequest.state !== 'Cancelled' &&
(changeRequest.createdBy.id ===
user?.id ||
isAdmin)
}
show={
<ConditionallyRender
condition={Boolean(scheduledAt)}
show={
<StyledButton
variant='outlined'
onClick={() =>
setShowRejectScheduledDialog(
true,
)
}
disabled={disabled}
>
Reject changes
</StyledButton>
}
elseShow={
<StyledButton
variant='outlined'
onClick={onCancel}
disabled={disabled}
>
Cancel changes
</StyledButton>
}
/>
}
/>
</StyledButtonBox>
</StyledInnerContainer>
</StyledPaper>
<ConditionallyRender
condition={changeRequest.state === 'Approved'}
show={
<StyledApplyPaper elevation={0}>
<StyledApplyInnerContainer>
<StyledOuterContainer>
<StyledButtonContainer>
<ApprovedIcon
style={{
transform: `scale(1.5)`,
}}
/>
</StyledButtonContainer>
<StyledBox>
<StyledTypography>
Apply changes
</StyledTypography>
<Typography>
The change request has been
reviewed and approved
</Typography>
</StyledBox>
<StyledApplyContainer>
<ApplyButton
onApply={onApplyChanges}
disabled={
!allowChangeRequestActions ||
disabled
}
onSchedule={() =>
setShowScheduleChangeDialog(
true,
)
}
>
Apply or schedule changes
</ApplyButton>
</StyledApplyContainer>
</StyledOuterContainer>
</StyledApplyInnerContainer>
</StyledApplyPaper>
}
/>
</StyledDiv>
<Dialogue
open={showCancelDialog}
onClick={onCancelChanges}
onClose={onCancelAbort}
title='Cancel change request'
>
<Typography sx={{ marginBottom: 2 }}>
You are about to cancel this change request
</Typography>
<Typography
variant='body2'
sx={(theme) => ({ color: theme.palette.neutral.dark })}
>
The change request will be moved to closed, and it can't
be applied anymore. Once cancelled, the change request
can't be reopened.
</Typography>
</Dialogue>
<ChangeRequestRejectDialogue
open={showRejectDialog}
onConfirm={onReject}
onClose={onCancelReject}
disabled={disabled}
/>
<>
<ScheduleChangeRequestDialog
open={showScheduleChangesDialog}
onConfirm={onScheduleChangeRequest}
onClose={onScheduleChangeAbort}
disabled={!allowChangeRequestActions || disabled}
projectId={projectId}
environment={changeRequest.environment}
primaryButtonText={
changeRequest.state === 'Scheduled'
? 'Update scheduled time'
: 'Schedule changes'
}
title={
changeRequest.state === 'Scheduled'
? 'Update schedule'
: 'Schedule changes'
}
scheduledAt={scheduledAt}
/>
<ChangeRequestApplyScheduledDialogue
open={showApplyScheduledDialog}
onConfirm={onApplyChanges}
onClose={onApplyScheduledAbort}
scheduledTime={scheduledAt}
disabled={!allowChangeRequestActions || disabled}
projectId={projectId}
environment={changeRequest.environment}
/>
<ChangeRequestRejectScheduledDialogue
open={showRejectScheduledDialog}
onConfirm={onReject}
onClose={onRejectScheduledAbort}
scheduledTime={scheduledAt}
disabled={disabled}
/>
</>
</ChangeRequestBody>
</>
);
};