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 9440dff902..d62c6a637b 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 = ({ totalPages, fetchTopicsList, deleteTopic, + deleteTopics, clearTopicMessages, + clearTopicsMessages, search, orderBy, setTopicsSearch, @@ -78,6 +83,45 @@ const List: React.FC = ({ 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>( + 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 (
{showInternal ? `All Topics` : `External Topics`} @@ -119,9 +163,47 @@ const List: React.FC = ({ ) : (
+ {selectedTopics.size > 0 && ( + <> +
+ + +
+ + {confirmationModal === 'deleteTopics' + ? 'Are you sure want to remove selected topics?' + : 'Are you sure want to purge messages of selected topics?'} + + + )} + = ({ 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/ListItem.tsx b/kafka-ui-react-app/src/components/Topics/List/ListItem.tsx index 2a533027aa..903c988c8a 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 = ({ replicationFactor, cleanUpPolicy, }, + selected, + toggleTopicSelected, deleteTopic, clusterName, clearTopicMessages, @@ -70,6 +74,17 @@ const ListItem: React.FC = ({ return ( + '); + }); + it('renders correct tags for external topic', () => { const wrapper = mount( @@ -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( + +
+ {!internal && ( + { + toggleTopicSelected(name); + }} + /> + )} + 'mock-ConfirmationModal' +); describe('List', () => { const setupComponent = (props: Partial = {}) => ( @@ -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( + + + + {setupComponent({ + topics: [ + externalTopicPayload, + { ...externalTopicPayload, name: 'external.topic2' }, + ], + deleteTopics: mockDeleteTopics, + clearTopicsMessages: mockClearTopicsMessages, + })} + + + + ); + 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); + }); + }); }); 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 f652155374..9af84430ed 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( + + + {setupComponent()} +
+
+ ); + + expect(wrapper.find('td').at(0).html()).toEqual('
+ {setupComponent({ topic: externalTopicPayload })} +
+ + ); + + expect(wrapper.find('td').at(0).html()).toEqual( + '' + ); + }); + + 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( @@ -98,6 +135,6 @@ describe('ListItem', () => { ); - 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 29e22250df..59f2f1930e 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`] = ` + + +