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:
parent
818b9274bb
commit
f80e9a9e17
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -24,6 +24,9 @@ import Input from 'component/common/Input/Input';
|
|||||||
import { ChangeRequestTitle } from './ChangeRequestTitle.tsx';
|
import { ChangeRequestTitle } from './ChangeRequestTitle.tsx';
|
||||||
import { UpdateCount } from 'component/changeRequest/UpdateCount';
|
import { UpdateCount } from 'component/changeRequest/UpdateCount';
|
||||||
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
|
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<{
|
const SubmitChangeRequestButton: FC<{
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
@ -71,11 +74,22 @@ export const EnvironmentChangeRequest: FC<{
|
|||||||
const [commentText, setCommentText] = useState('');
|
const [commentText, setCommentText] = useState('');
|
||||||
const { user } = useAuthUser();
|
const { user } = useAuthUser();
|
||||||
const [title, setTitle] = useState(environmentChangeRequest.title);
|
const [title, setTitle] = useState(environmentChangeRequest.title);
|
||||||
const { changeState } = useChangeRequestApi();
|
const { changeState, updateRequestedReviewers } = useChangeRequestApi();
|
||||||
|
const [reviewers, setReviewers] = useState<AvailableReviewerSchema[]>([]);
|
||||||
|
|
||||||
const [disabled, setDisabled] = useState(false);
|
const [disabled, setDisabled] = useState(false);
|
||||||
|
const approversEnabled = useUiFlag('changeRequestApproverEmails');
|
||||||
const sendToReview = async (project: string) => {
|
const sendToReview = async (project: string) => {
|
||||||
setDisabled(true);
|
setDisabled(true);
|
||||||
try {
|
try {
|
||||||
|
if (reviewers && reviewers.length > 0) {
|
||||||
|
await updateRequestedReviewers(
|
||||||
|
project,
|
||||||
|
environmentChangeRequest.id,
|
||||||
|
reviewers.map((reviewer) => reviewer.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await changeState(project, environmentChangeRequest.id, 'Draft', {
|
await changeState(project, environmentChangeRequest.id, 'Draft', {
|
||||||
state: 'In review',
|
state: 'In review',
|
||||||
comment: commentText,
|
comment: commentText,
|
||||||
@ -153,27 +167,50 @@ export const EnvironmentChangeRequest: FC<{
|
|||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={environmentChangeRequest?.state === 'Draft'}
|
condition={environmentChangeRequest?.state === 'Draft'}
|
||||||
show={
|
show={
|
||||||
<>
|
<ConditionallyRender
|
||||||
<SubmitChangeRequestButton
|
condition={approversEnabled}
|
||||||
onClick={() => onReview(sendToReview)}
|
show={
|
||||||
count={changesCount(
|
<DraftChangeRequestActions
|
||||||
environmentChangeRequest,
|
environmentChangeRequest={
|
||||||
)}
|
environmentChangeRequest
|
||||||
disabled={disabled}
|
}
|
||||||
/>
|
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
|
<Button
|
||||||
sx={{ ml: 2 }}
|
sx={{ ml: 2 }}
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDisabled(true);
|
setDisabled(true);
|
||||||
onDiscard(environmentChangeRequest.id);
|
onDiscard(
|
||||||
}}
|
environmentChangeRequest.id,
|
||||||
>
|
);
|
||||||
Discard changes
|
}}
|
||||||
</Button>
|
>
|
||||||
</>
|
Discard changes
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
|
@ -190,6 +190,23 @@ export const useChangeRequestApi = () => {
|
|||||||
|
|
||||||
return makeRequest(req.caller, req.id);
|
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 {
|
return {
|
||||||
addChange,
|
addChange,
|
||||||
@ -200,6 +217,7 @@ export const useChangeRequestApi = () => {
|
|||||||
discardDraft,
|
discardDraft,
|
||||||
addComment,
|
addComment,
|
||||||
updateTitle,
|
updateTitle,
|
||||||
|
updateRequestedReviewers,
|
||||||
errors,
|
errors,
|
||||||
loading,
|
loading,
|
||||||
};
|
};
|
||||||
|
@ -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());
|
||||||
|
};
|
@ -90,6 +90,7 @@ export type UiFlags = {
|
|||||||
createFlagDialogCache?: boolean;
|
createFlagDialogCache?: boolean;
|
||||||
healthToTechDebt?: boolean;
|
healthToTechDebt?: boolean;
|
||||||
improvedJsonDiff?: boolean;
|
improvedJsonDiff?: boolean;
|
||||||
|
changeRequestApproverEmails?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IVersionInfo {
|
export interface IVersionInfo {
|
||||||
|
Loading…
Reference in New Issue
Block a user