1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-27 01:19:00 +02:00

feat(change-requests): requesting reviews when submitting change requests

This commit is contained in:
David Leek 2025-06-24 11:22:15 +02:00
parent 818b9274bb
commit f80e9a9e17
No known key found for this signature in database
GPG Key ID: 515EE0F1BB6D0BE1
5 changed files with 288 additions and 21 deletions

View File

@ -0,0 +1,164 @@
import type { FC } from 'react';
import { styled, Button, Checkbox, TextField, useTheme } from '@mui/material';
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
import CheckBoxIcon from '@mui/icons-material/CheckBox';
import AutocompleteVirtual from 'component/common/AutocompleteVirtual/AutcompleteVirtual';
import { caseInsensitiveSearch } from 'utils/search';
import type { ChangeRequestType } from 'component/changeRequest/changeRequest.types';
import { changesCount } from '../../changesCount.js';
import {
type AvailableReviewerSchema,
useAvailableChangeRequestReviewers,
} from 'hooks/api/getters/useAvailableChangeRequestReviewers/useAvailableChangeRequestReviewers.js';
const SubmitChangeRequestButton: FC<{
onClick: () => void;
count: number;
disabled?: boolean;
}> = ({ onClick, count, disabled = false }) => (
<Button
sx={{ ml: 2 }}
variant='contained'
onClick={onClick}
disabled={disabled}
>
Submit change request ({count})
</Button>
);
const StyledTags = styled('div')(({ theme }) => ({
paddingLeft: 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 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} users selected`
: value[0].name || value[0].username || value[0].email}
</StyledTags>
);
export const DraftChangeRequestActions: FC<{
environmentChangeRequest: ChangeRequestType;
reviewers: AvailableReviewerSchema[];
setReviewers: React.Dispatch<
React.SetStateAction<AvailableReviewerSchema[]>
>;
onReview: (changeState: (project: string) => Promise<void>) => void;
onDiscard: (id: number) => void;
sendToReview: (project: string) => Promise<void>;
disabled?: boolean;
setDisabled: (disabled: boolean) => void;
}> = ({
environmentChangeRequest,
reviewers,
setReviewers,
onReview,
onDiscard,
sendToReview,
disabled,
setDisabled,
}) => {
const theme = useTheme();
const { reviewers: availableReviewers, loading: isLoading } =
useAvailableChangeRequestReviewers(
environmentChangeRequest.project,
environmentChangeRequest.environment,
);
return (
<>
<AutocompleteVirtual
sx={{ ml: 'auto', width: theme.spacing(40) }}
size='small'
limitTags={3}
openOnFocus
multiple
disableCloseOnSelect
value={reviewers as AvailableReviewerSchema[]}
onChange={(event, newValue, reason) => {
if (
event.type === 'keydown' &&
(event as React.KeyboardEvent).key === 'Backspace' &&
reason === 'removeOption'
) {
return;
}
setReviewers(newValue);
}}
options={availableReviewers}
renderOption={renderOption}
filterOptions={(options, { inputValue }) =>
options.filter(
({ name, username, email }) =>
caseInsensitiveSearch(inputValue, email) ||
caseInsensitiveSearch(inputValue, name) ||
caseInsensitiveSearch(inputValue, username),
)
}
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'}
/>
<SubmitChangeRequestButton
onClick={() => onReview(sendToReview)}
count={changesCount(environmentChangeRequest)}
disabled={disabled}
/>
<Button
sx={{ ml: 2 }}
variant='outlined'
disabled={disabled}
onClick={() => {
setDisabled(true);
onDiscard(environmentChangeRequest.id);
}}
>
Discard changes
</Button>
</>
);
};

View File

@ -24,6 +24,9 @@ import Input from 'component/common/Input/Input';
import { ChangeRequestTitle } from './ChangeRequestTitle.tsx';
import { UpdateCount } from 'component/changeRequest/UpdateCount';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { useUiFlag } from 'hooks/useUiFlag';
import { DraftChangeRequestActions } from '../DraftChangeRequestActions/DraftChangeRequestActions.tsx';
import type { AvailableReviewerSchema } from 'hooks/api/getters/useAvailableChangeRequestReviewers/useAvailableChangeRequestReviewers.ts';
const SubmitChangeRequestButton: FC<{
onClick: () => void;
@ -71,11 +74,22 @@ export const EnvironmentChangeRequest: FC<{
const [commentText, setCommentText] = useState('');
const { user } = useAuthUser();
const [title, setTitle] = useState(environmentChangeRequest.title);
const { changeState } = useChangeRequestApi();
const { changeState, updateRequestedReviewers } = useChangeRequestApi();
const [reviewers, setReviewers] = useState<AvailableReviewerSchema[]>([]);
const [disabled, setDisabled] = useState(false);
const approversEnabled = useUiFlag('changeRequestApproverEmails');
const sendToReview = async (project: string) => {
setDisabled(true);
try {
if (reviewers && reviewers.length > 0) {
await updateRequestedReviewers(
project,
environmentChangeRequest.id,
reviewers.map((reviewer) => reviewer.id),
);
}
await changeState(project, environmentChangeRequest.id, 'Draft', {
state: 'In review',
comment: commentText,
@ -153,27 +167,50 @@ export const EnvironmentChangeRequest: FC<{
<ConditionallyRender
condition={environmentChangeRequest?.state === 'Draft'}
show={
<>
<SubmitChangeRequestButton
onClick={() => onReview(sendToReview)}
count={changesCount(
environmentChangeRequest,
)}
disabled={disabled}
/>
<ConditionallyRender
condition={approversEnabled}
show={
<DraftChangeRequestActions
environmentChangeRequest={
environmentChangeRequest
}
reviewers={reviewers}
setReviewers={setReviewers}
onReview={onReview}
onDiscard={onDiscard}
sendToReview={sendToReview}
disabled={disabled}
setDisabled={setDisabled}
/>
}
elseShow={
<>
<SubmitChangeRequestButton
onClick={() =>
onReview(sendToReview)
}
count={changesCount(
environmentChangeRequest,
)}
disabled={disabled}
/>
<Button
sx={{ ml: 2 }}
variant='outlined'
disabled={disabled}
onClick={() => {
setDisabled(true);
onDiscard(environmentChangeRequest.id);
}}
>
Discard changes
</Button>
</>
<Button
sx={{ ml: 2 }}
variant='outlined'
disabled={disabled}
onClick={() => {
setDisabled(true);
onDiscard(
environmentChangeRequest.id,
);
}}
>
Discard changes
</Button>
</>
}
/>
}
/>
<ConditionallyRender

View File

@ -190,6 +190,23 @@ export const useChangeRequestApi = () => {
return makeRequest(req.caller, req.id);
};
const updateRequestedReviewers = async (
project: string,
changeRequestId: number,
reviewers: string[],
) => {
trackEvent('change_request', {
props: {
eventType: 'reviewers updated',
},
});
const path = `api/admin/projects/${project}/change-requests/${changeRequestId}/reviewers`;
const req = createRequest(path, {
method: 'PUT',
body: JSON.stringify({ reviewers }),
});
return makeRequest(req.caller, req.id);
};
return {
addChange,
@ -200,6 +217,7 @@ export const useChangeRequestApi = () => {
discardDraft,
addComment,
updateTitle,
updateRequestedReviewers,
errors,
loading,
};

View File

@ -0,0 +1,47 @@
import useSWR from 'swr';
import { useMemo } from 'react';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler.js';
export interface AvailableReviewerSchema {
id: string;
name?: string;
email: string;
username?: string;
imageUrl?: string;
}
export interface IAvailableReviewersResponse {
reviewers: AvailableReviewerSchema[];
refetchReviewers: () => void;
loading: boolean;
error?: Error;
}
export const useAvailableChangeRequestReviewers = (
project: string,
environment: string,
): IAvailableReviewersResponse => {
const { data, error, mutate } = useSWR(
formatApiPath(
`api/admin/projects/${project}/change-requests/available-reviewers/${environment}`,
),
fetcher,
);
return useMemo(
() => ({
reviewers: data?.reviewers || [],
loading: !error && !data,
refetchReviewers: () => mutate(),
error,
}),
[data, error, mutate],
);
};
const fetcher = (path: string) => {
return fetch(path)
.then(handleErrorResponses('Available Change Request Reviewers'))
.then((res) => res.json());
};

View File

@ -90,6 +90,7 @@ export type UiFlags = {
createFlagDialogCache?: boolean;
healthToTechDebt?: boolean;
improvedJsonDiff?: boolean;
changeRequestApproverEmails?: boolean;
};
export interface IVersionInfo {