1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-04 00:18:40 +01:00

Change requests - add multiple reviewers (#2448)

This PR adds implements the frontend and migrations part of multiple
reviewers.

2 UI parts:

1. Configuration to add the count of required approvals
2. Handle multiple approvers in review page.
This commit is contained in:
sjaanus 2022-11-17 10:08:29 +01:00 committed by GitHub
parent f2dde9d63a
commit 9176ffae1e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 216 additions and 47 deletions

View File

@ -100,6 +100,9 @@ export const ChangeRequestOverview: FC = () => {
changeRequest.state === 'In review' && changeRequest.state === 'In review' &&
!isAdmin; !isAdmin;
const hasApprovedAlready = changeRequest.approvals.some(
approval => approval.createdBy.id === user?.id
);
return ( return (
<> <>
<ChangeRequestHeader changeRequest={changeRequest} /> <ChangeRequestHeader changeRequest={changeRequest} />
@ -154,10 +157,14 @@ export const ChangeRequestOverview: FC = () => {
/> />
<ChangeRequestReviewStatus <ChangeRequestReviewStatus
state={changeRequest.state} state={changeRequest.state}
environment={changeRequest.environment}
/> />
<StyledButtonBox> <StyledButtonBox>
<ConditionallyRender <ConditionallyRender
condition={changeRequest.state === 'In review'} condition={
changeRequest.state === 'In review' &&
!hasApprovedAlready
}
show={<ReviewButton />} show={<ReviewButton />}
/> />
<ConditionallyRender <ConditionallyRender

View File

@ -1,4 +1,4 @@
import { FC } from 'react'; import React, { FC } from 'react';
import { Box, Theme, Typography, useTheme } from '@mui/material'; import { Box, Theme, Typography, useTheme } from '@mui/material';
import { ReactComponent as ChangesAppliedIcon } from 'assets/icons/merge.svg'; import { ReactComponent as ChangesAppliedIcon } from 'assets/icons/merge.svg';
import { import {
@ -12,10 +12,35 @@ import {
StyledDivider, StyledDivider,
} from './ChangeRequestReviewStatus.styles'; } from './ChangeRequestReviewStatus.styles';
import { ChangeRequestState } from 'component/changeRequest/changeRequest.types'; import { ChangeRequestState } from 'component/changeRequest/changeRequest.types';
import { useRequiredPathParam } from '../../../../hooks/useRequiredPathParam';
import { useChangeRequestConfig } from '../../../../hooks/api/getters/useChangeRequestConfig/useChangeRequestConfig';
interface ISuggestChangeReviewsStatusProps { interface ISuggestChangeReviewsStatusProps {
state: ChangeRequestState; state: ChangeRequestState;
environment: string;
} }
export const useChangeRequestRequiredApprovals = (projectId: string) => {
const { data } = useChangeRequestConfig(projectId);
const getChangeRequestRequiredApprovals = React.useCallback(
(environment: string): number => {
const config = data.find(draft => {
return (
draft.environment === environment &&
draft.changeRequestEnabled
);
});
return config?.requiredApprovals || 1;
},
[data]
);
return {
getChangeRequestRequiredApprovals,
};
};
const resolveBorder = (state: ChangeRequestState, theme: Theme) => { const resolveBorder = (state: ChangeRequestState, theme: Theme) => {
if (state === 'Approved') { if (state === 'Approved') {
return `2px solid ${theme.palette.success.main}`; return `2px solid ${theme.palette.success.main}`;
@ -51,9 +76,8 @@ const resolveIconColors = (state: ChangeRequestState, theme: Theme) => {
export const ChangeRequestReviewStatus: FC< export const ChangeRequestReviewStatus: FC<
ISuggestChangeReviewsStatusProps ISuggestChangeReviewsStatusProps
> = ({ state }) => { > = ({ state, environment }) => {
const theme = useTheme(); const theme = useTheme();
return ( return (
<StyledOuterContainer> <StyledOuterContainer>
<StyledButtonContainer {...resolveIconColors(state, theme)}> <StyledButtonContainer {...resolveIconColors(state, theme)}>
@ -64,7 +88,7 @@ export const ChangeRequestReviewStatus: FC<
/> />
</StyledButtonContainer> </StyledButtonContainer>
<StyledReviewStatusContainer border={resolveBorder(state, theme)}> <StyledReviewStatusContainer border={resolveBorder(state, theme)}>
<ResolveComponent state={state} /> <ResolveComponent state={state} environment={environment} />
</StyledReviewStatusContainer> </StyledReviewStatusContainer>
</StyledOuterContainer> </StyledOuterContainer>
); );
@ -72,9 +96,10 @@ export const ChangeRequestReviewStatus: FC<
interface IResolveComponentProps { interface IResolveComponentProps {
state: ChangeRequestState; state: ChangeRequestState;
environment: string;
} }
const ResolveComponent = ({ state }: IResolveComponentProps) => { const ResolveComponent = ({ state, environment }: IResolveComponentProps) => {
if (!state) { if (!state) {
return null; return null;
} }
@ -91,7 +116,7 @@ const ResolveComponent = ({ state }: IResolveComponentProps) => {
return <Cancelled />; return <Cancelled />;
} }
return <ReviewRequired />; return <ReviewRequired environment={environment} />;
}; };
const Approved = () => { const Approved = () => {
@ -125,8 +150,16 @@ const Approved = () => {
); );
}; };
const ReviewRequired = () => { interface IReviewRequiredProps {
environment: string;
}
const ReviewRequired = ({ environment }: IReviewRequiredProps) => {
const theme = useTheme(); const theme = useTheme();
const projectId = useRequiredPathParam('projectId');
const { getChangeRequestRequiredApprovals } =
useChangeRequestRequiredApprovals(projectId);
const approvals = getChangeRequestRequiredApprovals(environment);
return ( return (
<> <>
@ -137,7 +170,7 @@ const ReviewRequired = () => {
Review required Review required
</StyledReviewTitle> </StyledReviewTitle>
<Typography> <Typography>
At least 1 approving review must be submitted before At least {approvals} approvals must be submitted before
changes can be applied changes can be applied
</Typography> </Typography>
</Box> </Box>

View File

@ -18,6 +18,7 @@ export interface IChangeRequestEnvironmentConfig {
environment: string; environment: string;
type: string; type: string;
changeRequestEnabled: boolean; changeRequestEnabled: boolean;
requiredApprovals: number;
} }
export interface IChangeRequestFeature { export interface IChangeRequestFeature {

View File

@ -9,12 +9,15 @@ import {
} from '@mui/material'; } from '@mui/material';
import { SELECT_ITEM_ID } from 'utils/testIds'; import { SELECT_ITEM_ID } from 'utils/testIds';
import { KeyboardArrowDownOutlined } from '@mui/icons-material'; import { KeyboardArrowDownOutlined } from '@mui/icons-material';
import { SxProps } from '@mui/system';
import { Theme } from '@mui/material/styles';
export interface ISelectOption { export interface ISelectOption {
key: string; key: string;
title?: string; title?: string;
label?: string; label?: string;
disabled?: boolean; disabled?: boolean;
sx?: SxProps<Theme>;
} }
export interface IGeneralSelectProps extends Omit<SelectProps, 'onChange'> { export interface IGeneralSelectProps extends Omit<SelectProps, 'onChange'> {
@ -68,6 +71,7 @@ const GeneralSelect: React.FC<IGeneralSelectProps> = ({
> >
{options.map(option => ( {options.map(option => (
<MenuItem <MenuItem
sx={option.sx}
key={option.key} key={option.key}
value={option.key} value={option.key}
title={option.title || ''} title={option.title || ''}

View File

@ -1,6 +1,6 @@
import { useMemo, useState, VFC } from 'react'; import React, { useMemo, useState, VFC } from 'react';
import { HeaderGroup, useGlobalFilter, useTable } from 'react-table'; import { HeaderGroup, useGlobalFilter, useTable } from 'react-table';
import { Alert, Box, Typography } from '@mui/material'; import { Alert, Box, styled, Typography } from '@mui/material';
import { import {
SortableTableHeader, SortableTableHeader,
Table, Table,
@ -17,57 +17,113 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { Dialogue } from 'component/common/Dialogue/Dialogue'; import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useChangeRequestConfig } from 'hooks/api/getters/useChangeRequestConfig/useChangeRequestConfig'; import { useChangeRequestConfig } from 'hooks/api/getters/useChangeRequestConfig/useChangeRequestConfig';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi'; import {
IChangeRequestConfig,
useChangeRequestApi,
} from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { UPDATE_PROJECT } from '@server/types/permissions'; import { UPDATE_PROJECT } from '@server/types/permissions';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { ChangeRequestProcessHelp } from './ChangeRequestProcessHelp/ChangeRequestProcessHelp'; import { ChangeRequestProcessHelp } from './ChangeRequestProcessHelp/ChangeRequestProcessHelp';
import GeneralSelect from '../../../../common/GeneralSelect/GeneralSelect';
import { KeyboardArrowDownOutlined } from '@mui/icons-material';
import { useTheme } from '@mui/material/styles';
const StyledBox = styled(Box)(({ theme }) => ({
padding: theme.spacing(1),
display: 'flex',
justifyContent: 'center',
'& .MuiInputBase-input': {
fontSize: theme.fontSizes.smallBody,
},
}));
export const ChangeRequestConfiguration: VFC = () => { export const ChangeRequestConfiguration: VFC = () => {
const [dialogState, setDialogState] = useState<{ const [dialogState, setDialogState] = useState<{
isOpen: boolean; isOpen: boolean;
enableEnvironment?: string; enableEnvironment: string;
isEnabled: boolean; isEnabled: boolean;
requiredApprovals: number;
}>({ }>({
isOpen: false, isOpen: false,
enableEnvironment: '', enableEnvironment: '',
isEnabled: false, isEnabled: false,
requiredApprovals: 1,
}); });
const theme = useTheme();
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const { data, loading, refetchChangeRequestConfig } = const { data, loading, refetchChangeRequestConfig } =
useChangeRequestConfig(projectId); useChangeRequestConfig(projectId);
const { updateChangeRequestEnvironmentConfig } = useChangeRequestApi(); const { updateChangeRequestEnvironmentConfig } = useChangeRequestApi();
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
const onClick = (enableEnvironment: string, isEnabled: boolean) => () => { const onRowChange =
setDialogState({ isOpen: true, enableEnvironment, isEnabled }); (
enableEnvironment: string,
isEnabled: boolean,
requiredApprovals: number
) =>
() => {
setDialogState({
isOpen: true,
enableEnvironment,
isEnabled,
requiredApprovals,
});
}; };
const onConfirm = async () => { const onConfirm = async () => {
if (dialogState.enableEnvironment) { if (dialogState.enableEnvironment) {
await updateConfiguration();
}
setDialogState({
isOpen: false,
enableEnvironment: '',
isEnabled: false,
requiredApprovals: 1,
});
};
async function updateConfiguration(config?: IChangeRequestConfig) {
try { try {
await updateChangeRequestEnvironmentConfig( await updateChangeRequestEnvironmentConfig(
projectId, config || {
dialogState.enableEnvironment, project: projectId,
!dialogState.isEnabled environment: dialogState.enableEnvironment,
enabled: !dialogState.isEnabled,
requiredApprovals: dialogState.requiredApprovals,
}
); );
setToastData({ setToastData({
type: 'success', type: 'success',
title: 'Updated change request status', title: 'Updated change request status',
text: 'Successfully updated change request status.', text: 'Successfully updated change request status.',
}); });
refetchChangeRequestConfig(); await refetchChangeRequestConfig();
} catch (error) { } catch (error) {
const message = formatUnknownError(error); setToastApiError(formatUnknownError(error));
setToastApiError(message);
} }
} }
setDialogState({
isOpen: false, const approvalOptions = Array.from(Array(10).keys())
enableEnvironment: '', .map(key => String(key + 1))
isEnabled: false, .map(key => {
}); const labelText = key === '1' ? 'approval' : 'approvals';
return {
key,
label: `${key} ${labelText}`,
sx: { 'font-size': theme.fontSizes.smallBody },
}; };
});
function onRequiredApprovalsChange(original: any, approvals: string) {
updateConfiguration({
project: projectId,
environment: original.environment,
enabled: original.changeRequestEnabled,
requiredApprovals: Number(approvals),
});
}
const columns = useMemo( const columns = useMemo(
() => [ () => [
@ -82,6 +138,34 @@ export const ChangeRequestConfiguration: VFC = () => {
disableGlobalFilter: true, disableGlobalFilter: true,
disableSortBy: true, disableSortBy: true,
}, },
{
Header: 'Required approvals',
align: 'center',
Cell: ({ row: { original } }: any) => (
<ConditionallyRender
condition={original.changeRequestEnabled}
show={
<StyledBox data-loading>
<GeneralSelect
options={approvalOptions}
value={original.requiredApprovals || 1}
onChange={approvals => {
onRequiredApprovalsChange(
original,
approvals
);
}}
IconComponent={KeyboardArrowDownOutlined}
fullWidth
/>
</StyledBox>
}
/>
),
width: 100,
disableGlobalFilter: true,
disableSortBy: true,
},
{ {
Header: 'Status', Header: 'Status',
accessor: 'changeRequestEnabled', accessor: 'changeRequestEnabled',
@ -89,22 +173,20 @@ export const ChangeRequestConfiguration: VFC = () => {
align: 'center', align: 'center',
Cell: ({ value, row: { original } }: any) => ( Cell: ({ value, row: { original } }: any) => (
<Box <StyledBox data-loading>
sx={{ display: 'flex', justifyContent: 'center' }}
data-loading
>
<PermissionSwitch <PermissionSwitch
checked={value} checked={value}
environmentId={original.environment} environmentId={original.environment}
projectId={projectId} projectId={projectId}
permission={UPDATE_PROJECT} permission={UPDATE_PROJECT}
inputProps={{ 'aria-label': original.environment }} inputProps={{ 'aria-label': original.environment }}
onClick={onClick( onClick={onRowChange(
original.environment, original.environment,
original.changeRequestEnabled original.changeRequestEnabled,
original.requiredApprovals
)} )}
/> />
</Box> </StyledBox>
), ),
width: 100, width: 100,
disableGlobalFilter: true, disableGlobalFilter: true,

View File

@ -10,6 +10,13 @@ export interface IChangeRequestsSchema {
payload: string | boolean | object | number; payload: string | boolean | object | number;
} }
export interface IChangeRequestConfig {
project: string;
environment: string;
enabled: boolean;
requiredApprovals: number;
}
export const useChangeRequestApi = () => { export const useChangeRequestApi = () => {
const { makeRequest, createRequest, errors, loading } = useAPI({ const { makeRequest, createRequest, errors, loading } = useAPI({
propagateErrors: true, propagateErrors: true,
@ -67,15 +74,19 @@ export const useChangeRequestApi = () => {
} }
}; };
const updateChangeRequestEnvironmentConfig = async ( const updateChangeRequestEnvironmentConfig = async ({
project: string, project,
environment: string, enabled,
enabled: boolean environment,
) => { requiredApprovals,
}: IChangeRequestConfig) => {
const path = `api/admin/projects/${project}/environments/${environment}/change-requests/config`; const path = `api/admin/projects/${project}/environments/${environment}/change-requests/config`;
const req = createRequest(path, { const req = createRequest(path, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ changeRequestsEnabled: enabled }), body: JSON.stringify({
changeRequestsEnabled: enabled,
requiredApprovals,
}),
}); });
try { try {

View File

@ -16,8 +16,7 @@ import {
ProjectGroupRemovedEvent, ProjectGroupRemovedEvent,
ProjectGroupUpdateRoleEvent, ProjectGroupUpdateRoleEvent,
} from '../types/events'; } from '../types/events';
import { IUnleashStores } from '../types'; import { IUnleashStores, IUnleashConfig } from '../types';
import { IUnleashConfig } from '../types/option';
import { import {
FeatureToggle, FeatureToggle,
IProject, IProject,
@ -195,6 +194,13 @@ export default class ProjectService {
); );
} }
async addEnvironmentToProject(
project: string,
environment: string,
): Promise<void> {
await this.store.addEnvironmentToProject(project, environment);
}
async changeProject( async changeProject(
newProjectId: string, newProjectId: string,
featureName: string, featureName: string,

View File

@ -0,0 +1,25 @@
'use strict';
exports.up = function (db, callback) {
db.runSql(
`
ALTER TABLE change_request_settings ADD COLUMN required_approvals integer default 1;
ALTER TABLE change_request_settings
ADD CONSTRAINT change_request_settings_project_environment_key
UNIQUE (project, environment)
`,
callback,
);
};
exports.down = function (db, callback) {
db.runSql(
`
ALTER TABLE change_request_settings DROP COLUMN IF EXISTS required_approvals;
ALTER TABLE change_request_settings
DROP CONSTRAINT IF EXISTS change_request_settings_project_environment_key;
`,
callback,
);
};