diff --git a/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.test.tsx b/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.test.tsx index 8ea6f2e0127f..8b5bac0d8a77 100644 --- a/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.test.tsx +++ b/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.test.tsx @@ -25,17 +25,29 @@ const setupHappyPathForChangeRequest = () => { }, ], ); +}; +const setupArchiveValidation = (orphanParents: string[]) => { testServerRoute(server, '/api/admin/ui-config', { versionInfo: { current: { oss: 'version', enterprise: 'version' }, }, + flags: { + dependentFeatures: true, + }, }); + testServerRoute( + server, + '/api/admin/projects/projectId/archive/validate', + orphanParents, + 'post', + ); }; test('Add single archive feature change to change request', async () => { const onClose = vi.fn(); const onConfirm = vi.fn(); setupHappyPathForChangeRequest(); + setupArchiveValidation([]); render( { const onClose = vi.fn(); const onConfirm = vi.fn(); setupHappyPathForChangeRequest(); + setupArchiveValidation([]); render( { const onClose = vi.fn(); const onConfirm = vi.fn(); setupHappyPathForChangeRequest(); + setupArchiveValidation([]); render( { }); expect(onConfirm).toBeCalledTimes(0); // we didn't setup non Change Request flow so failure }); + +test('Show error message when multiple parents of orphaned children are archived', async () => { + const onClose = vi.fn(); + const onConfirm = vi.fn(); + setupArchiveValidation(['parentA', 'parentB']); + render( + , + ); + + await screen.findByText('2 feature toggles'); + await screen.findByText( + 'have child features that depend on them and are not part of the archive operation. These parent features can not be archived:', + ); +}); + +test('Show error message when 1 parent of orphaned children is archived', async () => { + const onClose = vi.fn(); + const onConfirm = vi.fn(); + setupArchiveValidation(['parent']); + render( + , + ); + + await screen.findByText('parent'); + await screen.findByText( + 'has child features that depend on it and are not part of the archive operation.', + ); +}); diff --git a/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.tsx b/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.tsx index 05e307873852..6ac41ca70ffb 100644 --- a/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.tsx +++ b/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.tsx @@ -1,4 +1,4 @@ -import { VFC } from 'react'; +import { useEffect, useState, VFC } from 'react'; import { Dialogue } from 'component/common/Dialogue/Dialogue'; import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; import useToast from 'hooks/useToast'; @@ -12,6 +12,7 @@ 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'; +import { useUiFlag } from '../../../hooks/useUiFlag'; interface IFeatureArchiveDialogProps { isOpen: boolean; @@ -62,6 +63,59 @@ const UsageWarning = ({ return null; }; +const ArchiveParentError = ({ + ids, + projectId, +}: { + ids?: string[]; + projectId: string; +}) => { + const formatPath = (id: string) => { + return `/projects/${projectId}/features/${id}`; + }; + + if (ids && ids.length > 1) { + return ( + theme.spacing(2, 0) }} + > + + {`${ids.length} feature toggles `} + + + have child features that depend on them and are not part of + the archive operation. These parent features can not be + archived: + +
    + {ids?.map((id) => ( +
  • + {{id}} +
  • + ))} +
+
+ ); + } + if (ids && ids.length === 1) { + return ( + theme.spacing(2, 0) }} + > + {ids[0]} has child features + that depend on it and are not part of the archive operation. + + ); + } + return null; +}; + const useActionButtonText = (projectId: string, isBulkArchive: boolean) => { const getHighestEnvironment = useHighestPermissionChangeRequestEnvironment(projectId); @@ -167,6 +221,40 @@ const useArchiveAction = ({ }; }; +const useVerifyArchive = ( + featureIds: string[], + projectId: string, + isOpen: boolean, +) => { + const [disableArchive, setDisableArchive] = useState(true); + const [offendingParents, setOffendingParents] = useState([]); + const { verifyArchiveFeatures } = useProjectApi(); + + useEffect(() => { + if (isOpen) { + verifyArchiveFeatures(projectId, featureIds) + .then((res) => res.json()) + .then((offendingParents) => { + if (offendingParents.length === 0) { + setDisableArchive(false); + setOffendingParents(offendingParents); + } else { + setDisableArchive(true); + setOffendingParents(offendingParents); + } + }); + } + }, [ + JSON.stringify(featureIds), + isOpen, + projectId, + setOffendingParents, + setDisableArchive, + ]); + + return { disableArchive, offendingParents }; +}; + export const FeatureArchiveDialog: VFC = ({ isOpen, onClose, @@ -197,6 +285,14 @@ export const FeatureArchiveDialog: VFC = ({ }, }); + const { disableArchive, offendingParents } = useVerifyArchive( + featureIds, + projectId, + isOpen, + ); + + const dependentFeatures = useUiFlag('dependentFeatures'); + return ( = ({ primaryButtonText={buttonText} secondaryButtonText='Cancel' title={dialogTitle} + disabledPrimaryButton={dependentFeatures && disableArchive} > = ({ /> } /> + 0 + } + show={ + + } + /> = ({ } elseShow={ -

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

+ <> +

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

+ 0 + } + show={ + + } + /> + } />
diff --git a/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts b/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts index 6fc767f064d4..396769de4690 100644 --- a/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts +++ b/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts @@ -169,6 +169,19 @@ const useProjectApi = () => { return makeRequest(req.caller, req.id); }; + const verifyArchiveFeatures = async ( + projectId: string, + featureIds: string[], + ) => { + const path = `api/admin/projects/${projectId}/archive/validate`; + const req = createRequest(path, { + method: 'POST', + body: JSON.stringify({ features: featureIds }), + }); + + return makeRequest(req.caller, req.id); + }; + const reviveFeatures = async (projectId: string, featureIds: string[]) => { const path = `api/admin/projects/${projectId}/revive`; const req = createRequest(path, { @@ -245,6 +258,7 @@ const useProjectApi = () => { setUserRoles, setGroupRoles, archiveFeatures, + verifyArchiveFeatures, reviveFeatures, staleFeatures, deleteFeature,