#224 Deleting topics (#271)

* Implement topic deletion

* Test
This commit is contained in:
Alexander Krivonosov 2021-03-23 11:34:16 +03:00 committed by GitHub
parent 590bdfb610
commit 595707edb6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 156 additions and 7 deletions

View file

@ -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} />

View file

@ -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);

View file

@ -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>
);
};

View file

@ -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>

View file

@ -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);
});
});

View file

@ -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(),
]);
}
});
});
});

View file

@ -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',

View file

@ -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());
}
};

View file

@ -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,
});
});
});

View file

@ -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;
}