[ResponseOps][MW] Allow users to delete MWs (#211399)

Resolve: https://github.com/elastic/kibana/issues/198559
Resolve: https://github.com/elastic/kibana/issues/205269

Here I used the existing DELETE
/internal/alerting/rules/maintenance_window/{id} API to delete MWs from
the UI.
I added an action to the MW table so users can delete MWs. And show a
delete confirmation modal when users delete a MW from the UI.

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] This was checked for breaking HTTP API changes, and any breaking
changes have been approved by the breaking-change committee. The
`release_note:breaking` label should be applied in these situations.
- [x] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
This commit is contained in:
Julia 2025-03-03 16:20:31 +01:00 committed by GitHub
parent 63394e6bfd
commit 6ce22f4a33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 314 additions and 16 deletions

View file

@ -0,0 +1,69 @@
/*
* 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 { waitFor, renderHook } from '@testing-library/react';
import { AppMockRenderer, createAppMockRenderer } from '../lib/test_utils';
import { useDeleteMaintenanceWindow } from './use_delete_maintenance_window';
const mockAddDanger = jest.fn();
const mockAddSuccess = jest.fn();
jest.mock('../utils/kibana_react', () => {
const originalModule = jest.requireActual('../utils/kibana_react');
return {
...originalModule,
useKibana: () => {
const { services } = originalModule.useKibana();
return {
services: {
...services,
notifications: { toasts: { addSuccess: mockAddSuccess, addDanger: mockAddDanger } },
},
};
},
};
});
jest.mock('../services/maintenance_windows_api/delete', () => ({
deleteMaintenanceWindow: jest.fn(),
}));
const { deleteMaintenanceWindow } = jest.requireMock('../services/maintenance_windows_api/delete');
let appMockRenderer: AppMockRenderer;
describe('useDeleteMaintenanceWindow', () => {
beforeEach(() => {
jest.clearAllMocks();
appMockRenderer = createAppMockRenderer();
});
it('should call onSuccess if api succeeds', async () => {
const { result } = renderHook(() => useDeleteMaintenanceWindow(), {
wrapper: appMockRenderer.AppWrapper,
});
result.current.mutate({ maintenanceWindowId: '123' });
await waitFor(() => expect(mockAddSuccess).toBeCalledWith('Deleted maintenance window'));
});
it('should call onError if api fails', async () => {
deleteMaintenanceWindow.mockRejectedValue('');
const { result } = renderHook(() => useDeleteMaintenanceWindow(), {
wrapper: appMockRenderer.AppWrapper,
});
result.current.mutate({ maintenanceWindowId: '123' });
await waitFor(() =>
expect(mockAddDanger).toBeCalledWith('Failed to delete maintenance window.')
);
});
});

View file

@ -0,0 +1,39 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { useMutation } from '@tanstack/react-query';
import { useKibana } from '../utils/kibana_react';
import { deleteMaintenanceWindow } from '../services/maintenance_windows_api/delete';
export const useDeleteMaintenanceWindow = () => {
const {
http,
notifications: { toasts },
} = useKibana().services;
const mutationFn = ({ maintenanceWindowId }: { maintenanceWindowId: string }) => {
return deleteMaintenanceWindow({ http, maintenanceWindowId });
};
return useMutation(mutationFn, {
onSuccess: () => {
toasts.addSuccess(
i18n.translate('xpack.alerting.maintenanceWindowsDeleteSuccess', {
defaultMessage: 'Deleted maintenance window',
})
);
},
onError: () => {
toasts.addDanger(
i18n.translate('xpack.alerting.maintenanceWindowsDeleteFailure', {
defaultMessage: 'Failed to delete maintenance window.',
})
);
},
});
};

View file

@ -32,6 +32,7 @@ import { TableActionsPopover, TableActionsPopoverProps } from './table_actions_p
import { useFinishMaintenanceWindow } from '../../../hooks/use_finish_maintenance_window';
import { useArchiveMaintenanceWindow } from '../../../hooks/use_archive_maintenance_window';
import { useFinishAndArchiveMaintenanceWindow } from '../../../hooks/use_finish_and_archive_maintenance_window';
import { useDeleteMaintenanceWindow } from '../../../hooks/use_delete_maintenance_window';
interface MaintenanceWindowsListProps {
isLoading: boolean;
@ -143,9 +144,24 @@ export const MaintenanceWindowsList = React.memo<MaintenanceWindowsListProps>(
[finishAndArchiveMaintenanceWindow, refreshData]
);
const { mutate: deleteMaintenanceWindow, isLoading: isLoadingDelete } =
useDeleteMaintenanceWindow();
const onDelete = useCallback(
(id: string) =>
deleteMaintenanceWindow({ maintenanceWindowId: id }, { onSuccess: () => refreshData() }),
[deleteMaintenanceWindow, refreshData]
);
const isMutatingOrLoading = useMemo(() => {
return isLoadingFinish || isLoadingArchive || isLoadingFinishAndArchive || isLoading;
}, [isLoadingFinish, isLoadingArchive, isLoadingFinishAndArchive, isLoading]);
return (
isLoadingFinish ||
isLoadingArchive ||
isLoadingFinishAndArchive ||
isLoadingDelete ||
isLoading
);
}, [isLoadingFinish, isLoadingArchive, isLoadingFinishAndArchive, isLoadingDelete, isLoading]);
const actions: Array<EuiBasicTableColumn<MaintenanceWindow>> = useMemo(
() => [
@ -161,12 +177,13 @@ export const MaintenanceWindowsList = React.memo<MaintenanceWindowsListProps>(
onCancel={onCancel}
onArchive={onArchive}
onCancelAndArchive={onCancelAndArchive}
onDelete={onDelete}
/>
);
},
},
],
[isMutatingOrLoading, onArchive, onCancel, onCancelAndArchive, onEdit]
[isMutatingOrLoading, onArchive, onCancel, onCancelAndArchive, onDelete, onEdit]
);
const columns = useMemo(

View file

@ -48,6 +48,7 @@ describe('TableActionsPopover', () => {
onCancel={() => {}}
onArchive={() => {}}
onCancelAndArchive={() => {}}
onDelete={() => {}}
/>
);
@ -64,12 +65,14 @@ describe('TableActionsPopover', () => {
onCancel={() => {}}
onArchive={() => {}}
onCancelAndArchive={() => {}}
onDelete={() => {}}
/>
);
fireEvent.click(result.getByTestId('table-actions-icon-button'));
expect(result.getByTestId('table-actions-edit')).toBeInTheDocument();
expect(result.getByTestId('table-actions-cancel')).toBeInTheDocument();
expect(result.getByTestId('table-actions-cancel-and-archive')).toBeInTheDocument();
expect(result.getByTestId('table-actions-delete')).toBeInTheDocument();
});
test('it shows the correct actions when a maintenance window is upcoming', () => {
@ -82,11 +85,13 @@ describe('TableActionsPopover', () => {
onCancel={() => {}}
onArchive={() => {}}
onCancelAndArchive={() => {}}
onDelete={() => {}}
/>
);
fireEvent.click(result.getByTestId('table-actions-icon-button'));
expect(result.getByTestId('table-actions-edit')).toBeInTheDocument();
expect(result.getByTestId('table-actions-archive')).toBeInTheDocument();
expect(result.getByTestId('table-actions-delete')).toBeInTheDocument();
});
test('it shows the correct actions when a maintenance window is finished', () => {
@ -99,11 +104,13 @@ describe('TableActionsPopover', () => {
onCancel={() => {}}
onArchive={() => {}}
onCancelAndArchive={() => {}}
onDelete={() => {}}
/>
);
fireEvent.click(result.getByTestId('table-actions-icon-button'));
expect(result.getByTestId('table-actions-edit')).toBeInTheDocument();
expect(result.getByTestId('table-actions-archive')).toBeInTheDocument();
expect(result.getByTestId('table-actions-delete')).toBeInTheDocument();
});
test('it shows the correct actions when a maintenance window is archived', () => {
@ -116,10 +123,12 @@ describe('TableActionsPopover', () => {
onCancel={() => {}}
onArchive={() => {}}
onCancelAndArchive={() => {}}
onDelete={() => {}}
/>
);
fireEvent.click(result.getByTestId('table-actions-icon-button'));
expect(result.getByTestId('table-actions-unarchive')).toBeInTheDocument();
expect(result.getByTestId('table-actions-delete')).toBeInTheDocument();
});
test('it shows the success toast when maintenance window id is copied', async () => {
@ -138,6 +147,7 @@ describe('TableActionsPopover', () => {
onCancel={() => {}}
onArchive={() => {}}
onCancelAndArchive={() => {}}
onDelete={() => {}}
/>
);
@ -150,4 +160,30 @@ describe('TableActionsPopover', () => {
Object.assign(navigator, global.window.navigator.clipboard);
});
test('it calls onDelete function when maintenance window is deleted', async () => {
const onDelete = jest.fn();
const user = userEvent.setup();
const result = appMockRenderer.render(
<TableActionsPopover
id={'123'}
isLoading={false}
status={MaintenanceWindowStatus.Archived}
onEdit={() => {}}
onCancel={() => {}}
onArchive={() => {}}
onCancelAndArchive={() => {}}
onDelete={onDelete}
/>
);
await user.click(await result.findByTestId('table-actions-icon-button'));
expect(await result.findByTestId('table-actions-delete')).toBeInTheDocument();
await user.click(await result.findByTestId('table-actions-delete'));
const deleteModalConfirmButton = await result.findByTestId('confirmModalConfirmButton');
expect(deleteModalConfirmButton).toBeInTheDocument();
await user.click(deleteModalConfirmButton);
expect(onDelete).toHaveBeenCalledWith('123');
});
});

View file

@ -27,12 +27,13 @@ export interface TableActionsPopoverProps {
onCancel: (id: string) => void;
onArchive: (id: string, archive: boolean) => void;
onCancelAndArchive: (id: string) => void;
onDelete: (id: string) => void;
}
type ModalType = 'cancel' | 'cancelAndArchive' | 'archive' | 'unarchive';
type ModalType = 'cancel' | 'cancelAndArchive' | 'archive' | 'unarchive' | 'delete';
type ActionType = ModalType | 'edit' | 'copyId';
export const TableActionsPopover: React.FC<TableActionsPopoverProps> = React.memo(
({ id, status, isLoading, onEdit, onCancel, onArchive, onCancelAndArchive }) => {
({ id, status, isLoading, onEdit, onCancel, onArchive, onCancelAndArchive, onDelete }) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isModalVisible, setIsModalVisible] = useState(false);
const [modalType, setModalType] = useState<ModalType>();
@ -104,6 +105,18 @@ export const TableActionsPopover: React.FC<TableActionsPopoverProps> = React.mem
},
subtitle: i18n.UNARCHIVE_MODAL_SUBTITLE,
},
delete: {
props: {
title: i18n.DELETE_MODAL_TITLE,
onConfirm: () => {
closeModal();
onDelete(id);
},
cancelButtonText: i18n.CANCEL,
confirmButtonText: i18n.DELETE_MODAL_TITLE,
},
subtitle: i18n.DELETE_MODAL_SUBTITLE,
},
};
let m;
if (isModalVisible && modalType) {
@ -121,7 +134,16 @@ export const TableActionsPopover: React.FC<TableActionsPopoverProps> = React.mem
);
}
return m;
}, [id, modalType, isModalVisible, closeModal, onArchive, onCancel, onCancelAndArchive]);
}, [
id,
modalType,
isModalVisible,
closeModal,
onArchive,
onCancel,
onCancelAndArchive,
onDelete,
]);
const items = useMemo(() => {
const menuItems = {
@ -170,7 +192,7 @@ export const TableActionsPopover: React.FC<TableActionsPopoverProps> = React.mem
<EuiContextMenuItem
data-test-subj="table-actions-cancel-and-archive"
key="cancel-and-archive"
icon="trash"
icon="folderOpen"
onClick={() => {
closePopover();
showModal('cancelAndArchive');
@ -183,7 +205,7 @@ export const TableActionsPopover: React.FC<TableActionsPopoverProps> = React.mem
<EuiContextMenuItem
data-test-subj="table-actions-archive"
key="archive"
icon="trash"
icon="folderOpen"
onClick={() => {
closePopover();
showModal('archive');
@ -205,12 +227,25 @@ export const TableActionsPopover: React.FC<TableActionsPopoverProps> = React.mem
{i18n.TABLE_ACTION_UNARCHIVE}
</EuiContextMenuItem>
),
delete: (
<EuiContextMenuItem
data-test-subj="table-actions-delete"
key="delete"
icon="trash"
onClick={() => {
closePopover();
showModal('delete');
}}
>
{i18n.TABLE_ACTION_DELETE}
</EuiContextMenuItem>
),
};
const statusMenuItemsMap: Record<MaintenanceWindowStatus, ActionType[]> = {
running: ['edit', 'copyId', 'cancel', 'cancelAndArchive'],
upcoming: ['edit', 'copyId', 'archive'],
finished: ['edit', 'copyId', 'archive'],
archived: ['copyId', 'unarchive'],
running: ['edit', 'copyId', 'cancel', 'cancelAndArchive', 'delete'],
upcoming: ['edit', 'copyId', 'archive', 'delete'],
finished: ['edit', 'copyId', 'archive', 'delete'],
archived: ['copyId', 'unarchive', 'delete'],
};
return statusMenuItemsMap[status].map((type) => menuItems[type]);
}, [status, closePopover, onEdit, id, toasts, showModal]);

View file

@ -626,6 +626,20 @@ export const CANCEL_AND_ARCHIVE_MODAL_SUBTITLE = i18n.translate(
}
);
export const DELETE_MODAL_TITLE = i18n.translate(
'xpack.alerting.maintenanceWindows.deleteModal.title',
{
defaultMessage: 'Delete maintenance window',
}
);
export const DELETE_MODAL_SUBTITLE = i18n.translate(
'xpack.alerting.maintenanceWindows.deleteModal.subtitle',
{
defaultMessage: "You won't be able to recover a deleted maintenance window.",
}
);
export const ARCHIVE = i18n.translate('xpack.alerting.maintenanceWindows.archive', {
defaultMessage: 'Archive',
});
@ -660,6 +674,13 @@ export const TABLE_ACTION_UNARCHIVE = i18n.translate(
}
);
export const TABLE_ACTION_DELETE = i18n.translate(
'xpack.alerting.maintenanceWindows.table.delete',
{
defaultMessage: 'Delete',
}
);
export const UNARCHIVE_MODAL_TITLE = i18n.translate(
'xpack.alerting.maintenanceWindows.unarchiveModal.title',
{

View file

@ -0,0 +1,34 @@
/*
* 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.
*/
/*
* 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 { httpServiceMock } from '@kbn/core/public/mocks';
import { deleteMaintenanceWindow } from './delete';
const http = httpServiceMock.createStartContract();
beforeEach(() => jest.resetAllMocks());
describe('deleteMaintenanceWindow', () => {
test('should call delete maintenance window api', async () => {
await deleteMaintenanceWindow({
http,
maintenanceWindowId: '123',
});
expect(http.delete.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/internal/alerting/rules/maintenance_window/123",
]
`);
});
});

View file

@ -0,0 +1,20 @@
/*
* 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 type { HttpSetup } from '@kbn/core/public';
import { INTERNAL_ALERTING_API_MAINTENANCE_WINDOW_PATH } from '../../../common';
export const deleteMaintenanceWindow = async ({
http,
maintenanceWindowId,
}: {
http: HttpSetup;
maintenanceWindowId: string;
}): Promise<void> => {
await http.delete<Promise<void>>(
`${INTERNAL_ALERTING_API_MAINTENANCE_WINDOW_PATH}/${encodeURIComponent(maintenanceWindowId)}`
);
};

View file

@ -20,14 +20,12 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
const objectRemover = new ObjectRemover(supertest);
const browser = getService('browser');
// FLAKY: https://github.com/elastic/kibana/issues/205269
// Failing: See https://github.com/elastic/kibana/issues/205269
describe.skip('Maintenance windows table', function () {
describe('Maintenance windows table', function () {
beforeEach(async () => {
await pageObjects.common.navigateToApp('maintenanceWindows');
});
after(async () => {
afterEach(async () => {
await objectRemover.removeAll();
});
@ -291,5 +289,34 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
const listedOnSecondPageMWs = await testSubjects.findAll('list-item');
expect(listedOnSecondPageMWs.length).to.be(2);
});
it('should delete a maintenance window', async () => {
const name = generateUniqueKey();
await createMaintenanceWindow({
name,
getService,
});
await browser.refresh();
await pageObjects.maintenanceWindows.searchMaintenanceWindows(name);
const listBefore = await pageObjects.maintenanceWindows.getMaintenanceWindowsList();
expect(listBefore.length).to.eql(1);
await testSubjects.click('table-actions-popover');
await testSubjects.click('table-actions-delete');
await testSubjects.click('confirmModalConfirmButton');
await retry.try(async () => {
const toastTitle = await toasts.getTitleAndDismiss();
expect(toastTitle).to.eql('Deleted maintenance window');
});
await pageObjects.maintenanceWindows.searchMaintenanceWindows(name);
const listAfter = await pageObjects.maintenanceWindows.getMaintenanceWindowsList();
expect(listAfter.length).to.eql(0);
});
});
};