Repository: provectus/kafka-ui
Issue: Delete topics using multi-selection
Labels: type/enhancement
scope/frontend

As a user, I would like to select multiple Kafka topics (multi-selection) and then delete them instead of deleting them one by one.

Issue comments:

Pull request review comments:
```suggestion
                  ? 'Are you sure you want to remove selected topics?'
                  : 'Are you sure you want to purge messages of selected topics?'}
```

Patch that resolved the issue:
diff --git a/kafka-ui-react-app/src/components/Topics/List/List.tsx b/kafka-ui-react-app/src/components/Topics/List/List.tsx
index 9440dff902f..a2f21437ae1 100644
--- a/kafka-ui-react-app/src/components/Topics/List/List.tsx
+++ b/kafka-ui-react-app/src/components/Topics/List/List.tsx
@@ -12,6 +12,7 @@ import usePagination from 'lib/hooks/usePagination';
 import ClusterContext from 'components/contexts/ClusterContext';
 import PageLoader from 'components/common/PageLoader/PageLoader';
 import Pagination from 'components/common/Pagination/Pagination';
+import ConfirmationModal from 'components/common/ConfirmationModal/ConfirmationModal';
 import { GetTopicsRequest, TopicColumnsToSort } from 'generated-sources';
 import SortableColumnHeader from 'components/common/table/SortableCulumnHeader/SortableColumnHeader';
 import Search from 'components/common/Search/Search';
@@ -25,6 +26,8 @@ export interface TopicsListProps {
   totalPages: number;
   fetchTopicsList(props: GetTopicsRequest): void;
   deleteTopic(topicName: TopicName, clusterName: ClusterName): void;
+  deleteTopics(topicName: TopicName, clusterNames: ClusterName[]): void;
+  clearTopicsMessages(topicName: TopicName, clusterNames: ClusterName[]): void;
   clearTopicMessages(
     topicName: TopicName,
     clusterName: ClusterName,
@@ -42,7 +45,9 @@ const List: React.FC<TopicsListProps> = ({
   totalPages,
   fetchTopicsList,
   deleteTopic,
+  deleteTopics,
   clearTopicMessages,
+  clearTopicsMessages,
   search,
   orderBy,
   setTopicsSearch,
@@ -78,6 +83,45 @@ const List: React.FC<TopicsListProps> = ({
     history.push(`${pathname}?page=1&perPage=${perPage || PER_PAGE}`);
   }, [showInternal]);
 
+  const [confirmationModal, setConfirmationModal] = React.useState<
+    '' | 'deleteTopics' | 'purgeMessages'
+  >('');
+
+  const closeConfirmationModal = () => {
+    setConfirmationModal('');
+  };
+
+  const [selectedTopics, setSelectedTopics] = React.useState<Set<string>>(
+    new Set()
+  );
+
+  const clearSelectedTopics = () => {
+    setSelectedTopics(new Set());
+  };
+
+  const toggleTopicSelected = (topicName: string) => {
+    setSelectedTopics((prevState) => {
+      const newState = new Set(prevState);
+      if (newState.has(topicName)) {
+        newState.delete(topicName);
+      } else {
+        newState.add(topicName);
+      }
+      return newState;
+    });
+  };
+
+  const deleteTopicsHandler = React.useCallback(() => {
+    deleteTopics(clusterName, Array.from(selectedTopics));
+    closeConfirmationModal();
+    clearSelectedTopics();
+  }, [clusterName, selectedTopics]);
+  const purgeMessagesHandler = React.useCallback(() => {
+    clearTopicsMessages(clusterName, Array.from(selectedTopics));
+    closeConfirmationModal();
+    clearSelectedTopics();
+  }, [clusterName, selectedTopics]);
+
   return (
     <div className="section">
       <Breadcrumb>{showInternal ? `All Topics` : `External Topics`}</Breadcrumb>
@@ -119,9 +163,47 @@ const List: React.FC<TopicsListProps> = ({
         <PageLoader />
       ) : (
         <div className="box">
+          {selectedTopics.size > 0 && (
+            <>
+              <div className="buttons">
+                <button
+                  type="button"
+                  className="button is-danger"
+                  onClick={() => {
+                    setConfirmationModal('deleteTopics');
+                  }}
+                >
+                  Delete selected topics
+                </button>
+                <button
+                  type="button"
+                  className="button is-danger"
+                  onClick={() => {
+                    setConfirmationModal('purgeMessages');
+                  }}
+                >
+                  Purge messages of selected topics
+                </button>
+              </div>
+              <ConfirmationModal
+                isOpen={confirmationModal !== ''}
+                onCancel={closeConfirmationModal}
+                onConfirm={
+                  confirmationModal === 'deleteTopics'
+                    ? deleteTopicsHandler
+                    : purgeMessagesHandler
+                }
+              >
+                {confirmationModal === 'deleteTopics'
+                  ? 'Are you sure you want to remove selected topics?'
+                  : 'Are you sure you want to purge messages of selected topics?'}
+              </ConfirmationModal>
+            </>
+          )}
           <table className="table is-fullwidth">
             <thead>
               <tr>
+                <th> </th>
                 <SortableColumnHeader
                   value={TopicColumnsToSort.NAME}
                   title="Topic Name"
@@ -154,6 +236,8 @@ const List: React.FC<TopicsListProps> = ({
                   clusterName={clusterName}
                   key={topic.name}
                   topic={topic}
+                  selected={selectedTopics.has(topic.name)}
+                  toggleTopicSelected={toggleTopicSelected}
                   deleteTopic={deleteTopic}
                   clearTopicMessages={clearTopicMessages}
                 />
diff --git a/kafka-ui-react-app/src/components/Topics/List/ListContainer.ts b/kafka-ui-react-app/src/components/Topics/List/ListContainer.ts
index 2f2a3c02350..2c556d8f3e8 100644
--- a/kafka-ui-react-app/src/components/Topics/List/ListContainer.ts
+++ b/kafka-ui-react-app/src/components/Topics/List/ListContainer.ts
@@ -3,6 +3,8 @@ import { RootState } from 'redux/interfaces';
 import {
   fetchTopicsList,
   deleteTopic,
+  deleteTopics,
+  clearTopicsMessages,
   clearTopicMessages,
   setTopicsSearchAction,
   setTopicsOrderByAction,
@@ -28,6 +30,8 @@ const mapStateToProps = (state: RootState) => ({
 const mapDispatchToProps = {
   fetchTopicsList,
   deleteTopic,
+  deleteTopics,
+  clearTopicsMessages,
   clearTopicMessages,
   setTopicsSearch: setTopicsSearchAction,
   setTopicsOrderBy: setTopicsOrderByAction,
diff --git a/kafka-ui-react-app/src/components/Topics/List/ListItem.tsx b/kafka-ui-react-app/src/components/Topics/List/ListItem.tsx
index 2a533027aa6..903c988c8aa 100644
--- a/kafka-ui-react-app/src/components/Topics/List/ListItem.tsx
+++ b/kafka-ui-react-app/src/components/Topics/List/ListItem.tsx
@@ -14,6 +14,8 @@ import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
 
 export interface ListItemProps {
   topic: TopicWithDetailedInfo;
+  selected: boolean;
+  toggleTopicSelected(topicName: TopicName): void;
   deleteTopic: (clusterName: ClusterName, topicName: TopicName) => void;
   clusterName: ClusterName;
   clearTopicMessages(topicName: TopicName, clusterName: ClusterName): void;
@@ -28,6 +30,8 @@ const ListItem: React.FC<ListItemProps> = ({
     replicationFactor,
     cleanUpPolicy,
   },
+  selected,
+  toggleTopicSelected,
   deleteTopic,
   clusterName,
   clearTopicMessages,
@@ -70,6 +74,17 @@ const ListItem: React.FC<ListItemProps> = ({
 
   return (
     <tr>
+      <td>
+        {!internal && (
+          <input
+            type="checkbox"
+            checked={selected}
+            onChange={() => {
+              toggleTopicSelected(name);
+            }}
+          />
+        )}
+      </td>
       <td className="has-text-overflow-ellipsis">
         <NavLink
           exact
diff --git a/kafka-ui-react-app/src/components/Topics/List/__tests__/List.spec.tsx b/kafka-ui-react-app/src/components/Topics/List/__tests__/List.spec.tsx
index bab7e7fdf68..85dcc128067 100644
--- a/kafka-ui-react-app/src/components/Topics/List/__tests__/List.spec.tsx
+++ b/kafka-ui-react-app/src/components/Topics/List/__tests__/List.spec.tsx
@@ -1,6 +1,7 @@
 import React from 'react';
 import { mount, ReactWrapper } from 'enzyme';
-import { Router } from 'react-router-dom';
+import { Route, Router } from 'react-router-dom';
+import { act } from 'react-dom/test-utils';
 import ClusterContext, {
   ContextProps,
 } from 'components/contexts/ClusterContext';
@@ -8,6 +9,13 @@ import List, { TopicsListProps } from 'components/Topics/List/List';
 import { createMemoryHistory } from 'history';
 import { StaticRouter } from 'react-router';
 import Search from 'components/common/Search/Search';
+import { externalTopicPayload } from 'redux/reducers/topics/__test__/fixtures';
+import { ConfirmationModalProps } from 'components/common/ConfirmationModal/ConfirmationModal';
+
+jest.mock(
+  'components/common/ConfirmationModal/ConfirmationModal',
+  () => 'mock-ConfirmationModal'
+);
 
 describe('List', () => {
   const setupComponent = (props: Partial<TopicsListProps> = {}) => (
@@ -17,6 +25,8 @@ describe('List', () => {
       totalPages={1}
       fetchTopicsList={jest.fn()}
       deleteTopic={jest.fn()}
+      deleteTopics={jest.fn()}
+      clearTopicsMessages={jest.fn()}
       clearTopicMessages={jest.fn()}
       search=""
       orderBy={null}
@@ -123,4 +133,130 @@ describe('List', () => {
       expect(mockedHistory.push).toHaveBeenCalledWith('/?page=1&perPage=25');
     });
   });
+
+  describe('when some list items are selected', () => {
+    const mockDeleteTopics = jest.fn();
+    const mockClearTopicsMessages = jest.fn();
+    jest.useFakeTimers();
+    const pathname = '/ui/clusters/local/topics';
+    const component = mount(
+      <StaticRouter location={{ pathname }}>
+        <Route path="/ui/clusters/:clusterName">
+          <ClusterContext.Provider
+            value={{
+              isReadOnly: false,
+              hasKafkaConnectConfigured: true,
+              hasSchemaRegistryConfigured: true,
+            }}
+          >
+            {setupComponent({
+              topics: [
+                externalTopicPayload,
+                { ...externalTopicPayload, name: 'external.topic2' },
+              ],
+              deleteTopics: mockDeleteTopics,
+              clearTopicsMessages: mockClearTopicsMessages,
+            })}
+          </ClusterContext.Provider>
+        </Route>
+      </StaticRouter>
+    );
+    const getCheckboxInput = (at: number) =>
+      component.find('ListItem').at(at).find('input[type="checkbox"]').at(0);
+
+    const getConfirmationModal = () =>
+      component.find('mock-ConfirmationModal').at(0);
+
+    it('renders delete/purge buttons', () => {
+      expect(getCheckboxInput(0).props().checked).toBeFalsy();
+      expect(getCheckboxInput(1).props().checked).toBeFalsy();
+      expect(component.find('.buttons').length).toEqual(0);
+
+      // check first item
+      getCheckboxInput(0).simulate('change');
+      expect(getCheckboxInput(0).props().checked).toBeTruthy();
+      expect(getCheckboxInput(1).props().checked).toBeFalsy();
+      expect(component.find('.buttons').length).toEqual(1);
+
+      // check second item
+      getCheckboxInput(1).simulate('change');
+      expect(getCheckboxInput(0).props().checked).toBeTruthy();
+      expect(getCheckboxInput(1).props().checked).toBeTruthy();
+      expect(component.find('.buttons').length).toEqual(1);
+
+      // uncheck second item
+      getCheckboxInput(1).simulate('change');
+      expect(getCheckboxInput(0).props().checked).toBeTruthy();
+      expect(getCheckboxInput(1).props().checked).toBeFalsy();
+      expect(component.find('.buttons').length).toEqual(1);
+
+      // uncheck first item
+      getCheckboxInput(0).simulate('change');
+      expect(getCheckboxInput(0).props().checked).toBeFalsy();
+      expect(getCheckboxInput(1).props().checked).toBeFalsy();
+      expect(component.find('.buttons').length).toEqual(0);
+    });
+
+    const checkActionButtonClick = async (action: string) => {
+      const buttonIndex = action === 'deleteTopics' ? 0 : 1;
+      const confirmationText =
+        action === 'deleteTopics'
+          ? 'Are you sure you want to remove selected topics?'
+          : 'Are you sure you want to purge messages of selected topics?';
+      const mockFn =
+        action === 'deleteTopics' ? mockDeleteTopics : mockClearTopicsMessages;
+      getCheckboxInput(0).simulate('change');
+      getCheckboxInput(1).simulate('change');
+      let modal = getConfirmationModal();
+      expect(modal.prop('isOpen')).toBeFalsy();
+      component
+        .find('.buttons')
+        .find('button')
+        .at(buttonIndex)
+        .simulate('click');
+      expect(modal.text()).toEqual(confirmationText);
+      modal = getConfirmationModal();
+      expect(modal.prop('isOpen')).toBeTruthy();
+      await act(async () => {
+        (modal.props() as ConfirmationModalProps).onConfirm();
+      });
+      component.update();
+      expect(getConfirmationModal().prop('isOpen')).toBeFalsy();
+      expect(getCheckboxInput(0).props().checked).toBeFalsy();
+      expect(getCheckboxInput(1).props().checked).toBeFalsy();
+      expect(component.find('.buttons').length).toEqual(0);
+      expect(mockFn).toBeCalledTimes(1);
+      expect(mockFn).toBeCalledWith('local', [
+        externalTopicPayload.name,
+        'external.topic2',
+      ]);
+    };
+
+    it('triggers the deleteTopics when clicked on the delete button', async () => {
+      await checkActionButtonClick('deleteTopics');
+    });
+
+    it('triggers the clearTopicsMessages when clicked on the clear button', async () => {
+      await checkActionButtonClick('clearTopicsMessages');
+    });
+
+    it('closes ConfirmationModal when clicked on the cancel button', async () => {
+      getCheckboxInput(0).simulate('change');
+      getCheckboxInput(1).simulate('change');
+      let modal = getConfirmationModal();
+      expect(modal.prop('isOpen')).toBeFalsy();
+      component.find('.buttons').find('button').at(0).simulate('click');
+      modal = getConfirmationModal();
+      expect(modal.prop('isOpen')).toBeTruthy();
+      await act(async () => {
+        (modal.props() as ConfirmationModalProps).onCancel();
+      });
+      component.update();
+      expect(getConfirmationModal().prop('isOpen')).toBeFalsy();
+      expect(getCheckboxInput(0).props().checked).toBeTruthy();
+      expect(getCheckboxInput(1).props().checked).toBeTruthy();
+      expect(component.find('.buttons').length).toEqual(1);
+      expect(mockDeleteTopics).toBeCalledTimes(0);
+    });
+  });
 });
diff --git a/kafka-ui-react-app/src/components/Topics/List/__tests__/ListItem.spec.tsx b/kafka-ui-react-app/src/components/Topics/List/__tests__/ListItem.spec.tsx
index f6521553749..9af84430eda 100644
--- a/kafka-ui-react-app/src/components/Topics/List/__tests__/ListItem.spec.tsx
+++ b/kafka-ui-react-app/src/components/Topics/List/__tests__/ListItem.spec.tsx
@@ -10,6 +10,7 @@ import ListItem, { ListItemProps } from 'components/Topics/List/ListItem';
 const mockDelete = jest.fn();
 const clusterName = 'local';
 const mockDeleteMessages = jest.fn();
+const mockToggleTopicSelected = jest.fn();
 
 jest.mock(
   'components/common/ConfirmationModal/ConfirmationModal',
@@ -23,6 +24,8 @@ describe('ListItem', () => {
       deleteTopic={mockDelete}
       clusterName={clusterName}
       clearTopicMessages={mockDeleteMessages}
+      selected={false}
+      toggleTopicSelected={mockToggleTopicSelected}
       {...props}
     />
   );
@@ -73,6 +76,18 @@ describe('ListItem', () => {
     expect(wrapper.find('.tag.is-light').text()).toEqual('Internal');
   });
 
+  it('renders without checkbox for internal topic', () => {
+    const wrapper = mount(
+      <StaticRouter>
+        <table>
+          <tbody>{setupComponent()}</tbody>
+        </table>
+      </StaticRouter>
+    );
+
+    expect(wrapper.find('td').at(0).html()).toEqual('<td></td>');
+  });
+
   it('renders correct tags for external topic', () => {
     const wrapper = mount(
       <StaticRouter>
@@ -85,6 +100,28 @@ describe('ListItem', () => {
     expect(wrapper.find('.tag.is-primary').text()).toEqual('External');
   });
 
+  it('renders with checkbox for external topic', () => {
+    const wrapper = mount(
+      <StaticRouter>
+        <table>
+          <tbody>{setupComponent({ topic: externalTopicPayload })}</tbody>
+        </table>
+      </StaticRouter>
+    );
+
+    expect(wrapper.find('td').at(0).html()).toEqual(
+      '<td><input type="checkbox"></td>'
+    );
+  });
+
+  it('triggers the toggleTopicSelected when clicked on the checkbox input', () => {
+    const wrapper = shallow(setupComponent({ topic: externalTopicPayload }));
+    expect(wrapper.exists('input')).toBeTruthy();
+    wrapper.find('input[type="checkbox"]').at(0).simulate('change');
+    expect(mockToggleTopicSelected).toBeCalledTimes(1);
+    expect(mockToggleTopicSelected).toBeCalledWith(externalTopicPayload.name);
+  });
+
   it('renders correct out of sync replicas number', () => {
     const wrapper = mount(
       <StaticRouter>
@@ -98,6 +135,6 @@ describe('ListItem', () => {
       </StaticRouter>
     );
 
-    expect(wrapper.find('td').at(2).text()).toEqual('0');
+    expect(wrapper.find('td').at(3).text()).toEqual('0');
   });
 });
diff --git a/kafka-ui-react-app/src/components/Topics/List/__tests__/__snapshots__/List.spec.tsx.snap b/kafka-ui-react-app/src/components/Topics/List/__tests__/__snapshots__/List.spec.tsx.snap
index 29e22250df2..59f2f1930e3 100644
--- a/kafka-ui-react-app/src/components/Topics/List/__tests__/__snapshots__/List.spec.tsx.snap
+++ b/kafka-ui-react-app/src/components/Topics/List/__tests__/__snapshots__/List.spec.tsx.snap
@@ -27,7 +27,9 @@ exports[`List when it does not have readonly flag matches the snapshot 1`] = `
     <List
       areTopicsFetching={false}
       clearTopicMessages={[MockFunction]}
+      clearTopicsMessages={[MockFunction]}
       deleteTopic={[MockFunction]}
+      deleteTopics={[MockFunction]}
       fetchTopicsList={
         [MockFunction] {
           "calls": Array [
@@ -165,6 +167,9 @@ exports[`List when it does not have readonly flag matches the snapshot 1`] = `
           >
             <thead>
               <tr>
+                <th>
+                   
+                </th>
                 <ListHeaderCell
                   orderBy={null}
                   setOrderBy={[MockFunction]}
diff --git a/kafka-ui-react-app/src/redux/actions/thunks/topics.ts b/kafka-ui-react-app/src/redux/actions/thunks/topics.ts
index a00bee66bff..55dd2e6baff 100644
--- a/kafka-ui-react-app/src/redux/actions/thunks/topics.ts
+++ b/kafka-ui-react-app/src/redux/actions/thunks/topics.ts
@@ -87,6 +87,14 @@ export const clearTopicMessages =
     }
   };
 
+export const clearTopicsMessages =
+  (clusterName: ClusterName, topicsName: TopicName[]): PromiseThunkResult =>
+  async (dispatch) => {
+    topicsName.forEach((topicName) => {
+      dispatch(clearTopicMessages(clusterName, topicName));
+    });
+  };
+
 export const fetchTopicDetails =
   (clusterName: ClusterName, topicName: TopicName): PromiseThunkResult =>
   async (dispatch, getState) => {
@@ -285,6 +293,14 @@ export const deleteTopic =
     }
   };
 
+export const deleteTopics =
+  (clusterName: ClusterName, topicsName: TopicName[]): PromiseThunkResult =>
+  async (dispatch) => {
+    topicsName.forEach((topicName) => {
+      dispatch(deleteTopic(clusterName, topicName));
+    });
+  };
+
 export const fetchTopicConsumerGroups =
   (clusterName: ClusterName, topicName: TopicName): PromiseThunkResult =>
   async (dispatch, getState) => {


Test patch: