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

feat: edit change requests (#3573)

This commit is contained in:
Jaanus Sellin 2023-04-24 16:32:19 +03:00 committed by GitHub
parent e4f7a644e8
commit 514961632f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 447 additions and 89 deletions

View File

@ -3,7 +3,7 @@ import { Box, Typography } from '@mui/material';
import type { IChangeRequest } from '../changeRequest.types';
import { FeatureToggleChanges } from './Changes/FeatureToggleChanges';
import { Change } from './Changes/Change/Change';
import { DiscardContainer } from './Changes/Change/Discard';
import { ChangeActions } from './Changes/Change/ChangeActions';
interface IChangeRequestProps {
changeRequest: IChangeRequest;
@ -30,10 +30,11 @@ export const ChangeRequest: VFC<IChangeRequestProps> = ({
<Change
key={index}
discard={
<DiscardContainer
<ChangeActions
changeRequest={changeRequest}
changeId={change.id}
onPostDiscard={onRefetch}
feature={feature.name}
change={change}
onRefetch={onRefetch}
/>
}
index={index}

View File

@ -0,0 +1,200 @@
import React, { FC, useState } from 'react';
import {
IChange,
IChangeRequest,
IChangeRequestAddStrategy,
IChangeRequestUpdateStrategy,
} from '../../../changeRequest.types';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
import { changesCount } from '../../../changesCount';
import {
Box,
IconButton,
Link,
ListItemIcon,
ListItemText,
MenuItem,
MenuList,
Popover,
styled,
Tooltip,
Typography,
} from '@mui/material';
import { Delete, Edit, GroupRounded, MoreVert } from '@mui/icons-material';
import { EditChange } from './EditChange';
const useShowActions = (changeRequest: IChangeRequest, change: IChange) => {
const { isChangeRequestConfigured } = useChangeRequestsEnabled(
changeRequest.project
);
const allowChangeRequestActions = isChangeRequestConfigured(
changeRequest.environment
);
const isPending = !['Cancelled', 'Applied'].includes(changeRequest.state);
const { user } = useAuthUser();
const isAuthor = user?.id === changeRequest.createdBy.id;
const showActions = allowChangeRequestActions && isPending && isAuthor;
const showEdit =
showActions &&
['addStrategy', 'updateStrategy'].includes(change.action);
const showDiscard = showActions && changesCount(changeRequest) > 1;
return { showEdit, showDiscard };
};
const StyledPopover = styled(Popover)(({ theme }) => ({
borderRadius: theme.shape.borderRadiusLarge,
padding: theme.spacing(1, 1.5),
}));
export const ChangeActions: FC<{
changeRequest: IChangeRequest;
feature: string;
change: IChange;
onRefetch?: () => void;
}> = ({ changeRequest, feature, change, onRefetch }) => {
const { showDiscard, showEdit } = useShowActions(changeRequest, change);
const { discardChange } = useChangeRequestApi();
const { setToastData, setToastApiError } = useToast();
const [editOpen, setEditOpen] = useState(false);
const id = `cr-${change.id}-actions`;
const menuId = `${id}-menu`;
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const onEdit = () => {
setEditOpen(true);
};
const onDiscard = async () => {
try {
handleClose();
await discardChange(
changeRequest.project,
changeRequest.id,
change.id
);
setToastData({
title: 'Change discarded from change request draft.',
type: 'success',
});
onRefetch?.();
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
return (
<ConditionallyRender
condition={showEdit || showDiscard}
show={
<>
<Tooltip title="Change request actions" arrow describeChild>
<IconButton
id={id}
aria-controls={open ? menuId : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
onClick={handleClick}
type="button"
>
<MoreVert />
</IconButton>
</Tooltip>
<StyledPopover
id={menuId}
anchorEl={anchorEl}
open={open}
onClose={handleClose}
transformOrigin={{
horizontal: 'right',
vertical: 'top',
}}
anchorOrigin={{
horizontal: 'right',
vertical: 'bottom',
}}
disableScrollLock={true}
>
<MenuList aria-labelledby={id}>
<ConditionallyRender
condition={showEdit}
show={
<MenuItem onClick={onEdit}>
<ListItemIcon>
<Edit />
</ListItemIcon>
<ListItemText>
<Typography variant="body2">
Edit change
</Typography>
</ListItemText>
<EditChange
changeRequestId={changeRequest.id}
featureId={feature}
change={
change as
| IChangeRequestAddStrategy
| IChangeRequestUpdateStrategy
}
environment={
changeRequest.environment
}
open={editOpen}
onSubmit={() => {
setEditOpen(false);
onRefetch?.();
}}
onClose={() => {
setEditOpen(false);
}}
/>
</MenuItem>
}
/>
<ConditionallyRender
condition={showDiscard}
show={
<MenuItem
onClick={() => {
onDiscard();
}}
>
<ListItemIcon>
<Delete />
</ListItemIcon>
<ListItemText>
<Typography variant="body2">
Discard change
</Typography>
</ListItemText>
</MenuItem>
}
/>
</MenuList>
</StyledPopover>
</>
}
/>
);
};

View File

@ -1,74 +0,0 @@
import React, { FC } from 'react';
import { IChangeRequest } from '../../../changeRequest.types';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
import { changesCount } from '../../../changesCount';
import { Box, Link, styled } from '@mui/material';
const useShowDiscard = (changeRequest: IChangeRequest) => {
const { isChangeRequestConfigured } = useChangeRequestsEnabled(
changeRequest.project
);
const allowChangeRequestActions = isChangeRequestConfigured(
changeRequest.environment
);
const isPending = !['Cancelled', 'Applied'].includes(changeRequest.state);
const { user } = useAuthUser();
const isAuthor = user?.id === changeRequest.createdBy.id;
const showDiscard =
allowChangeRequestActions &&
isPending &&
isAuthor &&
changesCount(changeRequest) > 1;
return showDiscard;
};
const StyledLink = styled(Link)(() => ({
textDecoration: 'none',
'&:hover, &:focus': {
textDecoration: 'underline',
},
}));
const Discard: FC<{ onDiscard: () => void }> = ({ onDiscard }) => (
<Box>
<StyledLink onClick={onDiscard}>Discard</StyledLink>
</Box>
);
export const DiscardContainer: FC<{
changeRequest: IChangeRequest;
changeId: number;
onPostDiscard?: () => void;
}> = ({ changeRequest, changeId, onPostDiscard }) => {
const showDiscard = useShowDiscard(changeRequest);
const { discardChange } = useChangeRequestApi();
const { setToastData, setToastApiError } = useToast();
const onDiscard = (id: number) => async () => {
try {
await discardChange(changeRequest.project, changeRequest.id, id);
setToastData({
title: 'Change discarded from change request draft.',
type: 'success',
});
onPostDiscard?.();
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
return (
<ConditionallyRender
condition={showDiscard}
show={<Discard onDiscard={onDiscard(changeId)} />}
/>
);
};

View File

@ -0,0 +1,199 @@
import React, { useEffect, useRef, useState } from 'react';
import { FeatureStrategyForm } from 'component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm';
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { formatUnknownError } from 'utils/formatUnknownError';
import useToast from 'hooks/useToast';
import { IFeatureStrategy } from 'interfaces/strategy';
import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { ISegment } from 'interfaces/segment';
import { formatStrategyName } from 'utils/strategyNames';
import { useFormErrors } from 'hooks/useFormErrors';
import { useCollaborateData } from 'hooks/useCollaborateData';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { IFeatureToggle } from 'interfaces/featureToggle';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { comparisonModerator } from 'component/feature/FeatureStrategy/featureStrategy.utils';
import {
IChangeRequestAddStrategy,
IChangeRequestUpdateStrategy,
} from 'component/changeRequest/changeRequest.types';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
interface IEditChangeProps {
change: IChangeRequestAddStrategy | IChangeRequestUpdateStrategy;
changeRequestId: number;
featureId: string;
environment: string;
open: boolean;
onSubmit: () => void;
onClose: () => void;
}
export const EditChange = ({
change,
changeRequestId,
environment,
open,
onSubmit,
onClose,
featureId,
}: IEditChangeProps) => {
const projectId = useRequiredPathParam('projectId');
const { editChange } = useChangeRequestApi();
const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>(
change.payload
);
const { segments: allSegments } = useSegments();
const strategySegments =
allSegments?.filter(segment => {
return change.payload.segments?.includes(segment.id);
}) || [];
const [segments, setSegments] = useState<ISegment[]>(strategySegments);
const strategyDefinition = {
parameters: change.payload.parameters,
name: change.payload.name,
};
const { setToastData, setToastApiError } = useToast();
const errors = useFormErrors();
const { uiConfig } = useUiConfig();
const { unleashUrl } = uiConfig;
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const { feature, refetchFeature } = useFeature(projectId, featureId);
const ref = useRef<IFeatureToggle>(feature);
const { data, staleDataNotification, forceRefreshCache } =
useCollaborateData<IFeatureToggle>(
{
unleashGetter: useFeature,
params: [projectId, featureId],
dataKey: 'feature',
refetchFunctionKey: 'refetchFeature',
options: {},
},
feature,
{
afterSubmitAction: refetchFeature,
},
comparisonModerator
);
useEffect(() => {
if (ref.current.name === '' && feature.name) {
forceRefreshCache(feature);
ref.current = feature;
}
}, [feature]);
const payload = {
...strategy,
segments: segments.map(segment => segment.id),
};
const onInternalSubmit = async () => {
try {
await editChange(projectId, changeRequestId, change.id, {
action: strategy.id ? 'updateStrategy' : 'addStrategy',
feature: featureId,
payload,
});
onSubmit();
setToastData({
title: 'Change updated',
type: 'success',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
if (!strategyDefinition) {
return null;
}
if (!data) return null;
return (
<SidebarModal
open={open}
onClose={onClose}
label="Edit change"
onClick={e => {
e.stopPropagation();
}}
>
<FormTemplate
modal
title={formatStrategyName(strategyDefinition.name ?? '')}
description={featureStrategyHelp}
documentationLink={featureStrategyDocsLink}
documentationLinkLabel={featureStrategyDocsLinkLabel}
formatApiCode={() =>
formatUpdateStrategyApiCode(
projectId,
changeRequestId,
change.id,
payload,
unleashUrl
)
}
>
<FeatureStrategyForm
projectId={projectId}
feature={data}
strategy={strategy}
setStrategy={setStrategy}
segments={segments}
setSegments={setSegments}
environmentId={environment}
onSubmit={onInternalSubmit}
onCancel={onClose}
loading={false}
permission={UPDATE_FEATURE_STRATEGY}
errors={errors}
isChangeRequest={isChangeRequestConfigured(environment)}
/>
{staleDataNotification}
</FormTemplate>
</SidebarModal>
);
};
export const formatUpdateStrategyApiCode = (
projectId: string,
changeRequestId: number,
changeId: number,
strategy: Partial<IFeatureStrategy>,
unleashUrl?: string
): string => {
if (!unleashUrl) {
return '';
}
const url = `${unleashUrl}/api/admin/projects/${projectId}/change-requests/${changeRequestId}/changes/${changeId}`;
const payload = JSON.stringify(strategy, undefined, 2);
return `curl --location --request PUT '${url}' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${payload}'`;
};
export const featureStrategyHelp = `
An activation strategy will only run when a feature toggle is enabled and provides a way to control who will get access to the feature.
If any of a feature toggle's activation strategies returns true, the user will get access.
`;
export const featureStrategyDocsLink =
'https://docs.getunleash.io/reference/activation-strategies';
export const featureStrategyDocsLinkLabel = 'Strategies documentation';

View File

@ -3,8 +3,8 @@ import { screen } from '@testing-library/react';
import { ChangeRequestTitle } from './ChangeRequestTitle';
import { ChangeRequestState } from '../../changeRequest.types';
import userEvent from '@testing-library/user-event';
import { testServerRoute, testServerSetup } from '../../../../utils/testServer';
import { render } from '../../../../utils/testRenderer';
import { testServerRoute, testServerSetup } from 'utils/testServer';
import { render } from 'utils/testRenderer';
import { UIProviderContainer } from '../../../providers/UIProvider/UIProviderContainer';
const changeRequest = {

View File

@ -111,9 +111,18 @@ export const ChangeRequestsTabs = ({
Header: 'Updated feature toggles',
canSort: false,
accessor: 'features',
Cell: ({ value }: any) => {
return <FeaturesCell project={projectId} value={value} />;
Cell: ({
value,
row: {
original: { title },
},
}: any) => (
<FeaturesCell
project={projectId}
value={value}
key={title}
/>
),
},
{
Header: 'By',

View File

@ -106,7 +106,7 @@ type ChangeRequestEnabled = { enabled: boolean };
type ChangeRequestAddStrategy = Pick<
IFeatureStrategy,
'parameters' | 'constraints'
'parameters' | 'constraints' | 'segments'
> & { name: string };
type ChangeRequestEditStrategy = ChangeRequestAddStrategy & { id: string };

View File

@ -160,7 +160,6 @@ const FormTemplate: React.FC<ICreateProps> = ({
}) => {
const { setToastData } = useToast();
const smallScreen = useMediaQuery(`(max-width:${1099}px)`);
const copyCommand = () => {
if (copy(formatApiCode())) {
setToastData({

View File

@ -9,6 +9,7 @@ interface ISidebarModalProps {
open: boolean;
onClose: () => void;
label: string;
onClick?: (e: React.SyntheticEvent) => void;
children: React.ReactElement<any, any>;
}
@ -39,6 +40,7 @@ const StyledIconButton = styled(IconButton)(({ theme }) => ({
export const BaseModal: FC<ISidebarModalProps> = ({
open,
onClose,
onClick,
label,
children,
}) => {
@ -46,6 +48,7 @@ export const BaseModal: FC<ISidebarModalProps> = ({
<Modal
open={open}
onClose={onClose}
onClick={onClick}
closeAfterTransition
aria-label={label}
BackdropComponent={Backdrop}

View File

@ -37,6 +37,7 @@ interface IFeatureStrategyFormProps {
environmentId: string;
permission: string;
onSubmit: () => void;
onCancel?: () => void;
loading: boolean;
isChangeRequest?: boolean;
strategy: Partial<IFeatureStrategy>;
@ -74,6 +75,7 @@ export const FeatureStrategyForm = ({
environmentId,
permission,
onSubmit,
onCancel,
loading,
strategy,
setStrategy,
@ -149,7 +151,7 @@ export const FeatureStrategyForm = ({
.every(Boolean);
};
const onCancel = () => {
const onDefaultCancel = () => {
navigate(formatFeaturePath(feature.project, feature.name));
};
@ -270,7 +272,7 @@ export const FeatureStrategyForm = ({
<Button
type="button"
color="primary"
onClick={onCancel}
onClick={onCancel ? onCancel : onDefaultCancel}
disabled={loading}
>
Cancel

View File

@ -79,9 +79,9 @@ export const useChangeRequestApi = () => {
const discardChange = async (
project: string,
changeRequestId: number,
changeRequestEventId: number
changeId: number
) => {
const path = `api/admin/projects/${project}/change-requests/${changeRequestId}/changes/${changeRequestEventId}`;
const path = `api/admin/projects/${project}/change-requests/${changeRequestId}/changes/${changeId}`;
const req = createRequest(path, {
method: 'DELETE',
});
@ -92,6 +92,24 @@ export const useChangeRequestApi = () => {
}
};
const editChange = async (
project: string,
changeRequestId: number,
changeId: number,
payload: IChangeSchema
) => {
const path = `api/admin/projects/${project}/change-requests/${changeRequestId}/changes/${changeId}`;
const req = createRequest(path, {
method: 'PUT',
body: JSON.stringify(payload),
});
try {
return await makeRequest(req.caller, req.id);
} catch (e) {
throw e;
}
};
const updateChangeRequestEnvironmentConfig = async ({
project,
enabled,
@ -176,6 +194,7 @@ export const useChangeRequestApi = () => {
return {
addChange,
editChange,
changeState,
discardChange,
updateChangeRequestEnvironmentConfig,