Browse Source

Refactor topics (#2386)

Oleg Shur 2 years ago
parent
commit
8f0ffe665c
63 changed files with 2249 additions and 3576 deletions
  1. 19 41
      kafka-ui-react-app/src/components/Topics/List/ActionsCell.tsx
  2. 0 297
      kafka-ui-react-app/src/components/Topics/List/List.tsx
  3. 0 40
      kafka-ui-react-app/src/components/Topics/List/ListContainer.ts
  4. 87 0
      kafka-ui-react-app/src/components/Topics/List/ListPage.tsx
  5. 201 0
      kafka-ui-react-app/src/components/Topics/List/TopicsTable.tsx
  6. 13 13
      kafka-ui-react-app/src/components/Topics/List/TopicsTableCells.tsx
  7. 179 0
      kafka-ui-react-app/src/components/Topics/List/__tests__/ActionCell.spec.tsx
  8. 0 331
      kafka-ui-react-app/src/components/Topics/List/__tests__/List.spec.tsx
  9. 52 0
      kafka-ui-react-app/src/components/Topics/List/__tests__/ListPage.spec.tsx
  10. 168 0
      kafka-ui-react-app/src/components/Topics/List/__tests__/TopicsTable.spec.tsx
  11. 63 23
      kafka-ui-react-app/src/components/Topics/List/__tests__/TopicsTableCells.spec.tsx
  12. 5 9
      kafka-ui-react-app/src/components/Topics/New/New.tsx
  13. 12 40
      kafka-ui-react-app/src/components/Topics/New/__test__/New.spec.tsx
  14. 47 71
      kafka-ui-react-app/src/components/Topics/Topic/Details/ConsumerGroups/TopicConsumerGroups.tsx
  15. 0 18
      kafka-ui-react-app/src/components/Topics/Topic/Details/ConsumerGroups/TopicConsumerGroupsContainer.ts
  16. 30 77
      kafka-ui-react-app/src/components/Topics/Topic/Details/ConsumerGroups/__test__/TopicConsumerGroups.spec.tsx
  17. 43 55
      kafka-ui-react-app/src/components/Topics/Topic/Details/Details.tsx
  18. 0 19
      kafka-ui-react-app/src/components/Topics/Topic/Details/DetailsContainer.ts
  19. 4 5
      kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/Filters/Filters.tsx
  20. 32 13
      kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/Filters/__tests__/Filters.spec.tsx
  21. 15 8
      kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/__test__/Messages.spec.tsx
  22. 45 54
      kafka-ui-react-app/src/components/Topics/Topic/Details/Overview/Overview.tsx
  23. 0 9
      kafka-ui-react-app/src/components/Topics/Topic/Details/Overview/OverviewContainer.ts
  24. 69 115
      kafka-ui-react-app/src/components/Topics/Topic/Details/Overview/__test__/Overview.spec.tsx
  25. 18 45
      kafka-ui-react-app/src/components/Topics/Topic/Details/Settings/Settings.tsx
  26. 0 16
      kafka-ui-react-app/src/components/Topics/Topic/Details/Settings/SettingsContainer.ts
  27. 32 77
      kafka-ui-react-app/src/components/Topics/Topic/Details/Settings/__test__/Settings.spec.tsx
  28. 57 83
      kafka-ui-react-app/src/components/Topics/Topic/Details/__test__/Details.spec.tsx
  29. 3 2
      kafka-ui-react-app/src/components/Topics/Topic/Edit/DangerZone/DangerZone.styled.tsx
  30. 15 28
      kafka-ui-react-app/src/components/Topics/Topic/Edit/DangerZone/DangerZone.tsx
  31. 0 28
      kafka-ui-react-app/src/components/Topics/Topic/Edit/DangerZone/DangerZoneContainer.ts
  32. 25 21
      kafka-ui-react-app/src/components/Topics/Topic/Edit/DangerZone/__test__/DangerZone.spec.tsx
  33. 36 93
      kafka-ui-react-app/src/components/Topics/Topic/Edit/Edit.tsx
  34. 0 24
      kafka-ui-react-app/src/components/Topics/Topic/Edit/EditContainer.tsx
  35. 52 135
      kafka-ui-react-app/src/components/Topics/Topic/Edit/__test__/Edit.spec.tsx
  36. 0 617
      kafka-ui-react-app/src/components/Topics/Topic/Edit/__test__/fixtures.ts
  37. 47 89
      kafka-ui-react-app/src/components/Topics/Topic/Edit/__test__/topicParamsTransformer.spec.ts
  38. 22 24
      kafka-ui-react-app/src/components/Topics/Topic/Edit/topicParamsTransformer.ts
  39. 23 49
      kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.tsx
  40. 44 70
      kafka-ui-react-app/src/components/Topics/Topic/SendMessage/__test__/SendMessage.spec.tsx
  41. 0 50
      kafka-ui-react-app/src/components/Topics/Topic/SendMessage/__test__/fixtures.ts
  42. 15 12
      kafka-ui-react-app/src/components/Topics/Topic/SendMessage/__test__/validateMessage.spec.ts
  43. 19 40
      kafka-ui-react-app/src/components/Topics/Topic/Topic.tsx
  44. 0 18
      kafka-ui-react-app/src/components/Topics/Topic/TopicContainer.tsx
  45. 21 38
      kafka-ui-react-app/src/components/Topics/Topic/__tests__/Topic.spec.tsx
  46. 4 4
      kafka-ui-react-app/src/components/Topics/Topics.tsx
  47. 5 5
      kafka-ui-react-app/src/components/Topics/__tests__/Topics.spec.tsx
  48. 1 2
      kafka-ui-react-app/src/components/common/Alert/Alert.styled.ts
  49. 7 8
      kafka-ui-react-app/src/components/common/Pagination/Pagination.tsx
  50. 1 2
      kafka-ui-react-app/src/components/common/SmartTable/TableColumn.tsx
  51. 210 0
      kafka-ui-react-app/src/lib/fixtures/topics.ts
  52. 48 0
      kafka-ui-react-app/src/lib/hooks/api/__tests__/brokers.spec.ts
  53. 40 0
      kafka-ui-react-app/src/lib/hooks/api/__tests__/clusters.spec.ts
  54. 177 0
      kafka-ui-react-app/src/lib/hooks/api/__tests__/topics.spec.ts
  55. 238 0
      kafka-ui-react-app/src/lib/hooks/api/topics.ts
  56. 4 2
      kafka-ui-react-app/src/lib/hooks/useConfirm.ts
  57. 0 21
      kafka-ui-react-app/src/redux/interfaces/topic.ts
  58. 0 2
      kafka-ui-react-app/src/redux/reducers/index.ts
  59. 1 7
      kafka-ui-react-app/src/redux/reducers/topicMessages/topicMessagesSlice.ts
  60. 0 73
      kafka-ui-react-app/src/redux/reducers/topics/__test__/fixtures.ts
  61. 0 29
      kafka-ui-react-app/src/redux/reducers/topics/__test__/selectors.spec.ts
  62. 0 170
      kafka-ui-react-app/src/redux/reducers/topics/selectors.ts
  63. 0 454
      kafka-ui-react-app/src/redux/reducers/topics/topicsSlice.ts

+ 19 - 41
kafka-ui-react-app/src/components/Topics/List/ActionsCell/ActionsCell.tsx → kafka-ui-react-app/src/components/Topics/List/ActionsCell.tsx

@@ -1,62 +1,40 @@
 import React from 'react';
-import {
-  CleanUpPolicy,
-  SortOrder,
-  TopicColumnsToSort,
-} from 'generated-sources';
+import { CleanUpPolicy, Topic } from 'generated-sources';
 import { useAppDispatch } from 'lib/hooks/redux';
 import { TableCellProps } from 'components/common/SmartTable/TableColumn';
-import { TopicWithDetailedInfo } from 'redux/interfaces';
 import ClusterContext from 'components/contexts/ClusterContext';
 import * as S from 'components/Topics/List/List.styled';
 import { ClusterNameRoute } from 'lib/paths';
 import useAppParams from 'lib/hooks/useAppParams';
-import {
-  deleteTopic,
-  fetchTopicsList,
-  recreateTopic,
-} from 'redux/reducers/topics/topicsSlice';
 import { clearTopicMessages } from 'redux/reducers/topicMessages/topicMessagesSlice';
 import { Dropdown, DropdownItem } from 'components/common/Dropdown';
+import { useQueryClient } from '@tanstack/react-query';
+import {
+  topicKeys,
+  useDeleteTopic,
+  useRecreateTopic,
+} from 'lib/hooks/api/topics';
 
-interface TopicsListParams {
-  clusterName: string;
-  page?: number;
-  perPage?: number;
-  showInternal?: boolean;
-  search?: string;
-  orderBy?: TopicColumnsToSort;
-  sortOrder?: SortOrder;
-}
-export interface ActionsCellProps {
-  topicsListParams: TopicsListParams;
-}
-
-const ActionsCell: React.FC<
-  TableCellProps<TopicWithDetailedInfo, string> & ActionsCellProps
-> = ({
+const ActionsCell: React.FC<TableCellProps<Topic, string>> = ({
   hovered,
   dataItem: { internal, cleanUpPolicy, name },
-  topicsListParams,
 }) => {
   const { isReadOnly, isTopicDeletionAllowed } =
     React.useContext(ClusterContext);
   const dispatch = useAppDispatch();
   const { clusterName } = useAppParams<ClusterNameRoute>();
+  const queryClient = useQueryClient();
 
-  const isHidden = internal || isReadOnly || !hovered;
+  const deleteTopic = useDeleteTopic(clusterName);
+  const recreateTopic = useRecreateTopic({ clusterName, topicName: name });
 
-  const deleteTopicHandler = () => {
-    dispatch(deleteTopic({ clusterName, topicName: name }));
-  };
-
-  const clearTopicMessagesHandler = () => {
-    dispatch(clearTopicMessages({ clusterName, topicName: name }));
-    dispatch(fetchTopicsList(topicsListParams));
-  };
+  const isHidden = internal || isReadOnly || !hovered;
 
-  const recreateTopicHandler = () => {
-    dispatch(recreateTopic({ clusterName, topicName: name }));
+  const clearTopicMessagesHandler = async () => {
+    await dispatch(
+      clearTopicMessages({ clusterName, topicName: name })
+    ).unwrap();
+    queryClient.invalidateQueries(topicKeys.all(clusterName));
   };
 
   return (
@@ -73,7 +51,7 @@ const ActionsCell: React.FC<
             </DropdownItem>
           )}
           <DropdownItem
-            onClick={recreateTopicHandler}
+            onClick={recreateTopic.mutateAsync}
             confirm={
               <>
                 Are you sure to recreate <b>{name}</b> topic?
@@ -85,7 +63,7 @@ const ActionsCell: React.FC<
           </DropdownItem>
           {isTopicDeletionAllowed && (
             <DropdownItem
-              onClick={deleteTopicHandler}
+              onClick={() => deleteTopic.mutateAsync(name)}
               confirm={
                 <>
                   Are you sure want to remove <b>{name}</b> topic?

+ 0 - 297
kafka-ui-react-app/src/components/Topics/List/List.tsx

@@ -1,297 +0,0 @@
-import React from 'react';
-import { useNavigate } from 'react-router-dom';
-import useAppParams from 'lib/hooks/useAppParams';
-import {
-  TopicWithDetailedInfo,
-  ClusterName,
-  TopicName,
-} from 'redux/interfaces';
-import {
-  ClusterNameRoute,
-  clusterTopicCopyRelativePath,
-  clusterTopicNewRelativePath,
-} from 'lib/paths';
-import usePagination from 'lib/hooks/usePagination';
-import ClusterContext from 'components/contexts/ClusterContext';
-import PageLoader from 'components/common/PageLoader/PageLoader';
-import {
-  GetTopicsRequest,
-  SortOrder,
-  TopicColumnsToSort,
-} from 'generated-sources';
-import Search from 'components/common/Search/Search';
-import { PER_PAGE } from 'lib/constants';
-import { Button } from 'components/common/Button/Button';
-import PageHeading from 'components/common/PageHeading/PageHeading';
-import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
-import Switch from 'components/common/Switch/Switch';
-import { SmartTable } from 'components/common/SmartTable/SmartTable';
-import { TableColumn } from 'components/common/SmartTable/TableColumn';
-import { useTableState } from 'lib/hooks/useTableState';
-import PlusIcon from 'components/common/Icons/PlusIcon';
-import { useConfirm } from 'lib/hooks/useConfirm';
-
-import {
-  MessagesCell,
-  OutOfSyncReplicasCell,
-  TitleCell,
-  TopicSizeCell,
-} from './TopicsTableCells';
-import * as S from './List.styled';
-import ActionsCell from './ActionsCell/ActionsCell';
-
-export interface TopicsListProps {
-  areTopicsFetching: boolean;
-  topics: TopicWithDetailedInfo[];
-  totalPages: number;
-  fetchTopicsList(payload: GetTopicsRequest): void;
-  deleteTopics(payload: {
-    clusterName: ClusterName;
-    topicNames: TopicName[];
-  }): void;
-  clearTopicsMessages(payload: {
-    clusterName: ClusterName;
-    topicNames: TopicName[];
-  }): void;
-  clearTopicMessages(payload: {
-    topicName: TopicName;
-    clusterName: ClusterName;
-    partitions?: number[];
-  }): void;
-  search: string;
-  orderBy: string | null;
-  sortOrder: SortOrder;
-  setTopicsSearch(search: string): void;
-  setTopicsOrderBy(orderBy: string | null): void;
-}
-
-const List: React.FC<TopicsListProps> = ({
-  areTopicsFetching,
-  topics,
-  totalPages,
-  fetchTopicsList,
-  deleteTopics,
-  clearTopicsMessages,
-  search,
-  orderBy,
-  sortOrder,
-  setTopicsSearch,
-  setTopicsOrderBy,
-}) => {
-  const { isReadOnly } = React.useContext(ClusterContext);
-  const { clusterName } = useAppParams<ClusterNameRoute>();
-  const { page, perPage } = usePagination();
-  const [showInternal, setShowInternal] = React.useState<boolean>(
-    !localStorage.getItem('hideInternalTopics') && true
-  );
-  const [cachedPage, setCachedPage] = React.useState<number | null>(
-    page || null
-  );
-  const navigate = useNavigate();
-  const confirm = useConfirm();
-
-  const topicsListParams = React.useMemo(
-    () => ({
-      clusterName,
-      page,
-      perPage,
-      orderBy: (orderBy as TopicColumnsToSort) || undefined,
-      sortOrder,
-      search,
-      showInternal,
-    }),
-    [clusterName, page, perPage, orderBy, sortOrder, search, showInternal]
-  );
-
-  React.useEffect(() => {
-    fetchTopicsList(topicsListParams);
-  }, [fetchTopicsList, topicsListParams]);
-
-  const tableState = useTableState<TopicWithDetailedInfo, string>(
-    topics,
-    {
-      idSelector: (topic) => topic.name,
-      totalPages,
-      isRowSelectable: (topic) => !topic.internal,
-    },
-    {
-      handleOrderBy: setTopicsOrderBy,
-      orderBy,
-      sortOrder,
-    }
-  );
-
-  const getSelectedTopic = (): string => {
-    const name = Array.from(tableState.selectedIds)[0];
-    const selectedTopic =
-      tableState.data.find(
-        (topic: TopicWithDetailedInfo) => topic.name === name
-      ) || {};
-
-    return Object.keys(selectedTopic)
-      .map((x: string) => {
-        const value = selectedTopic[x as keyof typeof selectedTopic];
-        return value && x !== 'partitions' ? `${x}=${value}` : null;
-      })
-      .join('&');
-  };
-
-  const handleSwitch = () => {
-    if (showInternal) {
-      localStorage.setItem('hideInternalTopics', 'true');
-    } else {
-      localStorage.removeItem('hideInternalTopics');
-    }
-
-    setShowInternal(!showInternal);
-    navigate({
-      search: `?page=1&perPage=${perPage || PER_PAGE}`,
-    });
-  };
-
-  const clearSelectedTopics = () => tableState.toggleSelection(false);
-
-  const searchHandler = (searchString: string) => {
-    setTopicsSearch(searchString);
-
-    setCachedPage(page || null);
-
-    const newPageQuery = !searchString && cachedPage ? cachedPage : 1;
-
-    navigate({
-      search: `?page=${newPageQuery}&perPage=${perPage || PER_PAGE}`,
-    });
-  };
-
-  const deleteTopicsHandler = () => {
-    const selectedIds = Array.from(tableState.selectedIds);
-    confirm('Are you sure you want to remove selected topics?', () => {
-      deleteTopics({ clusterName, topicNames: selectedIds });
-      clearSelectedTopics();
-      fetchTopicsList(topicsListParams);
-    });
-  };
-
-  const purgeTopicsHandler = () => {
-    const selectedIds = Array.from(tableState.selectedIds);
-    confirm(
-      'Are you sure you want to purge messages of selected topics?',
-      () => {
-        clearTopicsMessages({ clusterName, topicNames: selectedIds });
-        clearSelectedTopics();
-        fetchTopicsList(topicsListParams);
-      }
-    );
-  };
-
-  return (
-    <div>
-      <div>
-        <PageHeading text="All Topics">
-          {!isReadOnly && (
-            <Button
-              buttonType="primary"
-              buttonSize="M"
-              to={clusterTopicNewRelativePath}
-            >
-              <PlusIcon /> Add a Topic
-            </Button>
-          )}
-        </PageHeading>
-        <ControlPanelWrapper hasInput>
-          <div>
-            <Search
-              handleSearch={searchHandler}
-              placeholder="Search by Topic Name"
-              value={search}
-            />
-          </div>
-          <div>
-            <Switch
-              name="ShowInternalTopics"
-              checked={showInternal}
-              onChange={handleSwitch}
-            />
-            <label>Show Internal Topics</label>
-          </div>
-        </ControlPanelWrapper>
-      </div>
-      {areTopicsFetching ? (
-        <PageLoader />
-      ) : (
-        <div>
-          {tableState.selectedCount > 0 && (
-            <ControlPanelWrapper data-testid="delete-buttons">
-              <Button
-                buttonSize="M"
-                buttonType="secondary"
-                onClick={deleteTopicsHandler}
-              >
-                Delete selected topics
-              </Button>
-              {tableState.selectedCount === 1 && (
-                <Button
-                  buttonSize="M"
-                  buttonType="secondary"
-                  to={{
-                    pathname: clusterTopicCopyRelativePath,
-                    search: `?${getSelectedTopic()}`,
-                  }}
-                >
-                  Copy selected topic
-                </Button>
-              )}
-
-              <Button
-                buttonSize="M"
-                buttonType="secondary"
-                onClick={purgeTopicsHandler}
-              >
-                Purge messages of selected topics
-              </Button>
-            </ControlPanelWrapper>
-          )}
-          <SmartTable
-            selectable={!isReadOnly}
-            tableState={tableState}
-            placeholder="No topics found"
-            isFullwidth
-            paginated
-            hoverable
-          >
-            <TableColumn
-              maxWidth="350px"
-              title="Topic Name"
-              cell={TitleCell}
-              orderValue={TopicColumnsToSort.NAME}
-            />
-            <TableColumn
-              title="Total Partitions"
-              field="partitions.length"
-              orderValue={TopicColumnsToSort.TOTAL_PARTITIONS}
-            />
-            <TableColumn
-              title="Out of sync replicas"
-              cell={OutOfSyncReplicasCell}
-              orderValue={TopicColumnsToSort.OUT_OF_SYNC_REPLICAS}
-            />
-            <TableColumn title="Replication Factor" field="replicationFactor" />
-            <TableColumn title="Number of messages" cell={MessagesCell} />
-            <TableColumn
-              title="Size"
-              cell={TopicSizeCell}
-              orderValue={TopicColumnsToSort.SIZE}
-            />
-            <TableColumn
-              maxWidth="4%"
-              cell={ActionsCell}
-              customTd={S.ActionsTd}
-            />
-          </SmartTable>
-        </div>
-      )}
-    </div>
-  );
-};
-
-export default List;

+ 0 - 40
kafka-ui-react-app/src/components/Topics/List/ListContainer.ts

@@ -1,40 +0,0 @@
-import { connect } from 'react-redux';
-import { RootState } from 'redux/interfaces';
-import { clearTopicMessages } from 'redux/reducers/topicMessages/topicMessagesSlice';
-import {
-  fetchTopicsList,
-  setTopicsSearch,
-  setTopicsOrderBy,
-  deleteTopics,
-  clearTopicsMessages,
-} from 'redux/reducers/topics/topicsSlice';
-import {
-  getTopicList,
-  getAreTopicsFetching,
-  getTopicListTotalPages,
-  getTopicsSearch,
-  getTopicsOrderBy,
-  getTopicsSortOrder,
-} from 'redux/reducers/topics/selectors';
-
-import List from './List';
-
-const mapStateToProps = (state: RootState) => ({
-  areTopicsFetching: getAreTopicsFetching(state),
-  topics: getTopicList(state),
-  totalPages: getTopicListTotalPages(state),
-  search: getTopicsSearch(state),
-  orderBy: getTopicsOrderBy(state),
-  sortOrder: getTopicsSortOrder(state),
-});
-
-const mapDispatchToProps = {
-  fetchTopicsList,
-  deleteTopics,
-  clearTopicsMessages,
-  clearTopicMessages,
-  setTopicsSearch,
-  setTopicsOrderBy,
-};
-
-export default connect(mapStateToProps, mapDispatchToProps)(List);

+ 87 - 0
kafka-ui-react-app/src/components/Topics/List/ListPage.tsx

@@ -0,0 +1,87 @@
+import React, { Suspense } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import { clusterTopicNewRelativePath } from 'lib/paths';
+import { PER_PAGE } from 'lib/constants';
+import ClusterContext from 'components/contexts/ClusterContext';
+import Search from 'components/common/Search/Search';
+import { Button } from 'components/common/Button/Button';
+import PageHeading from 'components/common/PageHeading/PageHeading';
+import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
+import Switch from 'components/common/Switch/Switch';
+import PlusIcon from 'components/common/Icons/PlusIcon';
+import useSearch from 'lib/hooks/useSearch';
+import PageLoader from 'components/common/PageLoader/PageLoader';
+import TopicsTable from 'components/Topics/List/TopicsTable';
+
+const ListPage: React.FC = () => {
+  const { isReadOnly } = React.useContext(ClusterContext);
+  const [searchQuery, handleSearchQuery] = useSearch();
+  const [searchParams, setSearchParams] = useSearchParams();
+
+  // Set the search params to the url based on the localStorage value
+  React.useEffect(() => {
+    if (!searchParams.has('perPage')) {
+      searchParams.set('perPage', String(PER_PAGE));
+    }
+    if (
+      !!localStorage.getItem('hideInternalTopics') &&
+      !searchParams.has('hideInternal')
+    ) {
+      searchParams.set('hideInternal', 'true');
+    }
+    setSearchParams(searchParams, { replace: true });
+  }, []);
+
+  const handleSwitch = () => {
+    if (searchParams.has('hideInternal')) {
+      localStorage.removeItem('hideInternalTopics');
+      searchParams.delete('hideInternal');
+    } else {
+      localStorage.setItem('hideInternalTopics', 'true');
+      searchParams.set('hideInternal', 'true');
+    }
+    // Page must be reset when the switch is toggled
+    searchParams.delete('page');
+    setSearchParams(searchParams.toString(), { replace: true });
+  };
+
+  return (
+    <>
+      <PageHeading text="All Topics">
+        {!isReadOnly && (
+          <Button
+            buttonType="primary"
+            buttonSize="M"
+            to={clusterTopicNewRelativePath}
+          >
+            <PlusIcon /> Add a Topic
+          </Button>
+        )}
+      </PageHeading>
+      <ControlPanelWrapper hasInput>
+        <div>
+          <Search
+            handleSearch={handleSearchQuery}
+            placeholder="Search by Topic Name"
+            value={searchQuery}
+          />
+        </div>
+        <div>
+          <label>
+            <Switch
+              name="ShowInternalTopics"
+              checked={!searchParams.has('hideInternal')}
+              onChange={handleSwitch}
+            />
+            Show Internal Topics
+          </label>
+        </div>
+      </ControlPanelWrapper>
+      <Suspense fallback={<PageLoader />}>
+        <TopicsTable />
+      </Suspense>
+    </>
+  );
+};
+
+export default ListPage;

+ 201 - 0
kafka-ui-react-app/src/components/Topics/List/TopicsTable.tsx

@@ -0,0 +1,201 @@
+import React from 'react';
+import { SortOrder, Topic, TopicColumnsToSort } from 'generated-sources';
+import { useSearchParams } from 'react-router-dom';
+import { useDeleteTopic, useTopics } from 'lib/hooks/api/topics';
+import useAppParams from 'lib/hooks/useAppParams';
+import { ClusterName } from 'redux/interfaces';
+import { PER_PAGE } from 'lib/constants';
+import { useTableState } from 'lib/hooks/useTableState';
+import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
+import { Button } from 'components/common/Button/Button';
+import { clusterTopicCopyRelativePath } from 'lib/paths';
+import { useConfirm } from 'lib/hooks/useConfirm';
+import { SmartTable } from 'components/common/SmartTable/SmartTable';
+import { TableColumn } from 'components/common/SmartTable/TableColumn';
+import ClusterContext from 'components/contexts/ClusterContext';
+import { useAppDispatch } from 'lib/hooks/redux';
+import { clearTopicMessages } from 'redux/reducers/topicMessages/topicMessagesSlice';
+
+import {
+  MessagesCell,
+  OutOfSyncReplicasCell,
+  TitleCell,
+  TopicSizeCell,
+} from './TopicsTableCells';
+import ActionsCell from './ActionsCell';
+import { ActionsTd } from './List.styled';
+
+const TopicsTable: React.FC = () => {
+  const { clusterName } = useAppParams<{ clusterName: ClusterName }>();
+  const [searchParams, setSearchParams] = useSearchParams();
+  const { isReadOnly } = React.useContext(ClusterContext);
+  const dispatch = useAppDispatch();
+  const confirm = useConfirm();
+  const deleteTopic = useDeleteTopic(clusterName);
+  const { data, refetch } = useTopics({
+    clusterName,
+    page: Number(searchParams.get('page') || 1),
+    perPage: Number(searchParams.get('perPage') || PER_PAGE),
+    search: searchParams.get('q') || undefined,
+    showInternal: !searchParams.has('hideInternal'),
+    orderBy: (searchParams.get('orderBy') as TopicColumnsToSort) || undefined,
+    sortOrder: (searchParams.get('sortOrder') as SortOrder) || undefined,
+  });
+
+  const handleOrderBy = (orderBy: string | null) => {
+    const currentOrderBy = searchParams.get('orderBy');
+    const currentSortOrder = searchParams.get('sortOrder');
+
+    if (orderBy) {
+      if (orderBy === currentOrderBy) {
+        searchParams.set(
+          'sortOrder',
+          currentSortOrder === SortOrder.DESC ? SortOrder.ASC : SortOrder.DESC
+        );
+      }
+
+      searchParams.set('orderBy', orderBy);
+    } else {
+      searchParams.delete('orderBy');
+      searchParams.delete('sortOrder');
+    }
+    setSearchParams(searchParams, { replace: true });
+  };
+
+  const tableState = useTableState<Topic, string>(
+    data?.topics || [],
+    {
+      idSelector: (topic) => topic.name,
+      totalPages: data?.pageCount || 0,
+      isRowSelectable: (topic) => !topic.internal,
+    },
+    {
+      handleOrderBy,
+      orderBy: searchParams.get('orderBy'),
+      sortOrder: (searchParams.get('sortOrder') as SortOrder) || SortOrder.ASC,
+    }
+  );
+
+  const getSelectedTopic = (): string => {
+    const name = Array.from(tableState.selectedIds)[0];
+    const selectedTopic =
+      tableState.data.find((topic: Topic) => topic.name === name) || {};
+
+    return Object.keys(selectedTopic)
+      .map((x: string) => {
+        const value = selectedTopic[x as keyof typeof selectedTopic];
+        return value && x !== 'partitions' ? `${x}=${value}` : null;
+      })
+      .join('&');
+  };
+
+  const clearSelectedTopics = () => tableState.toggleSelection(false);
+
+  const deleteTopicsHandler = () => {
+    const selectedTopics = Array.from(tableState.selectedIds);
+    confirm('Are you sure you want to remove selected topics?', async () => {
+      try {
+        await Promise.all(
+          selectedTopics.map((topicName) => deleteTopic.mutateAsync(topicName))
+        );
+        clearSelectedTopics();
+      } catch (e) {
+        // do nothing;
+      } finally {
+        refetch();
+      }
+    });
+  };
+
+  const purgeTopicsHandler = () => {
+    const selectedTopics = Array.from(tableState.selectedIds);
+    confirm(
+      'Are you sure you want to purge messages of selected topics?',
+      async () => {
+        try {
+          await Promise.all(
+            selectedTopics.map((topicName) =>
+              dispatch(clearTopicMessages({ clusterName, topicName })).unwrap()
+            )
+          );
+          clearSelectedTopics();
+        } catch (e) {
+          // do nothing;
+        } finally {
+          refetch();
+        }
+      }
+    );
+  };
+
+  return (
+    <>
+      {tableState.selectedCount > 0 && (
+        <ControlPanelWrapper>
+          <Button
+            buttonSize="M"
+            buttonType="secondary"
+            onClick={deleteTopicsHandler}
+          >
+            Delete selected topics
+          </Button>
+          {tableState.selectedCount === 1 && (
+            <Button
+              buttonSize="M"
+              buttonType="secondary"
+              to={{
+                pathname: clusterTopicCopyRelativePath,
+                search: `?${getSelectedTopic()}`,
+              }}
+            >
+              Copy selected topic
+            </Button>
+          )}
+
+          <Button
+            buttonSize="M"
+            buttonType="secondary"
+            onClick={purgeTopicsHandler}
+          >
+            Purge messages of selected topics
+          </Button>
+        </ControlPanelWrapper>
+      )}
+      <SmartTable
+        selectable={!isReadOnly}
+        tableState={tableState}
+        placeholder="No topics found"
+        isFullwidth
+        paginated
+        hoverable
+      >
+        <TableColumn
+          maxWidth="350px"
+          title="Topic Name"
+          cell={TitleCell}
+          orderValue={TopicColumnsToSort.NAME}
+        />
+        <TableColumn
+          title="Total Partitions"
+          field="partitions.length"
+          orderValue={TopicColumnsToSort.TOTAL_PARTITIONS}
+        />
+        <TableColumn
+          title="Out of sync replicas"
+          cell={OutOfSyncReplicasCell}
+          orderValue={TopicColumnsToSort.OUT_OF_SYNC_REPLICAS}
+        />
+        <TableColumn title="Replication Factor" field="replicationFactor" />
+        <TableColumn title="Number of messages" cell={MessagesCell} />
+        <TableColumn
+          title="Size"
+          cell={TopicSizeCell}
+          orderValue={TopicColumnsToSort.SIZE}
+        />
+        <TableColumn maxWidth="4%" cell={ActionsCell} customTd={ActionsTd} />
+      </SmartTable>
+    </>
+  );
+};
+
+export default TopicsTable;

+ 13 - 13
kafka-ui-react-app/src/components/Topics/List/TopicsTableCells.tsx

@@ -1,14 +1,14 @@
 import React from 'react';
-import { TopicWithDetailedInfo } from 'redux/interfaces';
 import { TableCellProps } from 'components/common/SmartTable/TableColumn';
 import { Tag } from 'components/common/Tag/Tag.styled';
 import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
+import { Topic } from 'generated-sources';
 
 import * as S from './List.styled';
 
-export const TitleCell: React.FC<
-  TableCellProps<TopicWithDetailedInfo, string>
-> = ({ dataItem: { internal, name } }) => {
+export const TitleCell: React.FC<TableCellProps<Topic, string>> = ({
+  dataItem: { internal, name },
+}) => {
   return (
     <>
       {internal && <Tag color="gray">IN</Tag>}
@@ -19,15 +19,15 @@ export const TitleCell: React.FC<
   );
 };
 
-export const TopicSizeCell: React.FC<
-  TableCellProps<TopicWithDetailedInfo, string>
-> = ({ dataItem: { segmentSize } }) => {
+export const TopicSizeCell: React.FC<TableCellProps<Topic, string>> = ({
+  dataItem: { segmentSize },
+}) => {
   return <BytesFormatted value={segmentSize} />;
 };
 
-export const OutOfSyncReplicasCell: React.FC<
-  TableCellProps<TopicWithDetailedInfo, string>
-> = ({ dataItem: { partitions } }) => {
+export const OutOfSyncReplicasCell: React.FC<TableCellProps<Topic, string>> = ({
+  dataItem: { partitions },
+}) => {
   const data = React.useMemo(() => {
     if (partitions === undefined || partitions.length === 0) {
       return 0;
@@ -42,9 +42,9 @@ export const OutOfSyncReplicasCell: React.FC<
   return <span>{data}</span>;
 };
 
-export const MessagesCell: React.FC<
-  TableCellProps<TopicWithDetailedInfo, string>
-> = ({ dataItem: { partitions } }) => {
+export const MessagesCell: React.FC<TableCellProps<Topic, string>> = ({
+  dataItem: { partitions },
+}) => {
   const data = React.useMemo(() => {
     if (partitions === undefined || partitions.length === 0) {
       return 0;

+ 179 - 0
kafka-ui-react-app/src/components/Topics/List/__tests__/ActionCell.spec.tsx

@@ -0,0 +1,179 @@
+import React from 'react';
+import { render } from 'lib/testHelpers';
+import { TableState } from 'lib/hooks/useTableState';
+import { act, screen, waitFor } from '@testing-library/react';
+import { CleanUpPolicy, Topic } from 'generated-sources';
+import { topicsPayload } from 'lib/fixtures/topics';
+import ActionsCell from 'components/Topics/List/ActionsCell';
+import ClusterContext from 'components/contexts/ClusterContext';
+import userEvent from '@testing-library/user-event';
+import { useDeleteTopic, useRecreateTopic } from 'lib/hooks/api/topics';
+
+const mockUnwrap = jest.fn();
+const useDispatchMock = () => jest.fn(() => ({ unwrap: mockUnwrap }));
+
+jest.mock('lib/hooks/redux', () => ({
+  ...jest.requireActual('lib/hooks/redux'),
+  useAppDispatch: useDispatchMock,
+}));
+
+jest.mock('lib/hooks/api/topics', () => ({
+  ...jest.requireActual('lib/hooks/api/topics'),
+  useDeleteTopic: jest.fn(),
+  useRecreateTopic: jest.fn(),
+}));
+
+const deleteTopicMock = jest.fn();
+const recreateTopicMock = jest.fn();
+
+describe('ActionCell Components', () => {
+  beforeEach(() => {
+    (useDeleteTopic as jest.Mock).mockImplementation(() => ({
+      mutateAsync: deleteTopicMock,
+    }));
+    (useRecreateTopic as jest.Mock).mockImplementation(() => ({
+      mutateAsync: recreateTopicMock,
+    }));
+  });
+
+  const mockTableState: TableState<Topic, string> = {
+    data: topicsPayload,
+    selectedIds: new Set([]),
+    idSelector: jest.fn(),
+    isRowSelectable: jest.fn(),
+    selectedCount: 0,
+    setRowsSelection: jest.fn(),
+    toggleSelection: jest.fn(),
+  };
+
+  const renderComponent = (
+    currentData: Topic,
+    isReadOnly = false,
+    hovered = true,
+    isTopicDeletionAllowed = true
+  ) => {
+    return render(
+      <ClusterContext.Provider
+        value={{
+          isReadOnly,
+          hasKafkaConnectConfigured: true,
+          hasSchemaRegistryConfigured: true,
+          isTopicDeletionAllowed,
+        }}
+      >
+        <ActionsCell
+          rowIndex={1}
+          dataItem={currentData}
+          tableState={mockTableState}
+          hovered={hovered}
+        />
+      </ClusterContext.Provider>
+    );
+  };
+
+  const expectCellIsEmpty = () => {
+    expect(
+      screen.queryByRole('button', { name: 'Dropdown Toggle' })
+    ).not.toBeInTheDocument();
+  };
+
+  const expectDropdownExists = () => {
+    const btn = screen.getByRole('button', { name: 'Dropdown Toggle' });
+    expect(btn).toBeInTheDocument();
+    userEvent.click(btn);
+    expect(screen.getByRole('menu')).toBeInTheDocument();
+  };
+
+  describe('is empty', () => {
+    it('for internal topic', () => {
+      renderComponent(topicsPayload[0]);
+      expectCellIsEmpty();
+    });
+    it('for readonly cluster', () => {
+      renderComponent(topicsPayload[1], true);
+      expectCellIsEmpty();
+    });
+    it('for non-hovered row', () => {
+      renderComponent(topicsPayload[1], false, false);
+      expectCellIsEmpty();
+    });
+  });
+
+  describe('is not empty', () => {
+    it('for external topic', async () => {
+      renderComponent(topicsPayload[1]);
+      expectDropdownExists();
+    });
+    describe('and clear messages action', () => {
+      it('is visible for topic with CleanUpPolicy.DELETE', async () => {
+        renderComponent({
+          ...topicsPayload[1],
+          cleanUpPolicy: CleanUpPolicy.DELETE,
+        });
+        expectDropdownExists();
+        expect(screen.getByText('Clear Messages')).toBeInTheDocument();
+      });
+      it('is hidden for topic without CleanUpPolicy.DELETE', async () => {
+        renderComponent({
+          ...topicsPayload[1],
+          cleanUpPolicy: CleanUpPolicy.COMPACT,
+        });
+        expectDropdownExists();
+        expect(screen.queryByText('Clear Messages')).not.toBeInTheDocument();
+      });
+      it('works as expected', async () => {
+        renderComponent({
+          ...topicsPayload[1],
+          cleanUpPolicy: CleanUpPolicy.DELETE,
+        });
+        expectDropdownExists();
+        userEvent.click(screen.getByText('Clear Messages'));
+        expect(
+          screen.getByText('Are you sure want to clear topic messages?')
+        ).toBeInTheDocument();
+        await act(() =>
+          userEvent.click(screen.getByRole('button', { name: 'Confirm' }))
+        );
+        expect(mockUnwrap).toHaveBeenCalled();
+      });
+    });
+
+    describe('and remove topic action', () => {
+      it('is visible only when topic deletion allowed for cluster', async () => {
+        renderComponent(topicsPayload[1]);
+        expectDropdownExists();
+        expect(screen.getByText('Remove Topic')).toBeInTheDocument();
+      });
+      it('is hidden when topic deletion is not allowed for cluster', async () => {
+        renderComponent(topicsPayload[1], false, true, false);
+        expectDropdownExists();
+        expect(screen.queryByText('Remove Topic')).not.toBeInTheDocument();
+      });
+      it('works as expected', async () => {
+        renderComponent(topicsPayload[1]);
+        expectDropdownExists();
+        userEvent.click(screen.getByText('Remove Topic'));
+        expect(screen.getByText('Confirm the action')).toBeInTheDocument();
+        expect(screen.getByText('external.topic')).toBeInTheDocument();
+        await waitFor(() =>
+          userEvent.click(screen.getByRole('button', { name: 'Confirm' }))
+        );
+        await waitFor(() => expect(deleteTopicMock).toHaveBeenCalled());
+      });
+    });
+
+    describe('and recreate topic action', () => {
+      it('works as expected', async () => {
+        renderComponent(topicsPayload[1]);
+        expectDropdownExists();
+        userEvent.click(screen.getByText('Recreate Topic'));
+        expect(screen.getByText('Confirm the action')).toBeInTheDocument();
+        expect(screen.getByText('external.topic')).toBeInTheDocument();
+        await waitFor(() =>
+          userEvent.click(screen.getByRole('button', { name: 'Confirm' }))
+        );
+        await waitFor(() => expect(recreateTopicMock).toHaveBeenCalled());
+      });
+    });
+  });
+});

+ 0 - 331
kafka-ui-react-app/src/components/Topics/List/__tests__/List.spec.tsx

@@ -1,331 +0,0 @@
-import React from 'react';
-import { render, WithRoute } from 'lib/testHelpers';
-import { act, screen, waitFor, within } from '@testing-library/react';
-import ClusterContext, {
-  ContextProps,
-} from 'components/contexts/ClusterContext';
-import List, { TopicsListProps } from 'components/Topics/List/List';
-import { externalTopicPayload } from 'redux/reducers/topics/__test__/fixtures';
-import { CleanUpPolicy, SortOrder } from 'generated-sources';
-import userEvent from '@testing-library/user-event';
-import { clusterTopicsPath } from 'lib/paths';
-
-const mockNavigate = jest.fn();
-jest.mock('react-router-dom', () => ({
-  ...jest.requireActual('react-router-dom'),
-  useNavigate: () => mockNavigate,
-}));
-
-describe('List', () => {
-  afterEach(() => {
-    mockNavigate.mockClear();
-  });
-
-  const setupComponent = (props: Partial<TopicsListProps> = {}) => (
-    <List
-      areTopicsFetching={false}
-      topics={[]}
-      totalPages={1}
-      fetchTopicsList={jest.fn()}
-      deleteTopics={jest.fn()}
-      clearTopicsMessages={jest.fn()}
-      clearTopicMessages={jest.fn()}
-      search=""
-      orderBy={null}
-      sortOrder={SortOrder.ASC}
-      setTopicsSearch={jest.fn()}
-      setTopicsOrderBy={jest.fn()}
-      {...props}
-    />
-  );
-
-  const renderComponentWithProviders = (
-    contextProps: Partial<ContextProps> = {},
-    props: Partial<TopicsListProps> = {},
-    queryParams = ''
-  ) =>
-    render(
-      <WithRoute path={clusterTopicsPath()}>
-        <ClusterContext.Provider
-          value={{
-            isReadOnly: true,
-            hasKafkaConnectConfigured: true,
-            hasSchemaRegistryConfigured: true,
-            isTopicDeletionAllowed: true,
-            ...contextProps,
-          }}
-        >
-          {setupComponent(props)}
-        </ClusterContext.Provider>
-      </WithRoute>,
-      { initialEntries: [`${clusterTopicsPath('test')}${queryParams}`] }
-    );
-
-  describe('when it has readonly flag', () => {
-    it('does not render the Add a Topic button', () => {
-      renderComponentWithProviders();
-      expect(screen.queryByText(/add a topic/)).not.toBeInTheDocument();
-    });
-  });
-
-  describe('when it does not have readonly flag', () => {
-    const fetchTopicsList = jest.fn();
-
-    jest.useFakeTimers();
-
-    afterEach(() => {
-      fetchTopicsList.mockClear();
-    });
-
-    it('renders the Add a Topic button', () => {
-      renderComponentWithProviders({ isReadOnly: false }, { fetchTopicsList });
-      expect(screen.getByText(/add a topic/i)).toBeInTheDocument();
-    });
-
-    it('calls setTopicsSearch on input', async () => {
-      const setTopicsSearch = jest.fn();
-      renderComponentWithProviders({}, { setTopicsSearch });
-      const query = 'topic';
-      const searchElement = screen.getByPlaceholderText('Search by Topic Name');
-      userEvent.type(searchElement, query);
-      await waitFor(() => {
-        expect(setTopicsSearch).toHaveBeenCalledWith(query);
-      });
-    });
-
-    it('show internal toggle state should be true if user has not used it yet', () => {
-      renderComponentWithProviders({ isReadOnly: false }, { fetchTopicsList });
-      const internalCheckBox = screen.getByRole('checkbox');
-
-      expect(internalCheckBox).toBeChecked();
-    });
-
-    it('show internal toggle state should match user preference', () => {
-      localStorage.setItem('hideInternalTopics', 'true');
-      renderComponentWithProviders({ isReadOnly: false }, { fetchTopicsList });
-
-      const internalCheckBox = screen.getByRole('checkbox');
-
-      expect(internalCheckBox).not.toBeChecked();
-    });
-
-    it('should re-fetch topics on show internal toggle change', async () => {
-      renderComponentWithProviders({ isReadOnly: false }, { fetchTopicsList });
-      const internalCheckBox: HTMLInputElement = screen.getByRole('checkbox');
-
-      userEvent.click(internalCheckBox);
-      const { value } = internalCheckBox;
-
-      await waitFor(() => {
-        expect(fetchTopicsList).toHaveBeenLastCalledWith({
-          clusterName: 'test',
-          orderBy: undefined,
-          page: undefined,
-          perPage: undefined,
-          search: '',
-          showInternal: value === 'on',
-          sortOrder: SortOrder.ASC,
-        });
-      });
-    });
-
-    it('should reset page query param on show internal toggle change', async () => {
-      renderComponentWithProviders({ isReadOnly: false }, { fetchTopicsList });
-
-      const internalCheckBox: HTMLInputElement = screen.getByRole('checkbox');
-      userEvent.click(internalCheckBox);
-
-      expect(mockNavigate).toHaveBeenCalledWith({
-        search: '?page=1&perPage=25',
-      });
-    });
-
-    it('should set cached page query param on show internal toggle change', async () => {
-      const cachedPage = 5;
-
-      renderComponentWithProviders(
-        { isReadOnly: false },
-        { fetchTopicsList, totalPages: 10 },
-        `?page=${cachedPage}&perPage=25`
-      );
-
-      const searchInput = screen.getByPlaceholderText('Search by Topic Name');
-      userEvent.type(searchInput, 'nonEmptyString');
-
-      await waitFor(() => {
-        expect(mockNavigate).toHaveBeenCalledWith({
-          search: '?page=1&perPage=25',
-        });
-      });
-
-      await act(() => {
-        userEvent.clear(searchInput);
-      });
-
-      await waitFor(() => {
-        expect(mockNavigate).toHaveBeenLastCalledWith({
-          search: `?page=${cachedPage}&perPage=25`,
-        });
-      });
-    });
-  });
-
-  describe('when some list items are selected', () => {
-    const mockDeleteTopics = jest.fn();
-    const mockClearTopic = jest.fn();
-    const mockClearTopicsMessages = jest.fn();
-    const fetchTopicsList = jest.fn();
-
-    jest.useFakeTimers();
-    const pathname = clusterTopicsPath('local');
-
-    beforeEach(() => {
-      render(
-        <WithRoute path={clusterTopicsPath()}>
-          <ClusterContext.Provider
-            value={{
-              isReadOnly: false,
-              hasKafkaConnectConfigured: true,
-              hasSchemaRegistryConfigured: true,
-              isTopicDeletionAllowed: true,
-            }}
-          >
-            {setupComponent({
-              topics: [
-                {
-                  ...externalTopicPayload,
-                  cleanUpPolicy: CleanUpPolicy.DELETE,
-                },
-                { ...externalTopicPayload, name: 'external.topic2' },
-              ],
-              deleteTopics: mockDeleteTopics,
-              clearTopicsMessages: mockClearTopicsMessages,
-              clearTopicMessages: mockClearTopic,
-              fetchTopicsList,
-            })}
-          </ClusterContext.Provider>
-        </WithRoute>,
-        { initialEntries: [pathname] }
-      );
-    });
-
-    afterEach(() => {
-      mockDeleteTopics.mockClear();
-      mockClearTopicsMessages.mockClear();
-    });
-
-    const getCheckboxInput = (at: number) => {
-      const rows = screen.getAllByRole('row');
-      return within(rows[at + 1]).getByRole('checkbox');
-    };
-
-    it('renders delete/purge buttons', () => {
-      const firstCheckbox = getCheckboxInput(0);
-      const secondCheckbox = getCheckboxInput(1);
-      expect(firstCheckbox).not.toBeChecked();
-      expect(secondCheckbox).not.toBeChecked();
-      // expect(component.find('.buttons').length).toEqual(0);
-
-      // check first item
-      userEvent.click(firstCheckbox);
-      expect(firstCheckbox).toBeChecked();
-      expect(secondCheckbox).not.toBeChecked();
-
-      expect(screen.getByTestId('delete-buttons')).toBeInTheDocument();
-
-      // check second item
-      userEvent.click(secondCheckbox);
-      expect(firstCheckbox).toBeChecked();
-      expect(secondCheckbox).toBeChecked();
-
-      expect(screen.getByTestId('delete-buttons')).toBeInTheDocument();
-
-      // uncheck second item
-      userEvent.click(secondCheckbox);
-      expect(firstCheckbox).toBeChecked();
-      expect(secondCheckbox).not.toBeChecked();
-
-      expect(screen.getByTestId('delete-buttons')).toBeInTheDocument();
-
-      // uncheck first item
-      userEvent.click(firstCheckbox);
-      expect(firstCheckbox).not.toBeChecked();
-      expect(secondCheckbox).not.toBeChecked();
-
-      expect(screen.queryByTestId('delete-buttons')).not.toBeInTheDocument();
-    });
-
-    const checkActionButtonClick = async (
-      action: 'deleteTopics' | 'clearTopicsMessages'
-    ) => {
-      const buttonIndex = action === 'deleteTopics' ? 0 : 1;
-
-      const confirmationText =
-        action === 'deleteTopics'
-          ? 'Are you sure you want to remove selected topics?'
-          : 'Are you sure you want to purge messages of selected topics?';
-      const mockFn =
-        action === 'deleteTopics' ? mockDeleteTopics : mockClearTopicsMessages;
-
-      const firstCheckbox = getCheckboxInput(0);
-      const secondCheckbox = getCheckboxInput(1);
-      userEvent.click(firstCheckbox);
-      userEvent.click(secondCheckbox);
-
-      expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
-
-      const deleteButtonContainer = screen.getByTestId('delete-buttons');
-      const buttonClickedElement = within(deleteButtonContainer).getAllByRole(
-        'button'
-      )[buttonIndex];
-      userEvent.click(buttonClickedElement);
-
-      const modal = await screen.findByRole('dialog');
-      expect(within(modal).getByText(confirmationText)).toBeInTheDocument();
-      userEvent.click(within(modal).getByRole('button', { name: 'Confirm' }));
-
-      await waitFor(() => {
-        expect(screen.queryByTestId('delete-buttons')).not.toBeInTheDocument();
-      });
-
-      expect(mockFn).toBeCalledTimes(1);
-      expect(mockFn).toBeCalledWith({
-        clusterName: 'local',
-        topicNames: [externalTopicPayload.name, 'external.topic2'],
-      });
-    };
-
-    it('triggers the deleteTopics when clicked on the delete button', async () => {
-      await checkActionButtonClick('deleteTopics');
-      expect(mockDeleteTopics).toBeCalledTimes(1);
-    });
-
-    it('triggers the clearTopicsMessages when clicked on the clear button', async () => {
-      await checkActionButtonClick('clearTopicsMessages');
-      expect(mockClearTopicsMessages).toBeCalledTimes(1);
-    });
-
-    it('closes ConfirmationModal when clicked on the cancel button', async () => {
-      const firstCheckbox = getCheckboxInput(0);
-      const secondCheckbox = getCheckboxInput(1);
-
-      userEvent.click(firstCheckbox);
-      userEvent.click(secondCheckbox);
-
-      const deleteButton = screen.getByText('Delete selected topics');
-
-      userEvent.click(deleteButton);
-
-      const modal = screen.getByRole('dialog');
-      userEvent.click(within(modal).getByText('Cancel'));
-
-      expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
-      expect(firstCheckbox).toBeChecked();
-      expect(secondCheckbox).toBeChecked();
-
-      expect(screen.getByTestId('delete-buttons')).toBeInTheDocument();
-
-      expect(mockDeleteTopics).not.toHaveBeenCalled();
-    });
-  });
-});

+ 52 - 0
kafka-ui-react-app/src/components/Topics/List/__tests__/ListPage.spec.tsx

@@ -0,0 +1,52 @@
+import React from 'react';
+import { render, WithRoute } from 'lib/testHelpers';
+import { screen } from '@testing-library/react';
+import ClusterContext from 'components/contexts/ClusterContext';
+import userEvent from '@testing-library/user-event';
+import { clusterTopicsPath } from 'lib/paths';
+import ListPage from 'components/Topics/List/ListPage';
+
+const clusterName = 'test-cluster';
+
+jest.mock('components/Topics/List/TopicsTable', () => () => (
+  <>TopicsTableMock</>
+));
+
+describe('ListPage Component', () => {
+  const renderComponent = () => {
+    return render(
+      <ClusterContext.Provider
+        value={{
+          isReadOnly: false,
+          hasKafkaConnectConfigured: true,
+          hasSchemaRegistryConfigured: true,
+          isTopicDeletionAllowed: true,
+        }}
+      >
+        <WithRoute path={clusterTopicsPath()}>
+          <ListPage />
+        </WithRoute>
+      </ClusterContext.Provider>,
+      { initialEntries: [clusterTopicsPath(clusterName)] }
+    );
+  };
+
+  beforeEach(() => {
+    renderComponent();
+  });
+
+  it('handles switch of Internal Topics visibility', () => {
+    const switchInput = screen.getByLabelText('Show Internal Topics');
+    expect(switchInput).toBeInTheDocument();
+
+    expect(global.localStorage.getItem('hideInternalTopics')).toBeNull();
+    userEvent.click(switchInput);
+    expect(global.localStorage.getItem('hideInternalTopics')).toBeTruthy();
+    userEvent.click(switchInput);
+    expect(global.localStorage.getItem('hideInternalTopics')).toBeNull();
+  });
+
+  it('renders the TopicsTable', () => {
+    expect(screen.getByText('TopicsTableMock')).toBeInTheDocument();
+  });
+});

+ 168 - 0
kafka-ui-react-app/src/components/Topics/List/__tests__/TopicsTable.spec.tsx

@@ -0,0 +1,168 @@
+import React from 'react';
+import { render, WithRoute } from 'lib/testHelpers';
+import { act, screen, within } from '@testing-library/react';
+import { externalTopicPayload } from 'lib/fixtures/topics';
+import ClusterContext from 'components/contexts/ClusterContext';
+import userEvent from '@testing-library/user-event';
+import { useDeleteTopic, useTopics } from 'lib/hooks/api/topics';
+import TopicsTable from 'components/Topics/List/TopicsTable';
+import { clusterTopicsPath } from 'lib/paths';
+
+const mockUnwrap = jest.fn();
+const useDispatchMock = () => jest.fn(() => ({ unwrap: mockUnwrap }));
+
+jest.mock('lib/hooks/redux', () => ({
+  ...jest.requireActual('lib/hooks/redux'),
+  useAppDispatch: useDispatchMock,
+}));
+
+jest.mock('lib/hooks/api/topics', () => ({
+  ...jest.requireActual('lib/hooks/api/topics'),
+  useDeleteTopic: jest.fn(),
+  useTopics: jest.fn(),
+}));
+
+const deleteTopicMock = jest.fn();
+const refetchMock = jest.fn();
+
+const clusterName = 'test-cluster';
+
+const getCheckboxInput = (at: number) => {
+  const rows = screen.getAllByRole('row');
+  return within(rows[at + 1]).getByRole('checkbox');
+};
+
+describe('TopicsTable Component', () => {
+  beforeEach(() => {
+    (useDeleteTopic as jest.Mock).mockImplementation(() => ({
+      mutateAsync: deleteTopicMock,
+    }));
+    (useTopics as jest.Mock).mockImplementation(() => ({
+      data: {
+        topics: [
+          externalTopicPayload,
+          { ...externalTopicPayload, name: 'test-topic' },
+        ],
+        totalPages: 1,
+      },
+      refetch: refetchMock,
+    }));
+  });
+
+  const renderComponent = () => {
+    return render(
+      <ClusterContext.Provider
+        value={{
+          isReadOnly: false,
+          hasKafkaConnectConfigured: true,
+          hasSchemaRegistryConfigured: true,
+          isTopicDeletionAllowed: true,
+        }}
+      >
+        <WithRoute path={clusterTopicsPath()}>
+          <TopicsTable />
+        </WithRoute>
+      </ClusterContext.Provider>,
+      { initialEntries: [clusterTopicsPath(clusterName)] }
+    );
+  };
+
+  beforeEach(() => {
+    renderComponent();
+  });
+
+  const getButtonByName = (name: string) =>
+    screen.getByRole('button', { name });
+
+  const queryButtonByName = (name: string) =>
+    screen.queryByRole('button', { name });
+
+  it('renders the table', () => {
+    expect(screen.getByRole('table')).toBeInTheDocument();
+  });
+
+  it('renders batch actions bar', () => {
+    expect(screen.getByRole('table')).toBeInTheDocument();
+
+    // check batch actions bar is hidden
+    const firstCheckbox = getCheckboxInput(0);
+    expect(firstCheckbox).not.toBeChecked();
+    expect(queryButtonByName('Delete selected topics')).not.toBeInTheDocument();
+
+    // select firsr row
+    userEvent.click(firstCheckbox);
+    expect(firstCheckbox).toBeChecked();
+
+    // check batch actions bar is shown
+    expect(getButtonByName('Delete selected topics')).toBeInTheDocument();
+    expect(getButtonByName('Copy selected topic')).toBeInTheDocument();
+    expect(
+      getButtonByName('Purge messages of selected topics')
+    ).toBeInTheDocument();
+
+    // select second row
+    const secondCheckbox = getCheckboxInput(1);
+    expect(secondCheckbox).not.toBeChecked();
+    userEvent.click(secondCheckbox);
+    expect(secondCheckbox).toBeChecked();
+
+    // check batch actions bar is still shown
+    expect(getButtonByName('Delete selected topics')).toBeInTheDocument();
+    expect(
+      getButtonByName('Purge messages of selected topics')
+    ).toBeInTheDocument();
+
+    // check Copy button is hidden
+    expect(queryButtonByName('Copy selected topic')).not.toBeInTheDocument();
+  });
+
+  describe('', () => {
+    beforeEach(() => {
+      userEvent.click(getCheckboxInput(0));
+      userEvent.click(getCheckboxInput(1));
+    });
+
+    it('handels delete button click', async () => {
+      const button = getButtonByName('Delete selected topics');
+      expect(button).toBeInTheDocument();
+
+      await act(() => userEvent.click(button));
+
+      expect(
+        screen.getByText('Are you sure you want to remove selected topics?')
+      ).toBeInTheDocument();
+
+      const confirmBtn = getButtonByName('Confirm');
+      expect(confirmBtn).toBeInTheDocument();
+      expect(deleteTopicMock).not.toHaveBeenCalled();
+      await act(() => userEvent.click(confirmBtn));
+
+      expect(deleteTopicMock).toHaveBeenCalledTimes(2);
+
+      expect(getCheckboxInput(0)).not.toBeChecked();
+      expect(getCheckboxInput(1)).not.toBeChecked();
+    });
+
+    it('handels purge messages button click', async () => {
+      const button = getButtonByName('Purge messages of selected topics');
+      expect(button).toBeInTheDocument();
+
+      await act(() => userEvent.click(button));
+
+      expect(
+        screen.getByText(
+          'Are you sure you want to purge messages of selected topics?'
+        )
+      ).toBeInTheDocument();
+
+      const confirmBtn = getButtonByName('Confirm');
+      expect(confirmBtn).toBeInTheDocument();
+      expect(mockUnwrap).not.toHaveBeenCalled();
+      await act(() => userEvent.click(confirmBtn));
+      expect(mockUnwrap).toHaveBeenCalledTimes(2);
+
+      expect(getCheckboxInput(0)).not.toBeChecked();
+      expect(getCheckboxInput(1)).not.toBeChecked();
+    });
+  });
+});

+ 63 - 23
kafka-ui-react-app/src/components/Topics/List/__tests__/TopicsTableCells.spec.tsx

@@ -4,11 +4,12 @@ import {
   MessagesCell,
   OutOfSyncReplicasCell,
   TitleCell,
+  TopicSizeCell,
 } from 'components/Topics/List/TopicsTableCells';
 import { TableState } from 'lib/hooks/useTableState';
 import { screen } from '@testing-library/react';
 import { Topic } from 'generated-sources';
-import { topicsPayload } from 'redux/reducers/topics/__test__/fixtures';
+import { topicsPayload } from 'lib/fixtures/topics';
 
 describe('TopicsTableCells Components', () => {
   const mockTableState: TableState<Topic, string> = {
@@ -49,8 +50,22 @@ describe('TopicsTableCells Components', () => {
     });
   });
 
+  describe('TopicSizeCell Component', () => {
+    const currentData = topicsPayload[1];
+    it('should check the TopicSizeCell component Render', () => {
+      render(
+        <TopicSizeCell
+          rowIndex={1}
+          dataItem={currentData}
+          tableState={mockTableState}
+        />
+      );
+      expect(screen.getByText('1KB')).toBeInTheDocument();
+    });
+  });
+
   describe('OutOfSyncReplicasCell Component', () => {
-    it('should check the content of the OutOfSyncReplicasCell to return 0 if no partition is empty array', () => {
+    it('returns 0 if no partition is empty array', () => {
       const currentData = topicsPayload[0];
       currentData.partitions = [];
       render(
@@ -63,7 +78,7 @@ describe('TopicsTableCells Components', () => {
       expect(screen.getByText('0')).toBeInTheDocument();
     });
 
-    it('should check the content of the OutOfSyncReplicasCell to return 0 if no partition is found', () => {
+    it('returns 0 if no partition is found', () => {
       const currentData = topicsPayload[1];
       currentData.partitions = undefined;
       render(
@@ -76,6 +91,29 @@ describe('TopicsTableCells Components', () => {
       expect(screen.getByText('0')).toBeInTheDocument();
     });
 
+    it('returns number of out of sync partitions', () => {
+      const currentData = {
+        ...topicsPayload[1],
+        partitions: [
+          {
+            partition: 0,
+            leader: 1,
+            replicas: [{ broker: 1, leader: false, inSync: false }],
+            offsetMax: 0,
+            offsetMin: 0,
+          },
+        ],
+      };
+      render(
+        <OutOfSyncReplicasCell
+          rowIndex={1}
+          dataItem={currentData}
+          tableState={mockTableState}
+        />
+      );
+      expect(screen.getByText('1')).toBeInTheDocument();
+    });
+
     it('should check the content of the OutOfSyncReplicasCell with the correct partition number', () => {
       const currentData = topicsPayload[0];
       const partitionNumber = currentData.partitions?.reduce(
@@ -100,50 +138,52 @@ describe('TopicsTableCells Components', () => {
   });
 
   describe('MessagesCell Component', () => {
-    it('should check the content of the MessagesCell to return 0 if no partition is empty array ', () => {
-      const currentData = topicsPayload[0];
-      currentData.partitions = [];
+    it('returns 0 if partition is empty array ', () => {
       render(
         <MessagesCell
           rowIndex={1}
-          dataItem={topicsPayload[0]}
+          dataItem={{ ...topicsPayload[0], partitions: [] }}
           tableState={mockTableState}
         />
       );
       expect(screen.getByText('0')).toBeInTheDocument();
     });
 
-    it('should check the content of the MessagesCell to return 0 if no partition is found', () => {
-      const currentData = topicsPayload[0];
-      currentData.partitions = undefined;
+    it('returns 0 if no partition is found', () => {
       render(
         <MessagesCell
           rowIndex={1}
-          dataItem={topicsPayload[0]}
+          dataItem={{ ...topicsPayload[0], partitions: undefined }}
           tableState={mockTableState}
         />
       );
       expect(screen.getByText('0')).toBeInTheDocument();
     });
 
-    it('should check the content of the MessagesCell with the correct partition number', () => {
-      const currentData = topicsPayload[0];
-      const partitionNumber = currentData.partitions?.reduce(
-        (memo, { offsetMax, offsetMin }) => {
-          return memo + (offsetMax - offsetMin);
-        },
-        0
-      );
+    it('returns the correct messages number', () => {
+      const offsetMax = 10034;
+      const offsetMin = 345;
+      const currentData = {
+        ...topicsPayload[0],
+        partitions: [
+          {
+            partition: 0,
+            leader: 1,
+            replicas: [{ broker: 1, leader: false, inSync: false }],
+            offsetMax,
+            offsetMin,
+          },
+        ],
+      };
       render(
         <MessagesCell
           rowIndex={1}
-          dataItem={topicsPayload[0]}
+          dataItem={currentData}
           tableState={mockTableState}
         />
       );
-      expect(
-        screen.getByText(partitionNumber ? partitionNumber.toString() : '0')
-      ).toBeInTheDocument();
+      expect(offsetMax - offsetMin).toEqual(9689);
+      expect(screen.getByText(offsetMax - offsetMin)).toBeInTheDocument();
     });
   });
 });

+ 5 - 9
kafka-ui-react-app/src/components/Topics/New/New.tsx

@@ -4,13 +4,11 @@ import { useForm, FormProvider } from 'react-hook-form';
 import { ClusterNameRoute } from 'lib/paths';
 import TopicForm from 'components/Topics/shared/Form/TopicForm';
 import { useNavigate, useLocation } from 'react-router-dom';
-import { createTopic } from 'redux/reducers/topics/topicsSlice';
 import { yupResolver } from '@hookform/resolvers/yup';
 import { topicFormValidationSchema } from 'lib/yupExtended';
 import PageHeading from 'components/common/PageHeading/PageHeading';
-import { useAppDispatch } from 'lib/hooks/redux';
 import useAppParams from 'lib/hooks/useAppParams';
-import { AsyncRequestStatus } from 'lib/constants';
+import { useCreateTopic } from 'lib/hooks/api/topics';
 
 enum Filters {
   NAME = 'name',
@@ -27,10 +25,11 @@ const New: React.FC = () => {
   });
 
   const { clusterName } = useAppParams<ClusterNameRoute>();
+  const createTopic = useCreateTopic(clusterName);
+
   const navigate = useNavigate();
 
   const { search } = useLocation();
-  const dispatch = useAppDispatch();
   const params = new URLSearchParams(search);
 
   const name = params.get(Filters.NAME) || '';
@@ -40,11 +39,8 @@ const New: React.FC = () => {
   const cleanUpPolicy = params.get(Filters.CLEANUP_POLICY) || 'Delete';
 
   const onSubmit = async (data: TopicFormData) => {
-    const { meta } = await dispatch(createTopic({ clusterName, data }));
-
-    if (meta.requestStatus === AsyncRequestStatus.fulfilled) {
-      navigate(`../${data.name}`);
-    }
+    await createTopic.mutateAsync(data);
+    navigate(`../${data.name}`);
   };
 
   return (

+ 12 - 40
kafka-ui-react-app/src/components/Topics/New/__test__/New.spec.tsx

@@ -9,7 +9,7 @@ import {
 } from 'lib/paths';
 import userEvent from '@testing-library/user-event';
 import { render } from 'lib/testHelpers';
-import { useAppDispatch } from 'lib/hooks/redux';
+import { useCreateTopic } from 'lib/hooks/api/topics';
 
 const clusterName = 'local';
 const topicName = 'test-topic';
@@ -19,9 +19,9 @@ jest.mock('react-router-dom', () => ({
   ...jest.requireActual('react-router-dom'),
   useNavigate: () => mockNavigate,
 }));
-jest.mock('lib/hooks/redux', () => ({
-  ...jest.requireActual('lib/hooks/redux'),
-  useAppDispatch: jest.fn(),
+
+jest.mock('lib/hooks/api/topics', () => ({
+  useCreateTopic: jest.fn(),
 }));
 
 const renderComponent = (path: string) => {
@@ -34,15 +34,20 @@ const renderComponent = (path: string) => {
     { initialEntries: [path] }
   );
 };
+const createTopicMock = jest.fn();
 
 describe('New', () => {
+  beforeEach(() => {
+    (useCreateTopic as jest.Mock).mockImplementation(() => ({
+      mutateAsync: createTopicMock,
+    }));
+  });
   afterEach(() => {
     mockNavigate.mockClear();
   });
 
   it('checks header for create new', async () => {
     await act(() => renderComponent(clusterTopicNewPath(clusterName)));
-
     expect(
       screen.getByRole('heading', { name: 'Create new Topic' })
     ).toHaveTextContent('Create new Topic');
@@ -68,15 +73,11 @@ describe('New', () => {
     await waitFor(() => {
       expect(screen.getByText('name is a required field')).toBeInTheDocument();
     });
+    expect(createTopicMock).not.toHaveBeenCalled();
     expect(mockNavigate).not.toHaveBeenCalled();
   });
 
   it('submits valid form', async () => {
-    const useDispatchMock = jest.fn(() => ({
-      meta: { requestStatus: 'fulfilled' },
-    }));
-    (useAppDispatch as jest.Mock).mockImplementation(() => useDispatchMock);
-
     await act(() => renderComponent(clusterTopicNewPath(clusterName)));
     await act(() => {
       userEvent.type(screen.getByPlaceholderText('Topic Name'), topicName);
@@ -84,38 +85,9 @@ describe('New', () => {
     await act(() => {
       userEvent.click(screen.getByText('Create topic'));
     });
-    await waitFor(() => expect(useDispatchMock).toHaveBeenCalledTimes(1));
+    await waitFor(() => expect(createTopicMock).toHaveBeenCalledTimes(1));
     await waitFor(() =>
       expect(mockNavigate).toHaveBeenLastCalledWith(`../${topicName}`)
     );
   });
-
-  it('does not redirect page when request is not fulfilled', async () => {
-    const useDispatchMock = jest.fn(() => ({
-      meta: { requestStatus: 'pending' },
-    }));
-    (useAppDispatch as jest.Mock).mockImplementation(() => useDispatchMock);
-    await act(() => renderComponent(clusterTopicNewPath(clusterName)));
-    await act(() =>
-      userEvent.type(screen.getByPlaceholderText('Topic Name'), topicName)
-    );
-    await act(() => userEvent.click(screen.getByText('Create topic')));
-    expect(mockNavigate).not.toHaveBeenCalled();
-  });
-
-  it('submits valid form that result in an error', async () => {
-    const useDispatchMock = jest.fn();
-    (useAppDispatch as jest.Mock).mockImplementation(() => useDispatchMock);
-
-    await act(() => renderComponent(clusterTopicNewPath(clusterName)));
-    await act(() => {
-      userEvent.type(screen.getByPlaceholderText('Topic Name'), topicName);
-    });
-    await act(() => {
-      userEvent.click(screen.getByText('Create topic'));
-    });
-
-    expect(useDispatchMock).toHaveBeenCalledTimes(1);
-    expect(mockNavigate).not.toHaveBeenCalled();
-  });
 });

+ 47 - 71
kafka-ui-react-app/src/components/Topics/Topic/Details/ConsumerGroups/TopicConsumerGroups.tsx

@@ -1,89 +1,65 @@
 import React from 'react';
 import { Link } from 'react-router-dom';
-import { ClusterName, TopicName } from 'redux/interfaces';
 import { clusterConsumerGroupsPath, RouteParamsClusterTopic } from 'lib/paths';
 import { Table } from 'components/common/table/Table/Table.styled';
 import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
 import { Tag } from 'components/common/Tag/Tag.styled';
 import { TableKeyLink } from 'components/common/table/Table/TableKeyLink.styled';
-import PageLoader from 'components/common/PageLoader/PageLoader';
 import getTagColor from 'components/common/Tag/getTagColor';
-import { useAppSelector } from 'lib/hooks/redux';
-import { getTopicConsumerGroups } from 'redux/reducers/topics/selectors';
 import useAppParams from 'lib/hooks/useAppParams';
+import { useTopicConsumerGroups } from 'lib/hooks/api/topics';
 
-export interface Props {
-  isFetched: boolean;
-  fetchTopicConsumerGroups(payload: {
-    clusterName: ClusterName;
-    topicName: TopicName;
-  }): void;
-}
-
-const TopicConsumerGroups: React.FC<Props> = ({
-  fetchTopicConsumerGroups,
-  isFetched,
-}) => {
+const TopicConsumerGroups: React.FC = () => {
   const { clusterName, topicName } = useAppParams<RouteParamsClusterTopic>();
 
-  const consumerGroups = useAppSelector((state) =>
-    getTopicConsumerGroups(state, topicName)
-  );
-
-  React.useEffect(() => {
-    fetchTopicConsumerGroups({ clusterName, topicName });
-  }, [clusterName, fetchTopicConsumerGroups, topicName]);
-
-  if (!isFetched) {
-    return <PageLoader />;
-  }
-
+  const { data: consumerGroups } = useTopicConsumerGroups({
+    clusterName,
+    topicName,
+  });
   return (
-    <div>
-      <Table isFullwidth>
-        <thead>
+    <Table isFullwidth>
+      <thead>
+        <tr>
+          <TableHeaderCell title="Consumer Group ID" />
+          <TableHeaderCell title="Num Of Members" />
+          <TableHeaderCell title="Messages Behind" />
+          <TableHeaderCell title="Coordinator" />
+          <TableHeaderCell title="State" />
+        </tr>
+      </thead>
+      <tbody>
+        {consumerGroups?.map((consumer) => (
+          <tr key={consumer.groupId}>
+            <TableKeyLink>
+              <Link
+                to={`${clusterConsumerGroupsPath(clusterName)}/${
+                  consumer.groupId
+                }`}
+              >
+                {consumer.groupId}
+              </Link>
+            </TableKeyLink>
+            <td>{consumer.members}</td>
+            <td>{consumer.messagesBehind}</td>
+            <td>{consumer.coordinator?.id}</td>
+            <td>
+              {consumer.state && (
+                <Tag color={getTagColor(consumer)}>{`${consumer.state
+                  .charAt(0)
+                  .toUpperCase()}${consumer.state
+                  .slice(1)
+                  .toLowerCase()}`}</Tag>
+              )}
+            </td>
+          </tr>
+        ))}
+        {(!consumerGroups || consumerGroups.length === 0) && (
           <tr>
-            <TableHeaderCell title="Consumer Group ID" />
-            <TableHeaderCell title="Num Of Members" />
-            <TableHeaderCell title="Messages Behind" />
-            <TableHeaderCell title="Coordinator" />
-            <TableHeaderCell title="State" />
+            <td colSpan={10}>No active consumer groups</td>
           </tr>
-        </thead>
-        <tbody>
-          {consumerGroups.map((consumer) => (
-            <tr key={consumer.groupId}>
-              <TableKeyLink>
-                <Link
-                  to={`${clusterConsumerGroupsPath(clusterName)}/${
-                    consumer.groupId
-                  }`}
-                >
-                  {consumer.groupId}
-                </Link>
-              </TableKeyLink>
-              <td>{consumer.members}</td>
-              <td>{consumer.messagesBehind}</td>
-              <td>{consumer.coordinator?.id}</td>
-              <td>
-                {consumer.state && (
-                  <Tag color={getTagColor(consumer)}>{`${consumer.state
-                    .charAt(0)
-                    .toUpperCase()}${consumer.state
-                    .slice(1)
-                    .toLowerCase()}`}</Tag>
-                )}
-              </td>
-            </tr>
-          ))}
-          {consumerGroups.length === 0 && (
-            <tr>
-              <td colSpan={10}>No active consumer groups</td>
-            </tr>
-          )}
-        </tbody>
-      </Table>
-    </div>
+        )}
+      </tbody>
+    </Table>
   );
 };
 

+ 0 - 18
kafka-ui-react-app/src/components/Topics/Topic/Details/ConsumerGroups/TopicConsumerGroupsContainer.ts

@@ -1,18 +0,0 @@
-import { connect } from 'react-redux';
-import { RootState } from 'redux/interfaces';
-import { fetchTopicConsumerGroups } from 'redux/reducers/topics/topicsSlice';
-import TopicConsumerGroups from 'components/Topics/Topic/Details/ConsumerGroups/TopicConsumerGroups';
-import { getTopicsConsumerGroupsFetched } from 'redux/reducers/topics/selectors';
-
-const mapStateToProps = (state: RootState) => ({
-  isFetched: getTopicsConsumerGroupsFetched(state),
-});
-
-const mapDispatchToProps = {
-  fetchTopicConsumerGroups,
-};
-
-export default connect(
-  mapStateToProps,
-  mapDispatchToProps
-)(TopicConsumerGroups);

+ 30 - 77
kafka-ui-react-app/src/components/Topics/Topic/Details/ConsumerGroups/__test__/TopicConsumerGroups.spec.tsx

@@ -1,96 +1,49 @@
 import React from 'react';
 import { render, WithRoute } from 'lib/testHelpers';
 import { screen } from '@testing-library/react';
-import TopicConsumerGroups, {
-  Props,
-} from 'components/Topics/Topic/Details/ConsumerGroups/TopicConsumerGroups';
-import { ConsumerGroup, ConsumerGroupState } from 'generated-sources';
-import { getTopicStateFixtures } from 'redux/reducers/topics/__test__/fixtures';
-import { TopicWithDetailedInfo } from 'redux/interfaces';
+import TopicConsumerGroups from 'components/Topics/Topic/Details/ConsumerGroups/TopicConsumerGroups';
 import { clusterTopicConsumerGroupsPath } from 'lib/paths';
+import { useTopicConsumerGroups } from 'lib/hooks/api/topics';
+import { ConsumerGroup } from 'generated-sources';
+import { topicConsumerGroups } from 'lib/fixtures/topics';
 
-describe('TopicConsumerGroups', () => {
-  const mockClusterName = 'localClusterName';
-  const mockTopicName = 'localTopicName';
-  const mockWithConsumerGroup = [
-    {
-      groupId: 'amazon.msk.canary.group.broker-7',
-      topics: 0,
-      members: 0,
-      simple: false,
-      partitionAssignor: '',
-      state: ConsumerGroupState.UNKNOWN,
-      coordinator: { id: 1 },
-      messagesBehind: 9,
-    },
-    {
-      groupId: 'amazon.msk.canary.group.broker-4',
-      topics: 0,
-      members: 0,
-      simple: false,
-      partitionAssignor: '',
-      state: ConsumerGroupState.COMPLETING_REBALANCE,
-      coordinator: { id: 1 },
-      messagesBehind: 9,
-    },
-  ];
+const clusterName = 'local';
+const topicName = 'my-topicName';
+const path = clusterTopicConsumerGroupsPath(clusterName, topicName);
+
+jest.mock('lib/hooks/api/topics', () => ({
+  useTopicConsumerGroups: jest.fn(),
+}));
 
-  const setUpComponent = (
-    props: Partial<Props> = {},
-    consumerGroups?: ConsumerGroup[]
-  ) => {
-    const topic: TopicWithDetailedInfo = {
-      name: mockTopicName,
-      consumerGroups,
-    };
-    const topicsState = getTopicStateFixtures([topic]);
+describe('TopicConsumerGroups', () => {
+  const renderComponent = async (payload?: ConsumerGroup[]) => {
+    (useTopicConsumerGroups as jest.Mock).mockImplementation(() => ({
+      data: payload,
+    }));
 
-    return render(
+    render(
       <WithRoute path={clusterTopicConsumerGroupsPath()}>
-        <TopicConsumerGroups
-          fetchTopicConsumerGroups={jest.fn()}
-          isFetched={false}
-          {...props}
-        />
+        <TopicConsumerGroups />
       </WithRoute>,
-      {
-        initialEntries: [
-          clusterTopicConsumerGroupsPath(mockClusterName, mockTopicName),
-        ],
-        preloadedState: {
-          topics: topicsState,
-        },
-      }
+      { initialEntries: [path] }
     );
   };
 
-  describe('Default Setup', () => {
-    beforeEach(() => {
-      setUpComponent();
-    });
-    it('should view the Page loader when it is fetching state', () => {
-      expect(screen.getByRole('progressbar')).toBeInTheDocument();
-    });
+  it('renders empty table if consumer groups payload is empty', async () => {
+    await renderComponent([]);
+    expect(screen.getByText('No active consumer groups')).toBeInTheDocument();
   });
 
-  it("don't render ConsumerGroups in Topic", () => {
-    setUpComponent({ isFetched: true });
-    expect(screen.getByText(/No active consumer groups/i)).toBeInTheDocument();
+  it('renders empty table if consumer groups payload is undefined', async () => {
+    await renderComponent();
+    expect(screen.getByText('No active consumer groups')).toBeInTheDocument();
   });
 
-  it('render ConsumerGroups in Topic', () => {
-    setUpComponent(
-      {
-        isFetched: true,
-      },
-      mockWithConsumerGroup
+  it('renders table of consumer groups', async () => {
+    await renderComponent(topicConsumerGroups);
+    const groupIds = topicConsumerGroups.map(({ groupId }) => groupId);
+    groupIds.forEach((groupId) =>
+      expect(screen.getByText(groupId)).toBeInTheDocument()
     );
-    expect(screen.getAllByRole('rowgroup')).toHaveLength(2);
-    expect(
-      screen.getByText(mockWithConsumerGroup[0].groupId)
-    ).toBeInTheDocument();
-    expect(
-      screen.getByText(mockWithConsumerGroup[1].groupId)
-    ).toBeInTheDocument();
   });
 });

+ 43 - 55
kafka-ui-react-app/src/components/Topics/Topic/Details/Details.tsx

@@ -1,5 +1,4 @@
-import React from 'react';
-import { ClusterName, TopicName } from 'redux/interfaces';
+import React, { Suspense } from 'react';
 import { NavLink, Route, Routes, useNavigate } from 'react-router-dom';
 import {
   RouteParamsClusterTopic,
@@ -14,37 +13,26 @@ import PageHeading from 'components/common/PageHeading/PageHeading';
 import { Button } from 'components/common/Button/Button';
 import styled from 'styled-components';
 import Navbar from 'components/common/Navigation/Navbar.styled';
-import { useAppSelector } from 'lib/hooks/redux';
-import {
-  getIsTopicDeletePolicy,
-  getIsTopicInternal,
-} from 'redux/reducers/topics/selectors';
+import { useAppDispatch } from 'lib/hooks/redux';
 import useAppParams from 'lib/hooks/useAppParams';
 import {
   Dropdown,
   DropdownItem,
   DropdownItemHint,
 } from 'components/common/Dropdown';
+import {
+  useDeleteTopic,
+  useRecreateTopic,
+  useTopicDetails,
+} from 'lib/hooks/api/topics';
+import { clearTopicMessages } from 'redux/reducers/topicMessages/topicMessagesSlice';
+import { CleanUpPolicy } from 'generated-sources';
+import PageLoader from 'components/common/PageLoader/PageLoader';
 
-import OverviewContainer from './Overview/OverviewContainer';
-import TopicConsumerGroupsContainer from './ConsumerGroups/TopicConsumerGroupsContainer';
-import SettingsContainer from './Settings/SettingsContainer';
 import Messages from './Messages/Messages';
-
-interface Props {
-  deleteTopic: (payload: {
-    clusterName: ClusterName;
-    topicName: TopicName;
-  }) => void;
-  recreateTopic: (payload: {
-    clusterName: ClusterName;
-    topicName: TopicName;
-  }) => void;
-  clearTopicMessages(params: {
-    clusterName: ClusterName;
-    topicName: TopicName;
-  }): void;
-}
+import Overview from './Overview/Overview';
+import Settings from './Settings/Settings';
+import TopicConsumerGroups from './ConsumerGroups/TopicConsumerGroups';
 
 const HeaderControlsWrapper = styled.div`
   display: flex;
@@ -53,25 +41,19 @@ const HeaderControlsWrapper = styled.div`
   gap: 26px;
 `;
 
-const Details: React.FC<Props> = ({
-  deleteTopic,
-  recreateTopic,
-  clearTopicMessages,
-}) => {
+const Details: React.FC = () => {
+  const dispatch = useAppDispatch();
   const { clusterName, topicName } = useAppParams<RouteParamsClusterTopic>();
-  const isInternal = useAppSelector((state) =>
-    getIsTopicInternal(state, topicName)
-  );
-  const isDeletePolicy = useAppSelector((state) =>
-    getIsTopicDeletePolicy(state, topicName)
-  );
   const navigate = useNavigate();
+  const deleteTopic = useDeleteTopic(clusterName);
+  const recreateTopic = useRecreateTopic({ clusterName, topicName });
+  const { data } = useTopicDetails({ clusterName, topicName });
 
   const { isReadOnly, isTopicDeletionAllowed } =
     React.useContext(ClusterContext);
 
-  const deleteTopicHandler = () => {
-    deleteTopic({ clusterName, topicName });
+  const deleteTopicHandler = async () => {
+    await deleteTopic.mutateAsync(topicName);
     navigate('../..');
   };
 
@@ -94,7 +76,7 @@ const Details: React.FC<Props> = ({
               }
             />
           </Routes>
-          {!isReadOnly && !isInternal && (
+          {!isReadOnly && !data?.internal && (
             <Routes>
               <Route
                 index
@@ -110,11 +92,12 @@ const Details: React.FC<Props> = ({
                         especially important consequences.
                       </DropdownItemHint>
                     </DropdownItem>
-                    {isDeletePolicy && (
+                    {data?.cleanUpPolicy === CleanUpPolicy.DELETE && (
                       <DropdownItem
-                        disabled={!isDeletePolicy}
                         onClick={() =>
-                          clearTopicMessages({ clusterName, topicName })
+                          dispatch(
+                            clearTopicMessages({ clusterName, topicName })
+                          ).unwrap()
                         }
                         confirm="Are you sure want to clear topic messages?"
                         danger
@@ -123,7 +106,7 @@ const Details: React.FC<Props> = ({
                       </DropdownItem>
                     )}
                     <DropdownItem
-                      onClick={() => recreateTopic({ clusterName, topicName })}
+                      onClick={recreateTopic.mutateAsync}
                       confirm={
                         <>
                           Are you sure want to recreate <b>{topicName}</b>{' '}
@@ -182,18 +165,23 @@ const Details: React.FC<Props> = ({
           Settings
         </NavLink>
       </Navbar>
-      <Routes>
-        <Route index element={<OverviewContainer />} />
-        <Route path={clusterTopicMessagesRelativePath} element={<Messages />} />
-        <Route
-          path={clusterTopicSettingsRelativePath}
-          element={<SettingsContainer />}
-        />
-        <Route
-          path={clusterTopicConsumerGroupsRelativePath}
-          element={<TopicConsumerGroupsContainer />}
-        />
-      </Routes>
+      <Suspense fallback={<PageLoader />}>
+        <Routes>
+          <Route index element={<Overview />} />
+          <Route
+            path={clusterTopicMessagesRelativePath}
+            element={<Messages />}
+          />
+          <Route
+            path={clusterTopicSettingsRelativePath}
+            element={<Settings />}
+          />
+          <Route
+            path={clusterTopicConsumerGroupsRelativePath}
+            element={<TopicConsumerGroups />}
+          />
+        </Routes>
+      </Suspense>
     </div>
   );
 };

+ 0 - 19
kafka-ui-react-app/src/components/Topics/Topic/Details/DetailsContainer.ts

@@ -1,19 +0,0 @@
-import { connect } from 'react-redux';
-import { RootState } from 'redux/interfaces';
-import { deleteTopic, recreateTopic } from 'redux/reducers/topics/topicsSlice';
-import { clearTopicMessages } from 'redux/reducers/topicMessages/topicMessagesSlice';
-import { getIsTopicDeleted } from 'redux/reducers/topics/selectors';
-
-import Details from './Details';
-
-const mapStateToProps = (state: RootState) => ({
-  isDeleted: getIsTopicDeleted(state),
-});
-
-const mapDispatchToProps = {
-  recreateTopic,
-  deleteTopic,
-  clearTopicMessages,
-};
-
-export default connect(mapStateToProps, mapDispatchToProps)(Details);

+ 4 - 5
kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/Filters/Filters.tsx

@@ -26,8 +26,6 @@ import FilterModal, {
 import { SeekDirectionOptions } from 'components/Topics/Topic/Details/Messages/Messages';
 import TopicMessagesContext from 'components/contexts/TopicMessagesContext';
 import useModal from 'lib/hooks/useModal';
-import { getPartitionsByTopicName } from 'redux/reducers/topics/selectors';
-import { useAppSelector } from 'lib/hooks/redux';
 import { RouteParamsClusterTopic } from 'lib/paths';
 import useAppParams from 'lib/hooks/useAppParams';
 import PlusIcon from 'components/common/Icons/PlusIcon';
@@ -35,6 +33,7 @@ import CloseIcon from 'components/common/Icons/CloseIcon';
 import ClockIcon from 'components/common/Icons/ClockIcon';
 import ArrowDownIcon from 'components/common/Icons/ArrowDownIcon';
 import FileIcon from 'components/common/Icons/FileIcon';
+import { useTopicDetails } from 'lib/hooks/api/topics';
 
 import * as S from './Filters.styled';
 import {
@@ -89,9 +88,9 @@ const Filters: React.FC<FiltersProps> = ({
   const location = useLocation();
   const navigate = useNavigate();
 
-  const partitions = useAppSelector((state) =>
-    getPartitionsByTopicName(state, topicName)
-  );
+  const { data: topic } = useTopicDetails({ clusterName, topicName });
+
+  const partitions = topic?.partitions || [];
 
   const { searchParams, seekDirection, isLive, changeSeekDirection } =
     useContext(TopicMessagesContext);

+ 32 - 13
kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/Filters/__tests__/Filters.spec.tsx

@@ -4,13 +4,20 @@ import Filters, {
   FiltersProps,
   SeekTypeOptions,
 } from 'components/Topics/Topic/Details/Messages/Filters/Filters';
-import { EventSourceMock, render } from 'lib/testHelpers';
+import { EventSourceMock, render, WithRoute } from 'lib/testHelpers';
 import { act, screen, within, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import TopicMessagesContext, {
   ContextProps,
 } from 'components/contexts/TopicMessagesContext';
 import { SeekDirection } from 'generated-sources';
+import { clusterTopicPath } from 'lib/paths';
+import { useTopicDetails } from 'lib/hooks/api/topics';
+import { externalTopicPayload } from 'lib/fixtures/topics';
+
+jest.mock('lib/hooks/api/topics', () => ({
+  useTopicDetails: jest.fn(),
+}));
 
 const defaultContextValue: ContextProps = {
   isLive: false,
@@ -21,26 +28,38 @@ const defaultContextValue: ContextProps = {
 
 jest.mock('components/common/Icons/CloseIcon', () => () => 'mock-CloseIcon');
 
+const clusterName = 'cluster-name';
+const topicName = 'topic-name';
+
 const renderComponent = (
   props: Partial<FiltersProps> = {},
   ctx: ContextProps = defaultContextValue
 ) => {
   render(
-    <TopicMessagesContext.Provider value={ctx}>
-      <Filters
-        meta={{}}
-        isFetching={false}
-        addMessage={jest.fn()}
-        resetMessages={jest.fn()}
-        updatePhase={jest.fn()}
-        updateMeta={jest.fn()}
-        setIsFetching={jest.fn()}
-        {...props}
-      />
-    </TopicMessagesContext.Provider>
+    <WithRoute path={clusterTopicPath()}>
+      <TopicMessagesContext.Provider value={ctx}>
+        <Filters
+          meta={{}}
+          isFetching={false}
+          addMessage={jest.fn()}
+          resetMessages={jest.fn()}
+          updatePhase={jest.fn()}
+          updateMeta={jest.fn()}
+          setIsFetching={jest.fn()}
+          {...props}
+        />
+      </TopicMessagesContext.Provider>
+    </WithRoute>,
+    { initialEntries: [clusterTopicPath(clusterName, topicName)] }
   );
 };
 
+beforeEach(async () => {
+  (useTopicDetails as jest.Mock).mockImplementation(() => ({
+    data: externalTopicPayload,
+  }));
+});
+
 describe('Filters component', () => {
   Object.defineProperty(window, 'EventSource', {
     value: EventSourceMock,

+ 15 - 8
kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/__test__/Messages.spec.tsx

@@ -1,20 +1,27 @@
 import React from 'react';
 import { screen, waitFor } from '@testing-library/react';
-import { render, EventSourceMock } from 'lib/testHelpers';
+import { render, EventSourceMock, WithRoute } from 'lib/testHelpers';
 import Messages, {
   SeekDirectionOptions,
   SeekDirectionOptionsObj,
 } from 'components/Topics/Topic/Details/Messages/Messages';
 import { SeekDirection, SeekType } from 'generated-sources';
 import userEvent from '@testing-library/user-event';
+import { clusterTopicMessagesPath } from 'lib/paths';
 
 describe('Messages', () => {
   const searchParams = `?filterQueryType=STRING_CONTAINS&attempt=0&limit=100&seekDirection=${SeekDirection.FORWARD}&seekType=${SeekType.OFFSET}&seekTo=0::9`;
-
-  const setUpComponent = (param: string = searchParams) => {
-    return render(<Messages />, {
-      initialEntries: [`/?${new URLSearchParams(param).toString()}`],
-    });
+  const renderComponent = (param: string = searchParams) => {
+    const query = new URLSearchParams(param).toString();
+    const path = `${clusterTopicMessagesPath()}?${query}`;
+    return render(
+      <WithRoute path={clusterTopicMessagesPath()}>
+        <Messages />
+      </WithRoute>,
+      {
+        initialEntries: [path],
+      }
+    );
   };
 
   beforeEach(() => {
@@ -24,7 +31,7 @@ describe('Messages', () => {
   });
   describe('component rendering default behavior with the search params', () => {
     beforeEach(() => {
-      setUpComponent();
+      renderComponent();
     });
     it('should check default seekDirection if it actually take the value from the url', () => {
       expect(screen.getAllByRole('listbox')[1]).toHaveTextContent(
@@ -69,7 +76,7 @@ describe('Messages', () => {
 
   describe('Component rendering with custom Url search params', () => {
     it('reacts to a change of seekDirection in the url which make the select pick up different value', () => {
-      setUpComponent(
+      renderComponent(
         searchParams.replace(SeekDirection.FORWARD, SeekDirection.BACKWARD)
       );
       expect(screen.getAllByRole('listbox')[1]).toHaveTextContent(

+ 45 - 54
kafka-ui-react-app/src/components/Topics/Topic/Details/Overview/Overview.tsx

@@ -1,55 +1,32 @@
 import React from 'react';
 import { Partition, Replica } from 'generated-sources';
-import { ClusterName, TopicName } from 'redux/interfaces';
 import ClusterContext from 'components/contexts/ClusterContext';
 import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
 import { Table } from 'components/common/table/Table/Table.styled';
 import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
 import * as Metrics from 'components/common/Metrics';
 import { Tag } from 'components/common/Tag/Tag.styled';
-import { useAppSelector } from 'lib/hooks/redux';
-import { getTopicByName } from 'redux/reducers/topics/selectors';
+import { useAppDispatch } from 'lib/hooks/redux';
 import { RouteParamsClusterTopic } from 'lib/paths';
 import useAppParams from 'lib/hooks/useAppParams';
 import { Dropdown, DropdownItem } from 'components/common/Dropdown';
+import { clearTopicMessages } from 'redux/reducers/topicMessages/topicMessagesSlice';
+import { useTopicDetails } from 'lib/hooks/api/topics';
 
 import * as S from './Overview.styled';
 
-export interface Props {
-  clearTopicMessages(params: {
-    clusterName: ClusterName;
-    topicName: TopicName;
-    partitions?: number[];
-  }): void;
-}
-
-const Overview: React.FC<Props> = ({ clearTopicMessages }) => {
+const Overview: React.FC = () => {
   const { clusterName, topicName } = useAppParams<RouteParamsClusterTopic>();
-
-  const {
-    partitions,
-    underReplicatedPartitions,
-    inSyncReplicas,
-    replicas,
-    partitionCount,
-    internal,
-    replicationFactor,
-    segmentSize,
-    segmentCount,
-    cleanUpPolicy,
-  } = useAppSelector((state) => {
-    const res = getTopicByName(state, topicName);
-    return res || {};
-  });
-
+  const dispatch = useAppDispatch();
+  const { data } = useTopicDetails({ clusterName, topicName });
   const { isReadOnly } = React.useContext(ClusterContext);
 
   const messageCount = React.useMemo(
     () =>
-      (partitions || []).reduce((memo, partition) => {
+      (data?.partitions || []).reduce((memo, partition) => {
         return memo + partition.offsetMax - partition.offsetMin;
       }, 0),
-    [partitions]
+    [data]
   );
 
   return (
@@ -57,46 +34,56 @@ const Overview: React.FC<Props> = ({ clearTopicMessages }) => {
       <Metrics.Wrapper>
         <Metrics.Section>
           <Metrics.Indicator label="Partitions">
-            {partitionCount}
+            {data?.partitionCount}
           </Metrics.Indicator>
           <Metrics.Indicator label="Replication Factor">
-            {replicationFactor}
+            {data?.replicationFactor}
           </Metrics.Indicator>
           <Metrics.Indicator
             label="URP"
             title="Under replicated partitions"
             isAlert
-            alertType={underReplicatedPartitions === 0 ? 'success' : 'error'}
+            alertType={
+              data?.underReplicatedPartitions === 0 ? 'success' : 'error'
+            }
           >
-            {underReplicatedPartitions === 0 ? (
-              <Metrics.LightText>{underReplicatedPartitions}</Metrics.LightText>
+            {data?.underReplicatedPartitions === 0 ? (
+              <Metrics.LightText>
+                {data?.underReplicatedPartitions}
+              </Metrics.LightText>
             ) : (
-              <Metrics.RedText>{underReplicatedPartitions}</Metrics.RedText>
+              <Metrics.RedText>
+                {data?.underReplicatedPartitions}
+              </Metrics.RedText>
             )}
           </Metrics.Indicator>
           <Metrics.Indicator
             label="In Sync Replicas"
             isAlert
-            alertType={inSyncReplicas === replicas ? 'success' : 'error'}
+            alertType={
+              data?.inSyncReplicas === data?.replicas ? 'success' : 'error'
+            }
           >
-            {inSyncReplicas && replicas && inSyncReplicas < replicas ? (
-              <Metrics.RedText>{inSyncReplicas}</Metrics.RedText>
+            {data?.inSyncReplicas &&
+            data?.replicas &&
+            data?.inSyncReplicas < data?.replicas ? (
+              <Metrics.RedText>{data?.inSyncReplicas}</Metrics.RedText>
             ) : (
-              inSyncReplicas
+              data?.inSyncReplicas
             )}
-            <Metrics.LightText> of {replicas}</Metrics.LightText>
+            <Metrics.LightText> of {data?.replicas}</Metrics.LightText>
           </Metrics.Indicator>
           <Metrics.Indicator label="Type">
-            <Tag color="gray">{internal ? 'Internal' : 'External'}</Tag>
+            <Tag color="gray">{data?.internal ? 'Internal' : 'External'}</Tag>
           </Metrics.Indicator>
           <Metrics.Indicator label="Segment Size" title="">
-            <BytesFormatted value={segmentSize} />
+            <BytesFormatted value={data?.segmentSize} />
           </Metrics.Indicator>
           <Metrics.Indicator label="Segment Count">
-            {segmentCount}
+            {data?.segmentCount}
           </Metrics.Indicator>
           <Metrics.Indicator label="Clean Up Policy">
-            <Tag color="gray">{cleanUpPolicy || 'Unknown'}</Tag>
+            <Tag color="gray">{data?.cleanUpPolicy || 'Unknown'}</Tag>
           </Metrics.Indicator>
           <Metrics.Indicator label="Message Count">
             {messageCount}
@@ -116,7 +103,7 @@ const Overview: React.FC<Props> = ({ clearTopicMessages }) => {
             </tr>
           </thead>
           <tbody>
-            {partitions?.map((partition: Partition) => (
+            {data?.partitions?.map((partition: Partition) => (
               <tr key={`partition-list-item-key-${partition.partition}`}>
                 <td>{partition.partition}</td>
                 <td>
@@ -134,15 +121,19 @@ const Overview: React.FC<Props> = ({ clearTopicMessages }) => {
                 <td>{partition.offsetMax}</td>
                 <td>{partition.offsetMax - partition.offsetMin}</td>
                 <td style={{ width: '5%' }}>
-                  {!internal && !isReadOnly && cleanUpPolicy === 'DELETE' ? (
+                  {!data?.internal &&
+                  !isReadOnly &&
+                  data?.cleanUpPolicy === 'DELETE' ? (
                     <Dropdown>
                       <DropdownItem
                         onClick={() =>
-                          clearTopicMessages({
-                            clusterName,
-                            topicName,
-                            partitions: [partition.partition],
-                          })
+                          dispatch(
+                            clearTopicMessages({
+                              clusterName,
+                              topicName,
+                              partitions: [partition.partition],
+                            })
+                          ).unwrap()
                         }
                         danger
                       >
@@ -153,7 +144,7 @@ const Overview: React.FC<Props> = ({ clearTopicMessages }) => {
                 </td>
               </tr>
             ))}
-            {partitions?.length === 0 && (
+            {data?.partitions?.length === 0 && (
               <tr>
                 <td colSpan={10}>No Partitions found</td>
               </tr>

+ 0 - 9
kafka-ui-react-app/src/components/Topics/Topic/Details/Overview/OverviewContainer.ts

@@ -1,9 +0,0 @@
-import { connect } from 'react-redux';
-import { clearTopicMessages } from 'redux/reducers/topicMessages/topicMessagesSlice';
-import Overview from 'components/Topics/Topic/Details/Overview/Overview';
-
-const mapDispatchToProps = {
-  clearTopicMessages,
-};
-
-export default connect(null, mapDispatchToProps)(Overview);

+ 69 - 115
kafka-ui-react-app/src/components/Topics/Topic/Details/Overview/__test__/Overview.spec.tsx

@@ -1,74 +1,61 @@
 import React from 'react';
 import { screen } from '@testing-library/react';
 import { render, WithRoute } from 'lib/testHelpers';
-import Overview, {
-  Props as OverviewProps,
-} from 'components/Topics/Topic/Details/Overview/Overview';
+import Overview from 'components/Topics/Topic/Details/Overview/Overview';
 import theme from 'theme/theme';
 import { CleanUpPolicy, Topic } from 'generated-sources';
 import ClusterContext from 'components/contexts/ClusterContext';
 import userEvent from '@testing-library/user-event';
-import { getTopicStateFixtures } from 'redux/reducers/topics/__test__/fixtures';
 import { clusterTopicPath } from 'lib/paths';
 import { Replica } from 'components/Topics/Topic/Details/Overview/Overview.styled';
+import { useTopicDetails } from 'lib/hooks/api/topics';
+import {
+  externalTopicPayload,
+  internalTopicPayload,
+} from 'lib/fixtures/topics';
+
+const clusterName = 'local';
+const topicName = 'topic';
+const defaultContextValues = {
+  isReadOnly: false,
+  hasKafkaConnectConfigured: true,
+  hasSchemaRegistryConfigured: true,
+  isTopicDeletionAllowed: true,
+};
+
+jest.mock('lib/hooks/api/topics', () => ({
+  useTopicDetails: jest.fn(),
+}));
+
+const uwrapMock = jest.fn();
+const useDispatchMock = () => jest.fn(() => ({ unwrap: uwrapMock }));
+
+jest.mock('lib/hooks/redux', () => ({
+  ...jest.requireActual('lib/hooks/redux'),
+  useAppDispatch: useDispatchMock,
+}));
 
 describe('Overview', () => {
-  const mockClusterName = 'local';
-  const mockTopicName = 'topic';
-  const mockTopic = { name: mockTopicName };
-
-  const mockPartitions = [
-    {
-      partition: 1,
-      leader: 1,
-      replicas: [
-        {
-          broker: 1,
-          leader: true,
-          inSync: true,
-        },
-      ],
-      offsetMax: 0,
-      offsetMin: 0,
-    },
-  ];
-  const defaultContextValues = {
-    isReadOnly: false,
-    hasKafkaConnectConfigured: true,
-    hasSchemaRegistryConfigured: true,
-    isTopicDeletionAllowed: true,
-  };
-
-  const setupComponent = (
-    props: Partial<OverviewProps> = {},
-    topicState: Topic = mockTopic,
-    contextValues = defaultContextValues
+  const renderComponent = (
+    topic: Topic = externalTopicPayload,
+    context = defaultContextValues
   ) => {
-    const topics = getTopicStateFixtures([topicState]);
-
+    (useTopicDetails as jest.Mock).mockImplementation(() => ({
+      data: topic,
+    }));
+    const path = clusterTopicPath(clusterName, topicName);
     return render(
       <WithRoute path={clusterTopicPath()}>
-        <ClusterContext.Provider value={contextValues}>
-          <Overview clearTopicMessages={jest.fn()} {...props} />
+        <ClusterContext.Provider value={context}>
+          <Overview />
         </ClusterContext.Provider>
       </WithRoute>,
-      {
-        initialEntries: [clusterTopicPath(mockClusterName, mockTopicName)],
-        preloadedState: { topics },
-      }
+      { initialEntries: [path] }
     );
   };
 
   it('at least one replica was rendered', () => {
-    setupComponent(
-      {},
-      {
-        ...mockTopic,
-        partitions: mockPartitions,
-        internal: false,
-        cleanUpPolicy: CleanUpPolicy.DELETE,
-      }
-    );
+    renderComponent();
     expect(screen.getByLabelText('replica-info')).toBeInTheDocument();
   });
 
@@ -84,42 +71,33 @@ describe('Overview', () => {
 
   describe('when it has internal flag', () => {
     it('renders the Action button for Topic', () => {
-      setupComponent(
-        {},
-        {
-          ...mockTopic,
-          partitions: mockPartitions,
-          internal: false,
-          cleanUpPolicy: CleanUpPolicy.DELETE,
-        }
-      );
+      renderComponent({
+        ...externalTopicPayload,
+        cleanUpPolicy: CleanUpPolicy.DELETE,
+      });
       expect(screen.getAllByLabelText('Dropdown Toggle').length).toEqual(1);
     });
 
     it('does not render Partitions', () => {
-      setupComponent({}, { ...mockTopic, partitions: [] });
-
+      renderComponent({ ...externalTopicPayload, partitions: [] });
       expect(screen.getByText('No Partitions found')).toBeInTheDocument();
     });
   });
 
   describe('should render circular alert', () => {
     it('should be in document', () => {
-      setupComponent();
+      renderComponent();
       const circles = screen.getAllByRole('circle');
       expect(circles.length).toEqual(2);
     });
 
     it('should be the appropriate color', () => {
-      setupComponent(
-        {},
-        {
-          ...mockTopic,
-          underReplicatedPartitions: 0,
-          inSyncReplicas: 1,
-          replicas: 2,
-        }
-      );
+      renderComponent({
+        ...externalTopicPayload,
+        underReplicatedPartitions: 0,
+        inSyncReplicas: 1,
+        replicas: 2,
+      });
       const circles = screen.getAllByRole('circle');
       expect(circles[0]).toHaveStyle(
         `fill: ${theme.circularAlert.color.success}`
@@ -132,31 +110,22 @@ describe('Overview', () => {
 
   describe('when Clear Messages is clicked', () => {
     it('should when Clear Messages is clicked', () => {
-      const mockClearTopicMessages = jest.fn();
-      setupComponent(
-        { clearTopicMessages: mockClearTopicMessages },
-        {
-          ...mockTopic,
-          partitions: mockPartitions,
-          internal: false,
-          cleanUpPolicy: CleanUpPolicy.DELETE,
-        }
-      );
+      renderComponent({
+        ...externalTopicPayload,
+        cleanUpPolicy: CleanUpPolicy.DELETE,
+      });
 
       const clearMessagesButton = screen.getByText('Clear Messages');
       userEvent.click(clearMessagesButton);
-      expect(mockClearTopicMessages).toHaveBeenCalledTimes(1);
+      expect(uwrapMock).toHaveBeenCalledTimes(1);
     });
   });
 
   describe('when the table partition dropdown appearance', () => {
     it('should check if the dropdown is not present when it is readOnly', () => {
-      setupComponent(
-        {},
+      renderComponent(
         {
-          ...mockTopic,
-          partitions: mockPartitions,
-          internal: true,
+          ...internalTopicPayload,
           cleanUpPolicy: CleanUpPolicy.DELETE,
         },
         { ...defaultContextValues, isReadOnly: true }
@@ -165,41 +134,26 @@ describe('Overview', () => {
     });
 
     it('should check if the dropdown is not present when it is internal', () => {
-      setupComponent(
-        {},
-        {
-          ...mockTopic,
-          partitions: mockPartitions,
-          internal: true,
-          cleanUpPolicy: CleanUpPolicy.DELETE,
-        }
-      );
+      renderComponent({
+        ...internalTopicPayload,
+        cleanUpPolicy: CleanUpPolicy.DELETE,
+      });
       expect(screen.queryByText('Clear Messages')).not.toBeInTheDocument();
     });
 
     it('should check if the dropdown is not present when cleanUpPolicy is not DELETE', () => {
-      setupComponent(
-        {},
-        {
-          ...mockTopic,
-          partitions: mockPartitions,
-          internal: false,
-          cleanUpPolicy: CleanUpPolicy.COMPACT,
-        }
-      );
+      renderComponent({
+        ...externalTopicPayload,
+        cleanUpPolicy: CleanUpPolicy.COMPACT,
+      });
       expect(screen.queryByText('Clear Messages')).not.toBeInTheDocument();
     });
 
     it('should check if the dropdown action to be in visible', () => {
-      setupComponent(
-        {},
-        {
-          ...mockTopic,
-          partitions: mockPartitions,
-          internal: false,
-          cleanUpPolicy: CleanUpPolicy.DELETE,
-        }
-      );
+      renderComponent({
+        ...externalTopicPayload,
+        cleanUpPolicy: CleanUpPolicy.DELETE,
+      });
       expect(screen.getByText('Clear Messages')).toBeInTheDocument();
     });
   });

+ 18 - 45
kafka-ui-react-app/src/components/Topics/Topic/Details/Settings/Settings.tsx

@@ -1,57 +1,30 @@
 import React from 'react';
-import PageLoader from 'components/common/PageLoader/PageLoader';
 import { Table } from 'components/common/table/Table/Table.styled';
 import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
-import { ClusterName, TopicName } from 'redux/interfaces';
-import { useAppSelector } from 'lib/hooks/redux';
-import { getTopicConfig } from 'redux/reducers/topics/selectors';
 import { RouteParamsClusterTopic } from 'lib/paths';
 import useAppParams from 'lib/hooks/useAppParams';
+import { useTopicConfig } from 'lib/hooks/api/topics';
 
 import ConfigListItem from './ConfigListItem';
 
-export interface Props {
-  isFetched: boolean;
-  fetchTopicConfig: (payload: {
-    clusterName: ClusterName;
-    topicName: TopicName;
-  }) => void;
-}
-
-const Settings: React.FC<Props> = ({ isFetched, fetchTopicConfig }) => {
-  const { clusterName, topicName } = useAppParams<RouteParamsClusterTopic>();
-
-  const config = useAppSelector((state) => getTopicConfig(state, topicName));
-
-  React.useEffect(() => {
-    fetchTopicConfig({ clusterName, topicName });
-  }, [fetchTopicConfig, clusterName, topicName]);
-
-  if (!isFetched) {
-    return <PageLoader />;
-  }
-
-  if (!config) {
-    return null;
-  }
-
+const Settings: React.FC = () => {
+  const props = useAppParams<RouteParamsClusterTopic>();
+  const { data } = useTopicConfig(props);
   return (
-    <div>
-      <Table isFullwidth>
-        <thead>
-          <tr>
-            <TableHeaderCell title="Key" />
-            <TableHeaderCell title="Value" />
-            <TableHeaderCell title="Default Value" />
-          </tr>
-        </thead>
-        <tbody>
-          {config.map((item) => (
-            <ConfigListItem key={item.name} config={item} />
-          ))}
-        </tbody>
-      </Table>
-    </div>
+    <Table isFullwidth>
+      <thead>
+        <tr>
+          <TableHeaderCell title="Key" />
+          <TableHeaderCell title="Value" />
+          <TableHeaderCell title="Default Value" />
+        </tr>
+      </thead>
+      <tbody>
+        {data?.map((item) => (
+          <ConfigListItem key={item.name} config={item} />
+        ))}
+      </tbody>
+    </Table>
   );
 };
 

+ 0 - 16
kafka-ui-react-app/src/components/Topics/Topic/Details/Settings/SettingsContainer.ts

@@ -1,16 +0,0 @@
-import { connect } from 'react-redux';
-import { RootState } from 'redux/interfaces';
-import { fetchTopicConfig } from 'redux/reducers/topics/topicsSlice';
-import { getTopicConfigFetched } from 'redux/reducers/topics/selectors';
-
-import Settings from './Settings';
-
-const mapStateToProps = (state: RootState) => ({
-  isFetched: getTopicConfigFetched(state),
-});
-
-const mapDispatchToProps = {
-  fetchTopicConfig,
-};
-
-export default connect(mapStateToProps, mapDispatchToProps)(Settings);

+ 32 - 77
kafka-ui-react-app/src/components/Topics/Topic/Details/Settings/__test__/Settings.spec.tsx

@@ -1,94 +1,49 @@
 import React from 'react';
 import { render, WithRoute } from 'lib/testHelpers';
 import { screen } from '@testing-library/react';
-import Settings, {
-  Props,
-} from 'components/Topics/Topic/Details/Settings/Settings';
-import { TopicConfig } from 'generated-sources';
+import Settings from 'components/Topics/Topic/Details/Settings/Settings';
 import { clusterTopicSettingsPath } from 'lib/paths';
-import { getTopicStateFixtures } from 'redux/reducers/topics/__test__/fixtures';
+import { topicConfigPayload } from 'lib/fixtures/topics';
+import { useTopicConfig } from 'lib/hooks/api/topics';
+
+const clusterName = 'Cluster_Name';
+const topicName = 'Topic_Name';
+
+jest.mock('lib/hooks/api/topics', () => ({
+  useTopicConfig: jest.fn(),
+}));
+
+jest.mock(
+  'components/Topics/Topic/Details/Settings/ConfigListItem',
+  () => () =>
+    (
+      <tr>
+        <td>ConfigListItemMock</td>
+      </tr>
+    )
+);
 
 describe('Settings', () => {
-  const mockClusterName = 'Cluster_Name';
-  const mockTopicName = 'Topic_Name';
-
-  let expectedResult: number;
-  const mockFn = jest.fn();
-
-  const mockConfig: TopicConfig[] = [
-    {
-      name: 'first',
-      value: 'first-value-name',
-    },
-    {
-      name: 'second',
-      value: 'second-value-name',
-    },
-  ];
-
-  const setUpComponent = (
-    props: Partial<Props> = {},
-    config?: TopicConfig[]
-  ) => {
-    const topic = {
-      name: mockTopicName,
-      config,
-    };
-    const topics = getTopicStateFixtures([topic]);
-
+  const renderComponent = () => {
+    const path = clusterTopicSettingsPath(clusterName, topicName);
     return render(
       <WithRoute path={clusterTopicSettingsPath()}>
-        <Settings isFetched fetchTopicConfig={mockFn} {...props} />
+        <Settings />
       </WithRoute>,
-      {
-        initialEntries: [
-          clusterTopicSettingsPath(mockClusterName, mockTopicName),
-        ],
-        preloadedState: {
-          topics,
-        },
-      }
+      { initialEntries: [path] }
     );
   };
 
-  afterEach(() => {
-    mockFn.mockClear();
+  beforeEach(() => {
+    (useTopicConfig as jest.Mock).mockImplementation(() => ({
+      data: topicConfigPayload,
+    }));
+    renderComponent();
   });
 
   it('should check it returns null if no config is passed', () => {
-    setUpComponent();
-
-    expect(screen.queryByRole('table')).not.toBeInTheDocument();
-  });
-
-  it('should show Page loader when it is in fetching state and config is given', () => {
-    setUpComponent({ isFetched: false }, mockConfig);
-
-    expect(screen.queryByRole('table')).not.toBeInTheDocument();
-    expect(screen.getByRole('progressbar')).toBeInTheDocument();
-  });
-
-  it('should check and return null if it is not fetched and config is not given', () => {
-    setUpComponent({ isFetched: false });
-
-    expect(screen.queryByRole('table')).not.toBeInTheDocument();
-  });
-
-  describe('Settings Component with Data', () => {
-    beforeEach(() => {
-      expectedResult = mockConfig.length + 1; // include the header table row as well
-      setUpComponent({ isFetched: true }, mockConfig);
-    });
-
-    it('should view the correct number of table row with header included elements after config fetching', () => {
-      expect(screen.getAllByRole('row')).toHaveLength(expectedResult);
-    });
-
-    it('should view the correct number of table row in tbody included elements after config fetching', () => {
-      expectedResult = mockConfig.length;
-      const tbodyElement = screen.getAllByRole('rowgroup')[1]; // pick tbody
-      const tbodyTableRowsElements = tbodyElement.children;
-      expect(tbodyTableRowsElements).toHaveLength(expectedResult);
-    });
+    expect(screen.getByRole('table')).toBeInTheDocument();
+    const items = screen.getAllByText('ConfigListItemMock');
+    expect(items.length).toEqual(topicConfigPayload.length);
   });
 });

+ 57 - 83
kafka-ui-react-app/src/components/Topics/Topic/Details/__test__/Details.spec.tsx

@@ -1,98 +1,89 @@
 import React from 'react';
-import { screen, waitFor } from '@testing-library/react';
+import { act, screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import ClusterContext from 'components/contexts/ClusterContext';
 import Details from 'components/Topics/Topic/Details/Details';
-import {
-  getTopicStateFixtures,
-  internalTopicPayload,
-} from 'redux/reducers/topics/__test__/fixtures';
 import { render, WithRoute } from 'lib/testHelpers';
 import { clusterTopicEditRelativePath, clusterTopicPath } from 'lib/paths';
 import { CleanUpPolicy, Topic } from 'generated-sources';
+import { externalTopicPayload } from 'lib/fixtures/topics';
+import {
+  useDeleteTopic,
+  useRecreateTopic,
+  useTopicDetails,
+} from 'lib/hooks/api/topics';
 
 const mockNavigate = jest.fn();
 jest.mock('react-router-dom', () => ({
   ...jest.requireActual('react-router-dom'),
   useNavigate: () => mockNavigate,
 }));
+jest.mock('lib/hooks/api/topics', () => ({
+  useTopicDetails: jest.fn(),
+  useDeleteTopic: jest.fn(),
+  useRecreateTopic: jest.fn(),
+}));
+
+const mockUnwrap = jest.fn();
+const useDispatchMock = () => jest.fn(() => ({ unwrap: mockUnwrap }));
+
+jest.mock('lib/hooks/redux', () => ({
+  ...jest.requireActual('lib/hooks/redux'),
+  useAppDispatch: useDispatchMock,
+}));
+
+const mockDelete = jest.fn();
+const mockRecreate = jest.fn();
 
 describe('Details', () => {
-  const mockDelete = jest.fn();
   const mockClusterName = 'local';
-  const mockClearTopicMessages = jest.fn();
-  const mockRecreateTopic = jest.fn();
 
   const topic: Topic = {
-    ...internalTopicPayload,
+    ...externalTopicPayload,
     cleanUpPolicy: CleanUpPolicy.DELETE,
-    internal: false,
   };
 
-  const mockTopicsState = getTopicStateFixtures([topic]);
-
-  const setupComponent = (props = {}) =>
+  const renderComponent = (isReadOnly = false) => {
+    const path = clusterTopicPath(mockClusterName, topic.name);
     render(
       <ClusterContext.Provider
         value={{
-          isReadOnly: false,
+          isReadOnly,
           hasKafkaConnectConfigured: true,
           hasSchemaRegistryConfigured: true,
           isTopicDeletionAllowed: true,
         }}
       >
         <WithRoute path={clusterTopicPath()}>
-          <Details
-            deleteTopic={mockDelete}
-            recreateTopic={mockRecreateTopic}
-            clearTopicMessages={mockClearTopicMessages}
-            {...props}
-          />
+          <Details />
         </WithRoute>
       </ClusterContext.Provider>,
-      {
-        initialEntries: [
-          clusterTopicPath(mockClusterName, internalTopicPayload.name),
-        ],
-        preloadedState: {
-          topics: mockTopicsState,
-        },
-      }
+      { initialEntries: [path] }
     );
+  };
 
-  afterEach(() => {
-    mockNavigate.mockClear();
-    mockDelete.mockClear();
-    mockClearTopicMessages.mockClear();
-    mockRecreateTopic.mockClear();
+  beforeEach(async () => {
+    (useTopicDetails as jest.Mock).mockImplementation(() => ({
+      data: topic,
+    }));
+    (useDeleteTopic as jest.Mock).mockImplementation(() => ({
+      mutateAsync: mockDelete,
+    }));
+    (useRecreateTopic as jest.Mock).mockImplementation(() => ({
+      mutateAsync: mockRecreate,
+    }));
   });
 
   describe('when it has readonly flag', () => {
     it('does not render the Action button a Topic', () => {
-      render(
-        <ClusterContext.Provider
-          value={{
-            isReadOnly: true,
-            hasKafkaConnectConfigured: true,
-            hasSchemaRegistryConfigured: true,
-            isTopicDeletionAllowed: true,
-          }}
-        >
-          <Details
-            deleteTopic={mockDelete}
-            recreateTopic={mockRecreateTopic}
-            clearTopicMessages={mockClearTopicMessages}
-          />
-        </ClusterContext.Provider>
-      );
-
+      renderComponent(true);
       expect(screen.queryByText('Produce Message')).not.toBeInTheDocument();
     });
   });
 
   describe('when remove topic modal is open', () => {
     beforeEach(() => {
-      setupComponent();
+      renderComponent();
       const openModalButton = screen.getAllByText('Remove Topic')[0];
       userEvent.click(openModalButton);
     });
@@ -101,15 +92,9 @@ describe('Details', () => {
       const submitButton = screen.getAllByRole('button', {
         name: 'Confirm',
       })[0];
-
-      await waitFor(() => userEvent.click(submitButton));
-
-      expect(mockDelete).toHaveBeenCalledWith({
-        clusterName: mockClusterName,
-        topicName: internalTopicPayload.name,
-      });
+      await act(() => userEvent.click(submitButton));
+      expect(mockDelete).toHaveBeenCalledWith(topic.name);
     });
-
     it('closes the modal when cancel button is clicked', async () => {
       const cancelButton = screen.getAllByText('Cancel')[0];
       await waitFor(() => userEvent.click(cancelButton));
@@ -118,11 +103,10 @@ describe('Details', () => {
   });
 
   describe('when clear messages modal is open', () => {
-    beforeEach(() => {
-      setupComponent();
-
+    beforeEach(async () => {
+      await renderComponent();
       const confirmButton = screen.getAllByText('Clear messages')[0];
-      userEvent.click(confirmButton);
+      await act(() => userEvent.click(confirmButton));
     });
 
     it('it calls clearTopicMessages on confirm', async () => {
@@ -130,11 +114,7 @@ describe('Details', () => {
         name: 'Confirm',
       })[0];
       await waitFor(() => userEvent.click(submitButton));
-
-      expect(mockClearTopicMessages).toHaveBeenCalledWith({
-        clusterName: mockClusterName,
-        topicName: internalTopicPayload.name,
-      });
+      expect(mockUnwrap).toHaveBeenCalledTimes(1);
     });
 
     it('closes the modal when cancel button is clicked', async () => {
@@ -147,29 +127,24 @@ describe('Details', () => {
 
   describe('when edit settings is clicked', () => {
     it('redirects to the edit page', () => {
-      setupComponent();
-
+      renderComponent();
       const button = screen.getAllByText('Edit settings')[0];
       userEvent.click(button);
-
       expect(mockNavigate).toHaveBeenCalledWith(clusterTopicEditRelativePath);
     });
   });
 
   it('redirects to the correct route if topic is deleted', async () => {
-    setupComponent();
-
+    renderComponent();
     const deleteTopicButton = screen.getByText(/Remove topic/i);
-    userEvent.click(deleteTopicButton);
-
+    await waitFor(() => userEvent.click(deleteTopicButton));
     const submitDeleteButton = screen.getByRole('button', { name: 'Confirm' });
-    await waitFor(() => userEvent.click(submitDeleteButton));
-
+    await act(() => userEvent.click(submitDeleteButton));
     expect(mockNavigate).toHaveBeenCalledWith('../..');
   });
 
   it('shows a confirmation popup on deleting topic messages', () => {
-    setupComponent();
+    renderComponent();
     const clearMessagesButton = screen.getAllByText(/Clear messages/i)[0];
     userEvent.click(clearMessagesButton);
 
@@ -179,27 +154,26 @@ describe('Details', () => {
   });
 
   it('shows a confirmation popup on recreating topic', () => {
-    setupComponent();
+    renderComponent();
     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', async () => {
-    setupComponent();
+    renderComponent();
     const recreateTopicButton = screen.getByText(/Recreate topic/i);
     userEvent.click(recreateTopicButton);
     const confirmBtn = screen.getByRole('button', { name: /Confirm/i });
 
     await waitFor(() => userEvent.click(confirmBtn));
-    expect(mockRecreateTopic).toBeCalledTimes(1);
+    expect(mockRecreate).toBeCalledTimes(1);
   });
 
   it('close popup confirmation window after click on Cancel button', () => {
-    setupComponent();
+    renderComponent();
     const recreateTopicButton = screen.getByText(/Recreate topic/i);
     userEvent.click(recreateTopicButton);
     const cancelBtn = screen.getByRole('button', { name: /cancel/i });

+ 3 - 2
kafka-ui-react-app/src/components/Topics/Topic/Edit/DangerZone/DangerZone.styled.tsx

@@ -1,11 +1,12 @@
 import styled from 'styled-components';
 
 export const Wrapper = styled.div`
-  margin: 16px;
-  padding: 8px 16px;
+  margin: 32px auto;
+  padding: 16px;
   border: 1px solid ${({ theme }) => theme.dangerZone.borderColor};
   box-sizing: border-box;
   border-radius: 8px;
+  width: 768px;
 
   & > div {
     display: flex;

+ 15 - 28
kafka-ui-react-app/src/components/Topics/Topic/Edit/DangerZone/DangerZone.tsx

@@ -6,38 +6,31 @@ import { InputLabel } from 'components/common/Input/InputLabel.styled';
 import React from 'react';
 import { FormProvider, useForm } from 'react-hook-form';
 import { RouteParamsClusterTopic } from 'lib/paths';
-import { ClusterName, TopicName } from 'redux/interfaces';
 import useAppParams from 'lib/hooks/useAppParams';
 import { useConfirm } from 'lib/hooks/useConfirm';
+import {
+  useIncreaseTopicPartitionsCount,
+  useUpdateTopicReplicationFactor,
+} from 'lib/hooks/api/topics';
 
 import * as S from './DangerZone.styled';
 
-export interface Props {
+export interface DangerZoneProps {
   defaultPartitions: number;
   defaultReplicationFactor: number;
-  updateTopicPartitionsCount: (payload: {
-    clusterName: ClusterName;
-    topicName: TopicName;
-    partitions: number;
-  }) => void;
-  updateTopicReplicationFactor: (payload: {
-    clusterName: ClusterName;
-    topicName: TopicName;
-    replicationFactor: number;
-  }) => void;
 }
 
-const DangerZone: React.FC<Props> = ({
+const DangerZone: React.FC<DangerZoneProps> = ({
   defaultPartitions,
   defaultReplicationFactor,
-  updateTopicPartitionsCount,
-  updateTopicReplicationFactor,
 }) => {
-  const { clusterName, topicName } = useAppParams<RouteParamsClusterTopic>();
+  const params = useAppParams<RouteParamsClusterTopic>();
   const [partitions, setPartitions] = React.useState<number>(defaultPartitions);
   const [replicationFactor, setReplicationFactor] = React.useState<number>(
     defaultReplicationFactor
   );
+  const increaseTopicPartitionsCount = useIncreaseTopicPartitionsCount(params);
+  const updateTopicReplicationFactor = useUpdateTopicReplicationFactor(params);
 
   const partitionsMethods = useForm({
     defaultValues: {
@@ -52,26 +45,20 @@ const DangerZone: React.FC<Props> = ({
   });
 
   const confirm = useConfirm();
-
   const confirmPartitionsChange = () =>
     confirm(
       `Are you sure you want to increase the number of partitions?
         Do it only if you 100% know what you are doing!`,
       () =>
-        updateTopicPartitionsCount({
-          clusterName,
-          topicName,
-          partitions: partitionsMethods.getValues('partitions'),
-        })
+        increaseTopicPartitionsCount.mutateAsync(
+          partitionsMethods.getValues('partitions')
+        )
     );
   const confirmReplicationFactorChange = () =>
     confirm('Are you sure you want to update the replication factor?', () =>
-      updateTopicReplicationFactor({
-        clusterName,
-        topicName,
-        replicationFactor:
-          replicationFactorMethods.getValues('replicationFactor'),
-      })
+      updateTopicReplicationFactor.mutateAsync(
+        replicationFactorMethods.getValues('replicationFactor')
+      )
     );
 
   const validatePartitions = (data: { partitions: number }) => {

+ 0 - 28
kafka-ui-react-app/src/components/Topics/Topic/Edit/DangerZone/DangerZoneContainer.ts

@@ -1,28 +0,0 @@
-import { connect } from 'react-redux';
-import { RootState } from 'redux/interfaces';
-import {
-  updateTopicPartitionsCount,
-  updateTopicReplicationFactor,
-} from 'redux/reducers/topics/topicsSlice';
-
-import DangerZone from './DangerZone';
-
-type OwnProps = {
-  defaultPartitions: number;
-  defaultReplicationFactor: number;
-};
-
-const mapStateToProps = (
-  _: RootState,
-  { defaultPartitions, defaultReplicationFactor }: OwnProps
-) => ({
-  defaultPartitions,
-  defaultReplicationFactor,
-});
-
-const mapDispatchToProps = {
-  updateTopicPartitionsCount,
-  updateTopicReplicationFactor,
-};
-
-export default connect(mapStateToProps, mapDispatchToProps)(DangerZone);

+ 25 - 21
kafka-ui-react-app/src/components/Topics/Topic/Edit/DangerZone/__test__/DangerZone.spec.tsx

@@ -1,27 +1,33 @@
 import React from 'react';
 import DangerZone, {
-  Props,
+  DangerZoneProps,
 } from 'components/Topics/Topic/Edit/DangerZone/DangerZone';
 import { act, screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render, WithRoute } from 'lib/testHelpers';
-import {
-  topicName,
-  clusterName,
-} from 'components/Topics/Topic/Edit/__test__/fixtures';
 import { clusterTopicSendMessagePath } from 'lib/paths';
+import {
+  useIncreaseTopicPartitionsCount,
+  useUpdateTopicReplicationFactor,
+} from 'lib/hooks/api/topics';
 
 const defaultPartitions = 3;
 const defaultReplicationFactor = 3;
 
-const renderComponent = (props?: Partial<Props>) =>
+const clusterName = 'testCluster';
+const topicName = 'testTopic';
+
+jest.mock('lib/hooks/api/topics', () => ({
+  useIncreaseTopicPartitionsCount: jest.fn(),
+  useUpdateTopicReplicationFactor: jest.fn(),
+}));
+
+const renderComponent = (props?: Partial<DangerZoneProps>) =>
   render(
     <WithRoute path={clusterTopicSendMessagePath()}>
       <DangerZone
         defaultPartitions={defaultPartitions}
         defaultReplicationFactor={defaultReplicationFactor}
-        updateTopicPartitionsCount={jest.fn()}
-        updateTopicReplicationFactor={jest.fn()}
         {...props}
       />
     </WithRoute>,
@@ -76,33 +82,31 @@ describe('DangerZone', () => {
     ).toBeInTheDocument();
   });
 
-  it('calls updateTopicPartitionsCount', async () => {
-    const mockUpdateTopicPartitionsCount = jest.fn();
-    renderComponent({
-      updateTopicPartitionsCount: mockUpdateTopicPartitionsCount,
-    });
+  it('calls increaseTopicPartitionsCount mutation', async () => {
+    const mockIncreaseTopicPartitionsCount = jest.fn();
+    (useIncreaseTopicPartitionsCount as jest.Mock).mockImplementation(() => ({
+      mutateAsync: mockIncreaseTopicPartitionsCount,
+    }));
+    renderComponent();
     const numberOfPartitionsEditForm = screen.getByRole('form', {
       name: 'Edit number of partitions',
     });
-
     userEvent.type(
       within(numberOfPartitionsEditForm).getByRole('spinbutton'),
       '4'
     );
     userEvent.click(within(numberOfPartitionsEditForm).getByRole('button'));
-
     await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
     await waitFor(() => clickOnDialogSubmitButton());
-
-    expect(mockUpdateTopicPartitionsCount).toHaveBeenCalledTimes(1);
+    expect(mockIncreaseTopicPartitionsCount).toHaveBeenCalledTimes(1);
   });
 
   it('calls updateTopicReplicationFactor', async () => {
     const mockUpdateTopicReplicationFactor = jest.fn();
-    renderComponent({
-      updateTopicReplicationFactor: mockUpdateTopicReplicationFactor,
-    });
-
+    (useUpdateTopicReplicationFactor as jest.Mock).mockImplementation(() => ({
+      mutateAsync: mockUpdateTopicReplicationFactor,
+    }));
+    renderComponent();
     const replicationFactorEditForm = screen.getByRole('form', {
       name: 'Edit replication factor',
     });

+ 36 - 93
kafka-ui-react-app/src/components/Topics/Topic/Edit/Edit.tsx

@@ -1,8 +1,6 @@
-import React, { useEffect } from 'react';
+import React from 'react';
 import {
-  ClusterName,
   TopicFormDataRaw,
-  TopicName,
   TopicConfigByName,
   TopicFormData,
 } from 'redux/interfaces';
@@ -12,40 +10,18 @@ import { RouteParamsClusterTopic } from 'lib/paths';
 import { useNavigate } from 'react-router-dom';
 import { yupResolver } from '@hookform/resolvers/yup';
 import { topicFormValidationSchema } from 'lib/yupExtended';
-import styled from 'styled-components';
 import PageHeading from 'components/common/PageHeading/PageHeading';
-import { useAppSelector } from 'lib/hooks/redux';
-import { getFullTopic } from 'redux/reducers/topics/selectors';
 import useAppParams from 'lib/hooks/useAppParams';
 import topicParamsTransformer from 'components/Topics/Topic/Edit/topicParamsTransformer';
 import { MILLISECONDS_IN_WEEK } from 'lib/constants';
+import {
+  useTopicConfig,
+  useTopicDetails,
+  useUpdateTopic,
+} from 'lib/hooks/api/topics';
+import DangerZone from 'components/Topics/Topic/Edit/DangerZone/DangerZone';
 
-import DangerZoneContainer from './DangerZone/DangerZoneContainer';
-
-export interface Props {
-  isFetched: boolean;
-  isTopicUpdated: boolean;
-  fetchTopicConfig: (payload: {
-    clusterName: ClusterName;
-    topicName: TopicName;
-  }) => void;
-  updateTopic: (payload: {
-    clusterName: ClusterName;
-    topicName: TopicName;
-    form: TopicFormDataRaw;
-  }) => void;
-}
-
-const EditWrapperStyled = styled.div`
-  display: flex;
-  justify-content: center;
-
-  & > * {
-    width: 800px;
-  }
-`;
-
-export const DEFAULTS = {
+export const TOPIC_EDIT_FORM_DEFAULT_PROPS = {
   partitions: 1,
   replicationFactor: 1,
   minInSyncReplicas: 1,
@@ -56,19 +32,13 @@ export const DEFAULTS = {
   customParams: [],
 };
 
-let formInit = false;
-
-const Edit: React.FC<Props> = ({
-  isFetched,
-  isTopicUpdated,
-  fetchTopicConfig,
-  updateTopic,
-}) => {
+const Edit: React.FC = () => {
   const { clusterName, topicName } = useAppParams<RouteParamsClusterTopic>();
+  const { data: topic } = useTopicDetails({ clusterName, topicName });
+  const { data: topicConfig } = useTopicConfig({ clusterName, topicName });
+  const updateTopic = useUpdateTopic({ clusterName, topicName });
 
-  const topic = useAppSelector((state) => getFullTopic(state, topicName));
-
-  const defaultValues = topicParamsTransformer(topic);
+  const defaultValues = topicParamsTransformer(topic, topicConfig);
 
   const methods = useForm<TopicFormData>({
     defaultValues,
@@ -76,71 +46,44 @@ const Edit: React.FC<Props> = ({
     mode: 'onChange',
   });
 
-  useEffect(() => {
-    methods.reset(defaultValues);
-  }, [!topic]);
-
-  const [isSubmitting, setIsSubmitting] = React.useState<boolean>(false);
   const navigate = useNavigate();
 
-  React.useEffect(() => {
-    fetchTopicConfig({ clusterName, topicName });
-  }, [fetchTopicConfig, clusterName, topicName, isTopicUpdated]);
-
-  React.useEffect(() => {
-    if (isSubmitting && isTopicUpdated) {
-      navigate('../');
-    }
-  }, [isSubmitting, isTopicUpdated, clusterName, navigate]);
-
-  if (!isFetched || !topic || !topic.config) {
-    return null;
-  }
-
-  if (!formInit) {
-    methods.reset(defaultValues);
-    formInit = true;
-  }
-
   const config: TopicConfigByName = {
     byName: {},
   };
 
-  topic.config.forEach((param) => {
+  topicConfig?.forEach((param) => {
     config.byName[param.name] = param;
   });
 
   const onSubmit = async (data: TopicFormDataRaw) => {
-    updateTopic({ clusterName, topicName, form: data });
-    setIsSubmitting(true); // Keep this action after updateTopic to prevent redirect before update.
+    await updateTopic.mutateAsync(data);
+    navigate('../');
   };
 
   return (
     <>
       <PageHeading text={`Edit ${topicName}`} />
-      <EditWrapperStyled>
-        <div>
-          <FormProvider {...methods}>
-            <TopicForm
-              topicName={topicName}
-              retentionBytes={defaultValues.retentionBytes}
-              inSyncReplicas={Number(defaultValues.minInSyncReplicas)}
-              isSubmitting={isSubmitting}
-              cleanUpPolicy={topic.cleanUpPolicy}
-              isEditing
-              onSubmit={methods.handleSubmit(onSubmit)}
-            />
-          </FormProvider>
-          {topic && (
-            <DangerZoneContainer
-              defaultPartitions={defaultValues.partitions}
-              defaultReplicationFactor={
-                defaultValues.replicationFactor || DEFAULTS.replicationFactor
-              }
-            />
-          )}
-        </div>
-      </EditWrapperStyled>
+      <FormProvider {...methods}>
+        <TopicForm
+          topicName={topicName}
+          retentionBytes={defaultValues.retentionBytes}
+          inSyncReplicas={Number(defaultValues.minInSyncReplicas)}
+          isSubmitting={updateTopic.isLoading}
+          cleanUpPolicy={topic?.cleanUpPolicy}
+          isEditing
+          onSubmit={methods.handleSubmit(onSubmit)}
+        />
+      </FormProvider>
+      {topic && (
+        <DangerZone
+          defaultPartitions={defaultValues.partitions}
+          defaultReplicationFactor={
+            defaultValues.replicationFactor ||
+            TOPIC_EDIT_FORM_DEFAULT_PROPS.replicationFactor
+          }
+        />
+      )}
     </>
   );
 };

+ 0 - 24
kafka-ui-react-app/src/components/Topics/Topic/Edit/EditContainer.tsx

@@ -1,24 +0,0 @@
-import { connect } from 'react-redux';
-import { RootState } from 'redux/interfaces';
-import {
-  updateTopic,
-  fetchTopicConfig,
-} from 'redux/reducers/topics/topicsSlice';
-import {
-  getTopicConfigFetched,
-  getTopicUpdated,
-} from 'redux/reducers/topics/selectors';
-
-import Edit from './Edit';
-
-const mapStateToProps = (state: RootState) => ({
-  isFetched: getTopicConfigFetched(state),
-  isTopicUpdated: getTopicUpdated(state),
-});
-
-const mapDispatchToProps = {
-  fetchTopicConfig,
-  updateTopic,
-};
-
-export default connect(mapStateToProps, mapDispatchToProps)(Edit);

+ 52 - 135
kafka-ui-react-app/src/components/Topics/Topic/Edit/__test__/Edit.spec.tsx

@@ -1,13 +1,18 @@
 import React from 'react';
-import Edit, { DEFAULTS, Props } from 'components/Topics/Topic/Edit/Edit';
+import Edit from 'components/Topics/Topic/Edit/Edit';
 import { act, screen } from '@testing-library/react';
 import { render, WithRoute } from 'lib/testHelpers';
 import userEvent from '@testing-library/user-event';
 import { clusterTopicEditPath } from 'lib/paths';
-import { TopicsState, TopicWithDetailedInfo } from 'redux/interfaces';
-import { getTopicStateFixtures } from 'redux/reducers/topics/__test__/fixtures';
+import {
+  useTopicConfig,
+  useTopicDetails,
+  useUpdateTopic,
+} from 'lib/hooks/api/topics';
+import { internalTopicPayload, topicConfigPayload } from 'lib/fixtures/topics';
 
-import { topicName, clusterName, topicWithInfo } from './fixtures';
+const clusterName = 'testCluster';
+const topicName = 'testTopic';
 
 const mockNavigate = jest.fn();
 jest.mock('react-router-dom', () => ({
@@ -15,153 +20,65 @@ jest.mock('react-router-dom', () => ({
   useNavigate: () => mockNavigate,
 }));
 
-const renderComponent = async (
-  props: Partial<Props> = {},
-  topic: TopicWithDetailedInfo | null = topicWithInfo
-) => {
-  let topics: TopicsState | undefined;
+jest.mock('react-router-dom', () => ({
+  ...jest.requireActual('react-router-dom'),
+  useNavigate: () => mockNavigate,
+}));
+
+jest.mock('components/Topics/Topic/Edit/DangerZone/DangerZone', () => () => (
+  <>DangerZone</>
+));
+
+jest.mock('lib/hooks/api/topics', () => ({
+  useTopicDetails: jest.fn(),
+  useTopicConfig: jest.fn(),
+  useUpdateTopic: jest.fn(),
+}));
 
-  if (topic === null) {
-    topics = undefined;
-  } else {
-    topics = getTopicStateFixtures([topic]);
-  }
+const updateTopicMock = jest.fn();
 
+const renderComponent = () => {
+  const path = clusterTopicEditPath(clusterName, topicName);
   render(
     <WithRoute path={clusterTopicEditPath()}>
-      <Edit
-        isFetched
-        isTopicUpdated={false}
-        fetchTopicConfig={jest.fn()}
-        updateTopic={jest.fn()}
-        {...props}
-      />
+      <Edit />
     </WithRoute>,
-    {
-      initialEntries: [clusterTopicEditPath(clusterName, topicName)],
-      preloadedState: { topics },
-    }
+    { initialEntries: [path] }
   );
 };
 
 describe('Edit Component', () => {
-  afterEach(() => {});
-
-  it('renders the Edit Component', async () => {
+  beforeEach(async () => {
+    (useTopicDetails as jest.Mock).mockImplementation(() => ({
+      data: internalTopicPayload,
+    }));
+    (useTopicConfig as jest.Mock).mockImplementation(() => ({
+      data: topicConfigPayload,
+    }));
+    (useUpdateTopic as jest.Mock).mockImplementation(() => ({
+      isLoading: false,
+      mutateAsync: updateTopicMock,
+    }));
     await act(() => renderComponent());
-
-    expect(
-      screen.getByRole('heading', { name: `Edit ${topicName}` })
-    ).toBeInTheDocument();
-    expect(
-      screen.getByRole('heading', { name: `Danger Zone` })
-    ).toBeInTheDocument();
-  });
-
-  it('should check Edit component renders null is not rendered when topic is not passed', async () => {
-    await act(() =>
-      renderComponent({}, { ...topicWithInfo, config: undefined })
-    );
-    expect(
-      screen.queryByRole('heading', { name: `Edit ${topicName}` })
-    ).not.toBeInTheDocument();
-    expect(
-      screen.queryByRole('heading', { name: `Danger Zone` })
-    ).not.toBeInTheDocument();
   });
 
-  it('should check Edit component renders null is not isFetched is false', async () => {
-    await act(() => renderComponent({ isFetched: false }));
-    expect(
-      screen.queryByRole('heading', { name: `Edit ${topicName}` })
-    ).not.toBeInTheDocument();
-    expect(
-      screen.queryByRole('heading', { name: `Danger Zone` })
-    ).not.toBeInTheDocument();
+  it('renders component', async () => {
+    expect(screen.getByText(`Edit ${topicName}`)).toBeInTheDocument();
   });
 
-  it('should check Edit component renders null is not topic config is not passed is false', async () => {
-    const modifiedTopic = { ...topicWithInfo };
-    modifiedTopic.config = undefined;
-    await act(() => renderComponent({}, modifiedTopic));
-    expect(
-      screen.queryByRole('heading', { name: `Edit ${topicName}` })
-    ).not.toBeInTheDocument();
-    expect(
-      screen.queryByRole('heading', { name: `Danger Zone` })
-    ).not.toBeInTheDocument();
+  it('renders DangerZone component', async () => {
+    expect(screen.getByText(`DangerZone`)).toBeInTheDocument();
   });
 
-  describe('Edit Component with its topic default and modified values', () => {
-    it('should check the default partitions value in the DangerZone', async () => {
-      await act(() =>
-        renderComponent({}, { ...topicWithInfo, partitionCount: 0 })
-      );
-      // cause topic selector will return falsy
-      expect(
-        screen.queryByRole('heading', { name: `Edit ${topicName}` })
-      ).not.toBeInTheDocument();
-      expect(
-        screen.queryByRole('heading', { name: `Danger Zone` })
-      ).not.toBeInTheDocument();
-    });
-
-    it('should check the default partitions value in the DangerZone', async () => {
-      await act(() =>
-        renderComponent({}, { ...topicWithInfo, replicationFactor: undefined })
-      );
-      expect(screen.getByPlaceholderText('Replication Factor')).toHaveValue(
-        DEFAULTS.replicationFactor
-      );
-    });
-  });
-
-  describe('Submit Case of the Edit Component', () => {
-    it('should check the submit functionality when topic updated is false', async () => {
-      const updateTopicMock = jest.fn();
-
-      await act(() =>
-        renderComponent({ updateTopic: updateTopicMock }, undefined)
-      );
-
-      const btn = screen.getAllByText(/Save/i)[0];
-
-      await act(() => {
-        userEvent.type(
-          screen.getByPlaceholderText('Min In Sync Replicas'),
-          '1'
-        );
-      });
-
-      await act(() => {
-        userEvent.click(btn);
-      });
-      expect(updateTopicMock).toHaveBeenCalledTimes(1);
-      expect(mockNavigate).not.toHaveBeenCalled();
-    });
-
-    it('should check the submit functionality when topic updated is true', async () => {
-      const updateTopicMock = jest.fn();
-      await act(() =>
-        renderComponent(
-          { updateTopic: updateTopicMock, isTopicUpdated: true },
-          undefined
-        )
-      );
-
-      const btn = screen.getAllByText(/Save/i)[0];
-
-      await act(() => {
-        userEvent.type(
-          screen.getByPlaceholderText('Min In Sync Replicas'),
-          '1'
-        );
-      });
-      await act(() => {
-        userEvent.click(btn);
-      });
-      expect(updateTopicMock).toHaveBeenCalledTimes(1);
-      expect(mockNavigate).toHaveBeenLastCalledWith('../');
+  it('submits form correctly', async () => {
+    await act(() => renderComponent());
+    const btn = screen.getAllByText(/Save/i)[0];
+    const field = screen.getByRole('spinbutton', {
+      name: 'Min In Sync Replicas * Min In Sync Replicas *',
     });
+    await act(() => userEvent.type(field, '1'));
+    await act(() => userEvent.click(btn));
+    expect(updateTopicMock).toHaveBeenCalledTimes(1);
+    expect(mockNavigate).toHaveBeenCalledWith('../');
   });
 });

+ 0 - 617
kafka-ui-react-app/src/components/Topics/Topic/Edit/__test__/fixtures.ts

@@ -1,617 +0,0 @@
-import { CleanUpPolicy, ConfigSource, TopicConfig } from 'generated-sources';
-import { TopicWithDetailedInfo } from 'redux/interfaces/topic';
-
-export const clusterName = 'testCluster';
-export const topicName = 'testTopic';
-
-const config: TopicConfig[] = [
-  {
-    name: 'compression.type',
-    value: 'producer',
-    defaultValue: 'producer',
-    source: ConfigSource.DYNAMIC_TOPIC_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'compression.type',
-        value: 'producer',
-        source: ConfigSource.DYNAMIC_TOPIC_CONFIG,
-      },
-      {
-        name: 'compression.type',
-        value: 'producer',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'confluent.value.schema.validation',
-    value: 'false',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [],
-  },
-  {
-    name: 'leader.replication.throttled.replicas',
-    value: '',
-    defaultValue: '',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [],
-  },
-  {
-    name: 'confluent.key.subject.name.strategy',
-    value: 'io.confluent.kafka.serializers.subject.TopicNameStrategy',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [],
-  },
-  {
-    name: 'message.downconversion.enable',
-    value: 'true',
-    defaultValue: 'true',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'log.message.downconversion.enable',
-        value: 'true',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'min.insync.replicas',
-    value: '1',
-    defaultValue: '1',
-    source: ConfigSource.DYNAMIC_TOPIC_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'min.insync.replicas',
-        value: '1',
-        source: ConfigSource.DYNAMIC_TOPIC_CONFIG,
-      },
-      {
-        name: 'min.insync.replicas',
-        value: '1',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'segment.jitter.ms',
-    value: '0',
-    defaultValue: '0',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [],
-  },
-  {
-    name: 'cleanup.policy',
-    value: 'delete',
-    defaultValue: 'delete',
-    source: ConfigSource.DYNAMIC_TOPIC_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'cleanup.policy',
-        value: 'delete',
-        source: ConfigSource.DYNAMIC_TOPIC_CONFIG,
-      },
-      {
-        name: 'log.cleanup.policy',
-        value: 'delete',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'flush.ms',
-    value: '9223372036854775807',
-    defaultValue: '9223372036854775807',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [],
-  },
-  {
-    name: 'confluent.tier.local.hotset.ms',
-    value: '86400000',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'confluent.tier.local.hotset.ms',
-        value: '86400000',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'follower.replication.throttled.replicas',
-    value: '',
-    defaultValue: '',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [],
-  },
-  {
-    name: 'confluent.tier.local.hotset.bytes',
-    value: '-1',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'confluent.tier.local.hotset.bytes',
-        value: '-1',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'confluent.value.subject.name.strategy',
-    value: 'io.confluent.kafka.serializers.subject.TopicNameStrategy',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [],
-  },
-  {
-    name: 'segment.bytes',
-    value: '1073741824',
-    defaultValue: '1073741824',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'log.segment.bytes',
-        value: '1073741824',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'retention.ms',
-    value: '604800000',
-    defaultValue: '604800000',
-    source: ConfigSource.DYNAMIC_TOPIC_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'retention.ms',
-        value: '604800000',
-        source: ConfigSource.DYNAMIC_TOPIC_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'flush.messages',
-    value: '9223372036854775807',
-    defaultValue: '9223372036854775807',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'log.flush.interval.messages',
-        value: '9223372036854775807',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'confluent.tier.enable',
-    value: 'false',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'confluent.tier.enable',
-        value: 'false',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'confluent.tier.segment.hotset.roll.min.bytes',
-    value: '104857600',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'confluent.tier.segment.hotset.roll.min.bytes',
-        value: '104857600',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'confluent.segment.speculative.prefetch.enable',
-    value: 'false',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'confluent.segment.speculative.prefetch.enable',
-        value: 'false',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'message.format.version',
-    value: '2.7-IV2',
-    defaultValue: '2.7-IV2',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'log.message.format.version',
-        value: '2.7-IV2',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'max.compaction.lag.ms',
-    value: '9223372036854775807',
-    defaultValue: '9223372036854775807',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'log.cleaner.max.compaction.lag.ms',
-        value: '9223372036854775807',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'file.delete.delay.ms',
-    value: '60000',
-    defaultValue: '60000',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'log.segment.delete.delay.ms',
-        value: '60000',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'max.message.bytes',
-    value: '1000012',
-    defaultValue: '1000012',
-    source: ConfigSource.DYNAMIC_TOPIC_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'max.message.bytes',
-        value: '1000012',
-        source: ConfigSource.DYNAMIC_TOPIC_CONFIG,
-      },
-      {
-        name: 'message.max.bytes',
-        value: '1048588',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'min.compaction.lag.ms',
-    value: '0',
-    defaultValue: '0',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'log.cleaner.min.compaction.lag.ms',
-        value: '0',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'message.timestamp.type',
-    value: 'CreateTime',
-    defaultValue: 'CreateTime',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'log.message.timestamp.type',
-        value: 'CreateTime',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'preallocate',
-    value: 'false',
-    defaultValue: 'false',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'log.preallocate',
-        value: 'false',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'confluent.placement.constraints',
-    value: '',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [],
-  },
-  {
-    name: 'min.cleanable.dirty.ratio',
-    value: '0.5',
-    defaultValue: '0.5',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'log.cleaner.min.cleanable.ratio',
-        value: '0.5',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'index.interval.bytes',
-    value: '4096',
-    defaultValue: '4096',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'log.index.interval.bytes',
-        value: '4096',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'unclean.leader.election.enable',
-    value: 'false',
-    defaultValue: 'false',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'unclean.leader.election.enable',
-        value: 'false',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'retention.bytes',
-    value: '-1',
-    defaultValue: '-1',
-    source: ConfigSource.DYNAMIC_TOPIC_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'retention.bytes',
-        value: '-1',
-        source: ConfigSource.DYNAMIC_TOPIC_CONFIG,
-      },
-      {
-        name: 'log.retention.bytes',
-        value: '-1',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'delete.retention.ms',
-    value: '86400001',
-    defaultValue: '86400000',
-    source: ConfigSource.DYNAMIC_TOPIC_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'delete.retention.ms',
-        value: '86400001',
-        source: ConfigSource.DYNAMIC_TOPIC_CONFIG,
-      },
-      {
-        name: 'log.cleaner.delete.retention.ms',
-        value: '86400000',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'confluent.prefer.tier.fetch.ms',
-    value: '-1',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'confluent.prefer.tier.fetch.ms',
-        value: '-1',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'confluent.key.schema.validation',
-    value: 'false',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [],
-  },
-  {
-    name: 'segment.ms',
-    value: '604800000',
-    defaultValue: '604800000',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [],
-  },
-  {
-    name: 'message.timestamp.difference.max.ms',
-    value: '9223372036854775807',
-    defaultValue: '9223372036854775807',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'log.message.timestamp.difference.max.ms',
-        value: '9223372036854775807',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'segment.index.bytes',
-    value: '10485760',
-    defaultValue: '10485760',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'log.index.size.max.bytes',
-        value: '10485760',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-];
-
-const partitions = [
-  {
-    partition: 0,
-    leader: 2,
-    replicas: [
-      {
-        broker: 2,
-        leader: false,
-        inSync: true,
-      },
-    ],
-    offsetMax: 0,
-    offsetMin: 0,
-  },
-];
-
-export const topicWithInfo: TopicWithDetailedInfo = {
-  name: topicName,
-  internal: false,
-  partitionCount: 1,
-  replicationFactor: 1,
-  replicas: 1,
-  inSyncReplicas: 1,
-  segmentSize: 0,
-  segmentCount: 1,
-  underReplicatedPartitions: 0,
-  cleanUpPolicy: CleanUpPolicy.DELETE,
-  partitions,
-  config,
-};
-export const customConfigs = [
-  {
-    name: 'segment.bytes',
-    value: '1',
-    defaultValue: '1073741824',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'log.segment.bytes',
-        value: '1073741824',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'retention.ms',
-    value: '604',
-    defaultValue: '604800000',
-    source: ConfigSource.DYNAMIC_TOPIC_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'retention.ms',
-        value: '604800000',
-        source: ConfigSource.DYNAMIC_TOPIC_CONFIG,
-      },
-    ],
-  },
-  {
-    name: 'flush.messages',
-    value: '92233',
-    defaultValue: '9223372036854775807',
-    source: ConfigSource.DEFAULT_CONFIG,
-    isSensitive: false,
-    isReadOnly: false,
-    synonyms: [
-      {
-        name: 'log.flush.interval.messages',
-        value: '9223372036854775807',
-        source: ConfigSource.DEFAULT_CONFIG,
-      },
-    ],
-  },
-];
-
-export const transformedParams = {
-  partitions: 1,
-  replicationFactor: 1,
-  cleanupPolicy: 'delete',
-  retentionBytes: -1,
-  maxMessageBytes: 1000012,
-  name: topicName,
-  minInSyncReplicas: 1,
-  retentionMs: 604800000,
-  customParams: [
-    {
-      name: 'delete.retention.ms',
-      value: '86400001',
-    },
-  ],
-};

+ 47 - 89
kafka-ui-react-app/src/components/Topics/Topic/Edit/__test__/topicParamsTransformer.spec.ts

@@ -1,101 +1,59 @@
 import topicParamsTransformer, {
   getValue,
 } from 'components/Topics/Topic/Edit/topicParamsTransformer';
-import { DEFAULTS } from 'components/Topics/Topic/Edit/Edit';
+import { externalTopicPayload, topicConfigPayload } from 'lib/fixtures/topics';
+import { TOPIC_EDIT_FORM_DEFAULT_PROPS } from 'components/Topics/Topic/Edit/Edit';
 
-import { transformedParams, customConfigs, topicWithInfo } from './fixtures';
+const defaultValue = 3232326;
+describe('getValue', () => {
+  it('returns value when field exists', () => {
+    expect(getValue(topicConfigPayload, 'min.insync.replicas')).toEqual(1);
+  });
+  it('returns default value when field does not exists', () => {
+    expect(getValue(topicConfigPayload, 'min.max.mid', defaultValue)).toEqual(
+      defaultValue
+    );
+  });
+});
 
 describe('topicParamsTransformer', () => {
-  const testField = (name: keyof typeof DEFAULTS, fieldName: string) => {
-    it('returns transformed value', () => {
-      expect(topicParamsTransformer(topicWithInfo)[name]).toEqual(
-        transformedParams[name]
-      );
-    });
-    it(`returns default value when ${name} not defined`, () => {
-      expect(
-        topicParamsTransformer({
-          ...topicWithInfo,
-          config: topicWithInfo.config?.filter(
-            (config) => config.name !== fieldName
-          ),
-        })[name]
-      ).toEqual(DEFAULTS[name]);
-    });
-
-    it('returns number value', () => {
-      expect(
-        typeof topicParamsTransformer(topicWithInfo).retentionBytes
-      ).toEqual('number');
-    });
-  };
-
-  describe('getValue', () => {
-    it('returns value when field exists', () => {
-      expect(
-        getValue(topicWithInfo, 'confluent.tier.segment.hotset.roll.min.bytes')
-      ).toEqual(104857600);
-    });
-    it('returns undefined when filed name does not exist', () => {
-      expect(getValue(topicWithInfo, 'some.unsupported.fieldName')).toEqual(
-        undefined
-      );
-    });
-    it('returns default value when field does not exist', () => {
-      expect(
-        getValue(topicWithInfo, 'some.unsupported.fieldName', 100)
-      ).toEqual(100);
-    });
+  it('returns default values when config payload is not defined', () => {
+    expect(topicParamsTransformer(externalTopicPayload)).toEqual(
+      TOPIC_EDIT_FORM_DEFAULT_PROPS
+    );
   });
-  describe('Topic', () => {
-    it('returns default values when topic not defined found', () => {
-      expect(topicParamsTransformer(undefined)).toEqual(DEFAULTS);
-    });
-
-    it('returns transformed values', () => {
-      expect(topicParamsTransformer(topicWithInfo)).toEqual(transformedParams);
-    });
+  it('returns default values when topic payload is not defined', () => {
+    expect(topicParamsTransformer(undefined, topicConfigPayload)).toEqual(
+      TOPIC_EDIT_FORM_DEFAULT_PROPS
+    );
   });
-
-  describe('Topic partitions', () => {
-    it('returns transformed value', () => {
-      expect(topicParamsTransformer(topicWithInfo).partitions).toEqual(
-        transformedParams.partitions
-      );
-    });
-    it('returns default value when partitionCount not defined', () => {
-      expect(
-        topicParamsTransformer({ ...topicWithInfo, partitionCount: undefined })
-          .partitions
-      ).toEqual(DEFAULTS.partitions);
+  it('returns transformed config', () => {
+    expect(
+      topicParamsTransformer(externalTopicPayload, topicConfigPayload)
+    ).toEqual({
+      ...TOPIC_EDIT_FORM_DEFAULT_PROPS,
+      name: externalTopicPayload.name,
     });
   });
-
-  describe('maxMessageBytes', () =>
-    testField('maxMessageBytes', 'max.message.bytes'));
-
-  describe('minInSyncReplicas', () =>
-    testField('minInSyncReplicas', 'min.insync.replicas'));
-
-  describe('retentionBytes', () =>
-    testField('retentionBytes', 'retention.bytes'));
-
-  describe('retentionMs', () => testField('retentionMs', 'retention.ms'));
-
-  describe('customParams', () => {
-    it('returns value when configs is empty', () => {
-      expect(
-        topicParamsTransformer({ ...topicWithInfo, config: [] }).customParams
-      ).toEqual([]);
-    });
-
-    it('returns value when had a 2 custom configs', () => {
-      expect(
-        topicParamsTransformer({
-          ...topicWithInfo,
-          config: customConfigs,
-        }).customParams?.length
-      ).toEqual(2);
-    });
+  it('returns default partitions config', () => {
+    expect(
+      topicParamsTransformer(
+        { ...externalTopicPayload, partitionCount: undefined },
+        topicConfigPayload
+      ).partitions
+    ).toEqual(TOPIC_EDIT_FORM_DEFAULT_PROPS.partitions);
+  });
+  it('returns empty list of custom params', () => {
+    expect(
+      topicParamsTransformer(externalTopicPayload, topicConfigPayload)
+        .customParams
+    ).toEqual([]);
+  });
+  it('returns list of custom params', () => {
+    expect(
+      topicParamsTransformer(externalTopicPayload, [
+        { ...topicConfigPayload[0], value: 'SuperCustom' },
+      ]).customParams
+    ).toEqual([{ name: 'compression.type', value: 'SuperCustom' }]);
   });
 });

+ 22 - 24
kafka-ui-react-app/src/components/Topics/Topic/Edit/topicParamsTransformer.ts

@@ -1,43 +1,41 @@
-import { TopicWithDetailedInfo } from 'redux/interfaces';
 import {
   MILLISECONDS_IN_WEEK,
   TOPIC_CUSTOM_PARAMS,
   TOPIC_CUSTOM_PARAMS_PREFIX,
 } from 'lib/constants';
-import { DEFAULTS } from 'components/Topics/Topic/Edit/Edit';
+import { TOPIC_EDIT_FORM_DEFAULT_PROPS } from 'components/Topics/Topic/Edit/Edit';
+import { Topic, TopicConfig } from 'generated-sources';
 
 export const getValue = (
-  topic: TopicWithDetailedInfo,
+  config: TopicConfig[],
   fieldName: string,
   defaultValue?: number
 ) =>
-  Number(topic?.config?.find((config) => config.name === fieldName)?.value) ||
-  defaultValue;
+  Number(config.find(({ name }) => name === fieldName)?.value) || defaultValue;
 
-const topicParamsTransformer = (topic?: TopicWithDetailedInfo) => {
-  if (!topic) {
-    return DEFAULTS;
+const topicParamsTransformer = (topic?: Topic, config?: TopicConfig[]) => {
+  if (!config || !topic) {
+    return TOPIC_EDIT_FORM_DEFAULT_PROPS;
   }
 
-  const { name, replicationFactor } = topic;
+  const customParams = config.reduce((acc, { name, value, defaultValue }) => {
+    if (value === defaultValue) return acc;
+    if (!Object.keys(TOPIC_CUSTOM_PARAMS).includes(name)) return acc;
+    return [...acc, { name, value }];
+  }, [] as { name: string; value?: string }[]);
 
   return {
-    ...DEFAULTS,
-    name,
-    replicationFactor,
-    partitions: topic.partitionCount || DEFAULTS.partitions,
-    maxMessageBytes: getValue(topic, 'max.message.bytes', 1000012),
-    minInSyncReplicas: getValue(topic, 'min.insync.replicas', 1),
-    retentionBytes: getValue(topic, 'retention.bytes', -1),
-    retentionMs: getValue(topic, 'retention.ms', MILLISECONDS_IN_WEEK),
+    ...TOPIC_EDIT_FORM_DEFAULT_PROPS,
+    name: topic.name,
+    replicationFactor: topic.replicationFactor,
+    partitions:
+      topic.partitionCount || TOPIC_EDIT_FORM_DEFAULT_PROPS.partitions,
+    maxMessageBytes: getValue(config, 'max.message.bytes', 1000012),
+    minInSyncReplicas: getValue(config, 'min.insync.replicas', 1),
+    retentionBytes: getValue(config, 'retention.bytes', -1),
+    retentionMs: getValue(config, 'retention.ms', MILLISECONDS_IN_WEEK),
 
-    [TOPIC_CUSTOM_PARAMS_PREFIX]: topic.config
-      ?.filter(
-        (el) =>
-          el.value !== el.defaultValue &&
-          Object.keys(TOPIC_CUSTOM_PARAMS).includes(el.name)
-      )
-      .map((el) => ({ name: el.name, value: el.value })),
+    [TOPIC_CUSTOM_PARAMS_PREFIX]: customParams,
   };
 };
 export default topicParamsTransformer;

+ 23 - 49
kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.tsx

@@ -6,24 +6,17 @@ import {
   RouteParamsClusterTopic,
 } from 'lib/paths';
 import jsf from 'json-schema-faker';
-import {
-  fetchTopicMessageSchema,
-  fetchTopicDetails,
-} from 'redux/reducers/topics/topicsSlice';
-import { useAppDispatch, useAppSelector } from 'lib/hooks/redux';
 import { Button } from 'components/common/Button/Button';
 import Editor from 'components/common/Editor/Editor';
-import PageLoader from 'components/common/PageLoader/PageLoader';
-import {
-  getMessageSchemaByTopicName,
-  getPartitionsByTopicName,
-  getTopicMessageSchemaFetched,
-} from 'redux/reducers/topics/selectors';
 import Select, { SelectOption } from 'components/common/Select/Select';
 import useAppParams from 'lib/hooks/useAppParams';
 import Heading from 'components/common/heading/Heading.styled';
-import { messagesApiClient } from 'lib/api';
-import { showAlert, showServerError } from 'lib/errorHandling';
+import { showAlert } from 'lib/errorHandling';
+import {
+  useSendMessage,
+  useTopicDetails,
+  useTopicMessageSchema,
+} from 'lib/hooks/api/topics';
 
 import validateMessage from './validateMessage';
 import * as S from './SendMessage.styled';
@@ -36,31 +29,27 @@ type FieldValues = Partial<{
 }>;
 
 const SendMessage: React.FC = () => {
-  const dispatch = useAppDispatch();
   const { clusterName, topicName } = useAppParams<RouteParamsClusterTopic>();
   const navigate = useNavigate();
+  const { data: topic } = useTopicDetails({ clusterName, topicName });
+  const { data: messageSchema } = useTopicMessageSchema({
+    clusterName,
+    topicName,
+  });
+  const sendMessage = useSendMessage({ clusterName, topicName });
 
   jsf.option('fillProperties', false);
   jsf.option('alwaysFakeOptionals', true);
 
-  React.useEffect(() => {
-    dispatch(fetchTopicMessageSchema({ clusterName, topicName }));
-  }, [clusterName, dispatch, topicName]);
+  const partitions = topic?.partitions || [];
 
-  const messageSchema = useAppSelector((state) =>
-    getMessageSchemaByTopicName(state, topicName)
-  );
-  const partitions = useAppSelector((state) =>
-    getPartitionsByTopicName(state, topicName)
-  );
-  const schemaIsFetched = useAppSelector(getTopicMessageSchemaFetched);
   const selectPartitionOptions: Array<SelectOption> = partitions.map((p) => {
     const value = String(p.partition);
     return { value, label: value };
   });
 
   const keyDefaultValue = React.useMemo(() => {
-    if (!schemaIsFetched || !messageSchema) {
+    if (!messageSchema) {
       return undefined;
     }
     return JSON.stringify(
@@ -68,10 +57,10 @@ const SendMessage: React.FC = () => {
       null,
       '\t'
     );
-  }, [messageSchema, schemaIsFetched]);
+  }, [messageSchema]);
 
   const contentDefaultValue = React.useMemo(() => {
-    if (!schemaIsFetched || !messageSchema) {
+    if (!messageSchema) {
       return undefined;
     }
     return JSON.stringify(
@@ -79,7 +68,7 @@ const SendMessage: React.FC = () => {
       null,
       '\t'
     );
-  }, [messageSchema, schemaIsFetched]);
+  }, [messageSchema]);
 
   const {
     handleSubmit,
@@ -129,31 +118,16 @@ const SendMessage: React.FC = () => {
         return;
       }
       const headers = data.headers ? JSON.parse(data.headers) : undefined;
-      try {
-        await messagesApiClient.sendTopicMessages({
-          clusterName,
-          topicName,
-          createTopicMessage: {
-            key: !key ? null : key,
-            content: !content ? null : content,
-            headers,
-            partition: !partition ? 0 : partition,
-          },
-        });
-        dispatch(fetchTopicDetails({ clusterName, topicName }));
-      } catch (e) {
-        showServerError(e as Response, {
-          id: `${clusterName}-${topicName}-sendTopicMessagesError`,
-          message: `Error in sending a message to ${topicName}`,
-        });
-      }
+      await sendMessage.mutateAsync({
+        key: !key ? null : key,
+        content: !content ? null : content,
+        headers,
+        partition: !partition ? 0 : partition,
+      });
       navigate(`../${clusterTopicMessagesRelativePath}`);
     }
   };
 
-  if (!schemaIsFetched) {
-    return <PageLoader />;
-  }
   return (
     <S.Wrapper>
       <form onSubmit={handleSubmit(onSubmit)}>

+ 44 - 70
kafka-ui-react-app/src/components/Topics/Topic/SendMessage/__test__/SendMessage.spec.tsx

@@ -2,19 +2,18 @@ import React from 'react';
 import SendMessage from 'components/Topics/Topic/SendMessage/SendMessage';
 import { act, screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
-import fetchMock from 'fetch-mock';
 import { render, WithRoute } from 'lib/testHelpers';
 import {
   clusterTopicMessagesRelativePath,
   clusterTopicSendMessagePath,
 } from 'lib/paths';
-import { store } from 'redux/store';
-import { fetchTopicDetails } from 'redux/reducers/topics/topicsSlice';
-import { externalTopicPayload } from 'redux/reducers/topics/__test__/fixtures';
 import validateMessage from 'components/Topics/Topic/SendMessage/validateMessage';
-import { showServerError } from 'lib/errorHandling';
-
-import { testSchema } from './fixtures';
+import { externalTopicPayload, topicMessageSchema } from 'lib/fixtures/topics';
+import {
+  useSendMessage,
+  useTopicDetails,
+  useTopicMessageSchema,
+} from 'lib/hooks/api/topics';
 
 import Mock = jest.Mock;
 
@@ -42,26 +41,29 @@ jest.mock('react-router-dom', () => ({
   useNavigate: () => mockNavigate,
 }));
 
+jest.mock('lib/hooks/api/topics', () => ({
+  useTopicDetails: jest.fn(),
+  useTopicMessageSchema: jest.fn(),
+  useSendMessage: jest.fn(),
+}));
+
 const clusterName = 'testCluster';
 const topicName = externalTopicPayload.name;
 
 const renderComponent = async () => {
+  const path = clusterTopicSendMessagePath(clusterName, topicName);
   await act(() => {
     render(
       <WithRoute path={clusterTopicSendMessagePath()}>
         <SendMessage />
       </WithRoute>,
-      {
-        initialEntries: [clusterTopicSendMessagePath(clusterName, topicName)],
-        store,
-      }
+      { initialEntries: [path] }
     );
   });
 };
 
 const renderAndSubmitData = async (error: string[] = []) => {
   await renderComponent();
-  expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
   await act(() => {
     userEvent.click(screen.getByRole('listbox'));
   });
@@ -75,83 +77,55 @@ const renderAndSubmitData = async (error: string[] = []) => {
 };
 
 describe('SendMessage', () => {
-  beforeAll(() => {
-    store.dispatch(
-      fetchTopicDetails.fulfilled(
-        {
-          topicDetails: externalTopicPayload,
-          topicName,
-        },
-        'topic',
-        {
-          clusterName,
-          topicName,
-        }
-      )
-    );
+  beforeEach(() => {
+    (useTopicDetails as jest.Mock).mockImplementation(() => ({
+      data: externalTopicPayload,
+    }));
   });
+
   afterEach(() => {
-    fetchMock.reset();
     mockNavigate.mockClear();
   });
 
-  it('fetches schema on first render', async () => {
-    const fetchTopicMessageSchemaMock = fetchMock.getOnce(
-      `/api/clusters/${clusterName}/topics/${topicName}/messages/schema`,
-      testSchema
-    );
-    await renderComponent();
-    expect(fetchTopicMessageSchemaMock.called()).toBeTruthy();
-  });
-
   describe('when schema is fetched', () => {
-    const messagesUrl = `/api/clusters/${clusterName}/topics/${topicName}/messages`;
-    const detailsUrl = `/api/clusters/${clusterName}/topics/${topicName}`;
-
     beforeEach(() => {
-      fetchMock.getOnce(
-        `/api/clusters/${clusterName}/topics/${topicName}/messages/schema`,
-        testSchema
-      );
+      (useTopicMessageSchema as jest.Mock).mockImplementation(() => ({
+        data: topicMessageSchema,
+      }));
     });
 
     it('calls sendTopicMessage on submit', async () => {
-      const sendTopicMessageMock = fetchMock.postOnce(messagesUrl, 200);
-      const fetchTopicDetailsMock = fetchMock.getOnce(detailsUrl, 200);
-      await renderAndSubmitData();
-      expect(sendTopicMessageMock.called(messagesUrl)).toBeTruthy();
-      expect(fetchTopicDetailsMock.called(detailsUrl)).toBeTruthy();
-      expect(mockNavigate).toHaveBeenLastCalledWith(
-        `../${clusterTopicMessagesRelativePath}`
-      );
-    });
-
-    it('should make the sendTopicMessage but most find an error within it', async () => {
-      const showServerErrorMock = jest.fn();
-      (showServerError as jest.Mock).mockImplementation(showServerErrorMock);
-      const sendTopicMessageMock = fetchMock.postOnce(messagesUrl, {
-        throws: 'Error',
-      });
-      const fetchTopicDetailsMock = fetchMock.getOnce(detailsUrl, 200);
+      const sendTopicMessageMock = jest.fn();
+      (useSendMessage as jest.Mock).mockImplementation(() => ({
+        mutateAsync: sendTopicMessageMock,
+      }));
       await renderAndSubmitData();
-      expect(sendTopicMessageMock.called()).toBeTruthy();
-      expect(fetchTopicDetailsMock.called(detailsUrl)).toBeFalsy();
-
-      expect(showServerErrorMock).toHaveBeenCalledWith('Error', {
-        id: 'testCluster-external.topic-sendTopicMessagesError',
-        message: 'Error in sending a message to external.topic',
-      });
-
+      expect(sendTopicMessageMock).toHaveBeenCalledTimes(1);
       expect(mockNavigate).toHaveBeenLastCalledWith(
         `../${clusterTopicMessagesRelativePath}`
       );
     });
 
     it('should check and view validation error message when is not valid', async () => {
-      const sendTopicMessageMock = fetchMock.postOnce(messagesUrl, 200);
+      const sendTopicMessageMock = jest.fn();
+      (useSendMessage as jest.Mock).mockImplementation(() => ({
+        mutateAsync: sendTopicMessageMock,
+      }));
       await renderAndSubmitData(['error']);
-      expect(sendTopicMessageMock.called(messagesUrl)).toBeFalsy();
+      expect(sendTopicMessageMock).not.toHaveBeenCalled();
       expect(mockNavigate).not.toHaveBeenCalled();
     });
   });
+
+  describe('when schema is empty', () => {
+    beforeEach(() => {
+      (useTopicMessageSchema as jest.Mock).mockImplementation(() => ({
+        data: undefined,
+      }));
+    });
+    it('renders if schema is not defined', async () => {
+      await renderComponent();
+      expect(screen.getAllByRole('textbox')[0].nodeValue).toBeNull();
+    });
+  });
 });

+ 0 - 50
kafka-ui-react-app/src/components/Topics/Topic/SendMessage/__test__/fixtures.ts

@@ -1,50 +0,0 @@
-import { MessageSchemaSourceEnum } from 'generated-sources';
-
-export const testSchema = {
-  key: {
-    name: 'key',
-    source: MessageSchemaSourceEnum.SCHEMA_REGISTRY,
-    schema: `{
-  "$schema": "https://json-schema.org/draft/2020-12/schema",
-  "$id": "http://example.com/myURI.schema.json",
-  "title": "TestRecord",
-  "type": "object",
-  "additionalProperties": false,
-  "properties": {
-    "f1": {
-      "type": "integer"
-    },
-    "f2": {
-      "type": "string"
-    },
-    "schema": {
-      "type": "string"
-    }
-  }
-}
-`,
-  },
-  value: {
-    name: 'value',
-    source: MessageSchemaSourceEnum.SCHEMA_REGISTRY,
-    schema: `{
-  "$schema": "https://json-schema.org/draft/2020-12/schema",
-  "$id": "http://example.com/myURI1.schema.json",
-  "title": "TestRecord",
-  "type": "object",
-  "additionalProperties": false,
-  "properties": {
-    "f1": {
-      "type": "integer"
-    },
-    "f2": {
-      "type": "string"
-    },
-    "schema": {
-      "type": "string"
-    }
-  }
-}
-`,
-  },
-};

+ 15 - 12
kafka-ui-react-app/src/components/Topics/Topic/SendMessage/__test__/validateMessage.spec.ts

@@ -1,8 +1,7 @@
 import validateMessage from 'components/Topics/Topic/SendMessage/validateMessage';
+import { topicMessageSchema } from 'lib/fixtures/topics';
 import cloneDeep from 'lodash/cloneDeep';
 
-import { testSchema } from './fixtures';
-
 describe('validateMessage', () => {
   const defaultValidKey = `{"f1": 32, "f2": "multi-state", "schema": "Bedfordshire violet SAS"}`;
   const defaultValidContent = `{"f1": 21128, "f2": "Health Berkshire", "schema": "Dynamic"}`;
@@ -10,20 +9,20 @@ describe('validateMessage', () => {
   it('should return empty error data if value is empty', () => {
     const key = ``;
     const content = ``;
-    expect(validateMessage(key, content, testSchema)).toEqual([]);
+    expect(validateMessage(key, content, topicMessageSchema)).toEqual([]);
   });
 
   it('should return empty error data if schema is empty', () => {
     const key = `{"f1": 32, "f2": "multi-state", "schema": "Bedfordshire violet SAS"}`;
     const content = `{"f1": 21128, "f2": "Health Berkshire", "schema": "Dynamic"}`;
-    const schema = cloneDeep(testSchema);
+    const schema = cloneDeep(topicMessageSchema);
     schema.key.schema = '';
     schema.value.schema = '';
     expect(validateMessage(key, content, schema)).toEqual([]);
   });
 
   it('should return parsing error data if schema is not parsed with type of key', () => {
-    const schema = cloneDeep(testSchema);
+    const schema = cloneDeep(topicMessageSchema);
     schema.key.schema = '{invalid';
     expect(
       validateMessage(defaultValidKey, defaultValidContent, schema)
@@ -31,7 +30,7 @@ describe('validateMessage', () => {
   });
 
   it('should return parsing error data if schema is not parsed with type of value', () => {
-    const schema = cloneDeep(testSchema);
+    const schema = cloneDeep(topicMessageSchema);
     schema.value.schema = '{invalid';
     expect(
       validateMessage(defaultValidKey, defaultValidContent, schema)
@@ -39,7 +38,7 @@ describe('validateMessage', () => {
   });
 
   it('should return empty error data if schema type is string', () => {
-    const schema = cloneDeep(testSchema);
+    const schema = cloneDeep(topicMessageSchema);
     schema.key.schema = `{"type": "string"}`;
     schema.value.schema = `{"type": "string"}`;
     expect(
@@ -49,20 +48,24 @@ describe('validateMessage', () => {
 
   it('should return  error data if compile Ajv data throws an error', () => {
     expect(
-      validateMessage(defaultValidKey, defaultValidContent, testSchema)
+      validateMessage(defaultValidKey, defaultValidContent, topicMessageSchema)
     ).toEqual([]);
   });
 
   it('returns no errors on correct input data', () => {
     expect(
-      validateMessage(defaultValidContent, defaultValidContent, testSchema)
+      validateMessage(
+        defaultValidContent,
+        defaultValidContent,
+        topicMessageSchema
+      )
     ).toEqual([]);
   });
 
   it('returns errors on invalid input data', () => {
     const key = `{"f1": "32", "f2": "multi-state", "schema": "Bedfordshire violet SAS"}`;
     const content = `{"f1": "21128", "f2": "Health Berkshire", "schema": "Dynamic"}`;
-    expect(validateMessage(key, content, testSchema)).toEqual([
+    expect(validateMessage(key, content, topicMessageSchema)).toEqual([
       'Key/properties/f1/type - must be integer',
       'Content/properties/f1/type - must be integer',
     ]);
@@ -71,7 +74,7 @@ describe('validateMessage', () => {
   it('returns error on broken key value', () => {
     const key = `{"f1": "32", "f2": "multi-state", "schema": "Bedfordshire violet SAS"`;
     const content = `{"f1": 21128, "f2": "Health Berkshire", "schema": "Dynamic"}`;
-    expect(validateMessage(key, content, testSchema)).toEqual([
+    expect(validateMessage(key, content, topicMessageSchema)).toEqual([
       'Error in parsing the "key" field value',
     ]);
   });
@@ -79,7 +82,7 @@ describe('validateMessage', () => {
   it('returns error on broken content value', () => {
     const key = `{"f1": 32, "f2": "multi-state", "schema": "Bedfordshire violet SAS"}`;
     const content = `{"f1": 21128, "f2": "Health Berkshire", "schema": "Dynamic"`;
-    expect(validateMessage(key, content, testSchema)).toEqual([
+    expect(validateMessage(key, content, topicMessageSchema)).toEqual([
       'Error in parsing the "content" field value',
     ]);
   });

+ 19 - 40
kafka-ui-react-app/src/components/Topics/Topic/Topic.tsx

@@ -1,57 +1,36 @@
-import React from 'react';
+import React, { Suspense } from 'react';
 import { Routes, Route } from 'react-router-dom';
-import { ClusterName, TopicName } from 'redux/interfaces';
-import EditContainer from 'components/Topics/Topic/Edit/EditContainer';
-import DetailsContainer from 'components/Topics/Topic/Details/DetailsContainer';
-import PageLoader from 'components/common/PageLoader/PageLoader';
 import {
   clusterTopicEditRelativePath,
   clusterTopicSendMessageRelativePath,
-  RouteParamsClusterTopic,
 } from 'lib/paths';
-import useAppParams from 'lib/hooks/useAppParams';
+import PageLoader from 'components/common/PageLoader/PageLoader';
+import { resetTopicMessages } from 'redux/reducers/topicMessages/topicMessagesSlice';
+import { useAppDispatch } from 'lib/hooks/redux';
 
 import SendMessage from './SendMessage/SendMessage';
+import Details from './Details/Details';
+import Edit from './Edit/Edit';
 
-interface TopicProps {
-  isTopicFetching: boolean;
-  resetTopicMessages: () => void;
-  fetchTopicDetails: (payload: {
-    clusterName: ClusterName;
-    topicName: TopicName;
-  }) => void;
-}
-
-const Topic: React.FC<TopicProps> = ({
-  isTopicFetching,
-  fetchTopicDetails,
-  resetTopicMessages,
-}) => {
-  const { clusterName, topicName } = useAppParams<RouteParamsClusterTopic>();
-
-  React.useEffect(() => {
-    fetchTopicDetails({ clusterName, topicName });
-  }, [fetchTopicDetails, clusterName, topicName]);
-
+const Topic: React.FC = () => {
+  const dispatch = useAppDispatch();
   React.useEffect(() => {
     return () => {
-      resetTopicMessages();
+      dispatch(resetTopicMessages());
     };
   }, []);
 
-  if (isTopicFetching) {
-    return <PageLoader />;
-  }
-
   return (
-    <Routes>
-      <Route path="*" element={<DetailsContainer />} />
-      <Route path={clusterTopicEditRelativePath} element={<EditContainer />} />
-      <Route
-        path={clusterTopicSendMessageRelativePath}
-        element={<SendMessage />}
-      />
-    </Routes>
+    <Suspense fallback={<PageLoader />}>
+      <Routes>
+        <Route path="*" element={<Details />} />
+        <Route path={clusterTopicEditRelativePath} element={<Edit />} />
+        <Route
+          path={clusterTopicSendMessageRelativePath}
+          element={<SendMessage />}
+        />
+      </Routes>
+    </Suspense>
   );
 };
 

+ 0 - 18
kafka-ui-react-app/src/components/Topics/Topic/TopicContainer.tsx

@@ -1,18 +0,0 @@
-import { connect } from 'react-redux';
-import { RootState } from 'redux/interfaces';
-import { fetchTopicDetails } from 'redux/reducers/topics/topicsSlice';
-import { resetTopicMessages } from 'redux/reducers/topicMessages/topicMessagesSlice';
-import { getIsTopicDetailsFetching } from 'redux/reducers/topics/selectors';
-
-import Topic from './Topic';
-
-const mapStateToProps = (state: RootState) => ({
-  isTopicFetching: getIsTopicDetailsFetching(state),
-});
-
-const mapDispatchToProps = {
-  fetchTopicDetails,
-  resetTopicMessages,
-};
-
-export default connect(mapStateToProps, mapDispatchToProps)(Topic);

+ 21 - 38
kafka-ui-react-app/src/components/Topics/Topic/__tests__/Topic.spec.tsx

@@ -8,79 +8,62 @@ import {
   clusterTopicSendMessagePath,
   getNonExactPath,
 } from 'lib/paths';
+import { useAppDispatch } from 'lib/hooks/redux';
 
 const topicText = {
-  edit: 'Edit Container',
+  edit: 'Edit',
   send: 'Send Message',
-  detail: 'Details Container',
+  detail: 'Details',
   loading: 'Loading',
 };
 
-jest.mock('components/Topics/Topic/Edit/EditContainer', () => () => (
+jest.mock('components/Topics/Topic/Edit/Edit', () => () => (
   <div>{topicText.edit}</div>
 ));
 jest.mock('components/Topics/Topic/SendMessage/SendMessage', () => () => (
   <div>{topicText.send}</div>
 ));
-jest.mock('components/Topics/Topic/Details/DetailsContainer', () => () => (
+jest.mock('components/Topics/Topic/Details/Details', () => () => (
   <div>{topicText.detail}</div>
 ));
-jest.mock('components/common/PageLoader/PageLoader', () => () => (
-  <div>{topicText.loading}</div>
-));
+
+jest.mock('lib/hooks/redux', () => ({
+  ...jest.requireActual('lib/hooks/redux'),
+  useAppDispatch: jest.fn(),
+}));
+const useDispatchMock = jest.fn(jest.fn());
 
 describe('Topic Component', () => {
-  const resetTopicMessages = jest.fn();
-  const fetchTopicDetailsMock = jest.fn();
+  beforeEach(() => {
+    (useAppDispatch as jest.Mock).mockImplementation(() => useDispatchMock);
+  });
 
-  const renderComponent = (pathname: string, topicFetching: boolean) =>
+  const renderComponent = (pathname: string) =>
     render(
       <WithRoute path={getNonExactPath(clusterTopicPath())}>
-        <Topic
-          isTopicFetching={topicFetching}
-          resetTopicMessages={resetTopicMessages}
-          fetchTopicDetails={fetchTopicDetailsMock}
-        />
+        <Topic />
       </WithRoute>,
       { initialEntries: [pathname] }
     );
 
-  afterEach(() => {
-    resetTopicMessages.mockClear();
-    fetchTopicDetailsMock.mockClear();
-  });
-
   it('renders Edit page', () => {
-    renderComponent(clusterTopicEditPath('local', 'myTopicName'), false);
+    renderComponent(clusterTopicEditPath('local', 'myTopicName'));
     expect(screen.getByText(topicText.edit)).toBeInTheDocument();
   });
 
   it('renders Send Message page', () => {
-    renderComponent(clusterTopicSendMessagePath('local', 'myTopicName'), false);
+    renderComponent(clusterTopicSendMessagePath('local', 'myTopicName'));
     expect(screen.getByText(topicText.send)).toBeInTheDocument();
   });
 
   it('renders Details Container page', () => {
-    renderComponent(clusterTopicPath('local', 'myTopicName'), false);
+    renderComponent(clusterTopicPath('local', 'myTopicName'));
     expect(screen.getByText(topicText.detail)).toBeInTheDocument();
   });
 
-  it('renders Page loader', () => {
-    renderComponent(clusterTopicPath('local', 'myTopicName'), true);
-    expect(screen.getByText(topicText.loading)).toBeInTheDocument();
-  });
-
-  it('fetches topicDetails', () => {
-    renderComponent(clusterTopicPath('local', 'myTopicName'), false);
-    expect(fetchTopicDetailsMock).toHaveBeenCalledTimes(1);
-  });
-
   it('resets topic messages after unmount', () => {
-    const component = renderComponent(
-      clusterTopicPath('local', 'myTopicName'),
-      false
-    );
+    const component = renderComponent(clusterTopicPath('local', 'myTopicName'));
     component.unmount();
-    expect(resetTopicMessages).toHaveBeenCalledTimes(1);
+    expect(useDispatchMock).toHaveBeenCalledTimes(1);
   });
 });

+ 4 - 4
kafka-ui-react-app/src/components/Topics/Topics.tsx

@@ -8,9 +8,9 @@ import {
 } from 'lib/paths';
 import { BreadcrumbRoute } from 'components/common/Breadcrumb/Breadcrumb.route';
 
-import ListContainer from './List/ListContainer';
-import TopicContainer from './Topic/TopicContainer';
 import New from './New/New';
+import ListPage from './List/ListPage';
+import Topic from './Topic/Topic';
 
 const Topics: React.FC = () => (
   <Routes>
@@ -18,7 +18,7 @@ const Topics: React.FC = () => (
       index
       element={
         <BreadcrumbRoute>
-          <ListContainer />
+          <ListPage />
         </BreadcrumbRoute>
       }
     />
@@ -42,7 +42,7 @@ const Topics: React.FC = () => (
       path={getNonExactPath(RouteParams.topicName)}
       element={
         <BreadcrumbRoute>
-          <TopicContainer />
+          <Topic />
         </BreadcrumbRoute>
       }
     />

+ 5 - 5
kafka-ui-react-app/src/components/Topics/__tests__/Topics.spec.tsx

@@ -10,14 +10,14 @@ import {
   getNonExactPath,
 } from 'lib/paths';
 
-const listContainer = 'listContainer';
-const topicContainer = 'topicContainer';
-const newCopyContainer = 'newCopyContainer';
+const listContainer = 'My List Page';
+const topicContainer = 'My Topic Details Page';
+const newCopyContainer = 'My New/Copy Page';
 
-jest.mock('components/Topics/List/ListContainer', () => () => (
+jest.mock('components/Topics/List/ListPage', () => () => (
   <div>{listContainer}</div>
 ));
-jest.mock('components/Topics/Topic/TopicContainer', () => () => (
+jest.mock('components/Topics/Topic/Topic', () => () => (
   <div>{topicContainer}</div>
 ));
 jest.mock('components/Topics/New/New', () => () => (

+ 1 - 2
kafka-ui-react-app/src/components/common/Alert/Alert.styled.ts

@@ -3,8 +3,7 @@ import styled from 'styled-components';
 
 export const Alert = styled.div<{ $type: ToastType }>`
   background-color: ${({ $type, theme }) => theme.alert.color[$type]};
-  min-width: 400px;
-  max-width: 600px;
+  min-width: 500px;
   min-height: 64px;
   border-radius: 8px;
   padding: 12px;

+ 7 - 8
kafka-ui-react-app/src/components/common/Pagination/Pagination.tsx

@@ -3,7 +3,7 @@ import usePagination from 'lib/hooks/usePagination';
 import range from 'lodash/range';
 import React from 'react';
 import PageControl from 'components/common/Pagination/PageControl';
-import useSearch from 'lib/hooks/useSearch';
+import { useSearchParams } from 'react-router-dom';
 
 import * as S from './Pagination.styled';
 
@@ -15,17 +15,16 @@ const NEIGHBOURS = 2;
 
 const Pagination: React.FC<PaginationProps> = ({ totalPages }) => {
   const { page, perPage, pathname } = usePagination();
-  const [searchText] = useSearch();
+  const [searchParams] = useSearchParams();
 
   const currentPage = page || 1;
   const currentPerPage = perPage || PER_PAGE;
 
-  const searchParam = searchText ? `&q=${searchText}` : '';
-  const getPath = (newPage: number) =>
-    `${pathname}?page=${Math.max(
-      newPage,
-      1
-    )}&perPage=${currentPerPage}${searchParam}`;
+  const getPath = (newPage: number) => {
+    searchParams.set('page', Math.max(newPage, 1).toString());
+    searchParams.set('perPage', currentPerPage.toString());
+    return `${pathname}?${searchParams.toString()}`;
+  };
 
   const pages = React.useMemo(() => {
     // Total visible numbers: neighbours, current, first & last

+ 1 - 2
kafka-ui-react-app/src/components/common/SmartTable/TableColumn.tsx

@@ -3,7 +3,6 @@ import { TableState } from 'lib/hooks/useTableState';
 import { SortOrder } from 'generated-sources';
 import * as S from 'components/common/table/TableHeaderCell/TableHeaderCell.styled';
 import { DefaultTheme, StyledComponent } from 'styled-components';
-import { ActionsCellProps } from 'components/Topics/List/ActionsCell/ActionsCell';
 
 export interface OrderableProps {
   orderBy: string | null;
@@ -29,7 +28,7 @@ export interface TableCellProps<T, TId extends IdType>
 }
 
 interface TableColumnProps<T, TId extends IdType> {
-  cell?: React.FC<TableCellProps<T, TId> & ActionsCellProps>;
+  cell?: React.FC<TableCellProps<T, TId>>;
   children?: React.ReactElement;
   headerCell?: React.FC<TableHeaderCellProps<T, TId>>;
   field?: string;

+ 210 - 0
kafka-ui-react-app/src/lib/fixtures/topics.ts

@@ -0,0 +1,210 @@
+import {
+  ConfigSource,
+  ConsumerGroup,
+  ConsumerGroupState,
+  Topic,
+  TopicConfig,
+  MessageSchemaSourceEnum,
+} from 'generated-sources';
+
+export const internalTopicPayload = {
+  name: '__internal.topic',
+  internal: true,
+  partitionCount: 1,
+  replicationFactor: 1,
+  replicas: 1,
+  inSyncReplicas: 1,
+  segmentSize: 0,
+  segmentCount: 1,
+  underReplicatedPartitions: 0,
+  partitions: [
+    {
+      partition: 0,
+      leader: 1,
+      replicas: [{ broker: 1, leader: false, inSync: true }],
+      offsetMax: 0,
+      offsetMin: 0,
+    },
+  ],
+};
+
+export const externalTopicPayload = {
+  name: 'external.topic',
+  internal: false,
+  partitionCount: 1,
+  replicationFactor: 1,
+  replicas: 1,
+  inSyncReplicas: 1,
+  segmentSize: 1263,
+  segmentCount: 1,
+  underReplicatedPartitions: 0,
+  partitions: [
+    {
+      partition: 0,
+      leader: 1,
+      replicas: [{ broker: 1, leader: false, inSync: true }],
+      offsetMax: 0,
+      offsetMin: 0,
+    },
+  ],
+};
+
+export const topicsPayload: Topic[] = [
+  internalTopicPayload,
+  externalTopicPayload,
+];
+
+export const topicConsumerGroups: ConsumerGroup[] = [
+  {
+    groupId: 'amazon.msk.canary.group.broker-7',
+    topics: 0,
+    members: 0,
+    simple: false,
+    partitionAssignor: '',
+    state: ConsumerGroupState.UNKNOWN,
+    coordinator: { id: 1 },
+    messagesBehind: 9,
+  },
+  {
+    groupId: 'amazon.msk.canary.group.broker-4',
+    topics: 0,
+    members: 0,
+    simple: false,
+    partitionAssignor: '',
+    state: ConsumerGroupState.COMPLETING_REBALANCE,
+    coordinator: { id: 1 },
+    messagesBehind: 9,
+  },
+];
+
+export const topicConfigPayload: TopicConfig[] = [
+  {
+    name: 'compression.type',
+    value: 'producer',
+    defaultValue: 'producer',
+    source: ConfigSource.DYNAMIC_TOPIC_CONFIG,
+    isSensitive: false,
+    isReadOnly: false,
+    synonyms: [
+      {
+        name: 'compression.type',
+        value: 'producer',
+        source: ConfigSource.DYNAMIC_TOPIC_CONFIG,
+      },
+      {
+        name: 'compression.type',
+        value: 'producer',
+        source: ConfigSource.DEFAULT_CONFIG,
+      },
+    ],
+  },
+  {
+    name: 'confluent.value.schema.validation',
+    value: 'false',
+    source: ConfigSource.DEFAULT_CONFIG,
+    isSensitive: false,
+    isReadOnly: false,
+    synonyms: [],
+  },
+  {
+    name: 'leader.replication.throttled.replicas',
+    value: '',
+    defaultValue: '',
+    source: ConfigSource.DEFAULT_CONFIG,
+    isSensitive: false,
+    isReadOnly: false,
+    synonyms: [],
+  },
+  {
+    name: 'confluent.key.subject.name.strategy',
+    value: 'io.confluent.kafka.serializers.subject.TopicNameStrategy',
+    source: ConfigSource.DEFAULT_CONFIG,
+    isSensitive: false,
+    isReadOnly: false,
+    synonyms: [],
+  },
+  {
+    name: 'message.downconversion.enable',
+    value: 'true',
+    defaultValue: 'true',
+    source: ConfigSource.DEFAULT_CONFIG,
+    isSensitive: false,
+    isReadOnly: false,
+    synonyms: [
+      {
+        name: 'log.message.downconversion.enable',
+        value: 'true',
+        source: ConfigSource.DEFAULT_CONFIG,
+      },
+    ],
+  },
+  {
+    name: 'min.insync.replicas',
+    value: '1',
+    defaultValue: '1',
+    source: ConfigSource.DYNAMIC_TOPIC_CONFIG,
+    isSensitive: false,
+    isReadOnly: false,
+    synonyms: [
+      {
+        name: 'min.insync.replicas',
+        value: '1',
+        source: ConfigSource.DYNAMIC_TOPIC_CONFIG,
+      },
+      {
+        name: 'min.insync.replicas',
+        value: '1',
+        source: ConfigSource.DEFAULT_CONFIG,
+      },
+    ],
+  },
+];
+
+export const topicMessageSchema = {
+  key: {
+    name: 'key',
+    source: MessageSchemaSourceEnum.SCHEMA_REGISTRY,
+    schema: `{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "http://example.com/myURI.schema.json",
+  "title": "TestRecord",
+  "type": "object",
+  "additionalProperties": false,
+  "properties": {
+    "f1": {
+      "type": "integer"
+    },
+    "f2": {
+      "type": "string"
+    },
+    "schema": {
+      "type": "string"
+    }
+  }
+}
+`,
+  },
+  value: {
+    name: 'value',
+    source: MessageSchemaSourceEnum.SCHEMA_REGISTRY,
+    schema: `{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "http://example.com/myURI1.schema.json",
+  "title": "TestRecord",
+  "type": "object",
+  "additionalProperties": false,
+  "properties": {
+    "f1": {
+      "type": "integer"
+    },
+    "f2": {
+      "type": "string"
+    },
+    "schema": {
+      "type": "string"
+    }
+  }
+}
+`,
+  },
+};

+ 48 - 0
kafka-ui-react-app/src/lib/hooks/api/__tests__/brokers.spec.ts

@@ -0,0 +1,48 @@
+import { waitFor } from '@testing-library/react';
+import { renderQueryHook } from 'lib/testHelpers';
+import * as hooks from 'lib/hooks/api/brokers';
+import fetchMock from 'fetch-mock';
+import { UseQueryResult } from '@tanstack/react-query';
+
+const clusterName = 'test-cluster';
+const brokerId = 1;
+const brokersPath = `/api/clusters/${clusterName}/brokers`;
+const brokerPath = `${brokersPath}/${brokerId}`;
+
+const expectQueryWorks = async (
+  mock: fetchMock.FetchMockStatic,
+  result: { current: UseQueryResult<unknown, unknown> }
+) => {
+  await waitFor(() => expect(result.current.isFetched).toBeTruthy());
+  expect(mock.calls()).toHaveLength(1);
+  expect(result.current.data).toBeDefined();
+};
+
+describe('Brokers hooks', () => {
+  beforeEach(() => fetchMock.restore());
+  describe('useBrokers', () => {
+    it('useBrokers', async () => {
+      const mock = fetchMock.getOnce(brokersPath, []);
+      const { result } = renderQueryHook(() => hooks.useBrokers(clusterName));
+      await expectQueryWorks(mock, result);
+    });
+  });
+  describe('useBrokerMetrics', () => {
+    it('useBrokerMetrics', async () => {
+      const mock = fetchMock.getOnce(`${brokerPath}/metrics`, {});
+      const { result } = renderQueryHook(() =>
+        hooks.useBrokerMetrics(clusterName, brokerId)
+      );
+      await expectQueryWorks(mock, result);
+    });
+  });
+  describe('useBrokerLogDirs', () => {
+    it('useBrokerLogDirs', async () => {
+      const mock = fetchMock.getOnce(`${brokersPath}/logdirs?broker=1`, []);
+      const { result } = renderQueryHook(() =>
+        hooks.useBrokerLogDirs(clusterName, brokerId)
+      );
+      await expectQueryWorks(mock, result);
+    });
+  });
+});

+ 40 - 0
kafka-ui-react-app/src/lib/hooks/api/__tests__/clusters.spec.ts

@@ -0,0 +1,40 @@
+import { waitFor } from '@testing-library/react';
+import { renderQueryHook } from 'lib/testHelpers';
+import * as hooks from 'lib/hooks/api/clusters';
+import fetchMock from 'fetch-mock';
+import { UseQueryResult } from '@tanstack/react-query';
+import { clustersPayload } from 'lib/fixtures/clusters';
+
+const clusterName = 'test-cluster';
+
+const expectQueryWorks = async (
+  mock: fetchMock.FetchMockStatic,
+  result: { current: UseQueryResult<unknown, unknown> }
+) => {
+  await waitFor(() => expect(result.current.isFetched).toBeTruthy());
+  expect(mock.calls()).toHaveLength(1);
+  expect(result.current.data).toBeDefined();
+};
+
+describe('Clusters hooks', () => {
+  beforeEach(() => fetchMock.restore());
+  describe('useClusters', () => {
+    it('returns the correct data', async () => {
+      const mock = fetchMock.getOnce('/api/clusters', clustersPayload);
+      const { result } = renderQueryHook(() => hooks.useClusters());
+      await expectQueryWorks(mock, result);
+    });
+  });
+  describe('useClusterStats', () => {
+    it('returns the correct data', async () => {
+      const mock = fetchMock.getOnce(
+        `/api/clusters/${clusterName}/stats`,
+        clustersPayload
+      );
+      const { result } = renderQueryHook(() =>
+        hooks.useClusterStats(clusterName)
+      );
+      await expectQueryWorks(mock, result);
+    });
+  });
+});

+ 177 - 0
kafka-ui-react-app/src/lib/hooks/api/__tests__/topics.spec.ts

@@ -0,0 +1,177 @@
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { renderQueryHook, TestQueryClientProvider } from 'lib/testHelpers';
+import * as hooks from 'lib/hooks/api/topics';
+import fetchMock from 'fetch-mock';
+import { UseQueryResult } from '@tanstack/react-query';
+import { externalTopicPayload, topicConfigPayload } from 'lib/fixtures/topics';
+import { TopicFormData, TopicFormDataRaw } from 'redux/interfaces';
+import { CreateTopicMessage } from 'generated-sources';
+
+const clusterName = 'test-cluster';
+const topicName = 'test-topic';
+
+const expectQueryWorks = async (
+  mock: fetchMock.FetchMockStatic,
+  result: { current: UseQueryResult<unknown, unknown> }
+) => {
+  await waitFor(() => expect(result.current.isFetched).toBeTruthy());
+  expect(mock.calls()).toHaveLength(1);
+  expect(result.current.data).toBeDefined();
+};
+
+const topicsPath = `/api/clusters/${clusterName}/topics`;
+const topicPath = `${topicsPath}/${topicName}`;
+
+const topicParams = { clusterName, topicName };
+
+describe('Topics hooks', () => {
+  beforeEach(() => fetchMock.restore());
+  it('handles useTopics', async () => {
+    const mock = fetchMock.getOnce(topicsPath, []);
+    const { result } = renderQueryHook(() => hooks.useTopics({ clusterName }));
+    await expectQueryWorks(mock, result);
+  });
+  it('handles useTopicDetails', async () => {
+    const mock = fetchMock.getOnce(topicPath, externalTopicPayload);
+    const { result } = renderQueryHook(() =>
+      hooks.useTopicDetails(topicParams)
+    );
+    await expectQueryWorks(mock, result);
+  });
+  it('handles useTopicConfig', async () => {
+    const mock = fetchMock.getOnce(`${topicPath}/config`, topicConfigPayload);
+    const { result } = renderQueryHook(() => hooks.useTopicConfig(topicParams));
+    await expectQueryWorks(mock, result);
+  });
+  it('handles useTopicConsumerGroups', async () => {
+    const mock = fetchMock.getOnce(`${topicPath}/consumer-groups`, []);
+    const { result } = renderQueryHook(() =>
+      hooks.useTopicConsumerGroups(topicParams)
+    );
+    await expectQueryWorks(mock, result);
+  });
+  it('handles useTopicMessageSchema', async () => {
+    const mock = fetchMock.getOnce(`${topicPath}/messages/schema`, {});
+    const { result } = renderQueryHook(() =>
+      hooks.useTopicMessageSchema(topicParams)
+    );
+    await expectQueryWorks(mock, result);
+  });
+
+  describe('mutatations', () => {
+    it('useCreateTopic', async () => {
+      const mock = fetchMock.postOnce(topicsPath, {});
+      const { result } = renderHook(() => hooks.useCreateTopic(clusterName), {
+        wrapper: TestQueryClientProvider,
+      });
+      const formData: TopicFormData = {
+        name: 'Topic Name',
+        partitions: 0,
+        replicationFactor: 0,
+        minInSyncReplicas: 0,
+        cleanupPolicy: '',
+        retentionMs: 0,
+        retentionBytes: 0,
+        maxMessageBytes: 0,
+        customParams: [],
+      };
+      await act(() => {
+        result.current.mutateAsync(formData);
+      });
+      await waitFor(() => expect(result.current.isSuccess).toBeTruthy());
+      expect(mock.calls()).toHaveLength(1);
+    });
+
+    it('useUpdateTopic', async () => {
+      const mock = fetchMock.patchOnce(topicPath, {});
+      const { result } = renderHook(() => hooks.useUpdateTopic(topicParams), {
+        wrapper: TestQueryClientProvider,
+      });
+      const formData: TopicFormDataRaw = {
+        name: 'Topic Name',
+        partitions: 0,
+        replicationFactor: 0,
+        minInSyncReplicas: 0,
+        cleanupPolicy: '',
+        retentionMs: 0,
+        retentionBytes: 0,
+        maxMessageBytes: 0,
+        customParams: {
+          byIndex: {},
+          allIndexes: [],
+        },
+      };
+      await act(() => {
+        result.current.mutateAsync(formData);
+      });
+      await waitFor(() => expect(result.current.isSuccess).toBeTruthy());
+      expect(mock.calls()).toHaveLength(1);
+    });
+
+    it('useIncreaseTopicPartitionsCount', async () => {
+      const mock = fetchMock.patchOnce(`${topicPath}/partitions`, {});
+      const { result } = renderHook(
+        () => hooks.useIncreaseTopicPartitionsCount(topicParams),
+        { wrapper: TestQueryClientProvider }
+      );
+      await act(() => {
+        result.current.mutateAsync(3);
+      });
+      await waitFor(() => expect(result.current.isSuccess).toBeTruthy());
+      expect(mock.calls()).toHaveLength(1);
+    });
+
+    it('useUpdateTopicReplicationFactor', async () => {
+      const mock = fetchMock.patchOnce(`${topicPath}/replications`, {});
+      const { result } = renderHook(
+        () => hooks.useUpdateTopicReplicationFactor(topicParams),
+        { wrapper: TestQueryClientProvider }
+      );
+      await act(() => {
+        result.current.mutateAsync(3);
+      });
+      await waitFor(() => expect(result.current.isSuccess).toBeTruthy());
+      expect(mock.calls()).toHaveLength(1);
+    });
+
+    it('useDeleteTopic', async () => {
+      const mock = fetchMock.deleteOnce(topicPath, {});
+      const { result } = renderHook(() => hooks.useDeleteTopic(clusterName), {
+        wrapper: TestQueryClientProvider,
+      });
+      await act(() => {
+        result.current.mutateAsync(topicName);
+      });
+      await waitFor(() => expect(result.current.isSuccess).toBeTruthy());
+      expect(mock.calls()).toHaveLength(1);
+    });
+
+    it('useRecreateTopic', async () => {
+      const mock = fetchMock.postOnce(topicPath, {});
+      const { result } = renderHook(() => hooks.useRecreateTopic(topicParams), {
+        wrapper: TestQueryClientProvider,
+      });
+      await act(() => {
+        result.current.mutateAsync();
+      });
+      await waitFor(() => expect(result.current.isSuccess).toBeTruthy());
+      expect(mock.calls()).toHaveLength(1);
+    });
+
+    it('useSendMessage', async () => {
+      const mock = fetchMock.postOnce(`${topicPath}/messages`, {});
+      const { result } = renderHook(() => hooks.useSendMessage(topicParams), {
+        wrapper: TestQueryClientProvider,
+      });
+      const message: CreateTopicMessage = {
+        partition: 0,
+        content: 'Hello World',
+      };
+      await act(() => {
+        result.current.mutateAsync(message);
+      });
+      await waitFor(() => expect(result.current.isSuccess).toBeTruthy());
+      expect(mock.calls()).toHaveLength(1);
+    });
+  });
+});

+ 238 - 0
kafka-ui-react-app/src/lib/hooks/api/topics.ts

@@ -0,0 +1,238 @@
+import {
+  topicsApiClient as api,
+  messagesApiClient as messagesApi,
+  consumerGroupsApiClient,
+} from 'lib/api';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import {
+  ClusterName,
+  TopicFormData,
+  TopicFormDataRaw,
+  TopicFormFormattedParams,
+} from 'redux/interfaces';
+import {
+  CreateTopicMessage,
+  GetTopicDetailsRequest,
+  GetTopicsRequest,
+  Topic,
+  TopicConfig,
+  TopicCreation,
+  TopicUpdate,
+} from 'generated-sources';
+import { showServerError, showSuccessAlert } from 'lib/errorHandling';
+
+export const topicKeys = {
+  all: (clusterName: ClusterName) =>
+    ['clusters', clusterName, 'topics'] as const,
+  list: (
+    clusterName: ClusterName,
+    filters: Omit<GetTopicsRequest, 'clusterName'>
+  ) => [...topicKeys.all(clusterName), filters] as const,
+  details: ({ clusterName, topicName }: GetTopicDetailsRequest) =>
+    [...topicKeys.all(clusterName), topicName] as const,
+  config: (props: GetTopicDetailsRequest) =>
+    [...topicKeys.details(props), 'config'] as const,
+  schema: (props: GetTopicDetailsRequest) =>
+    [...topicKeys.details(props), 'schema'] as const,
+  consumerGroups: (props: GetTopicDetailsRequest) =>
+    [...topicKeys.details(props), 'consumerGroups'] as const,
+};
+
+export function useTopics(props: GetTopicsRequest) {
+  const { clusterName, ...filters } = props;
+  return useQuery(topicKeys.list(clusterName, filters), () =>
+    api.getTopics(props)
+  );
+}
+export function useTopicDetails(props: GetTopicDetailsRequest) {
+  return useQuery(topicKeys.details(props), () => api.getTopicDetails(props));
+}
+export function useTopicConfig(props: GetTopicDetailsRequest) {
+  return useQuery(topicKeys.config(props), () => api.getTopicConfigs(props));
+}
+export function useTopicConsumerGroups(props: GetTopicDetailsRequest) {
+  return useQuery(topicKeys.consumerGroups(props), () =>
+    consumerGroupsApiClient.getTopicConsumerGroups(props)
+  );
+}
+
+const topicReducer = (
+  result: TopicFormFormattedParams,
+  customParam: TopicConfig
+) => {
+  return {
+    ...result,
+    [customParam.name]: customParam.value,
+  };
+};
+const formatTopicCreation = (form: TopicFormData): TopicCreation => {
+  const {
+    name,
+    partitions,
+    replicationFactor,
+    cleanupPolicy,
+    retentionBytes,
+    retentionMs,
+    maxMessageBytes,
+    minInSyncReplicas,
+    customParams,
+  } = form;
+
+  return {
+    name,
+    partitions,
+    replicationFactor,
+    configs: {
+      'cleanup.policy': cleanupPolicy,
+      'retention.ms': retentionMs.toString(),
+      'retention.bytes': retentionBytes.toString(),
+      'max.message.bytes': maxMessageBytes.toString(),
+      'min.insync.replicas': minInSyncReplicas.toString(),
+      ...Object.values(customParams || {}).reduce(topicReducer, {}),
+    },
+  };
+};
+
+export function useCreateTopic(clusterName: ClusterName) {
+  const client = useQueryClient();
+  return useMutation(
+    (data: TopicFormData) =>
+      api.createTopic({
+        clusterName,
+        topicCreation: formatTopicCreation(data),
+      }),
+    {
+      onSuccess: () => {
+        showSuccessAlert({
+          message: `Topic successfully created.`,
+        });
+        client.invalidateQueries(topicKeys.all(clusterName));
+      },
+    }
+  );
+}
+
+const formatTopicUpdate = (form: TopicFormDataRaw): TopicUpdate => {
+  const {
+    cleanupPolicy,
+    retentionBytes,
+    retentionMs,
+    maxMessageBytes,
+    minInSyncReplicas,
+    customParams,
+  } = form;
+
+  return {
+    configs: {
+      ...Object.values(customParams || {}).reduce(topicReducer, {}),
+      'cleanup.policy': cleanupPolicy,
+      'retention.ms': retentionMs,
+      'retention.bytes': retentionBytes,
+      'max.message.bytes': maxMessageBytes,
+      'min.insync.replicas': minInSyncReplicas,
+    },
+  };
+};
+
+export function useUpdateTopic(props: GetTopicDetailsRequest) {
+  const client = useQueryClient();
+  return useMutation(
+    (data: TopicFormDataRaw) =>
+      api.updateTopic({ ...props, topicUpdate: formatTopicUpdate(data) }),
+    {
+      onSuccess: () => {
+        showSuccessAlert({
+          message: `Topic successfully updated.`,
+        });
+        client.invalidateQueries(topicKeys.all(props.clusterName));
+      },
+    }
+  );
+}
+export function useIncreaseTopicPartitionsCount(props: GetTopicDetailsRequest) {
+  const client = useQueryClient();
+  return useMutation(
+    (totalPartitionsCount: number) =>
+      api.increaseTopicPartitions({
+        ...props,
+        partitionsIncrease: { totalPartitionsCount },
+      }),
+    {
+      onSuccess: () => {
+        showSuccessAlert({
+          message: `Number of partitions successfully increased`,
+        });
+        client.invalidateQueries(topicKeys.all(props.clusterName));
+      },
+    }
+  );
+}
+export function useUpdateTopicReplicationFactor(props: GetTopicDetailsRequest) {
+  const client = useQueryClient();
+  return useMutation(
+    (totalReplicationFactor: number) =>
+      api.changeReplicationFactor({
+        ...props,
+        replicationFactorChange: { totalReplicationFactor },
+      }),
+    {
+      onSuccess: () => {
+        showSuccessAlert({
+          message: `Replication factor successfully updated`,
+        });
+        client.invalidateQueries(topicKeys.all(props.clusterName));
+      },
+    }
+  );
+}
+export function useDeleteTopic(clusterName: ClusterName) {
+  const client = useQueryClient();
+  return useMutation(
+    (topicName: Topic['name']) => api.deleteTopic({ clusterName, topicName }),
+    {
+      onSuccess: (_, topicName) => {
+        showSuccessAlert({
+          message: `Topic ${topicName} successfully deleted!`,
+        });
+        client.invalidateQueries(topicKeys.all(clusterName));
+      },
+    }
+  );
+}
+export function useRecreateTopic(props: GetTopicDetailsRequest) {
+  const client = useQueryClient();
+  return useMutation(() => api.recreateTopic(props), {
+    onSuccess: () => {
+      showSuccessAlert({
+        message: `Topic ${props.topicName} successfully recreated!`,
+      });
+      client.invalidateQueries(topicKeys.all(props.clusterName));
+    },
+  });
+}
+
+export function useTopicMessageSchema(props: GetTopicDetailsRequest) {
+  return useQuery(topicKeys.schema(props), () =>
+    messagesApi.getTopicSchema(props)
+  );
+}
+export function useSendMessage(props: GetTopicDetailsRequest) {
+  const client = useQueryClient();
+  return useMutation(
+    (message: CreateTopicMessage) =>
+      messagesApi.sendTopicMessages({ ...props, createTopicMessage: message }),
+    {
+      onSuccess: () => {
+        showSuccessAlert({
+          message: `Message successfully sent`,
+        });
+        client.invalidateQueries(topicKeys.all(props.clusterName));
+      },
+      onError: (e) => {
+        showServerError(e as Response, {
+          message: `Error in sending a message to ${props.topicName}`,
+        });
+      },
+    }
+  );
+}

+ 4 - 2
kafka-ui-react-app/src/lib/hooks/useConfirm.ts

@@ -3,8 +3,10 @@ import React, { useContext } from 'react';
 
 export const useConfirm = () => {
   const context = useContext(ConfirmContext);
-
-  return (message: React.ReactNode, callback: () => void | Promise<void>) => {
+  return (
+    message: React.ReactNode,
+    callback: () => void | Promise<unknown>
+  ) => {
     context?.setContent(message);
     context?.setConfirm(() => async () => {
       await callback();

+ 0 - 21
kafka-ui-react-app/src/redux/interfaces/topic.ts

@@ -1,14 +1,9 @@
 import {
   Topic,
-  TopicDetails,
   TopicConfig,
   TopicCreation,
-  ConsumerGroup,
-  TopicColumnsToSort,
   TopicMessage,
   TopicMessageConsuming,
-  TopicMessageSchema,
-  SortOrder,
 } from 'generated-sources';
 
 export type TopicName = Topic['name'];
@@ -26,22 +21,6 @@ interface TopicFormCustomParams {
   allIndexes: TopicName[];
 }
 
-export interface TopicWithDetailedInfo extends Topic, TopicDetails {
-  config?: TopicConfig[];
-  consumerGroups?: ConsumerGroup[];
-  messageSchema?: TopicMessageSchema;
-}
-
-export interface TopicsState {
-  byName: { [topicName: string]: TopicWithDetailedInfo };
-  allNames: TopicName[];
-  totalPages: number;
-  search: string;
-  orderBy: TopicColumnsToSort | null;
-  sortOrder: SortOrder;
-  consumerGroups: ConsumerGroup[];
-}
-
 export type TopicFormFormattedParams = TopicCreation['configs'];
 
 export interface TopicFormDataRaw {

+ 0 - 2
kafka-ui-react-app/src/redux/reducers/index.ts

@@ -2,13 +2,11 @@ import { combineReducers } from '@reduxjs/toolkit';
 import loader from 'redux/reducers/loader/loaderSlice';
 import schemas from 'redux/reducers/schemas/schemasSlice';
 import topicMessages from 'redux/reducers/topicMessages/topicMessagesSlice';
-import topics from 'redux/reducers/topics/topicsSlice';
 import consumerGroups from 'redux/reducers/consumerGroups/consumerGroupsSlice';
 import ksqlDb from 'redux/reducers/ksqlDb/ksqlDbSlice';
 
 export default combineReducers({
   loader,
-  topics,
   topicMessages,
   consumerGroups,
   schemas,

+ 1 - 7
kafka-ui-react-app/src/redux/reducers/topicMessages/topicMessagesSlice.ts

@@ -6,7 +6,6 @@ import {
   showServerError,
   showSuccessAlert,
 } from 'lib/errorHandling';
-import { fetchTopicDetails } from 'redux/reducers/topics/topicsSlice';
 import { messagesApiClient } from 'lib/api';
 
 export const clearTopicMessages = createAsyncThunk<
@@ -14,22 +13,17 @@ export const clearTopicMessages = createAsyncThunk<
   { clusterName: ClusterName; topicName: TopicName; partitions?: number[] }
 >(
   'topicMessages/clearTopicMessages',
-  async (
-    { clusterName, topicName, partitions },
-    { rejectWithValue, dispatch }
-  ) => {
+  async ({ clusterName, topicName, partitions }, { rejectWithValue }) => {
     try {
       await messagesApiClient.deleteTopicMessages({
         clusterName,
         topicName,
         partitions,
       });
-      dispatch(fetchTopicDetails({ clusterName, topicName }));
       showSuccessAlert({
         id: `message-${topicName}-${clusterName}-${partitions}`,
         message: `${topicName} messages have been successfully cleared!`,
       });
-
       return undefined;
     } catch (err) {
       showServerError(err as Response);

+ 0 - 73
kafka-ui-react-app/src/redux/reducers/topics/__test__/fixtures.ts

@@ -1,73 +0,0 @@
-import { SortOrder, Topic, ConsumerGroup } from 'generated-sources';
-import { TopicsState, TopicWithDetailedInfo } from 'redux/interfaces';
-
-export const internalTopicPayload = {
-  name: '__internal.topic',
-  internal: true,
-  partitionCount: 1,
-  replicationFactor: 1,
-  replicas: 1,
-  inSyncReplicas: 1,
-  segmentSize: 0,
-  segmentCount: 1,
-  underReplicatedPartitions: 0,
-  partitions: [
-    {
-      partition: 0,
-      leader: 1,
-      replicas: [{ broker: 1, leader: false, inSync: true }],
-      offsetMax: 0,
-      offsetMin: 0,
-    },
-  ],
-};
-
-export const externalTopicPayload = {
-  name: 'external.topic',
-  internal: false,
-  partitionCount: 1,
-  replicationFactor: 1,
-  replicas: 1,
-  inSyncReplicas: 1,
-  segmentSize: 1263,
-  segmentCount: 1,
-  underReplicatedPartitions: 0,
-  partitions: [
-    {
-      partition: 0,
-      leader: 1,
-      replicas: [{ broker: 1, leader: false, inSync: true }],
-      offsetMax: 0,
-      offsetMin: 0,
-    },
-  ],
-};
-
-export const topicsPayload: Topic[] = [
-  internalTopicPayload,
-  externalTopicPayload,
-];
-
-export const getTopicStateFixtures = (
-  topics: TopicWithDetailedInfo[],
-  consumerGroups?: ConsumerGroup[]
-): TopicsState => {
-  const byName = topics.reduce((acc: { [i in string]: Topic }, curr) => {
-    const obj = { ...acc };
-    obj[curr.name] = curr;
-    return obj;
-  }, {} as { [i in string]: Topic });
-
-  const allNames = Object.keys(byName);
-
-  return {
-    byName,
-    allNames,
-    totalPages: 1,
-    search: '',
-    orderBy: null,
-    sortOrder: SortOrder.ASC,
-    consumerGroups:
-      consumerGroups && consumerGroups.length ? consumerGroups : [],
-  };
-};

+ 0 - 29
kafka-ui-react-app/src/redux/reducers/topics/__test__/selectors.spec.ts

@@ -1,29 +0,0 @@
-import { store } from 'redux/store';
-import * as selectors from 'redux/reducers/topics/selectors';
-
-describe('Topics selectors', () => {
-  describe('Initial State', () => {
-    it('returns initial values', () => {
-      expect(selectors.getTopicListTotalPages(store.getState())).toEqual(1);
-      expect(selectors.getIsTopicDeleted(store.getState())).toBeFalsy();
-      expect(selectors.getAreTopicsFetching(store.getState())).toEqual(false);
-      expect(selectors.getAreTopicsFetched(store.getState())).toEqual(false);
-      expect(selectors.getIsTopicDetailsFetching(store.getState())).toEqual(
-        false
-      );
-      expect(selectors.getIsTopicDetailsFetched(store.getState())).toEqual(
-        false
-      );
-      expect(selectors.getTopicConfigFetched(store.getState())).toEqual(false);
-      expect(selectors.getTopicCreated(store.getState())).toEqual(false);
-      expect(selectors.getTopicUpdated(store.getState())).toEqual(false);
-      expect(selectors.getTopicMessageSchemaFetched(store.getState())).toEqual(
-        false
-      );
-      expect(
-        selectors.getTopicsConsumerGroupsFetched(store.getState())
-      ).toEqual(false);
-      expect(selectors.getTopicList(store.getState())).toEqual([]);
-    });
-  });
-});

+ 0 - 170
kafka-ui-react-app/src/redux/reducers/topics/selectors.ts

@@ -1,170 +0,0 @@
-import { createSelector } from '@reduxjs/toolkit';
-import { RootState, TopicName, TopicsState } from 'redux/interfaces';
-import { CleanUpPolicy } from 'generated-sources';
-import { createFetchingSelector } from 'redux/reducers/loader/selectors';
-import {
-  fetchTopicsList,
-  fetchTopicDetails,
-  fetchTopicConfig,
-  updateTopic,
-  fetchTopicMessageSchema,
-  fetchTopicConsumerGroups,
-  createTopic,
-  deleteTopic,
-} from 'redux/reducers/topics/topicsSlice';
-import { AsyncRequestStatus } from 'lib/constants';
-
-const topicsState = ({ topics }: RootState): TopicsState => topics;
-
-const getAllNames = (state: RootState) => topicsState(state).allNames;
-const getTopicMap = (state: RootState) => topicsState(state).byName;
-
-export const getTopicListTotalPages = (state: RootState) =>
-  topicsState(state).totalPages;
-
-const getTopicDeletingStatus = createFetchingSelector(deleteTopic.typePrefix);
-
-export const getIsTopicDeleted = createSelector(
-  getTopicDeletingStatus,
-  (status) => status === AsyncRequestStatus.fulfilled
-);
-
-const getAreTopicsFetchingStatus = createFetchingSelector(
-  fetchTopicsList.typePrefix
-);
-
-export const getAreTopicsFetching = createSelector(
-  getAreTopicsFetchingStatus,
-  (status) => status === AsyncRequestStatus.pending
-);
-export const getAreTopicsFetched = createSelector(
-  getAreTopicsFetchingStatus,
-  (status) => status === AsyncRequestStatus.fulfilled
-);
-
-const getTopicDetailsFetchingStatus = createFetchingSelector(
-  fetchTopicDetails.typePrefix
-);
-
-export const getIsTopicDetailsFetching = createSelector(
-  getTopicDetailsFetchingStatus,
-  (status) => status === AsyncRequestStatus.pending
-);
-
-export const getIsTopicDetailsFetched = createSelector(
-  getTopicDetailsFetchingStatus,
-  (status) => status === AsyncRequestStatus.fulfilled
-);
-
-const getTopicConfigFetchingStatus = createFetchingSelector(
-  fetchTopicConfig.typePrefix
-);
-
-export const getTopicConfigFetched = createSelector(
-  getTopicConfigFetchingStatus,
-  (status) => status === AsyncRequestStatus.fulfilled
-);
-
-const getTopicCreationStatus = createFetchingSelector(createTopic.typePrefix);
-
-export const getTopicCreated = createSelector(
-  getTopicCreationStatus,
-  (status) => status === AsyncRequestStatus.fulfilled
-);
-
-const getTopicUpdateStatus = createFetchingSelector(updateTopic.typePrefix);
-
-export const getTopicUpdated = createSelector(
-  getTopicUpdateStatus,
-  (status) => status === AsyncRequestStatus.fulfilled
-);
-
-const getTopicMessageSchemaFetchingStatus = createFetchingSelector(
-  fetchTopicMessageSchema.typePrefix
-);
-
-export const getTopicMessageSchemaFetched = createSelector(
-  getTopicMessageSchemaFetchingStatus,
-  (status) => status === AsyncRequestStatus.fulfilled
-);
-
-const getTopicConsumerGroupsStatus = createFetchingSelector(
-  fetchTopicConsumerGroups.typePrefix
-);
-
-export const getTopicsConsumerGroupsFetched = createSelector(
-  getTopicConsumerGroupsStatus,
-  (status) => status === AsyncRequestStatus.fulfilled
-);
-
-export const getTopicList = createSelector(
-  getAreTopicsFetched,
-  getAllNames,
-  getTopicMap,
-  (isFetched, allNames, byName) => {
-    if (!isFetched) {
-      return [];
-    }
-    return allNames.map((name) => byName[name]);
-  }
-);
-
-const getTopicName = (_: RootState, topicName: TopicName) => topicName;
-
-export const getTopicByName = createSelector(
-  getTopicMap,
-  getTopicName,
-  (topics, topicName) => topics[topicName] || {}
-);
-
-export const getPartitionsByTopicName = createSelector(
-  getTopicMap,
-  getTopicName,
-  (topics, topicName) => topics[topicName]?.partitions || []
-);
-
-export const getFullTopic = createSelector(getTopicByName, (topic) =>
-  topic && topic.config && !!topic.partitionCount ? topic : undefined
-);
-
-export const getTopicConfig = createSelector(
-  getTopicByName,
-  ({ config }) => config
-);
-
-export const getIsTopicDeletePolicy = createSelector(
-  getTopicByName,
-  (topic) => {
-    return topic?.cleanUpPolicy === CleanUpPolicy.DELETE;
-  }
-);
-
-export const getTopicsSearch = createSelector(
-  topicsState,
-  (state) => state.search
-);
-
-export const getTopicsOrderBy = createSelector(
-  topicsState,
-  (state) => state.orderBy
-);
-
-export const getTopicsSortOrder = createSelector(
-  topicsState,
-  (state) => state.sortOrder
-);
-
-export const getIsTopicInternal = createSelector(
-  getTopicByName,
-  (topic) => !!topic?.internal
-);
-
-export const getTopicConsumerGroups = createSelector(
-  getTopicByName,
-  ({ consumerGroups }) => consumerGroups || []
-);
-
-export const getMessageSchemaByTopicName = createSelector(
-  getTopicByName,
-  (topic) => topic?.messageSchema
-);

+ 0 - 454
kafka-ui-react-app/src/redux/reducers/topics/topicsSlice.ts

@@ -1,454 +0,0 @@
-import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
-import {
-  TopicsResponse,
-  TopicDetails,
-  GetTopicsRequest,
-  GetTopicDetailsRequest,
-  GetTopicConfigsRequest,
-  TopicConfig,
-  TopicCreation,
-  ConsumerGroup,
-  Topic,
-  TopicUpdate,
-  DeleteTopicRequest,
-  RecreateTopicRequest,
-  SortOrder,
-  TopicColumnsToSort,
-  GetTopicSchemaRequest,
-  TopicMessageSchema,
-} from 'generated-sources';
-import {
-  TopicsState,
-  TopicName,
-  TopicFormData,
-  TopicFormFormattedParams,
-  TopicFormDataRaw,
-  ClusterName,
-} from 'redux/interfaces';
-import {
-  getResponse,
-  showServerError,
-  showSuccessAlert,
-} from 'lib/errorHandling';
-import { clearTopicMessages } from 'redux/reducers/topicMessages/topicMessagesSlice';
-import {
-  consumerGroupsApiClient,
-  messagesApiClient,
-  topicsApiClient,
-} from 'lib/api';
-
-export const fetchTopicsList = createAsyncThunk<
-  TopicsResponse,
-  GetTopicsRequest
->('topic/fetchTopicsList', async (payload, { rejectWithValue }) => {
-  try {
-    return await topicsApiClient.getTopics(payload);
-  } catch (err) {
-    showServerError(err as Response);
-    return rejectWithValue(await getResponse(err as Response));
-  }
-});
-
-export const fetchTopicDetails = createAsyncThunk<
-  { topicDetails: TopicDetails; topicName: TopicName },
-  GetTopicDetailsRequest
->('topic/fetchTopicDetails', async (payload, { rejectWithValue }) => {
-  try {
-    const { topicName } = payload;
-    const topicDetails = await topicsApiClient.getTopicDetails(payload);
-
-    return { topicDetails, topicName };
-  } catch (err) {
-    showServerError(err as Response);
-    return rejectWithValue(await getResponse(err as Response));
-  }
-});
-
-export const fetchTopicConfig = createAsyncThunk<
-  { topicConfig: TopicConfig[]; topicName: TopicName },
-  GetTopicConfigsRequest
->('topic/fetchTopicConfig', async (payload, { rejectWithValue }) => {
-  try {
-    const { topicName } = payload;
-    const topicConfig = await topicsApiClient.getTopicConfigs(payload);
-
-    return { topicConfig, topicName };
-  } catch (err) {
-    showServerError(err as Response);
-    return rejectWithValue(await getResponse(err as Response));
-  }
-});
-
-const topicReducer = (
-  result: TopicFormFormattedParams,
-  customParam: TopicConfig
-) => {
-  return {
-    ...result,
-    [customParam.name]: customParam.value,
-  };
-};
-
-const formatTopicCreation = (form: TopicFormData): TopicCreation => {
-  const {
-    name,
-    partitions,
-    replicationFactor,
-    cleanupPolicy,
-    retentionBytes,
-    retentionMs,
-    maxMessageBytes,
-    minInSyncReplicas,
-    customParams,
-  } = form;
-
-  return {
-    name,
-    partitions,
-    replicationFactor,
-    configs: {
-      'cleanup.policy': cleanupPolicy,
-      'retention.ms': retentionMs.toString(),
-      'retention.bytes': retentionBytes.toString(),
-      'max.message.bytes': maxMessageBytes.toString(),
-      'min.insync.replicas': minInSyncReplicas.toString(),
-      ...Object.values(customParams || {}).reduce(topicReducer, {}),
-    },
-  };
-};
-
-export const createTopic = createAsyncThunk<
-  undefined,
-  {
-    clusterName: ClusterName;
-    data: TopicFormData;
-  }
->('topic/createTopic', async (payload, { rejectWithValue }) => {
-  try {
-    const { data, clusterName } = payload;
-    await topicsApiClient.createTopic({
-      clusterName,
-      topicCreation: formatTopicCreation(data),
-    });
-    showSuccessAlert({
-      message: `Topic ${data.name} created successfully`,
-    });
-    return undefined;
-  } catch (err) {
-    showServerError(err as Response);
-    return rejectWithValue(await getResponse(err as Response));
-  }
-});
-
-export const fetchTopicConsumerGroups = createAsyncThunk<
-  { consumerGroups: ConsumerGroup[]; topicName: TopicName },
-  GetTopicConfigsRequest
->('topic/fetchTopicConsumerGroups', async (payload, { rejectWithValue }) => {
-  try {
-    const { topicName } = payload;
-    const consumerGroups = await consumerGroupsApiClient.getTopicConsumerGroups(
-      payload
-    );
-
-    return { consumerGroups, topicName };
-  } catch (err) {
-    showServerError(err as Response);
-    return rejectWithValue(await getResponse(err as Response));
-  }
-});
-
-const formatTopicUpdate = (form: TopicFormDataRaw): TopicUpdate => {
-  const {
-    cleanupPolicy,
-    retentionBytes,
-    retentionMs,
-    maxMessageBytes,
-    minInSyncReplicas,
-    customParams,
-  } = form;
-
-  return {
-    configs: {
-      ...Object.values(customParams || {}).reduce(topicReducer, {}),
-      'cleanup.policy': cleanupPolicy,
-      'retention.ms': retentionMs,
-      'retention.bytes': retentionBytes,
-      'max.message.bytes': maxMessageBytes,
-      'min.insync.replicas': minInSyncReplicas,
-    },
-  };
-};
-
-export const updateTopic = createAsyncThunk<
-  { topic: Topic },
-  {
-    clusterName: ClusterName;
-    topicName: TopicName;
-    form: TopicFormDataRaw;
-  }
->(
-  'topic/updateTopic',
-  async ({ clusterName, topicName, form }, { rejectWithValue }) => {
-    try {
-      const topic = await topicsApiClient.updateTopic({
-        clusterName,
-        topicName,
-        topicUpdate: formatTopicUpdate(form),
-      });
-
-      return { topic };
-    } catch (err) {
-      showServerError(err as Response);
-      return rejectWithValue(await getResponse(err as Response));
-    }
-  }
-);
-
-export const deleteTopic = createAsyncThunk<
-  { topicName: TopicName },
-  DeleteTopicRequest
->('topic/deleteTopic', async (payload, { rejectWithValue }) => {
-  try {
-    const { topicName, clusterName } = payload;
-    await topicsApiClient.deleteTopic(payload);
-    showSuccessAlert({
-      id: `message-${topicName}-${clusterName}`,
-      message: 'Topic successfully deleted!',
-    });
-    return { topicName };
-  } catch (err) {
-    showServerError(err as Response);
-    return rejectWithValue(await getResponse(err as Response));
-  }
-});
-
-export const recreateTopic = createAsyncThunk<
-  { topic: Topic },
-  RecreateTopicRequest
->('topic/recreateTopic', async (payload, { rejectWithValue }) => {
-  try {
-    const { topicName, clusterName } = payload;
-    const topic = await topicsApiClient.recreateTopic(payload);
-    showSuccessAlert({
-      id: `message-${topicName}-${clusterName}`,
-      message: 'Topic successfully recreated!',
-    });
-    return { topic };
-  } catch (err) {
-    showServerError(err as Response);
-    return rejectWithValue(await getResponse(err as Response));
-  }
-});
-
-export const fetchTopicMessageSchema = createAsyncThunk<
-  { schema: TopicMessageSchema; topicName: TopicName },
-  GetTopicSchemaRequest
->('topic/fetchTopicMessageSchema', async (payload, { rejectWithValue }) => {
-  try {
-    const { topicName } = payload;
-    const schema = await messagesApiClient.getTopicSchema(payload);
-    return { schema, topicName };
-  } catch (err) {
-    showServerError(err as Response);
-    return rejectWithValue(await getResponse(err as Response));
-  }
-});
-
-export const updateTopicPartitionsCount = createAsyncThunk<
-  undefined,
-  {
-    clusterName: ClusterName;
-    topicName: TopicName;
-    partitions: number;
-  }
->(
-  'topic/updateTopicPartitionsCount',
-  async (payload, { rejectWithValue, dispatch }) => {
-    try {
-      const { clusterName, topicName, partitions } = payload;
-
-      await topicsApiClient.increaseTopicPartitions({
-        clusterName,
-        topicName,
-        partitionsIncrease: { totalPartitionsCount: partitions },
-      });
-      showSuccessAlert({
-        id: `message-${topicName}-${clusterName}-${partitions}`,
-        message: 'Number of partitions successfully increased',
-      });
-      dispatch(fetchTopicDetails({ clusterName, topicName }));
-      return undefined;
-    } catch (err) {
-      showServerError(err as Response);
-      return rejectWithValue(await getResponse(err as Response));
-    }
-  }
-);
-
-export const updateTopicReplicationFactor = createAsyncThunk<
-  undefined,
-  {
-    clusterName: ClusterName;
-    topicName: TopicName;
-    replicationFactor: number;
-  }
->(
-  'topic/updateTopicReplicationFactor',
-  async (payload, { rejectWithValue, dispatch }) => {
-    try {
-      const { clusterName, topicName, replicationFactor } = payload;
-
-      await topicsApiClient.changeReplicationFactor({
-        clusterName,
-        topicName,
-        replicationFactorChange: { totalReplicationFactor: replicationFactor },
-      });
-      showSuccessAlert({
-        id: `message-${topicName}-${clusterName}-replicationFactor`,
-        message: 'Replication Factor successfully updated',
-      });
-      dispatch(fetchTopicDetails({ clusterName, topicName }));
-      return undefined;
-    } catch (err) {
-      showServerError(err as Response);
-      return rejectWithValue(await getResponse(err as Response));
-    }
-  }
-);
-
-export const deleteTopics = createAsyncThunk<
-  undefined,
-  {
-    clusterName: ClusterName;
-    topicNames: TopicName[];
-  }
->('topic/deleteTopics', async (payload, { rejectWithValue, dispatch }) => {
-  try {
-    const { clusterName, topicNames } = payload;
-
-    topicNames.forEach((topicName) => {
-      dispatch(deleteTopic({ clusterName, topicName }));
-    });
-    dispatch(fetchTopicsList({ clusterName }));
-
-    return undefined;
-  } catch (err) {
-    showServerError(err as Response);
-    return rejectWithValue(await getResponse(err as Response));
-  }
-});
-
-export const clearTopicsMessages = createAsyncThunk<
-  undefined,
-  {
-    clusterName: ClusterName;
-    topicNames: TopicName[];
-  }
->(
-  'topic/clearTopicsMessages',
-  async (payload, { rejectWithValue, dispatch }) => {
-    try {
-      const { clusterName, topicNames } = payload;
-      topicNames.forEach((topicName) => {
-        dispatch(clearTopicMessages({ clusterName, topicName }));
-      });
-
-      return undefined;
-    } catch (err) {
-      showServerError(err as Response);
-      return rejectWithValue(await getResponse(err as Response));
-    }
-  }
-);
-
-const initialState: TopicsState = {
-  byName: {},
-  allNames: [],
-  totalPages: 1,
-  search: '',
-  orderBy: TopicColumnsToSort.NAME,
-  sortOrder: SortOrder.ASC,
-  consumerGroups: [],
-};
-
-const topicsSlice = createSlice({
-  name: 'topics',
-  initialState,
-  reducers: {
-    setTopicsSearch: (state, action) => {
-      state.search = action.payload;
-    },
-    setTopicsOrderBy: (state, action) => {
-      state.sortOrder =
-        state.orderBy === action.payload && state.sortOrder === SortOrder.ASC
-          ? SortOrder.DESC
-          : SortOrder.ASC;
-      state.orderBy = action.payload;
-    },
-  },
-  extraReducers: (builder) => {
-    builder.addCase(fetchTopicsList.fulfilled, (state, { payload }) => {
-      if (payload.topics) {
-        state.allNames = [];
-        state.totalPages = payload.pageCount || 1;
-
-        payload.topics.forEach((topic) => {
-          state.allNames.push(topic.name);
-          state.byName[topic.name] = {
-            ...state.byName[topic.name],
-            ...topic,
-          };
-        });
-      }
-    });
-    builder.addCase(fetchTopicDetails.fulfilled, (state, { payload }) => {
-      state.byName[payload.topicName] = {
-        ...state.byName[payload.topicName],
-        ...payload.topicDetails,
-      };
-    });
-    builder.addCase(fetchTopicConfig.fulfilled, (state, { payload }) => {
-      state.byName[payload.topicName] = {
-        ...state.byName[payload.topicName],
-        config: payload.topicConfig,
-      };
-    });
-    builder.addCase(
-      fetchTopicConsumerGroups.fulfilled,
-      (state, { payload }) => {
-        state.byName[payload.topicName] = {
-          ...state.byName[payload.topicName],
-          ...payload.consumerGroups,
-        };
-      }
-    );
-    builder.addCase(updateTopic.fulfilled, (state, { payload }) => {
-      state.byName[payload.topic.name] = {
-        ...state.byName[payload.topic.name],
-        ...payload.topic,
-      };
-    });
-    builder.addCase(deleteTopic.fulfilled, (state, { payload }) => {
-      delete state.byName[payload.topicName];
-      state.allNames = state.allNames.filter(
-        (name) => name !== payload.topicName
-      );
-    });
-    builder.addCase(recreateTopic.fulfilled, (state, { payload }) => {
-      state.byName = {
-        ...state.byName,
-        [payload.topic.name]: { ...payload.topic },
-      };
-    });
-    builder.addCase(fetchTopicMessageSchema.fulfilled, (state, { payload }) => {
-      state.byName[payload.topicName] = {
-        ...state.byName[payload.topicName],
-        messageSchema: payload.schema,
-      };
-    });
-  },
-});
-
-export const { setTopicsSearch, setTopicsOrderBy } = topicsSlice.actions;
-
-export default topicsSlice.reducer;