Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Verify archive dependent features UI #5024

Merged
merged 10 commits into from
Oct 13, 2023
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<FeatureArchiveDialog
featureIds={['featureA']}
Expand All @@ -62,6 +74,7 @@ test('Add multiple archive feature changes to change request', async () => {
const onClose = vi.fn();
const onConfirm = vi.fn();
setupHappyPathForChangeRequest();
setupArchiveValidation([]);
render(
<FeatureArchiveDialog
featureIds={['featureA', 'featureB']}
Expand All @@ -88,6 +101,7 @@ test('Skip change request', async () => {
const onClose = vi.fn();
const onConfirm = vi.fn();
setupHappyPathForChangeRequest();
setupArchiveValidation([]);
render(
<FeatureArchiveDialog
featureIds={['featureA', 'featureB']}
Expand All @@ -103,10 +117,56 @@ test('Skip change request', async () => {
await screen.findByText('Archive feature toggles');
const button = await screen.findByText('Archive toggles');

await waitFor(() => {
expect(button).toBeEnabled();
});

button.click();

await waitFor(() => {
expect(onClose).toBeCalledTimes(1);
});
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.',
);
});
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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 (
<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 getHighestEnvironment =
useHighestPermissionChangeRequestEnvironment(projectId);
Expand Down Expand Up @@ -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> = ({
isOpen,
onClose,
Expand Down Expand Up @@ -197,6 +285,14 @@ export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
},
});

const { disableArchive, offendingParents } = useVerifyArchive(
featureIds,
projectId,
isOpen,
);

const dependentFeatures = useUiFlag('dependentFeatures');

return (
<Dialogue
onClick={archiveAction}
Expand All @@ -205,6 +301,7 @@ export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
primaryButtonText={buttonText}
secondaryButtonText='Cancel'
title={dialogTitle}
disabledPrimaryButton={dependentFeatures && disableArchive}
>
<ConditionallyRender
condition={isBulkArchive}
Expand All @@ -228,6 +325,17 @@ export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
/>
}
/>
<ConditionallyRender
condition={
dependentFeatures && offendingParents.length > 0
}
show={
<ArchiveParentError
ids={offendingParents}
projectId={projectId}
/>
}
/>
<ConditionallyRender
condition={featureIds?.length <= 5}
show={
Expand All @@ -241,13 +349,26 @@ export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
</>
}
elseShow={
<p>
Are you sure you want to archive{' '}
{isBulkArchive
? 'these feature toggles'
: 'this feature toggle'}
?
</p>
<>
<p>
Are you sure you want to archive{' '}
{isBulkArchive
? 'these feature toggles'
: 'this feature toggle'}
?
</p>
<ConditionallyRender
condition={
dependentFeatures && offendingParents.length > 0
}
show={
<ArchiveParentError
ids={offendingParents}
projectId={projectId}
/>
}
/>
</>
}
/>
</Dialogue>
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -245,6 +258,7 @@ const useProjectApi = () => {
setUserRoles,
setGroupRoles,
archiveFeatures,
verifyArchiveFeatures,
reviveFeatures,
staleFeatures,
deleteFeature,
Expand Down
Loading