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

feat: Verify archive dependent features UI (#5024)

This commit is contained in:
Mateusz Kwasniewski 2023-10-13 14:31:37 +02:00 committed by GitHub
parent c7a990e5a9
commit 19bc519e1b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 199 additions and 8 deletions

View File

@ -25,17 +25,29 @@ const setupHappyPathForChangeRequest = () => {
}, },
], ],
); );
};
const setupArchiveValidation = (orphanParents: string[]) => {
testServerRoute(server, '/api/admin/ui-config', { testServerRoute(server, '/api/admin/ui-config', {
versionInfo: { versionInfo: {
current: { oss: 'version', enterprise: 'version' }, 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 () => { test('Add single archive feature change to change request', async () => {
const onClose = vi.fn(); const onClose = vi.fn();
const onConfirm = vi.fn(); const onConfirm = vi.fn();
setupHappyPathForChangeRequest(); setupHappyPathForChangeRequest();
setupArchiveValidation([]);
render( render(
<FeatureArchiveDialog <FeatureArchiveDialog
featureIds={['featureA']} featureIds={['featureA']}
@ -62,6 +74,7 @@ test('Add multiple archive feature changes to change request', async () => {
const onClose = vi.fn(); const onClose = vi.fn();
const onConfirm = vi.fn(); const onConfirm = vi.fn();
setupHappyPathForChangeRequest(); setupHappyPathForChangeRequest();
setupArchiveValidation([]);
render( render(
<FeatureArchiveDialog <FeatureArchiveDialog
featureIds={['featureA', 'featureB']} featureIds={['featureA', 'featureB']}
@ -88,6 +101,7 @@ test('Skip change request', async () => {
const onClose = vi.fn(); const onClose = vi.fn();
const onConfirm = vi.fn(); const onConfirm = vi.fn();
setupHappyPathForChangeRequest(); setupHappyPathForChangeRequest();
setupArchiveValidation([]);
render( render(
<FeatureArchiveDialog <FeatureArchiveDialog
featureIds={['featureA', 'featureB']} featureIds={['featureA', 'featureB']}
@ -110,3 +124,45 @@ test('Skip change request', async () => {
}); });
expect(onConfirm).toBeCalledTimes(0); // we didn't setup non Change Request flow so failure 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(
<FeatureArchiveDialog
featureIds={['parentA', 'parentB']}
projectId={'projectId'}
isOpen={true}
onClose={onClose}
onConfirm={onConfirm}
featuresWithUsage={[]}
/>,
);
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(
<FeatureArchiveDialog
featureIds={['parent', 'someOtherFeature']}
projectId={'projectId'}
isOpen={true}
onClose={onClose}
onConfirm={onConfirm}
featuresWithUsage={[]}
/>,
);
await screen.findByText('parent');
await screen.findByText(
'has child features that depend on it and are not part of the archive operation.',
);
});

View File

@ -1,4 +1,4 @@
import { VFC } from 'react'; import { useEffect, useState, VFC } from 'react';
import { Dialogue } from 'component/common/Dialogue/Dialogue'; import { Dialogue } from 'component/common/Dialogue/Dialogue';
import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
@ -12,6 +12,7 @@ import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi'; import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests'; import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import { useHighestPermissionChangeRequestEnvironment } from 'hooks/useHighestPermissionChangeRequestEnvironment'; import { useHighestPermissionChangeRequestEnvironment } from 'hooks/useHighestPermissionChangeRequestEnvironment';
import { useUiFlag } from '../../../hooks/useUiFlag';
interface IFeatureArchiveDialogProps { interface IFeatureArchiveDialogProps {
isOpen: boolean; isOpen: boolean;
@ -62,6 +63,59 @@ const UsageWarning = ({
return null; 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 (
<Alert
severity={'error'}
sx={{ m: (theme) => theme.spacing(2, 0) }}
>
<Typography
fontWeight={'bold'}
variant={'body2'}
display='inline'
>
{`${ids.length} feature toggles `}
</Typography>
<span>
have child features that depend on them and are not part of
the archive operation. These parent features can not be
archived:
</span>
<ul>
{ids?.map((id) => (
<li key={id}>
{<Link to={formatPath(id)}>{id}</Link>}
</li>
))}
</ul>
</Alert>
);
}
if (ids && ids.length === 1) {
return (
<Alert
severity={'error'}
sx={{ m: (theme) => theme.spacing(2, 0) }}
>
<Link to={formatPath(ids[0])}>{ids[0]}</Link> has child features
that depend on it and are not part of the archive operation.
</Alert>
);
}
return null;
};
const useActionButtonText = (projectId: string, isBulkArchive: boolean) => { const useActionButtonText = (projectId: string, isBulkArchive: boolean) => {
const getHighestEnvironment = const getHighestEnvironment =
useHighestPermissionChangeRequestEnvironment(projectId); 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<string[]>([]);
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<IFeatureArchiveDialogProps> = ({ export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
isOpen, isOpen,
onClose, onClose,
@ -197,6 +285,14 @@ export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
}, },
}); });
const { disableArchive, offendingParents } = useVerifyArchive(
featureIds,
projectId,
isOpen,
);
const dependentFeatures = useUiFlag('dependentFeatures');
return ( return (
<Dialogue <Dialogue
onClick={archiveAction} onClick={archiveAction}
@ -205,6 +301,7 @@ export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
primaryButtonText={buttonText} primaryButtonText={buttonText}
secondaryButtonText='Cancel' secondaryButtonText='Cancel'
title={dialogTitle} title={dialogTitle}
disabledPrimaryButton={dependentFeatures && disableArchive}
> >
<ConditionallyRender <ConditionallyRender
condition={isBulkArchive} condition={isBulkArchive}
@ -228,6 +325,17 @@ export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
/> />
} }
/> />
<ConditionallyRender
condition={
dependentFeatures && offendingParents.length > 0
}
show={
<ArchiveParentError
ids={offendingParents}
projectId={projectId}
/>
}
/>
<ConditionallyRender <ConditionallyRender
condition={featureIds?.length <= 5} condition={featureIds?.length <= 5}
show={ show={
@ -241,13 +349,26 @@ export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
</> </>
} }
elseShow={ elseShow={
<p> <>
Are you sure you want to archive{' '} <p>
{isBulkArchive Are you sure you want to archive{' '}
? 'these feature toggles' {isBulkArchive
: 'this feature toggle'} ? 'these feature toggles'
? : 'this feature toggle'}
</p> ?
</p>
<ConditionallyRender
condition={
dependentFeatures && offendingParents.length > 0
}
show={
<ArchiveParentError
ids={offendingParents}
projectId={projectId}
/>
}
/>
</>
} }
/> />
</Dialogue> </Dialogue>

View File

@ -169,6 +169,19 @@ const useProjectApi = () => {
return makeRequest(req.caller, req.id); 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 reviveFeatures = async (projectId: string, featureIds: string[]) => {
const path = `api/admin/projects/${projectId}/revive`; const path = `api/admin/projects/${projectId}/revive`;
const req = createRequest(path, { const req = createRequest(path, {
@ -245,6 +258,7 @@ const useProjectApi = () => {
setUserRoles, setUserRoles,
setGroupRoles, setGroupRoles,
archiveFeatures, archiveFeatures,
verifyArchiveFeatures,
reviveFeatures, reviveFeatures,
staleFeatures, staleFeatures,
deleteFeature, deleteFeature,