1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

feat: add a dialog when reviving / batch reviving features (#4988)

Adds a confirmation dialog when reviving features

Closes #
[SR-91](https://linear.app/unleash/issue/SR-91/reviving-a-feature-toggle-should-have-a-confirmation-dialog)




https://github.com/Unleash/unleash/assets/104830839/49e71590-fd66-4eb5-bd09-5eb322e3d1c9

---------

Signed-off-by: andreas-unleash <andreas@getunleash.ai>
This commit is contained in:
andreas-unleash 2023-10-13 16:28:36 +03:00 committed by GitHub
parent 19bc519e1b
commit 75fb7a0d93
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 303 additions and 38 deletions

View File

@ -12,6 +12,7 @@ import { useFeaturesArchive } from 'hooks/api/getters/useFeaturesArchive/useFeat
import useToast from 'hooks/useToast';
import { ArchivedFeatureDeleteConfirm } from './ArchivedFeatureActionCell/ArchivedFeatureDeleteConfirm/ArchivedFeatureDeleteConfirm';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { ArchivedFeatureReviveConfirm } from './ArchivedFeatureActionCell/ArchivedFeatureReviveConfirm/ArchivedFeatureReviveConfirm';
interface IArchiveBatchActionsProps {
selectedIds: string[];
@ -24,30 +25,13 @@ export const ArchiveBatchActions: FC<IArchiveBatchActionsProps> = ({
projectId,
onReviveConfirm,
}) => {
const { reviveFeatures } = useProjectApi();
const { setToastData, setToastApiError } = useToast();
const { refetchArchived } = useFeaturesArchive(projectId);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [reviveModalOpen, setReviveModalOpen] = useState(false);
const { trackEvent } = usePlausibleTracker();
const onRevive = async () => {
try {
await reviveFeatures(projectId, selectedIds);
onReviveConfirm?.();
await refetchArchived();
setToastData({
type: 'success',
title: "And we're back!",
text: 'The feature toggles have been revived.',
});
trackEvent('batch_operations', {
props: {
eventType: 'features revived',
},
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
setReviveModalOpen(true);
};
const onDelete = async () => {
@ -63,6 +47,7 @@ export const ArchiveBatchActions: FC<IArchiveBatchActionsProps> = ({
variant='outlined'
size='small'
onClick={onRevive}
date-testid={'batch_revive'}
>
Revive
</Button>
@ -95,6 +80,21 @@ export const ArchiveBatchActions: FC<IArchiveBatchActionsProps> = ({
});
}}
/>
<ArchivedFeatureReviveConfirm
revivedFeatures={selectedIds}
projectId={projectId}
open={reviveModalOpen}
setOpen={setReviveModalOpen}
refetch={() => {
refetchArchived();
onReviveConfirm?.();
trackEvent('batch_operations', {
props: {
eventType: 'features revived',
},
});
}}
/>
</>
);
};

View File

@ -0,0 +1,157 @@
import { ArchiveTable } from './ArchiveTable';
import { render } from 'utils/testRenderer';
import { useState } from 'react';
import { screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
DELETE_FEATURE,
UPDATE_FEATURE,
} from 'component/providers/AccessProvider/permissions';
import ToastRenderer from '../../common/ToastRenderer/ToastRenderer';
import { testServerRoute, testServerSetup } from '../../../utils/testServer';
const mockedFeatures = [
{
name: 'someFeature',
description: '',
type: 'release',
project: 'default',
stale: false,
createdAt: '2023-08-10T09:28:58.928Z',
lastSeenAt: null,
impressionData: false,
archivedAt: '2023-08-11T10:18:03.429Z',
archived: true,
},
{
name: 'someOtherFeature',
description: '',
type: 'release',
project: 'default',
stale: false,
createdAt: '2023-08-10T09:28:58.928Z',
lastSeenAt: null,
impressionData: false,
archivedAt: '2023-08-11T10:18:03.429Z',
archived: true,
},
];
const Component = () => {
const [storedParams, setStoredParams] = useState({});
return (
<ArchiveTable
title='Archived features'
archivedFeatures={mockedFeatures}
refetch={() => Promise.resolve({})}
loading={false}
setStoredParams={setStoredParams as any}
storedParams={storedParams as any}
projectId='default'
/>
);
};
const server = testServerSetup();
const setupApi = (disableAllEnvsOnRevive = false) => {
testServerRoute(
server,
'/api/admin/projects/default/revive',
{},
'post',
200,
);
testServerRoute(server, '/api/admin/ui-config', {
environment: 'Open Source',
flags: {
disableAllEnvsOnRevive,
},
});
};
test('should load the table', async () => {
render(<Component />, { permissions: [{ permission: UPDATE_FEATURE }] });
expect(screen.getByRole('table')).toBeInTheDocument();
await screen.findByText('someFeature');
});
test('should show confirm dialog when reviving toggle', async () => {
setupApi(false);
render(
<>
<ToastRenderer />
<Component />
</>,
{ permissions: [{ permission: UPDATE_FEATURE }] },
);
await screen.findByText('someFeature');
const reviveButton = screen.getAllByTestId(
'revive-feature-toggle-button',
)?.[0];
fireEvent.click(reviveButton);
await screen.findByText('Revive feature toggle?');
const reviveTogglesButton = screen.getByRole('button', {
name: /Revive feature toggle/i,
});
fireEvent.click(reviveTogglesButton);
await screen.findByText("And we're back!");
});
test('should show confirm dialog when batch reviving toggle', async () => {
setupApi(false);
render(
<>
<ToastRenderer />
<Component />
</>,
{
permissions: [
{ permission: UPDATE_FEATURE, project: 'default' },
{ permission: DELETE_FEATURE, project: 'default' },
],
},
);
await screen.findByText('someFeature');
const selectAll = await screen.findByTestId('select_all_rows');
fireEvent.click(selectAll.firstChild!);
const batchReviveButton = await screen.findByText(/Revive/i);
await userEvent.click(batchReviveButton!);
await screen.findByText('Revive feature toggles?');
const reviveTogglesButton = screen.getByRole('button', {
name: /Revive feature toggles/i,
});
fireEvent.click(reviveTogglesButton);
await screen.findByText("And we're back!");
});
test('should show info box when disableAllEnvsOnRevive flag is on', async () => {
setupApi(true);
render(
<>
<ToastRenderer />
<Component />
</>,
{ permissions: [{ permission: UPDATE_FEATURE }] },
);
await screen.findByText('someFeature');
const reviveButton = screen.getAllByTestId(
'revive-feature-toggle-button',
)?.[0];
fireEvent.click(reviveButton);
await screen.findByText('Revive feature toggle?');
await screen.findByText(
'Revived feature toggles will be automatically disabled in all environments',
);
});

View File

@ -37,6 +37,7 @@ import { BatchSelectionActionsBar } from '../../common/BatchSelectionActionsBar/
import { ArchiveBatchActions } from './ArchiveBatchActions';
import { FeatureEnvironmentSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { ArchivedFeatureReviveConfirm } from './ArchivedFeatureActionCell/ArchivedFeatureReviveConfirm/ArchivedFeatureReviveConfirm';
export interface IFeaturesArchiveTableProps {
archivedFeatures: FeatureSchema[];
@ -68,6 +69,9 @@ export const ArchiveTable = ({
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deletedFeature, setDeletedFeature] = useState<IFeatureToggle>();
const [reviveModalOpen, setReviveModalOpen] = useState(false);
const [revivedFeature, setRevivedFeature] = useState<IFeatureToggle>();
const [searchParams, setSearchParams] = useSearchParams();
const { reviveFeature } = useFeatureArchiveApi();
@ -80,23 +84,6 @@ export const ArchiveTable = ({
uiConfig.flags.lastSeenByEnvironment,
);
const onRevive = useCallback(
async (feature: string) => {
try {
await reviveFeature(feature);
await refetch();
setToastData({
type: 'success',
title: "And we're back!",
text: 'The feature toggle has been revived.',
});
} catch (e: unknown) {
setToastApiError(formatUnknownError(e));
}
},
[refetch, reviveFeature, setToastApiError, setToastData],
);
const columns = useMemo(
() => [
...(projectId
@ -104,7 +91,10 @@ export const ArchiveTable = ({
{
id: 'Select',
Header: ({ getToggleAllRowsSelectedProps }: any) => (
<Checkbox {...getToggleAllRowsSelectedProps()} />
<Checkbox
data-testid='select_all_rows'
{...getToggleAllRowsSelectedProps()}
/>
),
Cell: ({ row }: any) => (
<RowSelectCell
@ -192,7 +182,10 @@ export const ArchiveTable = ({
Cell: ({ row: { original: feature } }: any) => (
<ArchivedFeatureActionCell
project={feature.project}
onRevive={() => onRevive(feature.name)}
onRevive={() => {
setRevivedFeature(feature);
setReviveModalOpen(true);
}}
onDelete={() => {
setDeletedFeature(feature);
setDeleteModalOpen(true);
@ -351,6 +344,13 @@ export const ArchiveTable = ({
setOpen={setDeleteModalOpen}
refetch={refetch}
/>
<ArchivedFeatureReviveConfirm
revivedFeatures={[revivedFeature?.name!]}
projectId={projectId ?? revivedFeature?.project!}
open={reviveModalOpen}
setOpen={setReviveModalOpen}
refetch={refetch}
/>
</PageContent>
<ConditionallyRender
condition={Boolean(projectId)}

View File

@ -25,6 +25,7 @@ export const ArchivedFeatureActionCell: VFC<IReviveArchivedFeatureCell> = ({
projectId={project}
permission={UPDATE_FEATURE}
tooltipProps={{ title: 'Revive feature toggle' }}
data-testid={`revive-feature-toggle-button`}
>
<Undo />
</PermissionIconButton>

View File

@ -0,0 +1,106 @@
import { Alert, styled } from '@mui/material';
import React from 'react';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { formatUnknownError } from 'utils/formatUnknownError';
import useToast from 'hooks/useToast';
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useUiFlag } from '../../../../../hooks/useUiFlag';
interface IArchivedFeatureReviveConfirmProps {
revivedFeatures: string[];
projectId: string;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
refetch: () => void;
}
const StyledParagraph = styled('p')(({ theme }) => ({
marginTop: theme.spacing(2),
}));
export const ArchivedFeatureReviveConfirm = ({
revivedFeatures,
projectId,
open,
setOpen,
refetch,
}: IArchivedFeatureReviveConfirmProps) => {
const { setToastData, setToastApiError } = useToast();
const { reviveFeatures } = useProjectApi();
const disableAllEnvsOnRevive = useUiFlag('disableAllEnvsOnRevive');
const onReviveFeatureToggle = async () => {
try {
if (revivedFeatures.length === 0) {
return;
}
await reviveFeatures(projectId, revivedFeatures);
await refetch();
setToastData({
type: 'success',
title: "And we're back!",
text: 'The feature toggles have been revived.',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
} finally {
clearModal();
}
};
const clearModal = () => {
setOpen(false);
};
const title = `Revive feature toggle${
revivedFeatures.length > 1 ? 's' : ''
}?`;
const primaryBtnText = `Revive feature toggle${
revivedFeatures.length > 1 ? 's' : ''
}`;
return (
<Dialogue
title={title}
open={open}
primaryButtonText={primaryBtnText}
secondaryButtonText='Cancel'
onClick={onReviveFeatureToggle}
onClose={clearModal}
>
<ConditionallyRender
condition={Boolean(disableAllEnvsOnRevive)}
show={
<Alert severity='info'>
Revived feature toggles will be automatically disabled
in all environments
</Alert>
}
/>
<ConditionallyRender
condition={revivedFeatures.length > 1}
show={
<>
<StyledParagraph>
You are about to revive feature toggles:
</StyledParagraph>
<ul>
{revivedFeatures.map((name) => (
<li key={`revive-${name}`}>{name}</li>
))}
</ul>
</>
}
elseShow={
<StyledParagraph>
You are about to revive feature toggle:{' '}
{revivedFeatures[0]}
</StyledParagraph>
}
/>
</Dialogue>
);
};

View File

@ -70,6 +70,7 @@ export type UiFlags = {
datadogJsonTemplate?: boolean;
dependentFeatures?: boolean;
internalMessageBanners?: boolean;
disableAllEnvsOnRevive?: boolean;
};
export interface IVersionInfo {