From 75fb7a0d931f962210bc7b6b7734123b99ed2436 Mon Sep 17 00:00:00 2001 From: andreas-unleash Date: Fri, 13 Oct 2023 16:28:36 +0300 Subject: [PATCH] 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 --- .../ArchiveTable/ArchiveBatchActions.tsx | 38 ++--- .../ArchiveTable/ArchiveTable.test.tsx | 157 ++++++++++++++++++ .../archive/ArchiveTable/ArchiveTable.tsx | 38 ++--- .../ArchivedFeatureActionCell.tsx | 1 + .../ArchivedFeatureReviveConfirm.tsx | 106 ++++++++++++ frontend/src/interfaces/uiConfig.ts | 1 + 6 files changed, 303 insertions(+), 38 deletions(-) create mode 100644 frontend/src/component/archive/ArchiveTable/ArchiveTable.test.tsx create mode 100644 frontend/src/component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureReviveConfirm/ArchivedFeatureReviveConfirm.tsx diff --git a/frontend/src/component/archive/ArchiveTable/ArchiveBatchActions.tsx b/frontend/src/component/archive/ArchiveTable/ArchiveBatchActions.tsx index 86f3a68bc2bc..1352306c4d1e 100644 --- a/frontend/src/component/archive/ArchiveTable/ArchiveBatchActions.tsx +++ b/frontend/src/component/archive/ArchiveTable/ArchiveBatchActions.tsx @@ -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 = ({ 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 = ({ variant='outlined' size='small' onClick={onRevive} + date-testid={'batch_revive'} > Revive @@ -95,6 +80,21 @@ export const ArchiveBatchActions: FC = ({ }); }} /> + { + refetchArchived(); + onReviveConfirm?.(); + trackEvent('batch_operations', { + props: { + eventType: 'features revived', + }, + }); + }} + /> ); }; diff --git a/frontend/src/component/archive/ArchiveTable/ArchiveTable.test.tsx b/frontend/src/component/archive/ArchiveTable/ArchiveTable.test.tsx new file mode 100644 index 000000000000..54c690aab28d --- /dev/null +++ b/frontend/src/component/archive/ArchiveTable/ArchiveTable.test.tsx @@ -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 ( + 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(, { 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( + <> + + + , + { 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( + <> + + + , + { + 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( + <> + + + , + { 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', + ); +}); diff --git a/frontend/src/component/archive/ArchiveTable/ArchiveTable.tsx b/frontend/src/component/archive/ArchiveTable/ArchiveTable.tsx index 85d9b4563f08..f3a87fa15836 100644 --- a/frontend/src/component/archive/ArchiveTable/ArchiveTable.tsx +++ b/frontend/src/component/archive/ArchiveTable/ArchiveTable.tsx @@ -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(); + const [reviveModalOpen, setReviveModalOpen] = useState(false); + const [revivedFeature, setRevivedFeature] = useState(); + 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) => ( - + ), Cell: ({ row }: any) => ( ( onRevive(feature.name)} + onRevive={() => { + setRevivedFeature(feature); + setReviveModalOpen(true); + }} onDelete={() => { setDeletedFeature(feature); setDeleteModalOpen(true); @@ -351,6 +344,13 @@ export const ArchiveTable = ({ setOpen={setDeleteModalOpen} refetch={refetch} /> + = ({ projectId={project} permission={UPDATE_FEATURE} tooltipProps={{ title: 'Revive feature toggle' }} + data-testid={`revive-feature-toggle-button`} > diff --git a/frontend/src/component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureReviveConfirm/ArchivedFeatureReviveConfirm.tsx b/frontend/src/component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureReviveConfirm/ArchivedFeatureReviveConfirm.tsx new file mode 100644 index 000000000000..49fa5d48f77f --- /dev/null +++ b/frontend/src/component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureReviveConfirm/ArchivedFeatureReviveConfirm.tsx @@ -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>; + 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 ( + + + Revived feature toggles will be automatically disabled + in all environments + + } + /> + + 1} + show={ + <> + + You are about to revive feature toggles: + +
    + {revivedFeatures.map((name) => ( +
  • {name}
  • + ))} +
+ + } + elseShow={ + + You are about to revive feature toggle:{' '} + {revivedFeatures[0]} + + } + /> +
+ ); +}; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index e70a0ce21748..b008fa4cf8f7 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -70,6 +70,7 @@ export type UiFlags = { datadogJsonTemplate?: boolean; dependentFeatures?: boolean; internalMessageBanners?: boolean; + disableAllEnvsOnRevive?: boolean; }; export interface IVersionInfo {