mirror of
https://github.com/Unleash/unleash.git
synced 2025-09-05 17:53:12 +02:00
feat(changerequests): add requested approvers to overview (#10232)
This commit is contained in:
parent
f2766b6b3b
commit
28caa82ad1
@ -34,6 +34,8 @@ import { ScheduleChangeRequestDialog } from './ChangeRequestScheduledDialogs/Sch
|
||||
import type { PlausibleChangeRequestState } from '../changeRequest.types';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useActionableChangeRequests } from 'hooks/api/getters/useActionableChangeRequests/useActionableChangeRequests';
|
||||
import { useUiFlag } from 'hooks/useUiFlag.ts';
|
||||
import { ChangeRequestRequestedApprovers } from './ChangeRequestRequestedApprovers/ChangeRequestRequestedApprovers.tsx';
|
||||
|
||||
const StyledAsideBox = styled(Box)(({ theme }) => ({
|
||||
width: '30%',
|
||||
@ -106,6 +108,7 @@ export const ChangeRequestOverview: FC = () => {
|
||||
useChangeRequestsEnabled(projectId);
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const approversEnabled = useUiFlag('changeRequestApproverEmails');
|
||||
|
||||
if (!changeRequest) {
|
||||
return null;
|
||||
@ -288,7 +291,19 @@ export const ChangeRequestOverview: FC = () => {
|
||||
<ChangeRequestBody>
|
||||
<StyledAsideBox>
|
||||
<ChangeRequestTimeline {...timelineProps} />
|
||||
<ChangeRequestReviewers changeRequest={changeRequest} />
|
||||
<ConditionallyRender
|
||||
condition={approversEnabled}
|
||||
show={
|
||||
<ChangeRequestRequestedApprovers
|
||||
changeRequest={changeRequest}
|
||||
/>
|
||||
}
|
||||
elseShow={
|
||||
<ChangeRequestReviewers
|
||||
changeRequest={changeRequest}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</StyledAsideBox>
|
||||
<StyledPaper elevation={0}>
|
||||
<StyledInnerContainer>
|
||||
|
@ -0,0 +1,372 @@
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
styled,
|
||||
IconButton,
|
||||
useTheme,
|
||||
type AutocompleteChangeReason,
|
||||
type FilterOptionsState,
|
||||
Checkbox,
|
||||
TextField,
|
||||
Button,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
type ReviewerSchema,
|
||||
useRequestedApprovers,
|
||||
} from 'hooks/api/getters/useRequestedApprovers/useRequestedApprovers';
|
||||
import { useState, type FC } from 'react';
|
||||
import type { ChangeRequestType } from '../../changeRequest.types';
|
||||
import {
|
||||
ChangeRequestApprover,
|
||||
ChangeRequestPending,
|
||||
ChangeRequestRejector,
|
||||
} from '../ChangeRequestReviewers/ChangeRequestReviewer.js';
|
||||
import Add from '@mui/icons-material/Add';
|
||||
import KeyboardArrowUp from '@mui/icons-material/KeyboardArrowUp';
|
||||
import AutocompleteVirtual from 'component/common/AutocompleteVirtual/AutcompleteVirtual.js';
|
||||
import {
|
||||
type AvailableReviewerSchema,
|
||||
useAvailableChangeRequestReviewers,
|
||||
} from 'hooks/api/getters/useAvailableChangeRequestReviewers/useAvailableChangeRequestReviewers.js';
|
||||
import { caseInsensitiveSearch } from 'utils/search.js';
|
||||
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
|
||||
import CheckBoxIcon from '@mui/icons-material/CheckBox';
|
||||
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi.js';
|
||||
|
||||
export const StyledSpan = styled('span')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginRight: theme.spacing(1),
|
||||
fontSize: theme.fontSizes.bodySize,
|
||||
}));
|
||||
|
||||
const StyledBox = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
width: '100%',
|
||||
'& > div': { width: '100%' },
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: theme.spacing(2),
|
||||
marginRight: theme.spacing(-2),
|
||||
}));
|
||||
|
||||
const StyledIconButton = styled(IconButton)(({ theme }) => ({
|
||||
marginRight: theme.spacing(-1),
|
||||
}));
|
||||
|
||||
const StrechedLi = styled('li')({ width: '100%' });
|
||||
|
||||
const StyledOption = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'& > span:first-of-type': {
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledTags = styled('div')(({ theme }) => ({
|
||||
paddingLeft: theme.spacing(1),
|
||||
}));
|
||||
|
||||
const renderOption = (
|
||||
props: React.HTMLAttributes<HTMLLIElement>,
|
||||
option: AvailableReviewerSchema,
|
||||
{ selected }: { selected: boolean },
|
||||
) => (
|
||||
<StrechedLi {...props} key={option.id}>
|
||||
<Checkbox
|
||||
icon={<CheckBoxOutlineBlankIcon fontSize='small' />}
|
||||
checkedIcon={<CheckBoxIcon fontSize='small' />}
|
||||
style={{ marginRight: 8 }}
|
||||
checked={selected}
|
||||
/>
|
||||
<StyledOption>
|
||||
<span>{option.name || option.username}</span>
|
||||
<span>
|
||||
{option.name && option.username
|
||||
? option.username
|
||||
: option.email}
|
||||
</span>
|
||||
</StyledOption>
|
||||
</StrechedLi>
|
||||
);
|
||||
|
||||
const renderTags = (value: AvailableReviewerSchema[]) => (
|
||||
<StyledTags>
|
||||
{value.length > 1
|
||||
? `${value.length} reviewers`
|
||||
: value[0].name || value[0].username || value[0].email}
|
||||
</StyledTags>
|
||||
);
|
||||
|
||||
export const ChangeRequestReviewersHeader: FC<{
|
||||
canShowAddReviewers: boolean;
|
||||
showAddReviewers: boolean;
|
||||
actualApprovals: number;
|
||||
minApprovals: number;
|
||||
setShowAddReviewers: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}> = ({
|
||||
canShowAddReviewers,
|
||||
showAddReviewers,
|
||||
actualApprovals,
|
||||
minApprovals,
|
||||
setShowAddReviewers,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<StyledBox sx={{ mb: 1 }}>
|
||||
<StyledSpan>
|
||||
Reviewers
|
||||
<Typography
|
||||
component='span'
|
||||
color='text.secondary'
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
({actualApprovals}/{minApprovals} required)
|
||||
</Typography>
|
||||
</StyledSpan>
|
||||
{canShowAddReviewers &&
|
||||
(showAddReviewers ? (
|
||||
<StyledIconButton
|
||||
title='Close'
|
||||
onClick={() => {
|
||||
setShowAddReviewers(false);
|
||||
}}
|
||||
>
|
||||
<KeyboardArrowUp />
|
||||
</StyledIconButton>
|
||||
) : (
|
||||
<StyledIconButton
|
||||
title='Request approvals'
|
||||
onClick={() => {
|
||||
setShowAddReviewers(true);
|
||||
}}
|
||||
>
|
||||
<Add />
|
||||
</StyledIconButton>
|
||||
))}
|
||||
</StyledBox>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ChangeRequestAddRequestedApprovers: FC<{
|
||||
changeRequest: Pick<ChangeRequestType, 'id' | 'project' | 'environment'>;
|
||||
saveClicked: (reviewers: AvailableReviewerSchema[]) => void;
|
||||
existingReviewers: Pick<ReviewerSchema, 'id'>[];
|
||||
}> = ({ changeRequest, saveClicked, existingReviewers }) => {
|
||||
const theme = useTheme();
|
||||
const [reviewers, setReviewers] = useState<AvailableReviewerSchema[]>([]);
|
||||
const { reviewers: fetchedReviewers, loading: isLoading } =
|
||||
useAvailableChangeRequestReviewers(
|
||||
changeRequest.project,
|
||||
changeRequest.environment,
|
||||
);
|
||||
const availableReviewers = fetchedReviewers.filter(
|
||||
(reviewer) =>
|
||||
!existingReviewers.some((existing) => existing.id === reviewer.id),
|
||||
);
|
||||
const autoCompleteChange = (
|
||||
event: React.SyntheticEvent,
|
||||
newValue: AvailableReviewerSchema[],
|
||||
reason: AutocompleteChangeReason,
|
||||
) => {
|
||||
if (
|
||||
event.type === 'keydown' &&
|
||||
(event as React.KeyboardEvent).key === 'Backspace' &&
|
||||
reason === 'removeOption'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setReviewers(newValue);
|
||||
};
|
||||
|
||||
const filterOptions = (
|
||||
options: AvailableReviewerSchema[],
|
||||
{ inputValue }: FilterOptionsState<AvailableReviewerSchema>,
|
||||
) =>
|
||||
options.filter(
|
||||
({ name, username, email }) =>
|
||||
caseInsensitiveSearch(inputValue, email) ||
|
||||
caseInsensitiveSearch(inputValue, name) ||
|
||||
caseInsensitiveSearch(inputValue, username),
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledBox sx={{ mb: 4 }}>
|
||||
<AutocompleteVirtual
|
||||
sx={{ ml: 'auto', width: theme.spacing(40) }}
|
||||
size='small'
|
||||
limitTags={3}
|
||||
openOnFocus
|
||||
multiple
|
||||
disableCloseOnSelect
|
||||
value={reviewers as AvailableReviewerSchema[]}
|
||||
onChange={autoCompleteChange}
|
||||
options={availableReviewers}
|
||||
renderOption={renderOption}
|
||||
filterOptions={filterOptions}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
getOptionLabel={(option: AvailableReviewerSchema) =>
|
||||
option.email || option.name || option.username || ''
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={`Reviewers (${reviewers.length})`}
|
||||
/>
|
||||
)}
|
||||
renderTags={(value) => renderTags(value)}
|
||||
noOptionsText={isLoading ? 'Loading…' : 'No options'}
|
||||
/>
|
||||
<Button
|
||||
sx={{ ml: 2 }}
|
||||
variant='contained'
|
||||
color='primary'
|
||||
disabled={false}
|
||||
onClick={() => saveClicked(reviewers)}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</StyledBox>
|
||||
);
|
||||
};
|
||||
|
||||
export const ChangeRequestRequestedApprovers: FC<{
|
||||
changeRequest: Pick<
|
||||
ChangeRequestType,
|
||||
| 'id'
|
||||
| 'project'
|
||||
| 'approvals'
|
||||
| 'rejections'
|
||||
| 'state'
|
||||
| 'minApprovals'
|
||||
| 'environment'
|
||||
>;
|
||||
}> = ({ changeRequest }) => {
|
||||
const [showAddReviewers, setShowAddReviewers] = useState(false);
|
||||
const { reviewers: requestedReviewers, refetchReviewers } =
|
||||
useRequestedApprovers(changeRequest.project, changeRequest.id);
|
||||
const { updateRequestedApprovers } = useChangeRequestApi();
|
||||
const canShowAddReviewers =
|
||||
(changeRequest.state === 'Draft' ||
|
||||
changeRequest.state === 'In review') &&
|
||||
changeRequest.minApprovals > 0;
|
||||
|
||||
let reviewers = requestedReviewers.map((reviewer) => {
|
||||
const approver = changeRequest.approvals.find(
|
||||
(approval) => approval.createdBy.id === reviewer.id,
|
||||
);
|
||||
const rejector = changeRequest.rejections.find(
|
||||
(rejection) => rejection.createdBy.id === reviewer.id,
|
||||
);
|
||||
|
||||
return {
|
||||
id: reviewer.id,
|
||||
name: reviewer.username || reviewer.name || 'Unknown user',
|
||||
imageUrl: reviewer.imageUrl,
|
||||
status: approver ? 'approved' : rejector ? 'rejected' : 'pending',
|
||||
};
|
||||
});
|
||||
|
||||
reviewers = [
|
||||
...reviewers,
|
||||
...changeRequest.approvals
|
||||
.filter(
|
||||
(approval) =>
|
||||
!reviewers.find(
|
||||
(r) => r.name === approval.createdBy.username,
|
||||
),
|
||||
)
|
||||
.map((approval) => ({
|
||||
id: approval.createdBy.id,
|
||||
name: approval.createdBy.username || 'Unknown user',
|
||||
imageUrl: approval.createdBy.imageUrl,
|
||||
status: 'approved',
|
||||
})),
|
||||
...changeRequest.rejections
|
||||
.filter(
|
||||
(rejection) =>
|
||||
!reviewers.find(
|
||||
(r) => r.name === rejection.createdBy.username,
|
||||
),
|
||||
)
|
||||
.map((rejection) => ({
|
||||
id: rejection.createdBy.id,
|
||||
name: rejection.createdBy.username || 'Unknown user',
|
||||
imageUrl: rejection.createdBy.imageUrl,
|
||||
status: 'rejected',
|
||||
})),
|
||||
];
|
||||
|
||||
const saveClicked = async (
|
||||
selectedReviewers: AvailableReviewerSchema[],
|
||||
) => {
|
||||
if (selectedReviewers.length > 0) {
|
||||
const tosend = [
|
||||
...reviewers.map((reviewer) => reviewer.id),
|
||||
...selectedReviewers.map((reviewer) => reviewer.id),
|
||||
];
|
||||
await updateRequestedApprovers(
|
||||
changeRequest.project,
|
||||
changeRequest.id,
|
||||
tosend,
|
||||
);
|
||||
}
|
||||
refetchReviewers();
|
||||
setShowAddReviewers(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={(theme) => ({
|
||||
marginTop: theme.spacing(2),
|
||||
padding: theme.spacing(4),
|
||||
paddingTop: theme.spacing(2),
|
||||
borderRadius: (theme) => `${theme.shape.borderRadiusLarge}px`,
|
||||
})}
|
||||
>
|
||||
<ChangeRequestReviewersHeader
|
||||
canShowAddReviewers={canShowAddReviewers}
|
||||
showAddReviewers={showAddReviewers}
|
||||
minApprovals={changeRequest.minApprovals}
|
||||
actualApprovals={changeRequest.approvals.length}
|
||||
setShowAddReviewers={setShowAddReviewers}
|
||||
/>
|
||||
{canShowAddReviewers && showAddReviewers && (
|
||||
<ChangeRequestAddRequestedApprovers
|
||||
changeRequest={changeRequest}
|
||||
existingReviewers={reviewers}
|
||||
saveClicked={saveClicked}
|
||||
/>
|
||||
)}
|
||||
{reviewers.map((reviewer) => (
|
||||
<>
|
||||
{reviewer.status === 'approved' && (
|
||||
<ChangeRequestApprover
|
||||
key={reviewer.name}
|
||||
name={reviewer.name || 'Unknown user'}
|
||||
imageUrl={reviewer.imageUrl}
|
||||
/>
|
||||
)}
|
||||
{reviewer.status === 'rejected' && (
|
||||
<ChangeRequestRejector
|
||||
key={reviewer.name}
|
||||
name={reviewer.name || 'Unknown user'}
|
||||
imageUrl={reviewer.imageUrl}
|
||||
/>
|
||||
)}
|
||||
{reviewer.status === 'pending' && (
|
||||
<ChangeRequestPending
|
||||
key={reviewer.name}
|
||||
name={reviewer.name || 'Unknown user'}
|
||||
imageUrl={reviewer.imageUrl}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</Paper>
|
||||
);
|
||||
};
|
@ -3,6 +3,7 @@ import type { FC } from 'react';
|
||||
import { StyledAvatar } from '../ChangeRequestHeader/ChangeRequestHeader.styles';
|
||||
import CheckCircle from '@mui/icons-material/CheckCircle';
|
||||
import Cancel from '@mui/icons-material/Cancel';
|
||||
import Pending from '@mui/icons-material/Pending';
|
||||
interface IChangeRequestReviewerProps {
|
||||
name?: string;
|
||||
imageUrl?: string;
|
||||
@ -26,6 +27,11 @@ export const StyledErrorIcon = styled(Cancel)(({ theme }) => ({
|
||||
marginLeft: 'auto',
|
||||
}));
|
||||
|
||||
export const StyledPendingIcon = styled(Pending)(({ theme }) => ({
|
||||
color: theme.palette.neutral.main,
|
||||
marginLeft: 'auto',
|
||||
}));
|
||||
|
||||
export const ReviewerName = styled(Typography)({
|
||||
maxWidth: '170px',
|
||||
textOverflow: 'ellipsis',
|
||||
@ -39,7 +45,7 @@ export const ChangeRequestApprover: FC<IChangeRequestReviewerProps> = ({
|
||||
imageUrl,
|
||||
}) => {
|
||||
return (
|
||||
<StyledBox>
|
||||
<StyledBox title='Approved'>
|
||||
<StyledAvatar user={{ name, imageUrl }} />
|
||||
<ReviewerName variant='body1'>{name}</ReviewerName>
|
||||
<StyledSuccessIcon />
|
||||
@ -52,10 +58,23 @@ export const ChangeRequestRejector: FC<IChangeRequestReviewerProps> = ({
|
||||
imageUrl,
|
||||
}) => {
|
||||
return (
|
||||
<StyledBox>
|
||||
<StyledBox title='Rejected'>
|
||||
<StyledAvatar user={{ name, imageUrl }} />
|
||||
<ReviewerName variant='body1'>{name}</ReviewerName>
|
||||
<StyledErrorIcon />
|
||||
</StyledBox>
|
||||
);
|
||||
};
|
||||
|
||||
export const ChangeRequestPending: FC<IChangeRequestReviewerProps> = ({
|
||||
name,
|
||||
imageUrl,
|
||||
}) => {
|
||||
return (
|
||||
<StyledBox title='Pending'>
|
||||
<StyledAvatar user={{ name, imageUrl }} />
|
||||
<ReviewerName variant='body1'>{name}</ReviewerName>
|
||||
<StyledPendingIcon />
|
||||
</StyledBox>
|
||||
);
|
||||
};
|
||||
|
@ -74,7 +74,7 @@ const renderOption = (
|
||||
const renderTags = (value: AvailableReviewerSchema[]) => (
|
||||
<StyledTags>
|
||||
{value.length > 1
|
||||
? `${value.length} users selected`
|
||||
? `${value.length} reviewers`
|
||||
: value[0].name || value[0].username || value[0].email}
|
||||
</StyledTags>
|
||||
);
|
||||
|
@ -74,7 +74,7 @@ export const EnvironmentChangeRequest: FC<{
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const { user } = useAuthUser();
|
||||
const [title, setTitle] = useState(environmentChangeRequest.title);
|
||||
const { changeState, updateRequestedReviewers } = useChangeRequestApi();
|
||||
const { changeState, updateRequestedApprovers } = useChangeRequestApi();
|
||||
const [reviewers, setReviewers] = useState<AvailableReviewerSchema[]>([]);
|
||||
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
@ -83,7 +83,7 @@ export const EnvironmentChangeRequest: FC<{
|
||||
setDisabled(true);
|
||||
try {
|
||||
if (reviewers && reviewers.length > 0) {
|
||||
await updateRequestedReviewers(
|
||||
await updateRequestedApprovers(
|
||||
project,
|
||||
environmentChangeRequest.id,
|
||||
reviewers.map((reviewer) => reviewer.id),
|
||||
|
@ -190,17 +190,17 @@ export const useChangeRequestApi = () => {
|
||||
|
||||
return makeRequest(req.caller, req.id);
|
||||
};
|
||||
const updateRequestedReviewers = async (
|
||||
const updateRequestedApprovers = async (
|
||||
project: string,
|
||||
changeRequestId: number,
|
||||
reviewers: string[],
|
||||
reviewers: number[],
|
||||
) => {
|
||||
trackEvent('change_request', {
|
||||
props: {
|
||||
eventType: 'reviewers updated',
|
||||
eventType: 'approvers updated',
|
||||
},
|
||||
});
|
||||
const path = `api/admin/projects/${project}/change-requests/${changeRequestId}/reviewers`;
|
||||
const path = `api/admin/projects/${project}/change-requests/${changeRequestId}/approvers`;
|
||||
const req = createRequest(path, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ reviewers }),
|
||||
@ -217,7 +217,7 @@ export const useChangeRequestApi = () => {
|
||||
discardDraft,
|
||||
addComment,
|
||||
updateTitle,
|
||||
updateRequestedReviewers,
|
||||
updateRequestedApprovers,
|
||||
errors,
|
||||
loading,
|
||||
};
|
||||
|
@ -5,7 +5,7 @@ import handleErrorResponses from '../httpErrorResponseHandler.js';
|
||||
|
||||
// TODO: These will likely be created by Orval next time it is run
|
||||
export interface AvailableReviewerSchema {
|
||||
id: string;
|
||||
id: number;
|
||||
name?: string;
|
||||
email: string;
|
||||
username?: string;
|
||||
|
@ -0,0 +1,48 @@
|
||||
import useSWR from 'swr';
|
||||
import { useMemo } from 'react';
|
||||
import { formatApiPath } from 'utils/formatPath';
|
||||
import handleErrorResponses from '../httpErrorResponseHandler.js';
|
||||
|
||||
// TODO: These will likely be created by Orval next time it is run
|
||||
export interface ReviewerSchema {
|
||||
id: number;
|
||||
name?: string;
|
||||
email: string;
|
||||
username?: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
export interface IReviewersResponse {
|
||||
reviewers: ReviewerSchema[];
|
||||
refetchReviewers: () => void;
|
||||
loading: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export const useRequestedApprovers = (
|
||||
project: string,
|
||||
changeRequestId: number,
|
||||
): IReviewersResponse => {
|
||||
const { data, error, mutate } = useSWR(
|
||||
formatApiPath(
|
||||
`api/admin/projects/${project}/change-requests/${changeRequestId}/approvers`,
|
||||
),
|
||||
fetcher,
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
reviewers: data?.reviewers || [],
|
||||
loading: !error && !data,
|
||||
refetchReviewers: () => mutate(),
|
||||
error,
|
||||
}),
|
||||
[data, error, mutate],
|
||||
);
|
||||
};
|
||||
|
||||
const fetcher = (path: string) => {
|
||||
return fetch(path)
|
||||
.then(handleErrorResponses('Requested Approvers'))
|
||||
.then((res) => res.json());
|
||||
};
|
Loading…
Reference in New Issue
Block a user