UI for issues/243 and issues/244

This commit is contained in:
Ilnur Yakupov 2021-08-05 12:29:48 +03:00
parent 4067880966
commit 08a3c5e225
5 changed files with 279 additions and 2 deletions

View file

@ -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 want to remove selected topics?'
: 'Are you sure 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}
/>

View file

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

View file

@ -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 want to remove selected topics?'
: 'Are you sure 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);
});
});
});

View file

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

View file

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