From 595707edb67b93a98524265ce7f12db94ac796d4 Mon Sep 17 00:00:00 2001 From: Alexander Krivonosov <31561808+GneyHabub@users.noreply.github.com> Date: Tue, 23 Mar 2021 11:34:16 +0300 Subject: [PATCH] #224 Deleting topics (#271) * Implement topic deletion * Test --- .../src/components/Topics/List/List.tsx | 20 +++++++++--- .../components/Topics/List/ListContainer.ts | 3 +- .../src/components/Topics/List/ListItem.tsx | 25 ++++++++++++++- .../Topics/List/__tests__/List.spec.tsx | 2 ++ .../Topics/List/__tests__/ListItem.spec.tsx | 21 +++++++++++++ .../src/redux/actions/__test__/thunks.spec.ts | 31 +++++++++++++++++++ .../src/redux/actions/actions.ts | 8 ++++- .../src/redux/actions/thunks/topics.ts | 16 ++++++++++ .../reducers/topics/__tests__/reducer.spec.ts | 29 +++++++++++++++++ .../src/redux/reducers/topics/reducer.ts | 8 +++++ 10 files changed, 156 insertions(+), 7 deletions(-) create mode 100644 kafka-ui-react-app/src/components/Topics/List/__tests__/ListItem.spec.tsx create mode 100644 kafka-ui-react-app/src/redux/reducers/topics/__tests__/reducer.spec.ts 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 3d5db93eed..9b5ad3c039 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 = ({ @@ -24,6 +29,7 @@ const List: React.FC = ({ externalTopics, totalPages, fetchTopicsList, + deleteTopic, }) => { const { isReadOnly } = React.useContext(ClusterContext); const { clusterName } = useParams<{ clusterName: ClusterName }>(); @@ -82,17 +88,23 @@ const List: React.FC = ({ Total Partitions Out of sync replicas Type + + {items.map((topic) => ( + + ))} {items.length === 0 && ( No topics found )} - {items.map((topic) => ( - - ))} 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 6ade3cf75a..18f1df98aa 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 de2c2c78f2..c467b9831a 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 = ({ topic: { name, internal, partitions }, + deleteTopic, + clusterName, }) => { const outOfSyncReplicas = React.useMemo(() => { if (partitions === undefined || partitions.length === 0) { @@ -21,6 +29,10 @@ const ListItem: React.FC = ({ }, 0); }, [partitions]); + const deleteTopicHandler = React.useCallback(() => { + deleteTopic(clusterName, name); + }, [clusterName, name]); + return ( @@ -42,6 +54,17 @@ const ListItem: React.FC = ({ {internal ? 'Internal' : 'External'} + + + ); }; 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 bd065658d6..dda1242413 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()} /> @@ -35,6 +36,7 @@ describe('List', () => { externalTopics={[]} totalPages={1} fetchTopicsList={jest.fn()} + deleteTopic={jest.fn()} /> 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 0000000000..5c3c72e4d4 --- /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( + + ); + 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 522b7e1f12..382f8b2b84 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 = 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 7e9b1bc64b..a7e4132bd9 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' )(); +export const deleteTopicAction = createAsyncAction( + 'DELETE_TOPIC__REQUEST', + 'DELETE_TOPIC__SUCCESS', + 'DELETE_TOPIC__FAILURE' +)(); + 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 da5616b174..0b3e024182 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 0000000000..46bbcc0140 --- /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 665bb57e31..464bd6e6a1 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; }