mirror of
https://github.com/Unleash/unleash.git
synced 2025-08-04 13:48:56 +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:
parent
19bc519e1b
commit
75fb7a0d93
@ -12,6 +12,7 @@ import { useFeaturesArchive } from 'hooks/api/getters/useFeaturesArchive/useFeat
|
|||||||
import useToast from 'hooks/useToast';
|
import useToast from 'hooks/useToast';
|
||||||
import { ArchivedFeatureDeleteConfirm } from './ArchivedFeatureActionCell/ArchivedFeatureDeleteConfirm/ArchivedFeatureDeleteConfirm';
|
import { ArchivedFeatureDeleteConfirm } from './ArchivedFeatureActionCell/ArchivedFeatureDeleteConfirm/ArchivedFeatureDeleteConfirm';
|
||||||
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
||||||
|
import { ArchivedFeatureReviveConfirm } from './ArchivedFeatureActionCell/ArchivedFeatureReviveConfirm/ArchivedFeatureReviveConfirm';
|
||||||
|
|
||||||
interface IArchiveBatchActionsProps {
|
interface IArchiveBatchActionsProps {
|
||||||
selectedIds: string[];
|
selectedIds: string[];
|
||||||
@ -24,30 +25,13 @@ export const ArchiveBatchActions: FC<IArchiveBatchActionsProps> = ({
|
|||||||
projectId,
|
projectId,
|
||||||
onReviveConfirm,
|
onReviveConfirm,
|
||||||
}) => {
|
}) => {
|
||||||
const { reviveFeatures } = useProjectApi();
|
|
||||||
const { setToastData, setToastApiError } = useToast();
|
|
||||||
const { refetchArchived } = useFeaturesArchive(projectId);
|
const { refetchArchived } = useFeaturesArchive(projectId);
|
||||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||||
|
const [reviveModalOpen, setReviveModalOpen] = useState(false);
|
||||||
const { trackEvent } = usePlausibleTracker();
|
const { trackEvent } = usePlausibleTracker();
|
||||||
|
|
||||||
const onRevive = async () => {
|
const onRevive = async () => {
|
||||||
try {
|
setReviveModalOpen(true);
|
||||||
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));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDelete = async () => {
|
const onDelete = async () => {
|
||||||
@ -63,6 +47,7 @@ export const ArchiveBatchActions: FC<IArchiveBatchActionsProps> = ({
|
|||||||
variant='outlined'
|
variant='outlined'
|
||||||
size='small'
|
size='small'
|
||||||
onClick={onRevive}
|
onClick={onRevive}
|
||||||
|
date-testid={'batch_revive'}
|
||||||
>
|
>
|
||||||
Revive
|
Revive
|
||||||
</Button>
|
</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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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',
|
||||||
|
);
|
||||||
|
});
|
@ -37,6 +37,7 @@ import { BatchSelectionActionsBar } from '../../common/BatchSelectionActionsBar/
|
|||||||
import { ArchiveBatchActions } from './ArchiveBatchActions';
|
import { ArchiveBatchActions } from './ArchiveBatchActions';
|
||||||
import { FeatureEnvironmentSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell';
|
import { FeatureEnvironmentSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell';
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import { ArchivedFeatureReviveConfirm } from './ArchivedFeatureActionCell/ArchivedFeatureReviveConfirm/ArchivedFeatureReviveConfirm';
|
||||||
|
|
||||||
export interface IFeaturesArchiveTableProps {
|
export interface IFeaturesArchiveTableProps {
|
||||||
archivedFeatures: FeatureSchema[];
|
archivedFeatures: FeatureSchema[];
|
||||||
@ -68,6 +69,9 @@ export const ArchiveTable = ({
|
|||||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||||
const [deletedFeature, setDeletedFeature] = useState<IFeatureToggle>();
|
const [deletedFeature, setDeletedFeature] = useState<IFeatureToggle>();
|
||||||
|
|
||||||
|
const [reviveModalOpen, setReviveModalOpen] = useState(false);
|
||||||
|
const [revivedFeature, setRevivedFeature] = useState<IFeatureToggle>();
|
||||||
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { reviveFeature } = useFeatureArchiveApi();
|
const { reviveFeature } = useFeatureArchiveApi();
|
||||||
|
|
||||||
@ -80,23 +84,6 @@ export const ArchiveTable = ({
|
|||||||
uiConfig.flags.lastSeenByEnvironment,
|
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(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
...(projectId
|
...(projectId
|
||||||
@ -104,7 +91,10 @@ export const ArchiveTable = ({
|
|||||||
{
|
{
|
||||||
id: 'Select',
|
id: 'Select',
|
||||||
Header: ({ getToggleAllRowsSelectedProps }: any) => (
|
Header: ({ getToggleAllRowsSelectedProps }: any) => (
|
||||||
<Checkbox {...getToggleAllRowsSelectedProps()} />
|
<Checkbox
|
||||||
|
data-testid='select_all_rows'
|
||||||
|
{...getToggleAllRowsSelectedProps()}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
Cell: ({ row }: any) => (
|
Cell: ({ row }: any) => (
|
||||||
<RowSelectCell
|
<RowSelectCell
|
||||||
@ -192,7 +182,10 @@ export const ArchiveTable = ({
|
|||||||
Cell: ({ row: { original: feature } }: any) => (
|
Cell: ({ row: { original: feature } }: any) => (
|
||||||
<ArchivedFeatureActionCell
|
<ArchivedFeatureActionCell
|
||||||
project={feature.project}
|
project={feature.project}
|
||||||
onRevive={() => onRevive(feature.name)}
|
onRevive={() => {
|
||||||
|
setRevivedFeature(feature);
|
||||||
|
setReviveModalOpen(true);
|
||||||
|
}}
|
||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
setDeletedFeature(feature);
|
setDeletedFeature(feature);
|
||||||
setDeleteModalOpen(true);
|
setDeleteModalOpen(true);
|
||||||
@ -351,6 +344,13 @@ export const ArchiveTable = ({
|
|||||||
setOpen={setDeleteModalOpen}
|
setOpen={setDeleteModalOpen}
|
||||||
refetch={refetch}
|
refetch={refetch}
|
||||||
/>
|
/>
|
||||||
|
<ArchivedFeatureReviveConfirm
|
||||||
|
revivedFeatures={[revivedFeature?.name!]}
|
||||||
|
projectId={projectId ?? revivedFeature?.project!}
|
||||||
|
open={reviveModalOpen}
|
||||||
|
setOpen={setReviveModalOpen}
|
||||||
|
refetch={refetch}
|
||||||
|
/>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={Boolean(projectId)}
|
condition={Boolean(projectId)}
|
||||||
|
@ -25,6 +25,7 @@ export const ArchivedFeatureActionCell: VFC<IReviveArchivedFeatureCell> = ({
|
|||||||
projectId={project}
|
projectId={project}
|
||||||
permission={UPDATE_FEATURE}
|
permission={UPDATE_FEATURE}
|
||||||
tooltipProps={{ title: 'Revive feature toggle' }}
|
tooltipProps={{ title: 'Revive feature toggle' }}
|
||||||
|
data-testid={`revive-feature-toggle-button`}
|
||||||
>
|
>
|
||||||
<Undo />
|
<Undo />
|
||||||
</PermissionIconButton>
|
</PermissionIconButton>
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -70,6 +70,7 @@ export type UiFlags = {
|
|||||||
datadogJsonTemplate?: boolean;
|
datadogJsonTemplate?: boolean;
|
||||||
dependentFeatures?: boolean;
|
dependentFeatures?: boolean;
|
||||||
internalMessageBanners?: boolean;
|
internalMessageBanners?: boolean;
|
||||||
|
disableAllEnvsOnRevive?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IVersionInfo {
|
export interface IVersionInfo {
|
||||||
|
Loading…
Reference in New Issue
Block a user