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:
parent
c7a990e5a9
commit
19bc519e1b
@ -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.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
@ -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>
|
||||||
|
@ -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,
|
||||||
|
Loading…
Reference in New Issue
Block a user