From f80e9a9e17bb0bd1ed6fa3011d6bd116159f84f7 Mon Sep 17 00:00:00 2001 From: David Leek Date: Tue, 24 Jun 2025 11:22:15 +0200 Subject: [PATCH] feat(change-requests): requesting reviews when submitting change requests --- .../DraftChangeRequestActions.tsx | 164 ++++++++++++++++++ .../EnvironmentChangeRequest.tsx | 79 ++++++--- .../useChangeRequestApi.ts | 18 ++ .../useAvailableChangeRequestReviewers.ts | 47 +++++ frontend/src/interfaces/uiConfig.ts | 1 + 5 files changed, 288 insertions(+), 21 deletions(-) create mode 100644 frontend/src/component/changeRequest/ChangeRequestSidebar/DraftChangeRequestActions/DraftChangeRequestActions.tsx create mode 100644 frontend/src/hooks/api/getters/useAvailableChangeRequestReviewers/useAvailableChangeRequestReviewers.ts diff --git a/frontend/src/component/changeRequest/ChangeRequestSidebar/DraftChangeRequestActions/DraftChangeRequestActions.tsx b/frontend/src/component/changeRequest/ChangeRequestSidebar/DraftChangeRequestActions/DraftChangeRequestActions.tsx new file mode 100644 index 0000000000..65c4fad681 --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequestSidebar/DraftChangeRequestActions/DraftChangeRequestActions.tsx @@ -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 }) => ( + +); + +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, + option: AvailableReviewerSchema, + { selected }: { selected: boolean }, +) => ( + + } + checkedIcon={} + style={{ marginRight: 8 }} + checked={selected} + /> + + {option.name || option.username} + + {option.name && option.username + ? option.username + : option.email} + + + +); + +const renderTags = (value: AvailableReviewerSchema[]) => ( + + {value.length > 1 + ? `${value.length} users selected` + : value[0].name || value[0].username || value[0].email} + +); + +export const DraftChangeRequestActions: FC<{ + environmentChangeRequest: ChangeRequestType; + reviewers: AvailableReviewerSchema[]; + setReviewers: React.Dispatch< + React.SetStateAction + >; + onReview: (changeState: (project: string) => Promise) => void; + onDiscard: (id: number) => void; + sendToReview: (project: string) => Promise; + 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 ( + <> + { + 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) => ( + + )} + renderTags={(value) => renderTags(value)} + noOptionsText={isLoading ? 'Loading…' : 'No options'} + /> + onReview(sendToReview)} + count={changesCount(environmentChangeRequest)} + disabled={disabled} + /> + + + + ); +}; diff --git a/frontend/src/component/changeRequest/ChangeRequestSidebar/EnvironmentChangeRequest/EnvironmentChangeRequest.tsx b/frontend/src/component/changeRequest/ChangeRequestSidebar/EnvironmentChangeRequest/EnvironmentChangeRequest.tsx index 23aa8d456e..3d48ecae1b 100644 --- a/frontend/src/component/changeRequest/ChangeRequestSidebar/EnvironmentChangeRequest/EnvironmentChangeRequest.tsx +++ b/frontend/src/component/changeRequest/ChangeRequestSidebar/EnvironmentChangeRequest/EnvironmentChangeRequest.tsx @@ -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([]); + 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<{ - onReview(sendToReview)} - count={changesCount( - environmentChangeRequest, - )} - disabled={disabled} - /> + + } + elseShow={ + <> + + onReview(sendToReview) + } + count={changesCount( + environmentChangeRequest, + )} + disabled={disabled} + /> - - + + + } + /> } /> { 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, }; diff --git a/frontend/src/hooks/api/getters/useAvailableChangeRequestReviewers/useAvailableChangeRequestReviewers.ts b/frontend/src/hooks/api/getters/useAvailableChangeRequestReviewers/useAvailableChangeRequestReviewers.ts new file mode 100644 index 0000000000..5764c5e1db --- /dev/null +++ b/frontend/src/hooks/api/getters/useAvailableChangeRequestReviewers/useAvailableChangeRequestReviewers.ts @@ -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()); +}; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 4ddb37bc1c..c743b5a24e 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -90,6 +90,7 @@ export type UiFlags = { createFlagDialogCache?: boolean; healthToTechDebt?: boolean; improvedJsonDiff?: boolean; + changeRequestApproverEmails?: boolean; }; export interface IVersionInfo {