Repository: provectus/kafka-ui
Issue: Add support for deleting topic 
Labels: type/enhancement
scope/frontend

I would like to ask support for deleting a topic

Issue comments:

Pull request review comments:
pls use useCallback
Should be
```
const mapDispatchToProps = { deleteTopic };
```
I'm not sure there is a need to use a container in this case. I would suggest to provide deleteTopic from parent component. 

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 3d5db93eedf..9b5ad3c039b 100644
--- a/kafka-ui-react-app/src/components/Topics/List/List.tsx
+++ b/kafka-ui-react-app/src/components/Topics/List/List.tsx
@@ -1,5 +1,9 @@
 import React from 'react';
-import { TopicWithDetailedInfo, ClusterName } from 'redux/interfaces';
+import {
+  TopicWithDetailedInfo,
+  ClusterName,
+  TopicName,
+} from 'redux/interfaces';
 import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
 import { Link, useParams } from 'react-router-dom';
 import { clusterTopicNewPath } from 'lib/paths';
@@ -16,6 +20,7 @@ interface Props {
   externalTopics: TopicWithDetailedInfo[];
   totalPages: number;
   fetchTopicsList(props: FetchTopicsListParams): void;
+  deleteTopic(topicName: TopicName, clusterName: ClusterName): void;
 }
 
 const List: React.FC<Props> = ({
@@ -24,6 +29,7 @@ const List: React.FC<Props> = ({
   externalTopics,
   totalPages,
   fetchTopicsList,
+  deleteTopic,
 }) => {
   const { isReadOnly } = React.useContext(ClusterContext);
   const { clusterName } = useParams<{ clusterName: ClusterName }>();
@@ -82,17 +88,23 @@ const List: React.FC<Props> = ({
                 <th>Total Partitions</th>
                 <th>Out of sync replicas</th>
                 <th>Type</th>
+                <th> </th>
               </tr>
             </thead>
             <tbody>
+              {items.map((topic) => (
+                <ListItem
+                  clusterName={clusterName}
+                  key={topic.name}
+                  topic={topic}
+                  deleteTopic={deleteTopic}
+                />
+              ))}
               {items.length === 0 && (
                 <tr>
                   <td colSpan={10}>No topics found</td>
                 </tr>
               )}
-              {items.map((topic) => (
-                <ListItem key={topic.name} topic={topic} />
-              ))}
             </tbody>
           </table>
           <Pagination totalPages={totalPages} />
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 6ade3cf75a4..18f1df98aae 100644
--- a/kafka-ui-react-app/src/components/Topics/List/ListContainer.ts
+++ b/kafka-ui-react-app/src/components/Topics/List/ListContainer.ts
@@ -1,6 +1,6 @@
 import { connect } from 'react-redux';
 import { RootState } from 'redux/interfaces';
-import { fetchTopicsList } from 'redux/actions';
+import { fetchTopicsList, deleteTopic } from 'redux/actions';
 import {
   getTopicList,
   getExternalTopicList,
@@ -18,6 +18,7 @@ const mapStateToProps = (state: RootState) => ({
 
 const mapDispatchToProps = {
   fetchTopicsList,
+  deleteTopic,
 };
 
 export default connect(mapStateToProps, mapDispatchToProps)(List);
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 de2c2c78f2c..c467b9831a5 100644
--- a/kafka-ui-react-app/src/components/Topics/List/ListItem.tsx
+++ b/kafka-ui-react-app/src/components/Topics/List/ListItem.tsx
@@ -1,14 +1,22 @@
 import React from 'react';
 import cx from 'classnames';
 import { NavLink } from 'react-router-dom';
-import { TopicWithDetailedInfo } from 'redux/interfaces';
+import {
+  ClusterName,
+  TopicName,
+  TopicWithDetailedInfo,
+} from 'redux/interfaces';
 
 interface ListItemProps {
   topic: TopicWithDetailedInfo;
+  deleteTopic: (clusterName: ClusterName, topicName: TopicName) => void;
+  clusterName: ClusterName;
 }
 
 const ListItem: React.FC<ListItemProps> = ({
   topic: { name, internal, partitions },
+  deleteTopic,
+  clusterName,
 }) => {
   const outOfSyncReplicas = React.useMemo(() => {
     if (partitions === undefined || partitions.length === 0) {
@@ -21,6 +29,10 @@ const ListItem: React.FC<ListItemProps> = ({
     }, 0);
   }, [partitions]);
 
+  const deleteTopicHandler = React.useCallback(() => {
+    deleteTopic(clusterName, name);
+  }, [clusterName, name]);
+
   return (
     <tr>
       <td>
@@ -42,6 +54,17 @@ const ListItem: React.FC<ListItemProps> = ({
           {internal ? 'Internal' : 'External'}
         </div>
       </td>
+      <td>
+        <button
+          type="button"
+          className="is-small button is-danger"
+          onClick={deleteTopicHandler}
+        >
+          <span className="icon is-small">
+            <i className="far fa-trash-alt" />
+          </span>
+        </button>
+      </td>
     </tr>
   );
 };
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 bd065658d66..dda12424135 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
@@ -16,6 +16,7 @@ describe('List', () => {
               externalTopics={[]}
               totalPages={1}
               fetchTopicsList={jest.fn()}
+              deleteTopic={jest.fn()}
             />
           </ClusterContext.Provider>
         </StaticRouter>
@@ -35,6 +36,7 @@ describe('List', () => {
               externalTopics={[]}
               totalPages={1}
               fetchTopicsList={jest.fn()}
+              deleteTopic={jest.fn()}
             />
           </ClusterContext.Provider>
         </StaticRouter>
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
new file mode 100644
index 00000000000..5c3c72e4d44
--- /dev/null
+++ b/kafka-ui-react-app/src/components/Topics/List/__tests__/ListItem.spec.tsx
@@ -0,0 +1,21 @@
+import { shallow } from 'enzyme';
+import React from 'react';
+import ListItem from '../ListItem';
+
+describe('ListItem', () => {
+  it('triggers the deleting thunk when clicked on the delete button', () => {
+    const mockDelete = jest.fn();
+    const topic = { name: 'topic', id: 'id' };
+    const clustterName = 'cluster';
+    const component = shallow(
+      <ListItem
+        topic={topic}
+        deleteTopic={mockDelete}
+        clusterName={clustterName}
+      />
+    );
+    component.find('button').simulate('click');
+    expect(mockDelete).toBeCalledTimes(1);
+    expect(mockDelete).toBeCalledWith(clustterName, topic.name);
+  });
+});
diff --git a/kafka-ui-react-app/src/redux/actions/__test__/thunks.spec.ts b/kafka-ui-react-app/src/redux/actions/__test__/thunks.spec.ts
index 522b7e1f121..382f8b2b848 100644
--- a/kafka-ui-react-app/src/redux/actions/__test__/thunks.spec.ts
+++ b/kafka-ui-react-app/src/redux/actions/__test__/thunks.spec.ts
@@ -22,6 +22,7 @@ const mockStoreCreator: MockStoreCreator<
 const store: MockStoreEnhanced<RootState, DispatchExts> = mockStoreCreator();
 
 const clusterName = 'local';
+const topicName = 'localTopic';
 const subject = 'test';
 
 describe('Thunks', () => {
@@ -137,4 +138,34 @@ describe('Thunks', () => {
       }
     });
   });
+
+  describe('deleteTopis', () => {
+    it('creates DELETE_TOPIC__SUCCESS when deleting existing topic', async () => {
+      fetchMock.deleteOnce(
+        `/api/clusters/${clusterName}/topics/${topicName}`,
+        200
+      );
+      await store.dispatch(thunks.deleteTopic(clusterName, topicName));
+      expect(store.getActions()).toEqual([
+        actions.deleteTopicAction.request(),
+        actions.deleteTopicAction.success(topicName),
+      ]);
+    });
+
+    it('creates DELETE_TOPIC__FAILURE when deleting existing topic', async () => {
+      fetchMock.postOnce(
+        `/api/clusters/${clusterName}/topics/${topicName}`,
+        404
+      );
+      try {
+        await store.dispatch(thunks.deleteTopic(clusterName, topicName));
+      } catch (error) {
+        expect(error.status).toEqual(404);
+        expect(store.getActions()).toEqual([
+          actions.deleteTopicAction.request(),
+          actions.deleteTopicAction.failure(),
+        ]);
+      }
+    });
+  });
 });
diff --git a/kafka-ui-react-app/src/redux/actions/actions.ts b/kafka-ui-react-app/src/redux/actions/actions.ts
index 7e9b1bc64b1..a7e4132bd94 100644
--- a/kafka-ui-react-app/src/redux/actions/actions.ts
+++ b/kafka-ui-react-app/src/redux/actions/actions.ts
@@ -1,5 +1,5 @@
 import { createAsyncAction } from 'typesafe-actions';
-import { ConsumerGroupID, TopicsState } from 'redux/interfaces';
+import { ConsumerGroupID, TopicName, TopicsState } from 'redux/interfaces';
 
 import {
   Cluster,
@@ -79,6 +79,12 @@ export const updateTopicAction = createAsyncAction(
   'PATCH_TOPIC__FAILURE'
 )<undefined, TopicsState, undefined>();
 
+export const deleteTopicAction = createAsyncAction(
+  'DELETE_TOPIC__REQUEST',
+  'DELETE_TOPIC__SUCCESS',
+  'DELETE_TOPIC__FAILURE'
+)<undefined, TopicName, undefined>();
+
 export const fetchConsumerGroupsAction = createAsyncAction(
   'GET_CONSUMER_GROUPS__REQUEST',
   'GET_CONSUMER_GROUPS__SUCCESS',
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 da5616b174e..0b3e024182c 100644
--- a/kafka-ui-react-app/src/redux/actions/thunks/topics.ts
+++ b/kafka-ui-react-app/src/redux/actions/thunks/topics.ts
@@ -230,3 +230,19 @@ export const updateTopic = (
     dispatch(actions.updateTopicAction.failure());
   }
 };
+
+export const deleteTopic = (
+  clusterName: ClusterName,
+  topicName: TopicName
+): PromiseThunkResult => async (dispatch) => {
+  dispatch(actions.deleteTopicAction.request());
+  try {
+    await topicsApiClient.deleteTopic({
+      clusterName,
+      topicName,
+    });
+    dispatch(actions.deleteTopicAction.success(topicName));
+  } catch (e) {
+    dispatch(actions.deleteTopicAction.failure());
+  }
+};
diff --git a/kafka-ui-react-app/src/redux/reducers/topics/__tests__/reducer.spec.ts b/kafka-ui-react-app/src/redux/reducers/topics/__tests__/reducer.spec.ts
new file mode 100644
index 00000000000..46bbcc0140d
--- /dev/null
+++ b/kafka-ui-react-app/src/redux/reducers/topics/__tests__/reducer.spec.ts
@@ -0,0 +1,29 @@
+import { deleteTopicAction } from 'redux/actions';
+import reducer from '../reducer';
+
+describe('topics reducer', () => {
+  it('deletes the topic from the list on DELETE_TOPIC__SUCCESS', () => {
+    const topic = {
+      name: 'topic',
+      id: 'id',
+    };
+    expect(
+      reducer(
+        {
+          byName: {
+            topic,
+          },
+          allNames: [topic.name],
+          messages: [],
+          totalPages: 1,
+        },
+        deleteTopicAction.success(topic.name)
+      )
+    ).toEqual({
+      byName: {},
+      allNames: [],
+      messages: [],
+      totalPages: 1,
+    });
+  });
+});
diff --git a/kafka-ui-react-app/src/redux/reducers/topics/reducer.ts b/kafka-ui-react-app/src/redux/reducers/topics/reducer.ts
index 665bb57e319..464bd6e6a11 100644
--- a/kafka-ui-react-app/src/redux/reducers/topics/reducer.ts
+++ b/kafka-ui-react-app/src/redux/reducers/topics/reducer.ts
@@ -45,6 +45,14 @@ const reducer = (state = initialState, action: Action): TopicsState => {
       return action.payload;
     case getType(actions.fetchTopicMessagesAction.success):
       return transformTopicMessages(state, action.payload);
+    case getType(actions.deleteTopicAction.success): {
+      const newState: TopicsState = { ...state };
+      delete newState.byName[action.payload];
+      newState.allNames = newState.allNames.filter(
+        (name) => name !== action.payload
+      );
+      return newState;
+    }
     default:
       return state;
   }


Test patch: