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', {
 | 
					    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