Procházet zdrojové kódy

#188 Pagination. Refactor topic reducer (#255)

Oleg Shur před 4 roky
rodič
revize
49a7564366
21 změnil soubory, kde provedl 707 přidání a 107 odebrání
  1. 9 10
      kafka-ui-react-app/package-lock.json
  2. 2 2
      kafka-ui-react-app/package.json
  3. 3 3
      kafka-ui-react-app/src/components/Schemas/List/List.tsx
  4. 10 24
      kafka-ui-react-app/src/components/Schemas/New/__test__/__snapshots__/New.spec.tsx.snap
  5. 7 3
      kafka-ui-react-app/src/components/Topics/List/List.tsx
  6. 2 0
      kafka-ui-react-app/src/components/Topics/List/ListContainer.ts
  7. 5 3
      kafka-ui-react-app/src/components/Topics/List/__tests__/List.spec.tsx
  8. 4 4
      kafka-ui-react-app/src/components/common/Breadcrumb/Breadcrumb.tsx
  9. 3 3
      kafka-ui-react-app/src/components/common/Breadcrumb/__tests__/Breadcrumb.spec.tsx
  10. 25 0
      kafka-ui-react-app/src/components/common/Pagination/PageControl.tsx
  11. 130 0
      kafka-ui-react-app/src/components/common/Pagination/Pagination.tsx
  12. 34 0
      kafka-ui-react-app/src/components/common/Pagination/__tests__/PageControl.spec.tsx
  13. 96 0
      kafka-ui-react-app/src/components/common/Pagination/__tests__/Pagination.spec.tsx
  14. 13 0
      kafka-ui-react-app/src/components/common/Pagination/__tests__/__snapshots__/PageControl.spec.tsx.snap
  15. 288 0
      kafka-ui-react-app/src/components/common/Pagination/__tests__/__snapshots__/Pagination.spec.tsx.snap
  16. 2 0
      kafka-ui-react-app/src/lib/constants.ts
  17. 3 1
      kafka-ui-react-app/src/lib/hooks/usePagination.ts
  18. 4 4
      kafka-ui-react-app/src/redux/actions/actions.ts
  19. 60 14
      kafka-ui-react-app/src/redux/actions/thunks/topics.ts
  20. 5 36
      kafka-ui-react-app/src/redux/reducers/topics/reducer.ts
  21. 2 0
      kafka-ui-react-app/src/redux/reducers/topics/selectors.ts

+ 9 - 10
kafka-ui-react-app/package-lock.json

@@ -19335,12 +19335,11 @@
       "dev": true
     },
     "ts-jest": {
-      "version": "26.5.1",
-      "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-26.5.1.tgz",
-      "integrity": "sha512-G7Rmo3OJMvlqE79amJX8VJKDiRcd7/r61wh9fnvvG8cAjhA9edklGw/dCxRSQmfZ/z8NDums5srSVgwZos1qfg==",
+      "version": "26.5.3",
+      "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-26.5.3.tgz",
+      "integrity": "sha512-nBiiFGNvtujdLryU7MiMQh1iPmnZ/QvOskBbD2kURiI1MwqvxlxNnaAB/z9TbslMqCsSbu5BXvSSQPc5tvHGeA==",
       "dev": true,
       "requires": {
-        "@types/jest": "26.x",
         "bs-logger": "0.x",
         "buffer-from": "1.x",
         "fast-json-stable-stringify": "2.x",
@@ -19494,9 +19493,9 @@
       "integrity": "sha512-bna6Yi1pRznoo6Bz1cE6btB/Yy8Xywytyfrzu/wc+NFW3ZF0I+2iCGImhBsoYYCOWuICtRO4yHcnDlzgo1AdNg=="
     },
     "typescript": {
-      "version": "4.1.5",
-      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.5.tgz",
-      "integrity": "sha512-6OSu9PTIzmn9TCDiovULTnET6BgXtDYL4Gg4szY+cGsc3JP1dQL8qvE8kShTRx1NIw4Q9IBHlwODjkjWEtMUyA==",
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.3.tgz",
+      "integrity": "sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw==",
       "dev": true
     },
     "unicode-canonical-property-names-ecmascript": {
@@ -21622,9 +21621,9 @@
       }
     },
     "yargs-parser": {
-      "version": "20.2.5",
-      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.5.tgz",
-      "integrity": "sha512-jYRGS3zWy20NtDtK2kBgo/TlAoy5YUuhD9/LZ7z7W4j1Fdw2cqD0xEEclf8fxc8xjD6X5Qr+qQQwCEsP8iRiYg==",
+      "version": "20.2.7",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.7.tgz",
+      "integrity": "sha512-FiNkvbeHzB/syOjIUxFDCnhSfzAL8R5vs40MgLFBorXACCOAEaWu0gRZl14vG8MR9AOJIZbmkjhusqBYZ3HTHw==",
       "dev": true
     },
     "yn": {

+ 2 - 2
kafka-ui-react-app/package.json

@@ -112,9 +112,9 @@
     "prettier": "^2.2.1",
     "react-scripts": "4.0.2",
     "redux-mock-store": "^1.5.4",
-    "ts-jest": "^26.5.1",
+    "ts-jest": "^26.5.3",
     "ts-node": "^9.1.1",
-    "typescript": "^4.1.5"
+    "typescript": "^4.2.3"
   },
   "engines": {
     "node": ">=14.15.4"

+ 3 - 3
kafka-ui-react-app/src/components/Schemas/List/List.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import { SchemaSubject } from 'generated-sources';
-import { NavLink, useParams } from 'react-router-dom';
+import { Link, useParams } from 'react-router-dom';
 import { clusterSchemaNewPath } from 'lib/paths';
 import { ClusterName } from 'redux/interfaces';
 import PageLoader from 'components/common/PageLoader/PageLoader';
@@ -33,12 +33,12 @@ const List: React.FC<ListProps> = ({
         <div className="level">
           {!isReadOnly && (
             <div className="level-item level-right">
-              <NavLink
+              <Link
                 className="button is-primary"
                 to={clusterSchemaNewPath(clusterName)}
               >
                 Create Schema
-              </NavLink>
+              </Link>
             </div>
           )}
         </div>

+ 10 - 24
kafka-ui-react-app/src/components/Schemas/New/__test__/__snapshots__/New.spec.tsx.snap

@@ -60,35 +60,21 @@ exports[`New View matches snapshot 1`] = `
                   <li
                     key="/ui/clusters/undefined/schemas"
                   >
-                    <NavLink
+                    <Link
                       to="/ui/clusters/undefined/schemas"
                     >
-                      <Link
-                        aria-current={null}
-                        to={
-                          Object {
-                            "hash": "",
-                            "pathname": "/ui/clusters/undefined/schemas",
-                            "search": "",
-                            "state": null,
-                          }
-                        }
+                      <LinkAnchor
+                        href="/ui/clusters/undefined/schemas"
+                        navigate={[Function]}
                       >
-                        <LinkAnchor
-                          aria-current={null}
+                        <a
                           href="/ui/clusters/undefined/schemas"
-                          navigate={[Function]}
+                          onClick={[Function]}
                         >
-                          <a
-                            aria-current={null}
-                            href="/ui/clusters/undefined/schemas"
-                            onClick={[Function]}
-                          >
-                            Schema Registry
-                          </a>
-                        </LinkAnchor>
-                      </Link>
-                    </NavLink>
+                          Schema Registry
+                        </a>
+                      </LinkAnchor>
+                    </Link>
                   </li>
                   <li
                     className="is-active"

+ 7 - 3
kafka-ui-react-app/src/components/Topics/List/List.tsx

@@ -1,18 +1,20 @@
 import React from 'react';
 import { TopicWithDetailedInfo, ClusterName } from 'redux/interfaces';
 import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
-import { NavLink, useParams } from 'react-router-dom';
+import { Link, useParams } from 'react-router-dom';
 import { clusterTopicNewPath } from 'lib/paths';
 import usePagination from 'lib/hooks/usePagination';
 import { FetchTopicsListParams } from 'redux/actions';
 import ClusterContext from 'components/contexts/ClusterContext';
 import PageLoader from 'components/common/PageLoader/PageLoader';
+import Pagination from 'components/common/Pagination/Pagination';
 import ListItem from './ListItem';
 
 interface Props {
   areTopicsFetching: boolean;
   topics: TopicWithDetailedInfo[];
   externalTopics: TopicWithDetailedInfo[];
+  totalPages: number;
   fetchTopicsList(props: FetchTopicsListParams): void;
 }
 
@@ -20,6 +22,7 @@ const List: React.FC<Props> = ({
   areTopicsFetching,
   topics,
   externalTopics,
+  totalPages,
   fetchTopicsList,
 }) => {
   const { isReadOnly } = React.useContext(ClusterContext);
@@ -58,12 +61,12 @@ const List: React.FC<Props> = ({
           </div>
           <div className="level-item level-right">
             {!isReadOnly && (
-              <NavLink
+              <Link
                 className="button is-primary"
                 to={clusterTopicNewPath(clusterName)}
               >
                 Add a Topic
-              </NavLink>
+              </Link>
             )}
           </div>
         </div>
@@ -93,6 +96,7 @@ const List: React.FC<Props> = ({
               )}
             </tbody>
           </table>
+          <Pagination totalPages={totalPages} />
         </div>
       )}
     </div>

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

@@ -5,6 +5,7 @@ import {
   getTopicList,
   getExternalTopicList,
   getAreTopicsFetching,
+  getTopicListTotalPages,
 } from 'redux/reducers/topics/selectors';
 import List from './List';
 
@@ -12,6 +13,7 @@ const mapStateToProps = (state: RootState) => ({
   areTopicsFetching: getAreTopicsFetching(state),
   topics: getTopicList(state),
   externalTopics: getExternalTopicList(state),
+  totalPages: getTopicListTotalPages(state),
 });
 
 const mapDispatchToProps = {

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

@@ -1,5 +1,5 @@
-import { mount } from 'enzyme';
 import React from 'react';
+import { mount } from 'enzyme';
 import { StaticRouter } from 'react-router-dom';
 import ClusterContext from 'components/contexts/ClusterContext';
 import List from '../List';
@@ -14,12 +14,13 @@ describe('List', () => {
               areTopicsFetching={false}
               topics={[]}
               externalTopics={[]}
+              totalPages={1}
               fetchTopicsList={jest.fn()}
             />
           </ClusterContext.Provider>
         </StaticRouter>
       );
-      expect(component.exists('NavLink')).toBeFalsy();
+      expect(component.exists('Link')).toBeFalsy();
     });
   });
 
@@ -32,12 +33,13 @@ describe('List', () => {
               areTopicsFetching={false}
               topics={[]}
               externalTopics={[]}
+              totalPages={1}
               fetchTopicsList={jest.fn()}
             />
           </ClusterContext.Provider>
         </StaticRouter>
       );
-      expect(component.exists('NavLink')).toBeTruthy();
+      expect(component.exists('Link')).toBeTruthy();
     });
   });
 });

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

@@ -1,13 +1,13 @@
 import React from 'react';
-import { NavLink } from 'react-router-dom';
+import { Link } from 'react-router-dom';
 
-export interface Link {
+export interface BreadcrumbItem {
   label: string;
   href: string;
 }
 
 interface Props {
-  links?: Link[];
+  links?: BreadcrumbItem[];
 }
 
 const Breadcrumb: React.FC<Props> = ({ links, children }) => {
@@ -17,7 +17,7 @@ const Breadcrumb: React.FC<Props> = ({ links, children }) => {
         {links &&
           links.map(({ label, href }) => (
             <li key={href}>
-              <NavLink to={href}>{label}</NavLink>
+              <Link to={href}>{label}</Link>
             </li>
           ))}
 

+ 3 - 3
kafka-ui-react-app/src/components/common/Breadcrumb/__tests__/Breadcrumb.spec.tsx

@@ -1,10 +1,10 @@
 import { mount, shallow } from 'enzyme';
 import React from 'react';
 import { StaticRouter } from 'react-router-dom';
-import Breadcrumb, { Link } from '../Breadcrumb';
+import Breadcrumb, { BreadcrumbItem } from '../Breadcrumb';
 
 describe('Breadcrumb component', () => {
-  const links: Link[] = [
+  const links: BreadcrumbItem[] = [
     {
       label: 'link1',
       href: 'link1href',
@@ -28,7 +28,7 @@ describe('Breadcrumb component', () => {
   );
 
   it('renders the list of links', () => {
-    component.find(`NavLink`).forEach((link, idx) => {
+    component.find(`Link`).forEach((link, idx) => {
       expect(link.prop('to')).toEqual(links[idx].href);
       expect(link.contains(links[idx].label)).toBeTruthy();
     });

+ 25 - 0
kafka-ui-react-app/src/components/common/Pagination/PageControl.tsx

@@ -0,0 +1,25 @@
+import cx from 'classnames';
+import React from 'react';
+import { Link } from 'react-router-dom';
+
+export interface PageControlProps {
+  current: boolean;
+  url: string;
+  page: number;
+}
+
+const PageControl: React.FC<PageControlProps> = ({ current, url, page }) => {
+  const classNames = cx('pagination-link', {
+    'is-current': current,
+  });
+
+  return (
+    <li>
+      <Link className={classNames} to={url} aria-label={`Goto page ${page}`}>
+        {page}
+      </Link>
+    </li>
+  );
+};
+
+export default PageControl;

+ 130 - 0
kafka-ui-react-app/src/components/common/Pagination/Pagination.tsx

@@ -0,0 +1,130 @@
+import { PER_PAGE } from 'lib/constants';
+import usePagination from 'lib/hooks/usePagination';
+import { range } from 'lodash';
+import React from 'react';
+import { Link } from 'react-router-dom';
+import PageControl from './PageControl';
+
+export interface PaginationProps {
+  totalPages: number;
+}
+
+const NEIGHBOURS = 2;
+
+const Pagination: React.FC<PaginationProps> = ({ totalPages }) => {
+  const { page, perPage, pathname } = usePagination();
+
+  const currentPage = page || 1;
+  const currentPerPage = perPage || PER_PAGE;
+
+  const getPath = (newPage: number) =>
+    `${pathname}?page=${Math.max(newPage, 1)}&perPage=${currentPerPage}`;
+
+  const pages = React.useMemo(() => {
+    // Total visible numbers: neighbours, current, first & last
+    const totalNumbers = NEIGHBOURS * 2 + 3;
+    // totalNumbers + `...`*2
+    const totalBlocks = totalNumbers + 2;
+
+    if (totalPages <= totalBlocks) {
+      return range(1, totalPages + 1);
+    }
+
+    const startPage = Math.max(
+      2,
+      Math.min(currentPage - NEIGHBOURS, totalPages)
+    );
+    const endPage = Math.min(
+      totalPages - 1,
+      Math.min(currentPage + NEIGHBOURS, totalPages)
+    );
+
+    let p = range(startPage, endPage + 1);
+
+    const hasLeftSpill = startPage > 2;
+    const hasRightSpill = totalPages - endPage > 1;
+    const spillOffset = totalNumbers - (p.length + 1);
+
+    switch (true) {
+      case hasLeftSpill && !hasRightSpill: {
+        p = [...range(startPage - spillOffset - 1, startPage - 1), ...p];
+        break;
+      }
+
+      case !hasLeftSpill && hasRightSpill: {
+        p = [...p, ...range(endPage + 1, endPage + spillOffset + 1)];
+        break;
+      }
+
+      default:
+        break;
+    }
+
+    return p;
+  }, []);
+
+  return (
+    <nav
+      className="pagination is-small is-right"
+      role="navigation"
+      aria-label="pagination"
+    >
+      {currentPage > 1 ? (
+        <Link className="pagination-previous" to={getPath(currentPage - 1)}>
+          Previous
+        </Link>
+      ) : (
+        <button type="button" className="pagination-previous" disabled>
+          Previous
+        </button>
+      )}
+      {currentPage < totalPages ? (
+        <Link className="pagination-next" to={getPath(currentPage + 1)}>
+          Next page
+        </Link>
+      ) : (
+        <button type="button" className="pagination-next" disabled>
+          Next page
+        </button>
+      )}
+      {totalPages > 1 && (
+        <ul className="pagination-list">
+          {!pages.includes(1) && (
+            <PageControl
+              page={1}
+              current={currentPage === 1}
+              url={getPath(1)}
+            />
+          )}
+          {!pages.includes(2) && (
+            <li>
+              <span className="pagination-ellipsis">&hellip;</span>
+            </li>
+          )}
+          {pages.map((p) => (
+            <PageControl
+              key={`page-${p}`}
+              page={p}
+              current={p === currentPage}
+              url={getPath(p)}
+            />
+          ))}
+          {!pages.includes(totalPages - 1) && (
+            <li>
+              <span className="pagination-ellipsis">&hellip;</span>
+            </li>
+          )}
+          {!pages.includes(totalPages) && (
+            <PageControl
+              page={totalPages}
+              current={currentPage === totalPages}
+              url={getPath(totalPages)}
+            />
+          )}
+        </ul>
+      )}
+    </nav>
+  );
+};
+
+export default Pagination;

+ 34 - 0
kafka-ui-react-app/src/components/common/Pagination/__tests__/PageControl.spec.tsx

@@ -0,0 +1,34 @@
+import React from 'react';
+import { mount, shallow } from 'enzyme';
+import { StaticRouter } from 'react-router';
+import PageControl, { PageControlProps } from '../PageControl';
+
+const page = 138;
+
+describe('PageControl', () => {
+  const setupWrapper = (props: Partial<PageControlProps> = {}) => (
+    <StaticRouter>
+      <PageControl url="/test" page={page} current {...props} />
+    </StaticRouter>
+  );
+
+  it('renders current page', () => {
+    const wrapper = mount(setupWrapper({ current: true }));
+    expect(wrapper.exists('.pagination-link.is-current')).toBeTruthy();
+  });
+
+  it('renders non-current page', () => {
+    const wrapper = mount(setupWrapper({ current: false }));
+    expect(wrapper.exists('.pagination-link.is-current')).toBeFalsy();
+  });
+
+  it('renders page number', () => {
+    const wrapper = mount(setupWrapper({ current: false }));
+    expect(wrapper.text()).toEqual(String(page));
+  });
+
+  it('matches snapshot', () => {
+    const wrapper = shallow(<PageControl url="/test" page={page} current />);
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 96 - 0
kafka-ui-react-app/src/components/common/Pagination/__tests__/Pagination.spec.tsx

@@ -0,0 +1,96 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import { StaticRouter } from 'react-router';
+import Pagination, { PaginationProps } from '../Pagination';
+
+describe('Pagination', () => {
+  const setupWrapper = (search = '', props: Partial<PaginationProps> = {}) => (
+    <StaticRouter location={{ pathname: '/my/test/path/23', search }}>
+      <Pagination totalPages={11} {...props} />
+    </StaticRouter>
+  );
+
+  describe('next & prev buttons', () => {
+    it('renders disable prev button and enabled next link', () => {
+      const wrapper = mount(setupWrapper('?page=1'));
+      expect(wrapper.exists('a.pagination-previous')).toBeFalsy();
+      expect(
+        wrapper.find('button.pagination-previous').instance()
+      ).toBeDisabled();
+      expect(wrapper.exists('a.pagination-next')).toBeTruthy();
+    });
+
+    it('renders disable next button and enabled prev link', () => {
+      const wrapper = mount(setupWrapper('?page=11'));
+      expect(wrapper.exists('a.pagination-previous')).toBeTruthy();
+      expect(wrapper.exists('button.pagination-next')).toBeTruthy();
+    });
+
+    it('renders next & prev links with correct path', () => {
+      const wrapper = mount(setupWrapper('?page=5&perPage=20'));
+      expect(wrapper.exists('a.pagination-previous')).toBeTruthy();
+      expect(wrapper.find('a.pagination-previous').prop('href')).toEqual(
+        '/my/test/path/23?page=4&perPage=20'
+      );
+      expect(wrapper.exists('a.pagination-next')).toBeTruthy();
+      expect(wrapper.find('a.pagination-next').prop('href')).toEqual(
+        '/my/test/path/23?page=6&perPage=20'
+      );
+    });
+  });
+
+  describe('spread', () => {
+    it('renders 1 spread element after first page control', () => {
+      const wrapper = mount(setupWrapper('?page=8'));
+      expect(wrapper.find('span.pagination-ellipsis').length).toEqual(1);
+      expect(wrapper.find('ul li').at(1).text()).toEqual('…');
+    });
+
+    it('renders 1 spread element before last spread control', () => {
+      const wrapper = mount(setupWrapper('?page=2'));
+      expect(wrapper.find('span.pagination-ellipsis').length).toEqual(1);
+      expect(wrapper.find('ul li').at(7).text()).toEqual('…');
+    });
+
+    it('renders 2 spread elements', () => {
+      const wrapper = mount(setupWrapper('?page=6'));
+      expect(wrapper.find('span.pagination-ellipsis').length).toEqual(2);
+      expect(wrapper.find('ul li').at(0).text()).toEqual('1');
+      expect(wrapper.find('ul li').at(1).text()).toEqual('…');
+      expect(wrapper.find('ul li').at(7).text()).toEqual('…');
+      expect(wrapper.find('ul li').at(8).text()).toEqual('11');
+    });
+
+    it('renders 0 spread elements', () => {
+      const wrapper = mount(setupWrapper('?page=2', { totalPages: 8 }));
+      expect(wrapper.find('span.pagination-ellipsis').length).toEqual(0);
+      expect(wrapper.find('ul li').length).toEqual(8);
+    });
+  });
+
+  describe('current page', () => {
+    it('adds is-current class to correct page if page param is set', () => {
+      const wrapper = mount(setupWrapper('?page=8'));
+      expect(wrapper.exists('a.pagination-link.is-current')).toBeTruthy();
+      expect(wrapper.find('a.pagination-link.is-current').text()).toEqual('8');
+    });
+
+    it('adds is-current class to correct page even if page param is not set', () => {
+      const wrapper = mount(setupWrapper('', { totalPages: 8 }));
+      expect(wrapper.exists('a.pagination-link.is-current')).toBeTruthy();
+      expect(wrapper.find('a.pagination-link.is-current').text()).toEqual('1');
+    });
+
+    it('adds no is-current class if page numder is invalid', () => {
+      const wrapper = mount(setupWrapper('?page=80'));
+      expect(wrapper.exists('a.pagination-link.is-current')).toBeFalsy();
+    });
+  });
+
+  it('matches snapshot', () => {
+    const wrapper = mount(setupWrapper());
+    expect(wrapper.find('Pagination')).toMatchSnapshot();
+  });
+});
+
+// span.pagination-ellipsis

+ 13 - 0
kafka-ui-react-app/src/components/common/Pagination/__tests__/__snapshots__/PageControl.spec.tsx.snap

@@ -0,0 +1,13 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`PageControl matches snapshot 1`] = `
+<li>
+  <Link
+    aria-label="Goto page 138"
+    className="pagination-link is-current"
+    to="/test"
+  >
+    138
+  </Link>
+</li>
+`;

+ 288 - 0
kafka-ui-react-app/src/components/common/Pagination/__tests__/__snapshots__/Pagination.spec.tsx.snap

@@ -0,0 +1,288 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Pagination matches snapshot 1`] = `
+<Pagination
+  totalPages={11}
+>
+  <nav
+    aria-label="pagination"
+    className="pagination is-small is-right"
+    role="navigation"
+  >
+    <button
+      className="pagination-previous"
+      disabled={true}
+      type="button"
+    >
+      Previous
+    </button>
+    <Link
+      className="pagination-next"
+      to="/my/test/path/23?page=2&perPage=25"
+    >
+      <LinkAnchor
+        className="pagination-next"
+        href="/my/test/path/23?page=2&perPage=25"
+        navigate={[Function]}
+      >
+        <a
+          className="pagination-next"
+          href="/my/test/path/23?page=2&perPage=25"
+          onClick={[Function]}
+        >
+          Next page
+        </a>
+      </LinkAnchor>
+    </Link>
+    <ul
+      className="pagination-list"
+    >
+      <PageControl
+        current={true}
+        page={1}
+        url="/my/test/path/23?page=1&perPage=25"
+      >
+        <li>
+          <Link
+            aria-label="Goto page 1"
+            className="pagination-link is-current"
+            to="/my/test/path/23?page=1&perPage=25"
+          >
+            <LinkAnchor
+              aria-label="Goto page 1"
+              className="pagination-link is-current"
+              href="/my/test/path/23?page=1&perPage=25"
+              navigate={[Function]}
+            >
+              <a
+                aria-label="Goto page 1"
+                className="pagination-link is-current"
+                href="/my/test/path/23?page=1&perPage=25"
+                onClick={[Function]}
+              >
+                1
+              </a>
+            </LinkAnchor>
+          </Link>
+        </li>
+      </PageControl>
+      <PageControl
+        current={false}
+        key="page-2"
+        page={2}
+        url="/my/test/path/23?page=2&perPage=25"
+      >
+        <li>
+          <Link
+            aria-label="Goto page 2"
+            className="pagination-link"
+            to="/my/test/path/23?page=2&perPage=25"
+          >
+            <LinkAnchor
+              aria-label="Goto page 2"
+              className="pagination-link"
+              href="/my/test/path/23?page=2&perPage=25"
+              navigate={[Function]}
+            >
+              <a
+                aria-label="Goto page 2"
+                className="pagination-link"
+                href="/my/test/path/23?page=2&perPage=25"
+                onClick={[Function]}
+              >
+                2
+              </a>
+            </LinkAnchor>
+          </Link>
+        </li>
+      </PageControl>
+      <PageControl
+        current={false}
+        key="page-3"
+        page={3}
+        url="/my/test/path/23?page=3&perPage=25"
+      >
+        <li>
+          <Link
+            aria-label="Goto page 3"
+            className="pagination-link"
+            to="/my/test/path/23?page=3&perPage=25"
+          >
+            <LinkAnchor
+              aria-label="Goto page 3"
+              className="pagination-link"
+              href="/my/test/path/23?page=3&perPage=25"
+              navigate={[Function]}
+            >
+              <a
+                aria-label="Goto page 3"
+                className="pagination-link"
+                href="/my/test/path/23?page=3&perPage=25"
+                onClick={[Function]}
+              >
+                3
+              </a>
+            </LinkAnchor>
+          </Link>
+        </li>
+      </PageControl>
+      <PageControl
+        current={false}
+        key="page-4"
+        page={4}
+        url="/my/test/path/23?page=4&perPage=25"
+      >
+        <li>
+          <Link
+            aria-label="Goto page 4"
+            className="pagination-link"
+            to="/my/test/path/23?page=4&perPage=25"
+          >
+            <LinkAnchor
+              aria-label="Goto page 4"
+              className="pagination-link"
+              href="/my/test/path/23?page=4&perPage=25"
+              navigate={[Function]}
+            >
+              <a
+                aria-label="Goto page 4"
+                className="pagination-link"
+                href="/my/test/path/23?page=4&perPage=25"
+                onClick={[Function]}
+              >
+                4
+              </a>
+            </LinkAnchor>
+          </Link>
+        </li>
+      </PageControl>
+      <PageControl
+        current={false}
+        key="page-5"
+        page={5}
+        url="/my/test/path/23?page=5&perPage=25"
+      >
+        <li>
+          <Link
+            aria-label="Goto page 5"
+            className="pagination-link"
+            to="/my/test/path/23?page=5&perPage=25"
+          >
+            <LinkAnchor
+              aria-label="Goto page 5"
+              className="pagination-link"
+              href="/my/test/path/23?page=5&perPage=25"
+              navigate={[Function]}
+            >
+              <a
+                aria-label="Goto page 5"
+                className="pagination-link"
+                href="/my/test/path/23?page=5&perPage=25"
+                onClick={[Function]}
+              >
+                5
+              </a>
+            </LinkAnchor>
+          </Link>
+        </li>
+      </PageControl>
+      <PageControl
+        current={false}
+        key="page-6"
+        page={6}
+        url="/my/test/path/23?page=6&perPage=25"
+      >
+        <li>
+          <Link
+            aria-label="Goto page 6"
+            className="pagination-link"
+            to="/my/test/path/23?page=6&perPage=25"
+          >
+            <LinkAnchor
+              aria-label="Goto page 6"
+              className="pagination-link"
+              href="/my/test/path/23?page=6&perPage=25"
+              navigate={[Function]}
+            >
+              <a
+                aria-label="Goto page 6"
+                className="pagination-link"
+                href="/my/test/path/23?page=6&perPage=25"
+                onClick={[Function]}
+              >
+                6
+              </a>
+            </LinkAnchor>
+          </Link>
+        </li>
+      </PageControl>
+      <PageControl
+        current={false}
+        key="page-7"
+        page={7}
+        url="/my/test/path/23?page=7&perPage=25"
+      >
+        <li>
+          <Link
+            aria-label="Goto page 7"
+            className="pagination-link"
+            to="/my/test/path/23?page=7&perPage=25"
+          >
+            <LinkAnchor
+              aria-label="Goto page 7"
+              className="pagination-link"
+              href="/my/test/path/23?page=7&perPage=25"
+              navigate={[Function]}
+            >
+              <a
+                aria-label="Goto page 7"
+                className="pagination-link"
+                href="/my/test/path/23?page=7&perPage=25"
+                onClick={[Function]}
+              >
+                7
+              </a>
+            </LinkAnchor>
+          </Link>
+        </li>
+      </PageControl>
+      <li>
+        <span
+          className="pagination-ellipsis"
+        >
+          …
+        </span>
+      </li>
+      <PageControl
+        current={false}
+        page={11}
+        url="/my/test/path/23?page=11&perPage=25"
+      >
+        <li>
+          <Link
+            aria-label="Goto page 11"
+            className="pagination-link"
+            to="/my/test/path/23?page=11&perPage=25"
+          >
+            <LinkAnchor
+              aria-label="Goto page 11"
+              className="pagination-link"
+              href="/my/test/path/23?page=11&perPage=25"
+              navigate={[Function]}
+            >
+              <a
+                aria-label="Goto page 11"
+                className="pagination-link"
+                href="/my/test/path/23?page=11&perPage=25"
+                onClick={[Function]}
+              >
+                11
+              </a>
+            </LinkAnchor>
+          </Link>
+        </li>
+      </PageControl>
+    </ul>
+  </nav>
+</Pagination>
+`;

+ 2 - 0
kafka-ui-react-app/src/lib/constants.ts

@@ -16,3 +16,5 @@ export const MILLISECONDS_IN_DAY = 86_400_000;
 export const MILLISECONDS_IN_SECOND = 1_000;
 
 export const BYTES_IN_GB = 1_073_741_824;
+
+export const PER_PAGE = 25;

+ 3 - 1
kafka-ui-react-app/src/lib/hooks/usePagination.ts

@@ -1,7 +1,8 @@
 import { useLocation } from 'react-router';
 
 const usePagination = () => {
-  const params = new URLSearchParams(useLocation().search);
+  const { search, pathname } = useLocation();
+  const params = new URLSearchParams(search);
 
   const page = params.get('page');
   const perPage = params.get('perPage');
@@ -9,6 +10,7 @@ const usePagination = () => {
   return {
     page: page ? Number(page) : undefined,
     perPage: perPage ? Number(perPage) : undefined,
+    pathname,
   };
 };
 

+ 4 - 4
kafka-ui-react-app/src/redux/actions/actions.ts

@@ -62,25 +62,25 @@ export const fetchTopicDetailsAction = createAsyncAction(
   'GET_TOPIC_DETAILS__REQUEST',
   'GET_TOPIC_DETAILS__SUCCESS',
   'GET_TOPIC_DETAILS__FAILURE'
-)<undefined, { topicName: TopicName; details: TopicDetails }, undefined>();
+)<undefined, TopicsState, undefined>();
 
 export const fetchTopicConfigAction = createAsyncAction(
   'GET_TOPIC_CONFIG__REQUEST',
   'GET_TOPIC_CONFIG__SUCCESS',
   'GET_TOPIC_CONFIG__FAILURE'
-)<undefined, { topicName: TopicName; config: TopicConfig[] }, undefined>();
+)<undefined, TopicsState, undefined>();
 
 export const createTopicAction = createAsyncAction(
   'POST_TOPIC__REQUEST',
   'POST_TOPIC__SUCCESS',
   'POST_TOPIC__FAILURE'
-)<undefined, Topic, undefined>();
+)<undefined, TopicsState, undefined>();
 
 export const updateTopicAction = createAsyncAction(
   'PATCH_TOPIC__REQUEST',
   'PATCH_TOPIC__SUCCESS',
   'PATCH_TOPIC__FAILURE'
-)<undefined, Topic, undefined>();
+)<undefined, TopicsState, undefined>();
 
 export const fetchConsumerGroupsAction = createAsyncAction(
   'GET_CONSUMER_GROUPS__REQUEST',

+ 60 - 14
kafka-ui-react-app/src/redux/actions/thunks/topics.ts

@@ -16,7 +16,6 @@ import {
   TopicFormDataRaw,
   TopicsState,
 } from 'redux/interfaces';
-
 import { BASE_PARAMS } from 'lib/constants';
 import * as actions from '../actions';
 
@@ -82,19 +81,25 @@ export const fetchTopicMessages = (
 export const fetchTopicDetails = (
   clusterName: ClusterName,
   topicName: TopicName
-): PromiseThunkResult => async (dispatch) => {
+): PromiseThunkResult => async (dispatch, getState) => {
   dispatch(actions.fetchTopicDetailsAction.request());
   try {
     const topicDetails = await topicsApiClient.getTopicDetails({
       clusterName,
       topicName,
     });
-    dispatch(
-      actions.fetchTopicDetailsAction.success({
-        topicName,
-        details: topicDetails,
-      })
-    );
+    const state = getState().topics;
+    const newState = {
+      ...state,
+      byName: {
+        ...state.byName,
+        [topicName]: {
+          ...state.byName[topicName],
+          ...topicDetails,
+        },
+      },
+    };
+    dispatch(actions.fetchTopicDetailsAction.success(newState));
   } catch (e) {
     dispatch(actions.fetchTopicDetailsAction.failure());
   }
@@ -103,14 +108,29 @@ export const fetchTopicDetails = (
 export const fetchTopicConfig = (
   clusterName: ClusterName,
   topicName: TopicName
-): PromiseThunkResult => async (dispatch) => {
+): PromiseThunkResult => async (dispatch, getState) => {
   dispatch(actions.fetchTopicConfigAction.request());
   try {
     const config = await topicsApiClient.getTopicConfigs({
       clusterName,
       topicName,
     });
-    dispatch(actions.fetchTopicConfigAction.success({ topicName, config }));
+
+    const state = getState().topics;
+    const newState = {
+      ...state,
+      byName: {
+        ...state.byName,
+        [topicName]: {
+          ...state.byName[topicName],
+          config: config.map((inputConfig) => ({
+            ...inputConfig,
+          })),
+        },
+      },
+    };
+
+    dispatch(actions.fetchTopicConfigAction.success(newState));
   } catch (e) {
     dispatch(actions.fetchTopicConfigAction.failure());
   }
@@ -155,14 +175,27 @@ const formatTopicFormData = (form: TopicFormDataRaw): TopicFormData => {
 export const createTopic = (
   clusterName: ClusterName,
   form: TopicFormDataRaw
-): PromiseThunkResult => async (dispatch) => {
+): PromiseThunkResult => async (dispatch, getState) => {
   dispatch(actions.createTopicAction.request());
   try {
     const topic: Topic = await topicsApiClient.createTopic({
       clusterName,
       topicFormData: formatTopicFormData(form),
     });
-    dispatch(actions.createTopicAction.success(topic));
+
+    const state = getState().topics;
+    const newState = {
+      ...state,
+      byName: {
+        ...state.byName,
+        [topic.name]: {
+          ...topic,
+        },
+      },
+      allNames: [...state.allNames, topic.name],
+    };
+
+    dispatch(actions.createTopicAction.success(newState));
   } catch (e) {
     dispatch(actions.createTopicAction.failure());
   }
@@ -171,7 +204,7 @@ export const createTopic = (
 export const updateTopic = (
   clusterName: ClusterName,
   form: TopicFormDataRaw
-): PromiseThunkResult => async (dispatch) => {
+): PromiseThunkResult => async (dispatch, getState) => {
   dispatch(actions.updateTopicAction.request());
   try {
     const topic: Topic = await topicsApiClient.updateTopic({
@@ -179,7 +212,20 @@ export const updateTopic = (
       topicName: form.name,
       topicFormData: formatTopicFormData(form),
     });
-    dispatch(actions.updateTopicAction.success(topic));
+
+    const state = getState().topics;
+    const newState = {
+      ...state,
+      byName: {
+        ...state.byName,
+        [topic.name]: {
+          ...state.byName[topic.name],
+          ...topic,
+        },
+      },
+    };
+
+    dispatch(actions.updateTopicAction.success(newState));
   } catch (e) {
     dispatch(actions.updateTopicAction.failure());
   }

+ 5 - 36
kafka-ui-react-app/src/redux/reducers/topics/reducer.ts

@@ -1,4 +1,4 @@
-import { Topic, TopicMessage } from 'generated-sources';
+import { TopicMessage } from 'generated-sources';
 import { Action, TopicsState } from 'redux/interfaces';
 import { getType } from 'typesafe-actions';
 import * as actions from 'redux/actions';
@@ -10,15 +10,6 @@ export const initialState: TopicsState = {
   messages: [],
 };
 
-const addToTopicList = (state: TopicsState, payload: Topic): TopicsState => {
-  const newState: TopicsState = {
-    ...state,
-  };
-  newState.allNames.push(payload.name);
-  newState.byName[payload.name] = { ...payload };
-  return newState;
-};
-
 const transformTopicMessages = (
   state: TopicsState,
   messages: TopicMessage[]
@@ -47,35 +38,13 @@ const transformTopicMessages = (
 const reducer = (state = initialState, action: Action): TopicsState => {
   switch (action.type) {
     case getType(actions.fetchTopicsListAction.success):
-      return action.payload;
     case getType(actions.fetchTopicDetailsAction.success):
-      return {
-        ...state,
-        byName: {
-          ...state.byName,
-          [action.payload.topicName]: {
-            ...state.byName[action.payload.topicName],
-            ...action.payload.details,
-          },
-        },
-      };
-    case getType(actions.fetchTopicMessagesAction.success):
-      return transformTopicMessages(state, action.payload);
     case getType(actions.fetchTopicConfigAction.success):
-      return {
-        ...state,
-        byName: {
-          ...state.byName,
-          [action.payload.topicName]: {
-            ...state.byName[action.payload.topicName],
-            config: action.payload.config.map((inputConfig) => ({
-              ...inputConfig,
-            })),
-          },
-        },
-      };
     case getType(actions.createTopicAction.success):
-      return addToTopicList(state, action.payload);
+    case getType(actions.updateTopicAction.success):
+      return action.payload;
+    case getType(actions.fetchTopicMessagesAction.success):
+      return transformTopicMessages(state, action.payload);
     default:
       return state;
   }

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

@@ -14,6 +14,8 @@ const getAllNames = (state: RootState) => topicsState(state).allNames;
 const getTopicMap = (state: RootState) => topicsState(state).byName;
 export const getTopicMessages = (state: RootState) =>
   topicsState(state).messages;
+export const getTopicListTotalPages = (state: RootState) =>
+  topicsState(state).totalPages;
 
 const getTopicListFetchingStatus = createFetchingSelector('GET_TOPICS');
 const getTopicDetailsFetchingStatus = createFetchingSelector(