1
0
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:
David Leek 2025-06-30 08:51:51 +02:00 committed by GitHub
parent f2766b6b3b
commit 28caa82ad1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 466 additions and 12 deletions

View File

@ -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>

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);

View File

@ -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),

View File

@ -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,
};

View File

@ -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;

View File

@ -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());
};