mirror of
https://github.com/Unleash/unleash.git
synced 2025-02-04 00:18:01 +01:00
feat: archive toggles in change request UI (#4563)
This commit is contained in:
parent
63e052bafe
commit
cc62db46fb
@ -0,0 +1,22 @@
|
||||
import { FC, ReactNode } from 'react';
|
||||
import { Box, styled } from '@mui/material';
|
||||
import { ChangeItemWrapper } from './StrategyChange';
|
||||
|
||||
const ArchiveBox = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: theme.palette.error.main,
|
||||
}));
|
||||
|
||||
interface IArchiveFeatureChange {
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export const ArchiveFeatureChange: FC<IArchiveFeatureChange> = ({
|
||||
actions,
|
||||
}) => (
|
||||
<ChangeItemWrapper>
|
||||
<ArchiveBox>Archiving feature</ArchiveBox>
|
||||
{actions}
|
||||
</ChangeItemWrapper>
|
||||
);
|
@ -11,6 +11,7 @@ import { ToggleStatusChange } from './ToggleStatusChange';
|
||||
import { StrategyChange } from './StrategyChange';
|
||||
import { VariantPatch } from './VariantPatch/VariantPatch';
|
||||
import { EnvironmentStrategyExecutionOrder } from './EnvironmentStrategyExecutionOrder/EnvironmentStrategyExecutionOrder';
|
||||
import { ArchiveFeatureChange } from './ArchiveFeatureChange';
|
||||
|
||||
const StyledSingleChangeBox = styled(Box, {
|
||||
shouldForwardProp: (prop: string) => !prop.startsWith('$'),
|
||||
@ -90,6 +91,9 @@ export const FeatureChange: FC<{
|
||||
actions={actions}
|
||||
/>
|
||||
)}
|
||||
{change.action === 'archiveFeature' && (
|
||||
<ArchiveFeatureChange actions={actions} />
|
||||
)}
|
||||
|
||||
{change.action === 'addStrategy' ||
|
||||
change.action === 'deleteStrategy' ||
|
||||
|
@ -78,7 +78,8 @@ type ChangeRequestPayload =
|
||||
| ChangeRequestVariantPatch
|
||||
| IChangeRequestUpdateSegment
|
||||
| IChangeRequestDeleteSegment
|
||||
| SetStrategySortOrderSchema;
|
||||
| SetStrategySortOrderSchema
|
||||
| IChangeRequestArchiveFeature;
|
||||
|
||||
export interface IChangeRequestAddStrategy extends IChangeRequestChangeBase {
|
||||
action: 'addStrategy';
|
||||
@ -105,6 +106,10 @@ export interface IChangeRequestPatchVariant extends IChangeRequestChangeBase {
|
||||
payload: ChangeRequestVariantPatch;
|
||||
}
|
||||
|
||||
export interface IChangeRequestArchiveFeature extends IChangeRequestChangeBase {
|
||||
action: 'archiveFeature';
|
||||
}
|
||||
|
||||
export interface IChangeRequestReorderStrategy
|
||||
extends IChangeRequestChangeBase {
|
||||
action: 'reorderStrategy';
|
||||
@ -144,7 +149,8 @@ export type IFeatureChange =
|
||||
| IChangeRequestUpdateStrategy
|
||||
| IChangeRequestEnabled
|
||||
| IChangeRequestPatchVariant
|
||||
| IChangeRequestReorderStrategy;
|
||||
| IChangeRequestReorderStrategy
|
||||
| IChangeRequestArchiveFeature;
|
||||
|
||||
export type ISegmentChange =
|
||||
| IChangeRequestUpdateSegment
|
||||
@ -178,4 +184,5 @@ export type ChangeRequestAction =
|
||||
| 'patchVariant'
|
||||
| 'reorderStrategy'
|
||||
| 'updateSegment'
|
||||
| 'deleteSegment';
|
||||
| 'deleteSegment'
|
||||
| 'archiveFeature';
|
||||
|
@ -0,0 +1,119 @@
|
||||
import { vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { render } from 'utils/testRenderer';
|
||||
import { testServerRoute, testServerSetup } from 'utils/testServer';
|
||||
import { FeatureArchiveDialog } from './FeatureArchiveDialog';
|
||||
import { UIProviderContainer } from 'component/providers/UIProvider/UIProviderContainer';
|
||||
|
||||
const server = testServerSetup();
|
||||
const setupHappyPathForChangeRequest = () => {
|
||||
testServerRoute(
|
||||
server,
|
||||
'/api/admin/projects/projectId/environments/development/change-requests',
|
||||
{},
|
||||
'post'
|
||||
);
|
||||
testServerRoute(
|
||||
server,
|
||||
'/api/admin/projects/projectId/change-requests/config',
|
||||
[
|
||||
{
|
||||
environment: 'development',
|
||||
type: 'development',
|
||||
requiredApprovals: 1,
|
||||
changeRequestEnabled: true,
|
||||
},
|
||||
]
|
||||
);
|
||||
testServerRoute(server, '/api/admin/ui-config', {
|
||||
versionInfo: {
|
||||
current: { oss: 'version', enterprise: 'version' },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
test('Add single archive feature change to change request', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
setupHappyPathForChangeRequest();
|
||||
render(
|
||||
<UIProviderContainer>
|
||||
<FeatureArchiveDialog
|
||||
featureIds={['featureA']}
|
||||
projectId={'projectId'}
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
featuresWithUsage={[]}
|
||||
/>
|
||||
</UIProviderContainer>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Archive feature toggle')).toBeInTheDocument();
|
||||
const button = await screen.findByText('Add change to draft');
|
||||
|
||||
button.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onConfirm).toBeCalledTimes(1);
|
||||
});
|
||||
expect(onClose).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Add multiple archive feature changes to change request', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
setupHappyPathForChangeRequest();
|
||||
render(
|
||||
<UIProviderContainer>
|
||||
<FeatureArchiveDialog
|
||||
featureIds={['featureA', 'featureB']}
|
||||
projectId={'projectId'}
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
featuresWithUsage={[]}
|
||||
/>
|
||||
</UIProviderContainer>
|
||||
);
|
||||
|
||||
await screen.findByText('Archive feature toggles');
|
||||
const button = await screen.findByText('Add to change request');
|
||||
|
||||
button.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onConfirm).toBeCalledTimes(1);
|
||||
});
|
||||
expect(onClose).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Skip change request', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
setupHappyPathForChangeRequest();
|
||||
render(
|
||||
<UIProviderContainer>
|
||||
<FeatureArchiveDialog
|
||||
featureIds={['featureA', 'featureB']}
|
||||
projectId={'projectId'}
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
featuresWithUsage={[]}
|
||||
/>
|
||||
</UIProviderContainer>,
|
||||
{ permissions: [{ permission: 'SKIP_CHANGE_REQUEST' }] }
|
||||
);
|
||||
|
||||
await screen.findByText('Archive feature toggles');
|
||||
const button = await screen.findByText('Archive toggles');
|
||||
|
||||
button.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toBeCalledTimes(1);
|
||||
});
|
||||
expect(onConfirm).toBeCalledTimes(0); // we didn't setup non Change Request flow so failure
|
||||
});
|
@ -7,7 +7,11 @@ import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender'
|
||||
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
|
||||
import { Alert, Typography } from '@mui/material';
|
||||
import { Link } from 'react-router-dom';
|
||||
import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
|
||||
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
|
||||
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
|
||||
import { useHighestPermissionChangeRequestEnvironment } from 'hooks/useHighestPermissionChangeRequestEnvironment';
|
||||
|
||||
interface IFeatureArchiveDialogProps {
|
||||
isOpen: boolean;
|
||||
@ -58,6 +62,111 @@ const UsageWarning = ({
|
||||
return null;
|
||||
};
|
||||
|
||||
const useActionButtonText = (projectId: string, isBulkArchive: boolean) => {
|
||||
const getHighestEnvironment =
|
||||
useHighestPermissionChangeRequestEnvironment(projectId);
|
||||
const environment = getHighestEnvironment();
|
||||
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
|
||||
if (
|
||||
environment &&
|
||||
isChangeRequestConfigured(environment) &&
|
||||
isBulkArchive
|
||||
) {
|
||||
return 'Add to change request';
|
||||
}
|
||||
if (environment && isChangeRequestConfigured(environment)) {
|
||||
return 'Add change to draft';
|
||||
}
|
||||
if (isBulkArchive) {
|
||||
return 'Archive toggles';
|
||||
}
|
||||
return 'Archive toggle';
|
||||
};
|
||||
|
||||
const useArchiveAction = ({
|
||||
projectId,
|
||||
featureIds,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
projectId: string;
|
||||
featureIds: string[];
|
||||
onSuccess: () => void;
|
||||
onError: () => void;
|
||||
}) => {
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
const { archiveFeatureToggle } = useFeatureApi();
|
||||
const { archiveFeatures } = useProjectApi();
|
||||
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
|
||||
const { addChange } = useChangeRequestApi();
|
||||
const { refetch: refetchChangeRequests } =
|
||||
usePendingChangeRequests(projectId);
|
||||
const getHighestEnvironment =
|
||||
useHighestPermissionChangeRequestEnvironment(projectId);
|
||||
const isBulkArchive = featureIds?.length > 1;
|
||||
const environment = getHighestEnvironment();
|
||||
const addArchiveToggleToChangeRequest = async () => {
|
||||
if (!environment) {
|
||||
console.error('No change request environment');
|
||||
return;
|
||||
}
|
||||
await addChange(
|
||||
projectId,
|
||||
environment,
|
||||
featureIds.map(feature => ({
|
||||
action: 'archiveFeature',
|
||||
feature: feature,
|
||||
payload: undefined,
|
||||
}))
|
||||
);
|
||||
refetchChangeRequests();
|
||||
setToastData({
|
||||
text: isBulkArchive
|
||||
? 'Your archive feature toggles changes have been added to change request'
|
||||
: 'Your archive feature toggle change has been added to change request',
|
||||
type: 'success',
|
||||
title: isBulkArchive
|
||||
? 'Changes added to a draft'
|
||||
: 'Change added to a draft',
|
||||
});
|
||||
};
|
||||
|
||||
const archiveToggle = async () => {
|
||||
await archiveFeatureToggle(projectId, featureIds[0]);
|
||||
setToastData({
|
||||
text: 'Your feature toggle has been archived',
|
||||
type: 'success',
|
||||
title: 'Feature archived',
|
||||
});
|
||||
};
|
||||
|
||||
const archiveToggles = async () => {
|
||||
await archiveFeatures(projectId, featureIds);
|
||||
setToastData({
|
||||
text: 'Selected feature toggles have been archived',
|
||||
type: 'success',
|
||||
title: 'Features archived',
|
||||
});
|
||||
};
|
||||
|
||||
return async () => {
|
||||
try {
|
||||
if (environment && isChangeRequestConfigured(environment)) {
|
||||
await addArchiveToggleToChangeRequest();
|
||||
} else if (isBulkArchive) {
|
||||
await archiveToggles();
|
||||
} else {
|
||||
await archiveToggle();
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
} catch (error: unknown) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
onError();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
@ -66,58 +175,36 @@ export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
|
||||
featureIds,
|
||||
featuresWithUsage,
|
||||
}) => {
|
||||
const { archiveFeatureToggle } = useFeatureApi();
|
||||
const { archiveFeatures } = useProjectApi();
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
const { uiConfig } = useUiConfig();
|
||||
|
||||
const isBulkArchive = featureIds?.length > 1;
|
||||
|
||||
const archiveToggle = async () => {
|
||||
try {
|
||||
await archiveFeatureToggle(projectId, featureIds[0]);
|
||||
setToastData({
|
||||
text: 'Your feature toggle has been archived',
|
||||
type: 'success',
|
||||
title: 'Feature archived',
|
||||
});
|
||||
onConfirm();
|
||||
onClose();
|
||||
} catch (error: unknown) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const buttonText = useActionButtonText(projectId, isBulkArchive);
|
||||
|
||||
const archiveToggles = async () => {
|
||||
try {
|
||||
await archiveFeatures(projectId, featureIds);
|
||||
setToastData({
|
||||
text: 'Selected feature toggles have been archived',
|
||||
type: 'success',
|
||||
title: 'Feature toggles archived',
|
||||
});
|
||||
const dialogTitle = isBulkArchive
|
||||
? 'Archive feature toggles'
|
||||
: 'Archive feature toggle';
|
||||
|
||||
const archiveAction = useArchiveAction({
|
||||
projectId,
|
||||
featureIds,
|
||||
onSuccess() {
|
||||
onConfirm();
|
||||
onClose();
|
||||
} catch (error: unknown) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
},
|
||||
onError() {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialogue
|
||||
onClick={isBulkArchive ? archiveToggles : archiveToggle}
|
||||
onClick={archiveAction}
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
primaryButtonText={
|
||||
isBulkArchive ? 'Archive toggles' : 'Archive toggle'
|
||||
}
|
||||
primaryButtonText={buttonText}
|
||||
secondaryButtonText="Cancel"
|
||||
title={
|
||||
isBulkArchive
|
||||
? 'Archive feature toggles'
|
||||
: 'Archive feature toggle'
|
||||
}
|
||||
title={dialogTitle}
|
||||
>
|
||||
<ConditionallyRender
|
||||
condition={isBulkArchive}
|
||||
@ -155,7 +242,11 @@ export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
|
||||
}
|
||||
elseShow={
|
||||
<p>
|
||||
Are you sure you want to archive these feature toggles?
|
||||
Are you sure you want to archive{' '}
|
||||
{isBulkArchive
|
||||
? 'these feature toggles'
|
||||
: 'this feature toggle'}
|
||||
?
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
|
@ -10,8 +10,9 @@ export interface IChangeSchema {
|
||||
| 'deleteStrategy'
|
||||
| 'patchVariant'
|
||||
| 'reorderStrategy'
|
||||
| 'archiveFeature'
|
||||
| 'updateSegment';
|
||||
payload: string | boolean | object | number;
|
||||
payload: string | boolean | object | number | undefined;
|
||||
}
|
||||
|
||||
export interface IChangeRequestConfig {
|
||||
|
Loading…
Reference in New Issue
Block a user