diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ArchiveFeatureChange.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ArchiveFeatureChange.tsx new file mode 100644 index 0000000000..407ade27e7 --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ArchiveFeatureChange.tsx @@ -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 = ({ + actions, +}) => ( + + Archiving feature + {actions} + +); diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx index 0c27530548..3239cb5306 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx @@ -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' && ( + + )} {change.action === 'addStrategy' || change.action === 'deleteStrategy' || diff --git a/frontend/src/component/changeRequest/changeRequest.types.ts b/frontend/src/component/changeRequest/changeRequest.types.ts index de6cc0d293..cabd151c8d 100644 --- a/frontend/src/component/changeRequest/changeRequest.types.ts +++ b/frontend/src/component/changeRequest/changeRequest.types.ts @@ -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'; diff --git a/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.test.tsx b/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.test.tsx new file mode 100644 index 0000000000..0f9b7f7b2e --- /dev/null +++ b/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.test.tsx @@ -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( + + + + ); + + 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( + + + + ); + + 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( + + + , + { 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 +}); diff --git a/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.tsx b/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.tsx index 7cb2a0c088..4a01c5ce0b 100644 --- a/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.tsx +++ b/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.tsx @@ -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 = ({ isOpen, onClose, @@ -66,58 +175,36 @@ export const FeatureArchiveDialog: VFC = ({ 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 ( = ({ } elseShow={

- Are you sure you want to archive these feature toggles? + Are you sure you want to archive{' '} + {isBulkArchive + ? 'these feature toggles' + : 'this feature toggle'} + ?

} /> diff --git a/frontend/src/hooks/api/actions/useChangeRequestApi/useChangeRequestApi.ts b/frontend/src/hooks/api/actions/useChangeRequestApi/useChangeRequestApi.ts index 9ca5422d6c..ead4c71154 100644 --- a/frontend/src/hooks/api/actions/useChangeRequestApi/useChangeRequestApi.ts +++ b/frontend/src/hooks/api/actions/useChangeRequestApi/useChangeRequestApi.ts @@ -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 {