diff --git a/frontend/src/lib/Buttons.ts b/frontend/src/lib/Buttons.ts index ccc4ea776fc..ace2c205b47 100644 --- a/frontend/src/lib/Buttons.ts +++ b/frontend/src/lib/Buttons.ts @@ -148,7 +148,7 @@ export default class Buttons { // or recurring run config. public delete( getSelectedIds: () => string[], - resourceName: 'pipeline' | 'recurring run config' | 'pipeline version', + resourceName: 'pipeline' | 'recurring run config' | 'pipeline version' | 'run', callback: (selectedIds: string[], success: boolean) => void, useCurrentResource: boolean, ): Buttons { @@ -158,6 +158,8 @@ export default class Buttons { ? this._deletePipeline(getSelectedIds(), useCurrentResource, callback) : resourceName === 'pipeline version' ? this._deletePipelineVersion(getSelectedIds(), useCurrentResource, callback) + : resourceName === 'run' + ? this._deleteRun(getSelectedIds(), useCurrentResource, callback) : this._deleteRecurringRun(getSelectedIds()[0], useCurrentResource, callback), disabled: !useCurrentResource, disabledTitle: useCurrentResource @@ -497,6 +499,22 @@ export default class Buttons { ); } + private _deleteRun( + ids: string[], + useCurrentResource: boolean, + callback: (_: string[], success: boolean) => void, + ): void { + this._dialogActionHandler( + ids, + 'Do you want to delete the selected runs? This action cannot be undone.', + useCurrentResource, + id => Apis.runServiceApi.deleteRun(id), + callback, + 'Delete', + 'run', + ); + } + private _dialogActionHandler( selectedIds: string[], content: string, diff --git a/frontend/src/pages/Archive.test.tsx b/frontend/src/pages/Archive.test.tsx index f10ff0e1f55..39c104e4c9b 100644 --- a/frontend/src/pages/Archive.test.tsx +++ b/frontend/src/pages/Archive.test.tsx @@ -21,11 +21,15 @@ import { PageProps } from './Page'; import { RunStorageState } from '../apis/run'; import { ShallowWrapper, shallow } from 'enzyme'; import { ButtonKeys } from '../lib/Buttons'; +import { Apis } from '../lib/Apis'; describe('Archive', () => { const updateBannerSpy = jest.fn(); const updateToolbarSpy = jest.fn(); const historyPushSpy = jest.fn(); + const deleteRunSpy = jest.spyOn(Apis.runServiceApi, 'deleteRun'); + const updateDialogSpy = jest.fn(); + const updateSnackbarSpy = jest.fn(); let tree: ShallowWrapper; function generateProps(): PageProps { @@ -35,9 +39,9 @@ describe('Archive', () => { {} as any, historyPushSpy, updateBannerSpy, - null, + updateDialogSpy, updateToolbarSpy, - null, + updateSnackbarSpy, ); } @@ -45,6 +49,9 @@ describe('Archive', () => { updateBannerSpy.mockClear(); updateToolbarSpy.mockClear(); historyPushSpy.mockClear(); + deleteRunSpy.mockClear(); + updateDialogSpy.mockClear(); + updateSnackbarSpy.mockClear(); }); afterEach(() => tree.unmount()); @@ -60,17 +67,29 @@ describe('Archive', () => { expect(updateBannerSpy).toHaveBeenCalledWith({}); }); - it('only enables restore button when at least one run is selected', () => { + it('enables restore and delete button when at least one run is selected', () => { tree = shallow(); TestUtils.flushPromises(); tree.update(); expect(TestUtils.getToolbarButton(updateToolbarSpy, ButtonKeys.RESTORE).disabled).toBeTruthy(); + expect( + TestUtils.getToolbarButton(updateToolbarSpy, ButtonKeys.DELETE_RUN).disabled, + ).toBeTruthy(); tree.find('RunList').simulate('selectionChange', ['run1']); expect(TestUtils.getToolbarButton(updateToolbarSpy, ButtonKeys.RESTORE).disabled).toBeFalsy(); + expect( + TestUtils.getToolbarButton(updateToolbarSpy, ButtonKeys.DELETE_RUN).disabled, + ).toBeFalsy(); tree.find('RunList').simulate('selectionChange', ['run1', 'run2']); expect(TestUtils.getToolbarButton(updateToolbarSpy, ButtonKeys.RESTORE).disabled).toBeFalsy(); + expect( + TestUtils.getToolbarButton(updateToolbarSpy, ButtonKeys.DELETE_RUN).disabled, + ).toBeFalsy(); tree.find('RunList').simulate('selectionChange', []); expect(TestUtils.getToolbarButton(updateToolbarSpy, ButtonKeys.RESTORE).disabled).toBeTruthy(); + expect( + TestUtils.getToolbarButton(updateToolbarSpy, ButtonKeys.DELETE_RUN).disabled, + ).toBeTruthy(); }); it('refreshes the run list when refresh button is clicked', async () => { @@ -85,4 +104,64 @@ describe('Archive', () => { tree = shallow(); expect(tree.find('RunList').prop('storageState')).toBe(RunStorageState.ARCHIVED.toString()); }); + + it('cancells deletion when Cancel is clicked', async () => { + tree = shallow(); + + // Click delete button to delete selected ids. + const deleteBtn = (tree.instance() as Archive).getInitialToolbarState().actions[ + ButtonKeys.DELETE_RUN + ]; + await deleteBtn!.action(); + + // Dialog pops up to confirm the deletion. + expect(updateDialogSpy).toHaveBeenCalledTimes(1); + expect(updateDialogSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + content: 'Do you want to delete the selected runs? This action cannot be undone.', + }), + ); + + // Cancel deletion. + const call = updateDialogSpy.mock.calls[0][0]; + const cancelBtn = call.buttons.find((b: any) => b.text === 'Cancel'); + await cancelBtn.onClick(); + expect(deleteRunSpy).not.toHaveBeenCalled(); + }); + + it('deletes selected ids when Confirm is clicked', async () => { + tree = shallow(); + tree.setState({ selectedIds: ['id1', 'id2', 'id3'] }); + + // Mock the behavior where the deletion of id1 fails, the deletion of id2 and id3 succeed. + TestUtils.makeErrorResponseOnce(deleteRunSpy, 'woops'); + deleteRunSpy.mockImplementation(() => Promise.resolve({})); + + // Click delete button to delete selected ids. + const deleteBtn = (tree.instance() as Archive).getInitialToolbarState().actions[ + ButtonKeys.DELETE_RUN + ]; + await deleteBtn!.action(); + + // Dialog pops up to confirm the deletion. + expect(updateDialogSpy).toHaveBeenCalledTimes(1); + expect(updateDialogSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + content: 'Do you want to delete the selected runs? This action cannot be undone.', + }), + ); + + // Confirm. + const call = updateDialogSpy.mock.calls[0][0]; + const confirmBtn = call.buttons.find((b: any) => b.text === 'Delete'); + await confirmBtn.onClick(); + await deleteRunSpy; + await TestUtils.flushPromises(); + tree.update(); + expect(deleteRunSpy).toHaveBeenCalledTimes(3); + expect(deleteRunSpy).toHaveBeenCalledWith('id1'); + expect(deleteRunSpy).toHaveBeenCalledWith('id2'); + expect(deleteRunSpy).toHaveBeenCalledWith('id3'); + expect(tree.state('selectedIds')).toEqual(['id1']); // id1 is left over since its deletion failed. + }); }); diff --git a/frontend/src/pages/Archive.tsx b/frontend/src/pages/Archive.tsx index bdfe2ff9654..10c6557509f 100644 --- a/frontend/src/pages/Archive.tsx +++ b/frontend/src/pages/Archive.tsx @@ -44,6 +44,12 @@ export default class Archive extends Page<{}, ArchiveState> { actions: buttons .restore(() => this.state.selectedIds, false, this._selectionChanged.bind(this)) .refresh(this.refresh.bind(this)) + .delete( + () => this.state.selectedIds, + 'run', + this._selectionChanged.bind(this), + false /* useCurrentResource */, + ) .getToolbarActionMap(), breadcrumbs: [], pageTitle: 'Archive', @@ -76,6 +82,7 @@ export default class Archive extends Page<{}, ArchiveState> { private _selectionChanged(selectedIds: string[]): void { const toolbarActions = this.props.toolbarProps.actions; toolbarActions[ButtonKeys.RESTORE].disabled = !selectedIds.length; + toolbarActions[ButtonKeys.DELETE_RUN].disabled = !selectedIds.length; this.props.updateToolbar({ actions: toolbarActions, breadcrumbs: this.props.toolbarProps.breadcrumbs, diff --git a/frontend/src/pages/__snapshots__/Archive.test.tsx.snap b/frontend/src/pages/__snapshots__/Archive.test.tsx.snap index c7e1b82f852..23f34bfc98c 100644 --- a/frontend/src/pages/__snapshots__/Archive.test.tsx.snap +++ b/frontend/src/pages/__snapshots__/Archive.test.tsx.snap @@ -19,6 +19,14 @@ exports[`Archive renders archived runs 1`] = ` toolbarProps={ Object { "actions": Object { + "deleteRun": Object { + "action": [Function], + "disabled": true, + "disabledTitle": "Select at least one run to delete", + "id": "deleteBtn", + "title": "Delete", + "tooltip": "Delete", + }, "refresh": Object { "action": [Function], "id": "refreshBtn", @@ -39,8 +47,8 @@ exports[`Archive renders archived runs 1`] = ` } } updateBanner={[MockFunction]} - updateDialog={null} - updateSnackbar={null} + updateDialog={[MockFunction]} + updateSnackbar={[MockFunction]} updateToolbar={[MockFunction]} />