mirror of
https://github.com/Unleash/unleash.git
synced 2025-02-09 00:18:00 +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 { StrategyChange } from './StrategyChange';
|
||||||
import { VariantPatch } from './VariantPatch/VariantPatch';
|
import { VariantPatch } from './VariantPatch/VariantPatch';
|
||||||
import { EnvironmentStrategyExecutionOrder } from './EnvironmentStrategyExecutionOrder/EnvironmentStrategyExecutionOrder';
|
import { EnvironmentStrategyExecutionOrder } from './EnvironmentStrategyExecutionOrder/EnvironmentStrategyExecutionOrder';
|
||||||
|
import { ArchiveFeatureChange } from './ArchiveFeatureChange';
|
||||||
|
|
||||||
const StyledSingleChangeBox = styled(Box, {
|
const StyledSingleChangeBox = styled(Box, {
|
||||||
shouldForwardProp: (prop: string) => !prop.startsWith('$'),
|
shouldForwardProp: (prop: string) => !prop.startsWith('$'),
|
||||||
@ -90,6 +91,9 @@ export const FeatureChange: FC<{
|
|||||||
actions={actions}
|
actions={actions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{change.action === 'archiveFeature' && (
|
||||||
|
<ArchiveFeatureChange actions={actions} />
|
||||||
|
)}
|
||||||
|
|
||||||
{change.action === 'addStrategy' ||
|
{change.action === 'addStrategy' ||
|
||||||
change.action === 'deleteStrategy' ||
|
change.action === 'deleteStrategy' ||
|
||||||
|
@ -78,7 +78,8 @@ type ChangeRequestPayload =
|
|||||||
| ChangeRequestVariantPatch
|
| ChangeRequestVariantPatch
|
||||||
| IChangeRequestUpdateSegment
|
| IChangeRequestUpdateSegment
|
||||||
| IChangeRequestDeleteSegment
|
| IChangeRequestDeleteSegment
|
||||||
| SetStrategySortOrderSchema;
|
| SetStrategySortOrderSchema
|
||||||
|
| IChangeRequestArchiveFeature;
|
||||||
|
|
||||||
export interface IChangeRequestAddStrategy extends IChangeRequestChangeBase {
|
export interface IChangeRequestAddStrategy extends IChangeRequestChangeBase {
|
||||||
action: 'addStrategy';
|
action: 'addStrategy';
|
||||||
@ -105,6 +106,10 @@ export interface IChangeRequestPatchVariant extends IChangeRequestChangeBase {
|
|||||||
payload: ChangeRequestVariantPatch;
|
payload: ChangeRequestVariantPatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IChangeRequestArchiveFeature extends IChangeRequestChangeBase {
|
||||||
|
action: 'archiveFeature';
|
||||||
|
}
|
||||||
|
|
||||||
export interface IChangeRequestReorderStrategy
|
export interface IChangeRequestReorderStrategy
|
||||||
extends IChangeRequestChangeBase {
|
extends IChangeRequestChangeBase {
|
||||||
action: 'reorderStrategy';
|
action: 'reorderStrategy';
|
||||||
@ -144,7 +149,8 @@ export type IFeatureChange =
|
|||||||
| IChangeRequestUpdateStrategy
|
| IChangeRequestUpdateStrategy
|
||||||
| IChangeRequestEnabled
|
| IChangeRequestEnabled
|
||||||
| IChangeRequestPatchVariant
|
| IChangeRequestPatchVariant
|
||||||
| IChangeRequestReorderStrategy;
|
| IChangeRequestReorderStrategy
|
||||||
|
| IChangeRequestArchiveFeature;
|
||||||
|
|
||||||
export type ISegmentChange =
|
export type ISegmentChange =
|
||||||
| IChangeRequestUpdateSegment
|
| IChangeRequestUpdateSegment
|
||||||
@ -178,4 +184,5 @@ export type ChangeRequestAction =
|
|||||||
| 'patchVariant'
|
| 'patchVariant'
|
||||||
| 'reorderStrategy'
|
| 'reorderStrategy'
|
||||||
| 'updateSegment'
|
| '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 useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
|
||||||
import { Alert, Typography } from '@mui/material';
|
import { Alert, Typography } from '@mui/material';
|
||||||
import { Link } from 'react-router-dom';
|
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 {
|
interface IFeatureArchiveDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -58,6 +62,111 @@ const UsageWarning = ({
|
|||||||
return null;
|
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> = ({
|
export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@ -66,58 +175,36 @@ export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
|
|||||||
featureIds,
|
featureIds,
|
||||||
featuresWithUsage,
|
featuresWithUsage,
|
||||||
}) => {
|
}) => {
|
||||||
const { archiveFeatureToggle } = useFeatureApi();
|
|
||||||
const { archiveFeatures } = useProjectApi();
|
|
||||||
const { setToastData, setToastApiError } = useToast();
|
|
||||||
const { uiConfig } = useUiConfig();
|
const { uiConfig } = useUiConfig();
|
||||||
|
|
||||||
const isBulkArchive = featureIds?.length > 1;
|
const isBulkArchive = featureIds?.length > 1;
|
||||||
|
|
||||||
const archiveToggle = async () => {
|
const buttonText = useActionButtonText(projectId, isBulkArchive);
|
||||||
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 archiveToggles = async () => {
|
const dialogTitle = isBulkArchive
|
||||||
try {
|
? 'Archive feature toggles'
|
||||||
await archiveFeatures(projectId, featureIds);
|
: 'Archive feature toggle';
|
||||||
setToastData({
|
|
||||||
text: 'Selected feature toggles have been archived',
|
const archiveAction = useArchiveAction({
|
||||||
type: 'success',
|
projectId,
|
||||||
title: 'Feature toggles archived',
|
featureIds,
|
||||||
});
|
onSuccess() {
|
||||||
onConfirm();
|
onConfirm();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error: unknown) {
|
},
|
||||||
setToastApiError(formatUnknownError(error));
|
onError() {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialogue
|
<Dialogue
|
||||||
onClick={isBulkArchive ? archiveToggles : archiveToggle}
|
onClick={archiveAction}
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
primaryButtonText={
|
primaryButtonText={buttonText}
|
||||||
isBulkArchive ? 'Archive toggles' : 'Archive toggle'
|
|
||||||
}
|
|
||||||
secondaryButtonText="Cancel"
|
secondaryButtonText="Cancel"
|
||||||
title={
|
title={dialogTitle}
|
||||||
isBulkArchive
|
|
||||||
? 'Archive feature toggles'
|
|
||||||
: 'Archive feature toggle'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={isBulkArchive}
|
condition={isBulkArchive}
|
||||||
@ -155,7 +242,11 @@ export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
|
|||||||
}
|
}
|
||||||
elseShow={
|
elseShow={
|
||||||
<p>
|
<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>
|
</p>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -10,8 +10,9 @@ export interface IChangeSchema {
|
|||||||
| 'deleteStrategy'
|
| 'deleteStrategy'
|
||||||
| 'patchVariant'
|
| 'patchVariant'
|
||||||
| 'reorderStrategy'
|
| 'reorderStrategy'
|
||||||
|
| 'archiveFeature'
|
||||||
| 'updateSegment';
|
| 'updateSegment';
|
||||||
payload: string | boolean | object | number;
|
payload: string | boolean | object | number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IChangeRequestConfig {
|
export interface IChangeRequestConfig {
|
||||||
|
Loading…
Reference in New Issue
Block a user