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

feat: show scheduled CRs using strategies when removing it (#5560)

Show a warning about how deleting a strategy might mess up scheduled
change requests.

If there are change requests, list them. If there are no conflicts, show
nothing. If we don't know (because of no successful response from the
API), say that it might cause issues.


![image](https://github.com/Unleash/unleash/assets/17786332/2c6a4257-69f5-458a-ab6f-9b2ea2f5d550)
This commit is contained in:
Thomas Heartman 2023-12-06 15:39:17 +01:00 committed by GitHub
parent 87ebbb0fa2
commit a228f54344
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 297 additions and 21 deletions

View File

@ -117,7 +117,12 @@ const renderHeaderChildren = (
); );
if (draftChange) { if (draftChange) {
badges.push(<ChangeRequestStatusBadge change={draftChange.change} />); badges.push(
<ChangeRequestStatusBadge
key={`draft-change#${draftChange.change.id}`}
change={draftChange.change}
/>,
);
} }
const scheduledChanges = changes.filter( const scheduledChanges = changes.filter(
@ -127,6 +132,7 @@ const renderHeaderChildren = (
if (scheduledChanges.length > 0) { if (scheduledChanges.length > 0) {
badges.push( badges.push(
<ChangesScheduledBadge <ChangesScheduledBadge
key='scheduled-changes'
scheduledChangeRequestIds={scheduledChanges.map( scheduledChangeRequestIds={scheduledChanges.map(
(scheduledChange) => scheduledChange.changeRequestId, (scheduledChange) => scheduledChange.changeRequestId,
)} )}

View File

@ -0,0 +1,141 @@
import React from 'react';
import { render } from 'utils/testRenderer';
import { screen } from '@testing-library/react';
import {
FeatureStrategyRemoveDialogue,
SuggestFeatureStrategyRemoveDialogue,
} from './DialogStrategyRemove';
import {
ChangeRequestState,
IChangeRequestFeature,
IFeatureChange,
} from 'component/changeRequest/changeRequest.types';
const strategyId = 'c81e3a1d-e91c-4083-bd0f-75bb8a9e32a2';
const projectId = 'default';
const environment = 'development';
const featureId = 'bb1d79e0-95b0-4393-b248-64d1e0294ee3';
describe('Use in scheduled change requests', () => {
it.each(['enabled', 'disabled'])(
'should show usage in scheduled change requests with change requests %s for the project',
async (changeRequestsEnabled) => {
const changeRequestWithTitle = { id: 1, title: 'My CR' };
const changeRequestWithoutTitle = { id: 2 };
const scheduledChangeRequests = [
changeRequestWithTitle,
changeRequestWithoutTitle,
];
if (changeRequestsEnabled === 'enabled') {
render(
<SuggestFeatureStrategyRemoveDialogue
onRemove={async () => {}}
onClose={() => {}}
isOpen={true}
scheduledChangeRequestsForStrategy={{
projectId,
changeRequests: scheduledChangeRequests,
}}
/>,
);
} else {
render(
<FeatureStrategyRemoveDialogue
onRemove={async () => {}}
onClose={() => {}}
isOpen={true}
scheduledChangeRequestsForStrategy={{
projectId,
changeRequests: scheduledChangeRequests,
}}
/>,
);
}
const alerts = await screen.findAllByRole('alert');
expect(
alerts.find((alert) =>
alert.textContent!.startsWith('This strategy'),
),
).toBeTruthy();
const links = await screen.findAllByRole('link');
expect(links).toHaveLength(scheduledChangeRequests.length);
const [link1, link2] = links;
expect(link1).toHaveTextContent('#1 (My CR)');
expect(link1).toHaveAccessibleDescription('Change request 1');
expect(link1).toHaveAttribute(
'href',
`/projects/default/change-requests/1`,
);
expect(link2).toHaveTextContent('#2');
expect(link2).toHaveAccessibleDescription('Change request 2');
expect(link2).toHaveAttribute(
'href',
`/projects/default/change-requests/2`,
);
},
);
it('should not render scheduled change requests warning when there are no scheduled change requests', async () => {
render(
<SuggestFeatureStrategyRemoveDialogue
onRemove={async () => {}}
onClose={() => {}}
isOpen={true}
scheduledChangeRequestsForStrategy={{
projectId,
changeRequests: [],
}}
/>,
);
const alerts = await screen.findAllByRole('alert');
expect(
alerts.find((alert) =>
alert.textContent!.startsWith('This strategy'),
),
).toBeFalsy();
expect(alerts).toHaveLength(1);
const link = screen.queryByRole('link');
expect(link).toBe(null);
});
it("It should render a warning saying there might be scheduled change requests if it doesn't get a successful API response", async () => {
render(
<SuggestFeatureStrategyRemoveDialogue
onRemove={async () => {}}
onClose={() => {}}
isOpen={true}
scheduledChangeRequestsForStrategy={{
projectId,
changeRequests: undefined,
}}
/>,
);
const alerts = await screen.findAllByRole('alert');
expect(
alerts.find((alert) =>
alert.textContent!.startsWith('This strategy'),
),
).toBeTruthy();
expect(alerts).toHaveLength(2);
const link = screen.queryByRole('link');
expect(link).toBe(null);
});
});

View File

@ -1,7 +1,7 @@
import React, { FC } from 'react'; import React, { FC } from 'react';
import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi'; import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import { formatFeaturePath } from '../../../../../../../../FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit'; import { formatFeaturePath } from '../../../../../../../../FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
import { Dialogue } from 'component/common/Dialogue/Dialogue'; import { Dialogue } from 'component/common/Dialogue/Dialogue';
@ -10,7 +10,8 @@ import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi'; import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests'; import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useScheduledChangeRequestsWithStrategy } from 'hooks/api/getters/useScheduledChangeRequestsWithStrategy/useScheduledChangeRequestsWithStrategy';
interface IFeatureStrategyRemoveProps { interface IFeatureStrategyRemoveProps {
projectId: string; projectId: string;
featureId: string; featureId: string;
@ -18,13 +19,23 @@ interface IFeatureStrategyRemoveProps {
strategyId: string; strategyId: string;
disabled?: boolean; disabled?: boolean;
icon?: boolean; icon?: boolean;
text?: boolean;
} }
type ChangeRequest = {
id: number;
title?: string;
};
type ScheduledChangeRequestData = {
changeRequests?: ChangeRequest[];
projectId: string;
};
interface IFeatureStrategyRemoveDialogueProps { interface IFeatureStrategyRemoveDialogueProps {
onRemove: (event: React.FormEvent) => Promise<void>; onRemove: (event: React.FormEvent) => Promise<void>;
onClose: () => void; onClose: () => void;
isOpen: boolean; isOpen: boolean;
scheduledChangeRequestsForStrategy: ScheduledChangeRequestData;
} }
const RemoveAlert: FC = () => ( const RemoveAlert: FC = () => (
@ -34,30 +45,103 @@ const RemoveAlert: FC = () => (
</Alert> </Alert>
); );
const FeatureStrategyRemoveDialogue: FC<IFeatureStrategyRemoveDialogueProps> = const AlertContainer = styled('div')(({ theme }) => ({
({ onRemove, onClose, isOpen }) => { '> * + *': {
marginTop: theme.spacing(1),
},
}));
const StrategyInScheduledChangeRequestsWarning: FC<{
changeRequests?: ChangeRequest[];
projectId: string;
}> = ({ changeRequests, projectId }) => {
if (changeRequests && changeRequests.length > 0) {
return ( return (
<Dialogue <Alert severity='warning'>
title='Are you sure you want to delete this strategy?' <p>
open={isOpen} This strategy is in use by at least one scheduled change
primaryButtonText='Remove strategy' request. If you remove it, those change requests can no
secondaryButtonText='Cancel' longer be applied.
onClick={onRemove} </p>
onClose={onClose} <p>
> The following scheduled change requests use this strategy:
<RemoveAlert /> </p>
</Dialogue> <ul>
{changeRequests.map(({ id, title }) => {
const text = title ? `#${id} (${title})` : `#${id}`;
return (
<li key={id}>
<Link
to={`/projects/${projectId}/change-requests/${id}`}
target='_blank'
rel='noopener noreferrer'
title={`Change request ${id}`}
>
{text}
</Link>
</li>
);
})}
</ul>
</Alert>
); );
}; } else if (changeRequests === undefined) {
return (
<Alert severity='warning'>
<p>
This strategy may be in use by one or more scheduled change
requests. If you remove it, those change requests can no
longer be applied.
</p>
</Alert>
);
}
// all good, we have nothing to show
return null;
};
const Alerts: FC<{
scheduledChangeRequestsForStrategy: ScheduledChangeRequestData;
}> = ({ scheduledChangeRequestsForStrategy }) => (
<AlertContainer>
<RemoveAlert />
<StrategyInScheduledChangeRequestsWarning
projectId={scheduledChangeRequestsForStrategy.projectId}
changeRequests={scheduledChangeRequestsForStrategy.changeRequests}
/>
</AlertContainer>
);
export const FeatureStrategyRemoveDialogue: FC<
IFeatureStrategyRemoveDialogueProps
> = ({ onRemove, onClose, isOpen, scheduledChangeRequestsForStrategy }) => {
return (
<Dialogue
title='Are you sure you want to delete this strategy?'
open={isOpen}
primaryButtonText='Remove strategy'
secondaryButtonText='Cancel'
onClick={onRemove}
onClose={onClose}
>
<Alerts
scheduledChangeRequestsForStrategy={
scheduledChangeRequestsForStrategy
}
/>
</Dialogue>
);
};
const MsgContainer = styled('div')(({ theme }) => ({ const MsgContainer = styled('div')(({ theme }) => ({
marginTop: theme.spacing(3), marginTop: theme.spacing(3),
marginBottom: theme.spacing(1), marginBottom: theme.spacing(1),
})); }));
const SuggestFeatureStrategyRemoveDialogue: FC< export const SuggestFeatureStrategyRemoveDialogue: FC<
IFeatureStrategyRemoveDialogueProps IFeatureStrategyRemoveDialogueProps
> = ({ onRemove, onClose, isOpen }) => { > = ({ onRemove, onClose, isOpen, scheduledChangeRequestsForStrategy }) => {
return ( return (
<Dialogue <Dialogue
title='Suggest changes' title='Suggest changes'
@ -67,7 +151,11 @@ const SuggestFeatureStrategyRemoveDialogue: FC<
onClick={onRemove} onClick={onRemove}
onClose={onClose} onClose={onClose}
> >
<RemoveAlert /> <Alerts
scheduledChangeRequestsForStrategy={
scheduledChangeRequestsForStrategy
}
/>
<MsgContainer> <MsgContainer>
<Typography variant='body2' color='text.secondary'> <Typography variant='body2' color='text.secondary'>
Your suggestion: Your suggestion:
@ -155,7 +243,6 @@ export const DialogStrategyRemove = ({
featureId, featureId,
environmentId, environmentId,
strategyId, strategyId,
text,
isOpen, isOpen,
onClose, onClose,
}: IFeatureStrategyRemoveProps & { }: IFeatureStrategyRemoveProps & {
@ -164,6 +251,16 @@ export const DialogStrategyRemove = ({
}) => { }) => {
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const { changeRequests } = useScheduledChangeRequestsWithStrategy(
projectId,
strategyId,
);
const changeRequestData = {
changeRequests,
projectId,
};
const onRemove = useOnRemove({ const onRemove = useOnRemove({
featureId, featureId,
projectId, projectId,
@ -186,6 +283,7 @@ export const DialogStrategyRemove = ({
await onSuggestRemove(e); await onSuggestRemove(e);
onClose(); onClose();
}} }}
scheduledChangeRequestsForStrategy={changeRequestData}
/> />
); );
} }
@ -195,6 +293,7 @@ export const DialogStrategyRemove = ({
isOpen={isOpen} isOpen={isOpen}
onClose={() => onClose()} onClose={() => onClose()}
onRemove={onRemove} onRemove={onRemove}
scheduledChangeRequestsForStrategy={changeRequestData}
/> />
); );
}; };

View File

@ -0,0 +1,30 @@
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import { IChangeRequest } from 'component/changeRequest/changeRequest.types';
import { useEnterpriseSWR } from '../useEnterpriseSWR/useEnterpriseSWR';
const fetcher = (path: string) => {
return fetch(path)
.then(handleErrorResponses('ChangeRequest'))
.then((res) => res.json());
};
export const useScheduledChangeRequestsWithStrategy = (
project: string,
strategyId: string,
) => {
const { data, error, mutate } = useEnterpriseSWR<IChangeRequest[]>(
[],
formatApiPath(
`api/admin/projects/${project}/change-requests/scheduled/with-strategy/${strategyId}`,
),
fetcher,
);
return {
changeRequests: data,
loading: !error && !data,
refetch: mutate,
error,
};
};