From 634406ac91ec587a3dcaa7ef293f99ae2e68ab90 Mon Sep 17 00:00:00 2001 From: Zorii4 <83412197+Zorii4@users.noreply.github.com> Date: Mon, 21 Mar 2022 12:58:13 +0300 Subject: [PATCH] [Issue-998] Add Recreate topic button in to list of Topic and Details topic Overview (#1660) * Add Recreate topic button in to list of Topic and Details topic Overview * Add reducer and update test * update reducer test * update Topic/Details test * Table and TableColumn components, TableState and DataSource * Table: Migrate topics table to new Table component * fix module paths * test for propertyLookup * improve useTableState code * fix folder name * improve table ordering Co-authored-by: Anton Zorin Co-authored-by: Sash Stepanyan Co-authored-by: Sasha Stepanyan <100123785+sasunprov@users.noreply.github.com> --- .../src/components/Topics/List/List.tsx | 26 +++++++++++++ .../components/Topics/List/ListContainer.ts | 2 + .../src/components/Topics/List/ListItem.tsx | 25 ++++++++++++ .../Topics/List/__tests__/List.spec.tsx | 1 + .../Topics/List/__tests__/ListItem.spec.tsx | 3 +- .../Topics/Topic/Details/Details.tsx | 24 ++++++++++++ .../Topics/Topic/Details/DetailsContainer.ts | 3 +- .../Topic/Details/__test__/Details.spec.tsx | 39 +++++++++++++++++++ .../actions/__test__/thunks/topics.spec.ts | 32 +++++++++++++++ .../src/redux/actions/actions.ts | 8 ++++ .../src/redux/actions/thunks/topics.ts | 15 +++++++ .../reducers/topics/__test__/reducer.spec.ts | 10 +++++ .../src/redux/reducers/topics/reducer.ts | 8 ++++ 13 files changed, 194 insertions(+), 2 deletions(-) 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 4458761f7e..f3f42cdc80 100644 --- a/kafka-ui-react-app/src/components/Topics/List/List.tsx +++ b/kafka-ui-react-app/src/components/Topics/List/List.tsx @@ -47,6 +47,7 @@ export interface TopicsListProps { fetchTopicsList(props: GetTopicsRequest): void; deleteTopic(topicName: TopicName, clusterName: ClusterName): void; deleteTopics(topicName: TopicName, clusterNames: ClusterName[]): void; + recreateTopic(topicName: TopicName, clusterName: ClusterName): void; clearTopicsMessages(topicName: TopicName, clusterNames: ClusterName[]): void; clearTopicMessages( topicName: TopicName, @@ -67,6 +68,7 @@ const List: React.FC = ({ fetchTopicsList, deleteTopic, deleteTopics, + recreateTopic, clearTopicMessages, clearTopicsMessages, search, @@ -169,6 +171,11 @@ const List: React.FC = ({ setDeleteTopicConfirmationVisible, ] = React.useState(false); + const [ + isRecreateTopicConfirmationVisible, + setRecreateTopicConfirmationVisible, + ] = React.useState(false); + const deleteTopicHandler = React.useCallback(() => { deleteTopic(clusterName, name); }, [name]); @@ -176,6 +183,12 @@ const List: React.FC = ({ const clearTopicMessagesHandler = React.useCallback(() => { clearTopicMessages(clusterName, name); }, [name]); + + const recreateTopicHandler = React.useCallback(() => { + recreateTopic(clusterName, name); + setRecreateTopicConfirmationVisible(false); + }, [name]); + return ( <> {!internal && !isReadOnly && hovered ? ( @@ -194,6 +207,12 @@ const List: React.FC = ({ Remove Topic )} + setRecreateTopicConfirmationVisible(true)} + danger + > + Recreate Topic + ) : null} @@ -204,6 +223,13 @@ const List: React.FC = ({ > Are you sure want to remove {name} topic? + setRecreateTopicConfirmationVisible(false)} + onConfirm={recreateTopicHandler} + > + Are you sure to recreate {name} 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 f18111d415..f5b6380fd1 100644 --- a/kafka-ui-react-app/src/components/Topics/List/ListContainer.ts +++ b/kafka-ui-react-app/src/components/Topics/List/ListContainer.ts @@ -4,6 +4,7 @@ import { fetchTopicsList, deleteTopic, deleteTopics, + recreateTopic, clearTopicsMessages, clearTopicMessages, setTopicsSearchAction, @@ -33,6 +34,7 @@ const mapDispatchToProps = { fetchTopicsList, deleteTopic, deleteTopics, + recreateTopic, clearTopicsMessages, clearTopicMessages, setTopicsSearch: setTopicsSearchAction, 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 1374aa16a6..0b72343c6b 100644 --- a/kafka-ui-react-app/src/components/Topics/List/ListItem.tsx +++ b/kafka-ui-react-app/src/components/Topics/List/ListItem.tsx @@ -20,6 +20,7 @@ export interface ListItemProps { selected: boolean; toggleTopicSelected(topicName: TopicName): void; deleteTopic: (clusterName: ClusterName, topicName: TopicName) => void; + recreateTopic: (clusterName: ClusterName, topicName: TopicName) => void; clusterName: ClusterName; clearTopicMessages(topicName: TopicName, clusterName: ClusterName): void; } @@ -36,6 +37,7 @@ const ListItem: React.FC = ({ selected, toggleTopicSelected, deleteTopic, + recreateTopic, clusterName, clearTopicMessages, }) => { @@ -45,6 +47,11 @@ const ListItem: React.FC = ({ const [isDeleteTopicConfirmationVisible, setDeleteTopicConfirmationVisible] = React.useState(false); + const [ + isRecreateTopicConfirmationVisible, + setRecreateTopicConfirmationVisible, + ] = React.useState(false); + const { outOfSyncReplicas, numberOfMessages } = React.useMemo(() => { if (partitions === undefined || partitions.length === 0) { return { @@ -72,6 +79,11 @@ const ListItem: React.FC = ({ deleteTopic(clusterName, name); }, [clusterName, deleteTopic, name]); + const recreateTopicHandler = React.useCallback(() => { + recreateTopic(clusterName, name); + setRecreateTopicConfirmationVisible(false); + }, [recreateTopic, clusterName, name]); + const clearTopicMessagesHandler = React.useCallback(() => { clearTopicMessages(clusterName, name); }, [clearTopicMessages, clusterName, name]); @@ -125,6 +137,12 @@ const ListItem: React.FC = ({ Remove Topic )} + setRecreateTopicConfirmationVisible(true)} + danger + > + Recreate Topic + ) : null} @@ -135,6 +153,13 @@ const ListItem: React.FC = ({ > Are you sure want to remove {name} topic? + setRecreateTopicConfirmationVisible(false)} + onConfirm={recreateTopicHandler} + > + Are you sure to recreate {name} topic? + ); 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 5aacb2543e..3e6ca15129 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 @@ -32,6 +32,7 @@ describe('List', () => { deleteTopics={jest.fn()} clearTopicsMessages={jest.fn()} clearTopicMessages={jest.fn()} + recreateTopic={jest.fn()} search="" orderBy={null} sortOrder={SortOrder.ASC} 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 e89b3410f7..d2dc602bb7 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 @@ -13,7 +13,7 @@ const mockDelete = jest.fn(); const clusterName = 'local'; const mockDeleteMessages = jest.fn(); const mockToggleTopicSelected = jest.fn(); - +const mockRecreateTopic = jest.fn(); jest.mock( 'components/common/ConfirmationModal/ConfirmationModal', () => 'mock-ConfirmationModal' @@ -35,6 +35,7 @@ describe('ListItem', () => { deleteTopic={mockDelete} clusterName={clusterName} clearTopicMessages={mockDeleteMessages} + recreateTopic={mockRecreateTopic} selected={false} toggleTopicSelected={mockToggleTopicSelected} {...props} diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Details/Details.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Details/Details.tsx index d617ad90b1..982fe4fdeb 100644 --- a/kafka-ui-react-app/src/components/Topics/Topic/Details/Details.tsx +++ b/kafka-ui-react-app/src/components/Topics/Topic/Details/Details.tsx @@ -36,6 +36,7 @@ interface Props extends Topic, TopicDetails { isDeleted: boolean; isDeletePolicy: boolean; deleteTopic: (clusterName: ClusterName, topicName: TopicName) => void; + recreateTopic: (clusterName: ClusterName, topicName: TopicName) => void; clearTopicMessages(clusterName: ClusterName, topicName: TopicName): void; } @@ -53,6 +54,7 @@ const Details: React.FC = ({ isDeleted, isDeletePolicy, deleteTopic, + recreateTopic, clearTopicMessages, }) => { const history = useHistory(); @@ -63,6 +65,10 @@ const Details: React.FC = ({ React.useState(false); const [isClearTopicConfirmationVisible, setClearTopicConfirmationVisible] = React.useState(false); + const [ + isRecreateTopicConfirmationVisible, + setRecreateTopicConfirmationVisible, + ] = React.useState(false); const deleteTopicHandler = React.useCallback(() => { deleteTopic(clusterName, topicName); }, [clusterName, topicName, deleteTopic]); @@ -79,6 +85,11 @@ const Details: React.FC = ({ setClearTopicConfirmationVisible(false); }, [clusterName, topicName, clearTopicMessages]); + const recreateTopicHandler = React.useCallback(() => { + recreateTopic(clusterName, topicName); + setRecreateTopicConfirmationVisible(false); + }, [recreateTopic, clusterName, topicName]); + return (
@@ -116,6 +127,12 @@ const Details: React.FC = ({ Clear messages )} + setRecreateTopicConfirmationVisible(true)} + danger + > + Recreate Topic + {isTopicDeletionAllowed && ( setDeleteTopicConfirmationVisible(true)} @@ -143,6 +160,13 @@ const Details: React.FC = ({ > Are you sure want to clear topic messages? + setRecreateTopicConfirmationVisible(false)} + onConfirm={recreateTopicHandler} + > + Are you sure want to recreate {topicName} topic? + { const mockClusterName = 'local'; const mockClearTopicMessages = jest.fn(); const mockInternalTopicPayload = internalTopicPayload.internal; + const mockRecreateTopic = jest.fn(); const setupComponent = (pathname: string) => render( @@ -29,6 +30,7 @@ describe('Details', () => { name={internalTopicPayload.name} isInternal={false} deleteTopic={mockDelete} + recreateTopic={mockRecreateTopic} clearTopicMessages={mockClearTopicMessages} isDeleted={false} isDeletePolicy @@ -54,6 +56,7 @@ describe('Details', () => { name={internalTopicPayload.name} isInternal={mockInternalTopicPayload} deleteTopic={mockDelete} + recreateTopic={mockRecreateTopic} clearTopicMessages={mockClearTopicMessages} isDeleted={false} isDeletePolicy @@ -77,4 +80,40 @@ describe('Details', () => { getByText(/Are you sure want to clear topic messages?/i) ).toBeInTheDocument(); }); + + it('shows a confirmation popup on recreating topic', () => { + setupComponent( + clusterTopicPath(mockClusterName, internalTopicPayload.name) + ); + const recreateTopicButton = screen.getByText(/Recreate topic/i); + userEvent.click(recreateTopicButton); + + expect( + screen.getByText(/Are you sure want to recreate topic?/i) + ).toBeInTheDocument(); + }); + + it('calling recreation function after click on Submit button', () => { + setupComponent( + clusterTopicPath(mockClusterName, internalTopicPayload.name) + ); + const recreateTopicButton = screen.getByText(/Recreate topic/i); + userEvent.click(recreateTopicButton); + const confirmBtn = screen.getByRole('button', { name: /submit/i }); + userEvent.click(confirmBtn); + expect(mockRecreateTopic).toBeCalledTimes(1); + }); + + it('close popup confirmation window after click on Cancel button', () => { + setupComponent( + clusterTopicPath(mockClusterName, internalTopicPayload.name) + ); + const recreateTopicButton = screen.getByText(/Recreate topic/i); + userEvent.click(recreateTopicButton); + const cancelBtn = screen.getByRole('button', { name: /cancel/i }); + userEvent.click(cancelBtn); + expect( + screen.queryByText(/Are you sure want to recreate topic?/i) + ).not.toBeInTheDocument(); + }); }); diff --git a/kafka-ui-react-app/src/redux/actions/__test__/thunks/topics.spec.ts b/kafka-ui-react-app/src/redux/actions/__test__/thunks/topics.spec.ts index e7501b8f84..51426f6d67 100644 --- a/kafka-ui-react-app/src/redux/actions/__test__/thunks/topics.spec.ts +++ b/kafka-ui-react-app/src/redux/actions/__test__/thunks/topics.spec.ts @@ -6,6 +6,7 @@ import { mockTopicsState } from 'redux/actions/__test__/fixtures'; import { MessageSchemaSourceEnum, TopicMessageSchema } from 'generated-sources'; import { FailurePayload } from 'redux/interfaces'; import { getResponse } from 'lib/errorHandling'; +import { internalTopicPayload } from 'redux/reducers/topics/__test__/fixtures'; const store = mockStoreCreator; @@ -49,6 +50,37 @@ describe('Thunks', () => { }); }); + describe('recreateTopic', () => { + it('creates RECREATE_TOPIC__SUCCESS when recreating existing topic', async () => { + fetchMock.postOnce( + `/api/clusters/${clusterName}/topics/${topicName}`, + internalTopicPayload + ); + await store.dispatch(thunks.recreateTopic(clusterName, topicName)); + expect(store.getActions()).toEqual([ + actions.recreateTopicAction.request(), + actions.recreateTopicAction.success(internalTopicPayload), + ]); + }); + + it('creates RECREATE_TOPIC__FAILURE when recreating existing topic', async () => { + fetchMock.postOnce( + `/api/clusters/${clusterName}/topics/${topicName}`, + 404 + ); + try { + await store.dispatch(thunks.recreateTopic(clusterName, topicName)); + } catch (error) { + const err = error as Response; + expect(err.status).toEqual(404); + expect(store.getActions()).toEqual([ + actions.recreateTopicAction.request(), + actions.recreateTopicAction.failure(), + ]); + } + }); + }); + describe('clearTopicMessages', () => { it('creates CLEAR_TOPIC_MESSAGES__SUCCESS when deleting existing messages', async () => { fetchMock.deleteOnce( diff --git a/kafka-ui-react-app/src/redux/actions/actions.ts b/kafka-ui-react-app/src/redux/actions/actions.ts index ee280ba863..464d918520 100644 --- a/kafka-ui-react-app/src/redux/actions/actions.ts +++ b/kafka-ui-react-app/src/redux/actions/actions.ts @@ -12,6 +12,7 @@ import { FullConnectorInfo, Connect, Task, + Topic, TopicMessage, TopicMessageConsuming, TopicMessageSchema, @@ -60,6 +61,13 @@ export const deleteTopicAction = createAsyncAction( 'DELETE_TOPIC__CANCEL' )(); +export const recreateTopicAction = createAsyncAction( + 'RECREATE_TOPIC__REQUEST', + 'RECREATE_TOPIC__SUCCESS', + 'RECREATE_TOPIC__FAILURE', + 'RECREATE_TOPIC__CANCEL' +)(); + export const dismissAlert = createAction('DISMISS_ALERT')(); export const fetchConnectsAction = createAsyncAction( 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 a781aeb0e0..00aa1ea014 100644 --- a/kafka-ui-react-app/src/redux/actions/thunks/topics.ts +++ b/kafka-ui-react-app/src/redux/actions/thunks/topics.ts @@ -259,6 +259,21 @@ export const deleteTopic = } }; +export const recreateTopic = + (clusterName: ClusterName, topicName: TopicName): PromiseThunkResult => + async (dispatch) => { + dispatch(actions.recreateTopicAction.request()); + try { + const topic = await topicsApiClient.recreateTopic({ + clusterName, + topicName, + }); + dispatch(actions.recreateTopicAction.success(topic)); + } catch (e) { + dispatch(actions.recreateTopicAction.failure()); + } + }; + export const deleteTopics = (clusterName: ClusterName, topicsName: TopicName[]): PromiseThunkResult => async (dispatch) => { diff --git a/kafka-ui-react-app/src/redux/reducers/topics/__test__/reducer.spec.ts b/kafka-ui-react-app/src/redux/reducers/topics/__test__/reducer.spec.ts index ce05db0506..946d3b1146 100644 --- a/kafka-ui-react-app/src/redux/reducers/topics/__test__/reducer.spec.ts +++ b/kafka-ui-react-app/src/redux/reducers/topics/__test__/reducer.spec.ts @@ -10,6 +10,7 @@ import { setTopicsOrderByAction, fetchTopicConsumerGroupsAction, fetchTopicMessageSchemaAction, + recreateTopicAction, } from 'redux/actions'; import reducer from 'redux/reducers/topics/reducer'; @@ -94,6 +95,15 @@ describe('topics reducer', () => { it('delete topic messages on CLEAR_TOPIC_MESSAGES__SUCCESS', () => { expect(reducer(state, clearMessagesTopicAction.success())).toEqual(state); }); + + it('recreate topic', () => { + expect(reducer(state, recreateTopicAction.success(topic))).toEqual({ + ...state, + byName: { + [topic.name]: topic, + }, + }); + }); }); describe('search topics', () => { 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 e8fe50fad9..0838066352 100644 --- a/kafka-ui-react-app/src/redux/reducers/topics/reducer.ts +++ b/kafka-ui-react-app/src/redux/reducers/topics/reducer.ts @@ -32,6 +32,14 @@ const reducer = (state = initialState, action: Action): TopicsState => { ); return newState; } + case getType(actions.recreateTopicAction.success): + return { + ...state, + byName: { + ...state.byName, + [action.payload.name]: { ...action.payload }, + }, + }; case getType(actions.setTopicsSearchAction): { return { ...state,