mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +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', {
 | 
			
		||||
        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']}
 | 
			
		||||
@ -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']}
 | 
			
		||||
@ -88,6 +101,7 @@ test('Skip change request', async () => {
 | 
			
		||||
    const onClose = vi.fn();
 | 
			
		||||
    const onConfirm = vi.fn();
 | 
			
		||||
    setupHappyPathForChangeRequest();
 | 
			
		||||
    setupArchiveValidation([]);
 | 
			
		||||
    render(
 | 
			
		||||
        <FeatureArchiveDialog
 | 
			
		||||
            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
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
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 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 (
 | 
			
		||||
            <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);
 | 
			
		||||
@ -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,
 | 
			
		||||
@ -197,6 +285,14 @@ export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
 | 
			
		||||
        },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const { disableArchive, offendingParents } = useVerifyArchive(
 | 
			
		||||
        featureIds,
 | 
			
		||||
        projectId,
 | 
			
		||||
        isOpen,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const dependentFeatures = useUiFlag('dependentFeatures');
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <Dialogue
 | 
			
		||||
            onClick={archiveAction}
 | 
			
		||||
@ -205,6 +301,7 @@ export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
 | 
			
		||||
            primaryButtonText={buttonText}
 | 
			
		||||
            secondaryButtonText='Cancel'
 | 
			
		||||
            title={dialogTitle}
 | 
			
		||||
            disabledPrimaryButton={dependentFeatures && disableArchive}
 | 
			
		||||
        >
 | 
			
		||||
            <ConditionallyRender
 | 
			
		||||
                condition={isBulkArchive}
 | 
			
		||||
@ -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={
 | 
			
		||||
@ -241,6 +349,7 @@ export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
 | 
			
		||||
                    </>
 | 
			
		||||
                }
 | 
			
		||||
                elseShow={
 | 
			
		||||
                    <>
 | 
			
		||||
                        <p>
 | 
			
		||||
                            Are you sure you want to archive{' '}
 | 
			
		||||
                            {isBulkArchive
 | 
			
		||||
@ -248,6 +357,18 @@ export const FeatureArchiveDialog: VFC<IFeatureArchiveDialogProps> = ({
 | 
			
		||||
                                : 'this feature toggle'}
 | 
			
		||||
                            ?
 | 
			
		||||
                        </p>
 | 
			
		||||
                        <ConditionallyRender
 | 
			
		||||
                            condition={
 | 
			
		||||
                                dependentFeatures && offendingParents.length > 0
 | 
			
		||||
                            }
 | 
			
		||||
                            show={
 | 
			
		||||
                                <ArchiveParentError
 | 
			
		||||
                                    ids={offendingParents}
 | 
			
		||||
                                    projectId={projectId}
 | 
			
		||||
                                />
 | 
			
		||||
                            }
 | 
			
		||||
                        />
 | 
			
		||||
                    </>
 | 
			
		||||
                }
 | 
			
		||||
            />
 | 
			
		||||
        </Dialogue>
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user