Skip to content

Commit

Permalink
[Uptime] monitor management - adjust deletion logic (elastic#146908)
Browse files Browse the repository at this point in the history
## Summary

Resolves elastic#146932

Adjusts monitor delete logic for Uptime to ensure that multiple monitors
are able to be deleted in a row.

### Testing
1. Create at least two monitors
2. Navigate to Uptime monitor management. Delete a monitor. Ensure the
success toast appears and the monitor is removed from the monitor list
3. Delete a second monitor. Ensure the success toast appears and the
monitor is removed from the list.

Co-authored-by: shahzad31 <[email protected]>
  • Loading branch information
dominiqueclarke and shahzad31 authored Dec 5, 2022
1 parent b12859f commit 7e9f57c
Show file tree
Hide file tree
Showing 18 changed files with 230 additions and 142 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export const Actions = ({
</EuiFlexItem>
<EuiFlexItem grow={false}>
<DeleteMonitor
key={configId}
onUpdate={onUpdate}
name={name}
configId={configId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,9 @@
*/

import React from 'react';
import { screen, fireEvent } from '@testing-library/react';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { render } from '../../../lib/helper/rtl_helpers';
import * as fetchers from '../../../state/api/monitor_management';
import { FETCH_STATUS, useFetcher as originalUseFetcher } from '@kbn/observability-plugin/public';
import { spyOnUseFetcher } from '../../../lib/helper/spy_use_fetcher';
import { Actions } from './actions';
import { DeleteMonitor } from './delete_monitor';
import {
Expand All @@ -19,30 +17,46 @@ import {
MonitorManagementListResult,
SourceType,
} from '../../../../../common/runtime_types';
import userEvent from '@testing-library/user-event';

import { createRealStore } from '../../../lib/helper/helper_with_redux';

describe('<DeleteMonitor />', () => {
const onUpdate = jest.fn();
const useFetcher = spyOnUseFetcher({});

it('calls delete monitor on monitor deletion', () => {
useFetcher.mockImplementation(originalUseFetcher);
it('calls delete monitor on monitor deletion', async () => {
const deleteMonitor = jest.spyOn(fetchers, 'deleteMonitor');
const id = 'test-id';
render(<DeleteMonitor configId={id} name="sample name" onUpdate={onUpdate} />);
const store = createRealStore();
render(<DeleteMonitor configId={id} name="sample name" onUpdate={onUpdate} />, {
store,
});

const dispatchSpy = jest.spyOn(store, 'dispatch');

expect(deleteMonitor).not.toBeCalled();

fireEvent.click(screen.getByRole('button'));

fireEvent.click(screen.getByTestId('confirmModalConfirmButton'));

expect(deleteMonitor).toBeCalledWith({ id });
expect(dispatchSpy).toHaveBeenCalledWith({
payload: {
id: 'test-id',
name: 'sample name',
},
type: 'DELETE_MONITOR',
});

expect(store.getState().deleteMonitor.loading.includes(id)).toEqual(true);

expect(await screen.findByLabelText('Loading')).toBeTruthy();
});

it('calls set refresh when deletion is successful', () => {
it('calls set refresh when deletion is successful', async () => {
const id = 'test-id';
const name = 'sample monitor';
const store = createRealStore();

render(
<Actions
configId={id}
Expand All @@ -59,40 +73,18 @@ describe('<DeleteMonitor />', () => {
},
] as unknown as MonitorManagementListResult['monitors']
}
/>
/>,
{ store }
);

userEvent.click(screen.getByTestId('monitorManagementDeleteMonitor'));
fireEvent.click(screen.getByRole('button'));

expect(onUpdate).toHaveBeenCalled();
});
fireEvent.click(screen.getByTestId('confirmModalConfirmButton'));

it('shows loading spinner while waiting for monitor to delete', () => {
const id = 'test-id';
useFetcher.mockReturnValue({
data: {},
status: FETCH_STATUS.LOADING,
refetch: () => {},
await waitFor(() => {
expect(onUpdate).toHaveBeenCalled();
});
render(
<Actions
configId={id}
name="sample name"
onUpdate={onUpdate}
monitors={
[
{
id,
attributes: {
[ConfigKey.MONITOR_SOURCE_TYPE]: SourceType.PROJECT,
[ConfigKey.CONFIG_ID]: id,
} as BrowserFields,
},
] as unknown as MonitorManagementListResult['monitors']
}
/>
);

expect(screen.getByLabelText('Deleting monitor...')).toBeInTheDocument();
expect(store.getState().deleteMonitor.deletedMonitorIds.includes(id)).toEqual(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,19 @@

import { i18n } from '@kbn/i18n';
import React, { useEffect, useState } from 'react';
import {
EuiButtonIcon,
EuiCallOut,
EuiConfirmModal,
EuiLoadingSpinner,
EuiSpacer,
} from '@elastic/eui';

import { FETCH_STATUS, useFetcher } from '@kbn/observability-plugin/public';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { EuiButtonIcon, EuiCallOut, EuiConfirmModal, EuiSpacer } from '@elastic/eui';

import { useDispatch, useSelector } from 'react-redux';
import { deleteMonitorAction } from '../../../state/actions/delete_monitor';
import { AppState } from '../../../state';
import {
ProjectMonitorDisclaimer,
PROJECT_MONITOR_TITLE,
} from '../../../../apps/synthetics/components/monitors_page/management/monitor_list_table/delete_monitor';
import { deleteMonitor } from '../../../state/api';
import { kibanaService } from '../../../state/kibana_service';
import {
deleteMonitorLoadingSelector,
deleteMonitorSuccessSelector,
} from '../../../state/selectors';

export const DeleteMonitor = ({
configId,
Expand All @@ -37,61 +34,34 @@ export const DeleteMonitor = ({
isProjectMonitor?: boolean;
onUpdate: () => void;
}) => {
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);

const isDeleting = useSelector((state: AppState) =>
deleteMonitorLoadingSelector(state, configId)
);

const isSuccessfullyDeleted = useSelector((state: AppState) =>
deleteMonitorSuccessSelector(state, configId)
);

const dispatch = useDispatch();

const onConfirmDelete = () => {
setIsDeleting(true);
dispatch(deleteMonitorAction.get({ id: configId, name }));
setIsDeleteModalVisible(false);
};
const showDeleteModal = () => setIsDeleteModalVisible(true);

const { status } = useFetcher(() => {
if (isDeleting) {
return deleteMonitor({ id: configId });
}
}, [configId, isDeleting]);
const showDeleteModal = () => setIsDeleteModalVisible(true);

const handleDelete = () => {
showDeleteModal();
};

useEffect(() => {
if (!isDeleting) {
return;
}
if (status === FETCH_STATUS.SUCCESS || status === FETCH_STATUS.FAILURE) {
setIsDeleting(false);
}
if (status === FETCH_STATUS.FAILURE) {
kibanaService.toasts.addDanger(
{
title: toMountPoint(
<p data-test-subj="uptimeDeleteMonitorFailure">{MONITOR_DELETE_FAILURE_LABEL}</p>
),
},
{ toastLifeTimeMs: 3000 }
);
} else if (status === FETCH_STATUS.SUCCESS) {
if (isSuccessfullyDeleted) {
onUpdate();
kibanaService.toasts.addSuccess(
{
title: toMountPoint(
<p data-test-subj="uptimeDeleteMonitorSuccess">
{i18n.translate(
'xpack.synthetics.monitorManagement.monitorDeleteSuccessMessage.name',
{
defaultMessage: 'Deleted "{name}"',
values: { name },
}
)}
</p>
),
},
{ toastLifeTimeMs: 3000 }
);
}
}, [setIsDeleting, onUpdate, status, name, isDeleting]);
}, [onUpdate, isSuccessfullyDeleted]);

const destroyModal = (
<EuiConfirmModal
Expand Down Expand Up @@ -121,17 +91,15 @@ export const DeleteMonitor = ({

return (
<>
{status === FETCH_STATUS.LOADING ? (
<EuiLoadingSpinner size="m" aria-label={MONITOR_DELETE_LOADING_LABEL} />
) : (
<EuiButtonIcon
isDisabled={isDisabled}
iconType="trash"
onClick={handleDelete}
aria-label={DELETE_MONITOR_LABEL}
data-test-subj="monitorManagementDeleteMonitor"
/>
)}
<EuiButtonIcon
isDisabled={isDisabled}
iconType="trash"
onClick={handleDelete}
aria-label={DELETE_MONITOR_LABEL}
data-test-subj="monitorManagementDeleteMonitor"
isLoading={isDeleting}
/>

{isDeleteModalVisible && destroyModal}
</>
);
Expand All @@ -151,18 +119,3 @@ const DELETE_MONITOR_LABEL = i18n.translate(
defaultMessage: 'Delete monitor',
}
);

// TODO: Discuss error states with product
const MONITOR_DELETE_FAILURE_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.monitorDeleteFailureMessage',
{
defaultMessage: 'Monitor was unable to be deleted. Please try again later.',
}
);

const MONITOR_DELETE_LOADING_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.monitorDeleteLoadingMessage',
{
defaultMessage: 'Deleting monitor...',
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,6 @@ export const MonitorManagementList = ({
}),
sortable: true,
render: (urls: string, { hosts }: TCPSimpleFields | ICMPSimpleFields) => urls || hosts,
truncateText: true,
textOnly: true,
},
{
Expand All @@ -205,6 +204,7 @@ export const MonitorManagementList = ({
}),
render: (fields: EncryptedSyntheticsMonitorWithId) => (
<Actions
key={fields[ConfigKey.CONFIG_ID]}
configId={fields[ConfigKey.CONFIG_ID]}
name={fields[ConfigKey.NAME]}
isDisabled={!canEdit}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,5 @@ export const mockState: AppState = {
},
testNowRuns: {},
agentPolicies: { loading: false, data: null, error: null },
deleteMonitor: {},
};
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,19 @@ import { AppState } from '../../state';
import { rootReducer } from '../../state/reducers';
import { rootEffect } from '../../state/effects';

const createRealStore = (): Store => {
export const createRealStore = (): Store => {
const sagaMW = createSagaMiddleware();
const store = createReduxStore(rootReducer, applyMiddleware(sagaMW));
sagaMW.run(rootEffect);
return store;
};

export const MountWithReduxProvider: React.FC<{ state?: AppState; useRealStore?: boolean }> = ({
children,
state,
useRealStore,
}) => {
const store = useRealStore
export const MountWithReduxProvider: React.FC<{
state?: AppState;
useRealStore?: boolean;
store?: Store;
}> = ({ children, state, store, useRealStore }) => {
const newStore = useRealStore
? createRealStore()
: {
dispatch: jest.fn(),
Expand All @@ -38,5 +38,5 @@ export const MountWithReduxProvider: React.FC<{ state?: AppState; useRealStore?:
[Symbol.observable]: jest.fn(),
};

return <ReduxProvider store={store}>{children}</ReduxProvider>;
return <ReduxProvider store={store ?? newStore}>{children}</ReduxProvider>;
};
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { KibanaContextProvider, KibanaServices } from '@kbn/kibana-react-plugin/
import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks';
import { Store } from 'redux';
import { mockState } from '../__mocks__/uptime_store.mock';
import { MountWithReduxProvider } from './helper_with_redux';
import { AppState } from '../../state';
Expand Down Expand Up @@ -221,12 +222,17 @@ export function WrappedHelper<ExtraCore>({
url,
useRealStore,
path,
store,
history = createMemoryHistory(),
}: RenderRouterOptions<ExtraCore> & { children: ReactElement; useRealStore?: boolean }) {
}: RenderRouterOptions<ExtraCore> & {
children: ReactElement;
useRealStore?: boolean;
store?: Store;
}) {
const testState: AppState = merge({}, mockState, state);

return (
<MountWithReduxProvider state={testState} useRealStore={useRealStore}>
<MountWithReduxProvider state={testState} useRealStore={useRealStore} store={store}>
<MockRouter path={path} history={history} kibanaProps={kibanaProps} core={core}>
{children}
</MockRouter>
Expand All @@ -246,7 +252,8 @@ export function render<ExtraCore>(
url,
path,
useRealStore,
}: RenderRouterOptions<ExtraCore> & { useRealStore?: boolean } = {}
store,
}: RenderRouterOptions<ExtraCore> & { useRealStore?: boolean; store?: Store } = {}
): any {
if (url) {
history = getHistoryFromUrl(url);
Expand All @@ -262,6 +269,7 @@ export function render<ExtraCore>(
state={state}
path={path}
useRealStore={useRealStore}
store={store}
>
{ui}
</WrappedHelper>,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { createAsyncAction } from './utils';

export const deleteMonitorAction = createAsyncAction<
{ id: string; name: string },
string,
{ id: string; error: Error }
>('DELETE_MONITOR');
Loading

0 comments on commit 7e9f57c

Please sign in to comment.