[Uptime] monitor management - adjust deletion logic (#146908)

## Summary

Resolves https://github.com/elastic/kibana/issues/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 <shahzad31comp@gmail.com>
This commit is contained in:
Dominique Clarke 2022-12-05 13:51:08 -05:00 committed by GitHub
parent b12859fed9
commit 7e9f57ccce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 229 additions and 141 deletions

View file

@ -75,6 +75,7 @@ export const Actions = ({
</EuiFlexItem>
<EuiFlexItem grow={false}>
<DeleteMonitor
key={configId}
onUpdate={onUpdate}
name={name}
configId={configId}

View file

@ -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 {
@ -19,17 +17,21 @@ 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();
@ -37,12 +39,24 @@ describe('<DeleteMonitor />', () => {
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}
@ -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);
});
});

View file

@ -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 { EuiButtonIcon, EuiCallOut, EuiConfirmModal, EuiSpacer } from '@elastic/eui';
import { FETCH_STATUS, useFetcher } from '@kbn/observability-plugin/public';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
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,
@ -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
@ -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}
</>
);
@ -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...',
}
);

View file

@ -180,7 +180,6 @@ export const MonitorManagementList = ({
}),
sortable: true,
render: (urls: string, { hosts }: TCPSimpleFields | ICMPSimpleFields) => urls || hosts,
truncateText: true,
textOnly: true,
},
{
@ -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}

View file

@ -129,4 +129,5 @@ export const mockState: AppState = {
},
testNowRuns: {},
agentPolicies: { loading: false, data: null, error: null },
deleteMonitor: {},
};

View file

@ -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(),
@ -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>;
};

View file

@ -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';
@ -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>
@ -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);
@ -262,6 +269,7 @@ export function render<ExtraCore>(
state={state}
path={path}
useRealStore={useRealStore}
store={store}
>
{ui}
</WrappedHelper>,

View file

@ -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');

View file

@ -10,13 +10,13 @@ import type { IHttpFetchError } from '@kbn/core-http-browser';
import type { Rule } from '@kbn/triggers-actions-ui-plugin/public';
import type { UptimeAlertTypeParams } from '../alerts/alerts';
export interface AsyncAction<Payload, SuccessPayload> {
export interface AsyncAction<Payload, SuccessPayload, ErrorPayload = IHttpFetchError> {
get: (payload: Payload) => Action<Payload>;
success: (payload: SuccessPayload) => Action<SuccessPayload>;
fail: (payload: IHttpFetchError) => Action<IHttpFetchError>;
fail: (payload: ErrorPayload) => Action<ErrorPayload>;
}
export interface AsyncActionOptionalPayload<Payload, SuccessPayload>
extends AsyncAction<Payload, SuccessPayload> {
export interface AsyncActionOptionalPayload<Payload, SuccessPayload, ErrorPayload>
extends AsyncAction<Payload, SuccessPayload, ErrorPayload> {
get: (payload?: Payload) => Action<Payload>;
}

View file

@ -9,15 +9,15 @@ import { createAction } from 'redux-actions';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import type { AsyncAction, AsyncActionOptionalPayload } from './types';
export function createAsyncAction<Payload, SuccessPayload>(
export function createAsyncAction<Payload, SuccessPayload, ErrorPayload = IHttpFetchError>(
actionStr: string
): AsyncActionOptionalPayload<Payload, SuccessPayload>;
export function createAsyncAction<Payload, SuccessPayload>(
): AsyncActionOptionalPayload<Payload, SuccessPayload, ErrorPayload>;
export function createAsyncAction<Payload, SuccessPayload, ErrorPayload = IHttpFetchError>(
actionStr: string
): AsyncAction<Payload, SuccessPayload> {
): AsyncAction<Payload, SuccessPayload, ErrorPayload> {
return {
get: createAction<Payload>(actionStr),
success: createAction<SuccessPayload>(`${actionStr}_SUCCESS`),
fail: createAction<IHttpFetchError>(`${actionStr}_FAIL`),
fail: createAction<ErrorPayload>(`${actionStr}_FAIL`),
};
}

View file

@ -0,0 +1,52 @@
/*
* 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 React from 'react';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { put, call, takeEvery } from 'redux-saga/effects';
import { Action } from 'redux-actions';
import { i18n } from '@kbn/i18n';
import { deleteMonitorAction } from '../actions/delete_monitor';
import { deleteMonitor } from '../api';
import { kibanaService } from '../kibana_service';
export function* deleteMonitorEffect() {
yield takeEvery(
String(deleteMonitorAction.get),
function* (action: Action<{ id: string; name: string }>) {
try {
const { id, name } = action.payload;
yield call(deleteMonitor, { id });
yield put(deleteMonitorAction.success(id));
kibanaService.core.notifications.toasts.addSuccess({
title: toMountPoint(
<p data-test-subj="uptimeDeleteMonitorSuccess">
{i18n.translate(
'xpack.synthetics.monitorManagement.monitorDeleteSuccessMessage.name',
{
defaultMessage: 'Deleted "{name}"',
values: { name },
}
)}
</p>
),
});
} catch (err) {
kibanaService.core.notifications.toasts.addError(err, {
title: MONITOR_DELETE_FAILURE_LABEL,
});
yield put(deleteMonitorAction.fail({ id: action.payload.id, error: err }));
}
}
);
}
const MONITOR_DELETE_FAILURE_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.monitorDeleteFailureMessage',
{
defaultMessage: 'Monitor was unable to be deleted. Please try again later.',
}
);

View file

@ -6,6 +6,7 @@
*/
import { fork } from 'redux-saga/effects';
import { deleteMonitorEffect } from './delete_monitor';
import { fetchAgentPoliciesEffect } from '../private_locations';
import { fetchMonitorDetailsEffect } from './monitor';
import { fetchMonitorListEffect, fetchUpdatedMonitorEffect } from './monitor_list';
@ -51,4 +52,5 @@ export function* rootEffect() {
yield fork(pruneBlockCache);
yield fork(fetchSyntheticsServiceAllowedEffect);
yield fork(fetchAgentPoliciesEffect);
yield fork(deleteMonitorEffect);
}

View file

@ -0,0 +1,56 @@
/*
* 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 { createReducer, PayloadAction } from '@reduxjs/toolkit';
import { WritableDraft } from 'immer/dist/types/types-external';
import { deleteMonitorAction } from '../actions/delete_monitor';
export interface DeleteMonitorState {
error?: Record<string, Error | undefined>;
loading?: string[];
deletedMonitorIds?: string[];
}
export const initialState: DeleteMonitorState = {
error: {},
loading: [],
deletedMonitorIds: [],
};
export const deleteMonitorReducer = createReducer(initialState, (builder) => {
builder
.addCase(
String(deleteMonitorAction.get),
(
state: WritableDraft<DeleteMonitorState>,
action: PayloadAction<{ id: string; name: string }>
) => ({
...state,
loading: [...(state.loading ?? []), action.payload.id],
error: { ...state.error, [action.payload.id]: undefined },
})
)
.addCase(
String(deleteMonitorAction.success),
(state: WritableDraft<DeleteMonitorState>, action: PayloadAction<string>) => ({
...state,
loading: state.loading?.filter((id) => id !== action.payload),
deletedMonitorIds: [...(state.deletedMonitorIds ?? []), action.payload],
})
)
.addCase(
String(deleteMonitorAction.fail),
(
state: WritableDraft<DeleteMonitorState>,
action: PayloadAction<{ id: string; error: Error }>
) => ({
...state,
loading: state.loading?.filter((id) => id !== action.payload.id),
error: { ...state.error, [action.payload.id]: action.payload.error },
})
);
});

View file

@ -6,6 +6,7 @@
*/
import { combineReducers } from 'redux';
import { deleteMonitorReducer, DeleteMonitorState } from './delete_monitor';
import { agentPoliciesReducer, AgentPoliciesState } from '../private_locations';
import { monitorReducer, MonitorState } from './monitor';
import { uiReducer, UiState } from './ui';
@ -47,6 +48,7 @@ export interface RootState {
synthetics: SyntheticsReducerState;
testNowRuns: TestNowRunsState;
agentPolicies: AgentPoliciesState;
deleteMonitor: DeleteMonitorState;
}
export const rootReducer = combineReducers<RootState>({
@ -69,4 +71,5 @@ export const rootReducer = combineReducers<RootState>({
synthetics: syntheticsReducer,
testNowRuns: testNowRunsReducer,
agentPolicies: agentPoliciesReducer,
deleteMonitor: deleteMonitorReducer,
});

View file

@ -19,6 +19,15 @@ export const monitorDetailsSelector = (state: AppState, summary: any) => {
return state.monitor.monitorDetailsList[summary.monitor_id];
};
export const deleteMonitorLoadingSelector = (state: AppState, id?: string) => {
if (!id) return (state.deleteMonitor.loading ?? []).length > 0;
return state.deleteMonitor.loading?.includes(id) ?? false;
};
export const deleteMonitorSuccessSelector = (state: AppState, id: string) => {
return state.deleteMonitor.deletedMonitorIds?.includes(id) ?? false;
};
export const monitorDetailsLoadingSelector = (state: AppState) => state.monitor.loading;
export const monitorLocationsSelector = (state: AppState, monitorId: string) => {

View file

@ -31082,7 +31082,6 @@
"xpack.synthetics.monitorManagement.monitorAdvancedOptions.monitorNamespaceFieldLabel": "Espace de nom",
"xpack.synthetics.monitorManagement.monitorAdvancedOptions.namespaceHelpLearnMoreLabel": "En savoir plus",
"xpack.synthetics.monitorManagement.monitorDeleteFailureMessage": "Impossible de supprimer le moniteur. Réessayez plus tard.",
"xpack.synthetics.monitorManagement.monitorDeleteLoadingMessage": "Suppression du moniteur...",
"xpack.synthetics.monitorManagement.monitorEditedSuccessMessage": "Moniteur mis à jour.",
"xpack.synthetics.monitorManagement.monitorFailureMessage": "Impossible d'enregistrer le moniteur. Réessayez plus tard.",
"xpack.synthetics.monitorManagement.monitorList.actions": "Actions",

View file

@ -31058,7 +31058,6 @@
"xpack.synthetics.monitorManagement.monitorAdvancedOptions.monitorNamespaceFieldLabel": "名前空間",
"xpack.synthetics.monitorManagement.monitorAdvancedOptions.namespaceHelpLearnMoreLabel": "詳細情報",
"xpack.synthetics.monitorManagement.monitorDeleteFailureMessage": "モニターを削除できませんでした。しばらくたってから再試行してください。",
"xpack.synthetics.monitorManagement.monitorDeleteLoadingMessage": "モニターを削除しています...",
"xpack.synthetics.monitorManagement.monitorEditedSuccessMessage": "モニターは正常に更新されました。",
"xpack.synthetics.monitorManagement.monitorFailureMessage": "モニターを保存できませんでした。しばらくたってから再試行してください。",
"xpack.synthetics.monitorManagement.monitorList.actions": "アクション",

View file

@ -31093,7 +31093,6 @@
"xpack.synthetics.monitorManagement.monitorAdvancedOptions.monitorNamespaceFieldLabel": "命名空间",
"xpack.synthetics.monitorManagement.monitorAdvancedOptions.namespaceHelpLearnMoreLabel": "了解详情",
"xpack.synthetics.monitorManagement.monitorDeleteFailureMessage": "无法删除监测。请稍后重试。",
"xpack.synthetics.monitorManagement.monitorDeleteLoadingMessage": "正在删除监测......",
"xpack.synthetics.monitorManagement.monitorEditedSuccessMessage": "已成功更新监测。",
"xpack.synthetics.monitorManagement.monitorFailureMessage": "无法保存监测。请稍后重试。",
"xpack.synthetics.monitorManagement.monitorList.actions": "操作",