Explorar o código

Topics' filtration (#405)

* Implement topics' filtration
Alexander Krivonosov %!s(int64=4) %!d(string=hai) anos
pai
achega
98fcc90c6b
Modificáronse 29 ficheiros con 1155 adicións e 496 borrados
  1. 3 2
      kafka-ui-react-app/src/components/Schemas/List/ListContainer.tsx
  2. 49 8
      kafka-ui-react-app/src/components/Topics/List/List.tsx
  3. 8 0
      kafka-ui-react-app/src/components/Topics/List/ListContainer.ts
  4. 4 2
      kafka-ui-react-app/src/components/Topics/List/ListItem.tsx
  5. 44 21
      kafka-ui-react-app/src/components/Topics/List/__tests__/List.spec.tsx
  6. 255 0
      kafka-ui-react-app/src/components/Topics/List/__tests__/__snapshots__/List.spec.tsx.snap
  7. 4 2
      kafka-ui-react-app/src/components/Topics/Topic/Details/Details.tsx
  8. 3 2
      kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/Messages.tsx
  9. 6 5
      kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamSelect.tsx
  10. 7 5
      kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParams.tsx
  11. 35 0
      kafka-ui-react-app/src/components/common/Search/Search.tsx
  12. 35 0
      kafka-ui-react-app/src/components/common/Search/__tests__/Search.spec.tsx
  13. 43 0
      kafka-ui-react-app/src/components/common/Search/__tests__/__snapshots__/Search.spec.tsx.snap
  14. 29 0
      kafka-ui-react-app/src/components/common/table/SortableCulumnHeader/SortableColumnHeader.tsx
  15. 37 0
      kafka-ui-react-app/src/components/common/table/__tests__/SortableColumnHeader.spec.tsx
  16. 59 0
      kafka-ui-react-app/src/components/common/table/__tests__/__snapshots__/SortableColumnHeader.spec.tsx.snap
  17. 19 0
      kafka-ui-react-app/src/redux/actions/__test__/actions.spec.ts
  18. 9 0
      kafka-ui-react-app/src/redux/actions/actions.ts
  19. 26 25
      kafka-ui-react-app/src/redux/actions/thunks/brokers.ts
  20. 24 24
      kafka-ui-react-app/src/redux/actions/thunks/clusters.ts
  21. 0 1
      kafka-ui-react-app/src/redux/actions/thunks/connectors.ts
  22. 35 36
      kafka-ui-react-app/src/redux/actions/thunks/consumerGroups.ts
  23. 146 150
      kafka-ui-react-app/src/redux/actions/thunks/schemas.ts
  24. 198 196
      kafka-ui-react-app/src/redux/actions/thunks/topics.ts
  25. 3 0
      kafka-ui-react-app/src/redux/interfaces/topic.ts
  26. 40 11
      kafka-ui-react-app/src/redux/reducers/topics/__test__/reducer.spec.ts
  27. 14 0
      kafka-ui-react-app/src/redux/reducers/topics/reducer.ts
  28. 16 4
      kafka-ui-react-app/src/redux/reducers/topics/selectors.ts
  29. 4 2
      kafka-ui-react-app/src/redux/store/configureStore/mockStoreCreator.ts

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

@@ -18,8 +18,9 @@ const mapStateToProps = (state: RootState) => ({
   isFetching: getIsSchemaListFetching(state),
   schemas: getSchemaList(state),
   globalSchemaCompatibilityLevel: getGlobalSchemaCompatibilityLevel(state),
-  isGlobalSchemaCompatibilityLevelFetched:
-    getGlobalSchemaCompatibilityLevelFetched(state),
+  isGlobalSchemaCompatibilityLevelFetched: getGlobalSchemaCompatibilityLevelFetched(
+    state
+  ),
 });
 
 const mapDispatchToProps = {

+ 49 - 8
kafka-ui-react-app/src/components/Topics/List/List.tsx

@@ -12,6 +12,9 @@ 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 { TopicColumnsToSort } from 'generated-sources';
+import SortableColumnHeader from 'components/common/table/SortableCulumnHeader/SortableColumnHeader';
+import Search from 'components/common/Search/Search';
 
 import ListItem from './ListItem';
 
@@ -27,6 +30,10 @@ interface Props {
     clusterName: ClusterName,
     partitions?: number[]
   ): void;
+  search: string;
+  orderBy: TopicColumnsToSort | null;
+  setTopicsSearch(search: string): void;
+  setTopicsOrderBy(orderBy: TopicColumnsToSort | null): void;
 }
 
 const List: React.FC<Props> = ({
@@ -37,14 +44,24 @@ const List: React.FC<Props> = ({
   fetchTopicsList,
   deleteTopic,
   clearTopicMessages,
+  search,
+  orderBy,
+  setTopicsSearch,
+  setTopicsOrderBy,
 }) => {
   const { isReadOnly } = React.useContext(ClusterContext);
   const { clusterName } = useParams<{ clusterName: ClusterName }>();
   const { page, perPage } = usePagination();
 
   React.useEffect(() => {
-    fetchTopicsList({ clusterName, page, perPage });
-  }, [fetchTopicsList, clusterName, page, perPage]);
+    fetchTopicsList({
+      clusterName,
+      page,
+      perPage,
+      orderBy: orderBy || undefined,
+      search,
+    });
+  }, [fetchTopicsList, clusterName, page, perPage, orderBy, search]);
 
   const [showInternal, setShowInternal] = React.useState<boolean>(true);
 
@@ -52,14 +69,16 @@ const List: React.FC<Props> = ({
     setShowInternal(!showInternal);
   }, [showInternal]);
 
+  const handleSearch = (value: string) => setTopicsSearch(value);
+
   const items = showInternal ? topics : externalTopics;
 
   return (
     <div className="section">
       <Breadcrumb>{showInternal ? `All Topics` : `External Topics`}</Breadcrumb>
       <div className="box">
-        <div className="level">
-          <div className="level-item level-left">
+        <div className="columns">
+          <div className="column is-one-quarter is-align-items-center is-flex">
             <div className="field">
               <input
                 id="switchRoundedDefault"
@@ -72,7 +91,14 @@ const List: React.FC<Props> = ({
               <label htmlFor="switchRoundedDefault">Show Internal Topics</label>
             </div>
           </div>
-          <div className="level-item level-right">
+          <div className="column">
+            <Search
+              handleSearch={handleSearch}
+              placeholder="Search by Topic Name"
+              value={search}
+            />
+          </div>
+          <div className="column is-2 is-justify-content-flex-end is-flex">
             {!isReadOnly && (
               <Link
                 className="button is-primary"
@@ -91,9 +117,24 @@ const List: React.FC<Props> = ({
           <table className="table is-fullwidth">
             <thead>
               <tr>
-                <th>Topic Name</th>
-                <th>Total Partitions</th>
-                <th>Out of sync replicas</th>
+                <SortableColumnHeader
+                  value={TopicColumnsToSort.NAME}
+                  title="Topic Name"
+                  orderBy={orderBy}
+                  setOrderBy={setTopicsOrderBy}
+                />
+                <SortableColumnHeader
+                  value={TopicColumnsToSort.TOTAL_PARTITIONS}
+                  title="Total Partitions"
+                  orderBy={orderBy}
+                  setOrderBy={setTopicsOrderBy}
+                />
+                <SortableColumnHeader
+                  value={TopicColumnsToSort.OUT_OF_SYNC_REPLICAS}
+                  title="Out of sync replicas"
+                  orderBy={orderBy}
+                  setOrderBy={setTopicsOrderBy}
+                />
                 <th>Type</th>
                 <th> </th>
               </tr>

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

@@ -4,12 +4,16 @@ import {
   fetchTopicsList,
   deleteTopic,
   clearTopicMessages,
+  setTopicsSearchAction,
+  setTopicsOrderByAction,
 } from 'redux/actions';
 import {
   getTopicList,
   getExternalTopicList,
   getAreTopicsFetching,
   getTopicListTotalPages,
+  getTopicsSearch,
+  getTopicsOrderBy,
 } from 'redux/reducers/topics/selectors';
 
 import List from './List';
@@ -19,12 +23,16 @@ const mapStateToProps = (state: RootState) => ({
   topics: getTopicList(state),
   externalTopics: getExternalTopicList(state),
   totalPages: getTopicListTotalPages(state),
+  search: getTopicsSearch(state),
+  orderBy: getTopicsOrderBy(state),
 });
 
 const mapDispatchToProps = {
   fetchTopicsList,
   deleteTopic,
   clearTopicMessages,
+  setTopicsSearch: setTopicsSearchAction,
+  setTopicsOrderBy: setTopicsOrderByAction,
 };
 
 export default connect(mapStateToProps, mapDispatchToProps)(List);

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

@@ -23,8 +23,10 @@ const ListItem: React.FC<ListItemProps> = ({
   clusterName,
   clearTopicMessages,
 }) => {
-  const [isDeleteTopicConfirmationVisible, setDeleteTopicConfirmationVisible] =
-    React.useState(false);
+  const [
+    isDeleteTopicConfirmationVisible,
+    setDeleteTopicConfirmationVisible,
+  ] = React.useState(false);
 
   const outOfSyncReplicas = React.useMemo(() => {
     if (partitions === undefined || partitions.length === 0) {

+ 44 - 21
kafka-ui-react-app/src/components/Topics/List/__tests__/List.spec.tsx

@@ -24,6 +24,10 @@ describe('List', () => {
               fetchTopicsList={jest.fn()}
               deleteTopic={jest.fn()}
               clearTopicMessages={jest.fn()}
+              search=""
+              orderBy={null}
+              setTopicsSearch={jest.fn()}
+              setTopicsOrderBy={jest.fn()}
             />
           </ClusterContext.Provider>
         </StaticRouter>
@@ -33,29 +37,48 @@ describe('List', () => {
   });
 
   describe('when it does not have readonly flag', () => {
+    const mockFetch = jest.fn();
+    jest.useFakeTimers();
+    const component = mount(
+      <StaticRouter>
+        <ClusterContext.Provider
+          value={{
+            isReadOnly: false,
+            hasKafkaConnectConfigured: true,
+            hasSchemaRegistryConfigured: true,
+          }}
+        >
+          <List
+            areTopicsFetching={false}
+            topics={[]}
+            externalTopics={[]}
+            totalPages={1}
+            fetchTopicsList={mockFetch}
+            deleteTopic={jest.fn()}
+            clearTopicMessages={jest.fn()}
+            search=""
+            orderBy={null}
+            setTopicsSearch={jest.fn()}
+            setTopicsOrderBy={jest.fn()}
+          />
+        </ClusterContext.Provider>
+      </StaticRouter>
+    );
     it('renders the Add a Topic button', () => {
-      const component = mount(
-        <StaticRouter>
-          <ClusterContext.Provider
-            value={{
-              isReadOnly: false,
-              hasKafkaConnectConfigured: true,
-              hasSchemaRegistryConfigured: true,
-            }}
-          >
-            <List
-              areTopicsFetching={false}
-              topics={[]}
-              externalTopics={[]}
-              totalPages={1}
-              fetchTopicsList={jest.fn()}
-              deleteTopic={jest.fn()}
-              clearTopicMessages={jest.fn()}
-            />
-          </ClusterContext.Provider>
-        </StaticRouter>
-      );
       expect(component.exists('Link')).toBeTruthy();
     });
+    it('matches the snapshot', () => {
+      expect(component).toMatchSnapshot();
+    });
+
+    it('calls fetchTopicsList on input', () => {
+      const input = component.find('input').at(1);
+      input.simulate('change', { target: { value: 't' } });
+      expect(setTimeout).toHaveBeenCalledTimes(1);
+      expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 300);
+      setTimeout(() => {
+        expect(mockFetch).toHaveBeenCalledTimes(1);
+      }, 301);
+    });
   });
 });

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

@@ -0,0 +1,255 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`List when it does not have readonly flag matches the snapshot 1`] = `
+<StaticRouter>
+  <Router
+    history={
+      Object {
+        "action": "POP",
+        "block": [Function],
+        "createHref": [Function],
+        "go": [Function],
+        "goBack": [Function],
+        "goForward": [Function],
+        "listen": [Function],
+        "location": Object {
+          "hash": "",
+          "pathname": "/",
+          "search": "",
+          "state": undefined,
+        },
+        "push": [Function],
+        "replace": [Function],
+      }
+    }
+    staticContext={Object {}}
+  >
+    <List
+      areTopicsFetching={false}
+      clearTopicMessages={[MockFunction]}
+      deleteTopic={[MockFunction]}
+      externalTopics={Array []}
+      fetchTopicsList={[MockFunction]}
+      orderBy={null}
+      search=""
+      setTopicsOrderBy={[MockFunction]}
+      setTopicsSearch={[MockFunction]}
+      topics={Array []}
+      totalPages={1}
+    >
+      <div
+        className="section"
+      >
+        <Breadcrumb>
+          <nav
+            aria-label="breadcrumbs"
+            className="breadcrumb"
+          >
+            <ul>
+              <li
+                className="is-active"
+              >
+                <span
+                  className=""
+                >
+                  All Topics
+                </span>
+              </li>
+            </ul>
+          </nav>
+        </Breadcrumb>
+        <div
+          className="box"
+        >
+          <div
+            className="columns"
+          >
+            <div
+              className="column is-one-quarter is-align-items-center is-flex"
+            >
+              <div
+                className="field"
+              >
+                <input
+                  checked={true}
+                  className="switch is-rounded"
+                  id="switchRoundedDefault"
+                  name="switchRoundedDefault"
+                  onChange={[Function]}
+                  type="checkbox"
+                />
+                <label
+                  htmlFor="switchRoundedDefault"
+                >
+                  Show Internal Topics
+                </label>
+              </div>
+            </div>
+            <div
+              className="column"
+            >
+              <Search
+                handleSearch={[Function]}
+                placeholder="Search by Topic Name"
+                value=""
+              >
+                <p
+                  className="control has-icons-left"
+                >
+                  <input
+                    className="input"
+                    defaultValue=""
+                    onChange={[Function]}
+                    placeholder="Search by Topic Name"
+                    type="text"
+                  />
+                  <span
+                    className="icon is-small is-left"
+                  >
+                    <i
+                      className="fas fa-search"
+                    />
+                  </span>
+                </p>
+              </Search>
+            </div>
+            <div
+              className="column is-2 is-justify-content-flex-end is-flex"
+            >
+              <Link
+                className="button is-primary"
+                to="/ui/clusters/undefined/topics/create_new"
+              >
+                <LinkAnchor
+                  className="button is-primary"
+                  href="/ui/clusters/undefined/topics/create_new"
+                  navigate={[Function]}
+                >
+                  <a
+                    className="button is-primary"
+                    href="/ui/clusters/undefined/topics/create_new"
+                    onClick={[Function]}
+                  >
+                    Add a Topic
+                  </a>
+                </LinkAnchor>
+              </Link>
+            </div>
+          </div>
+        </div>
+        <div
+          className="box"
+        >
+          <table
+            className="table is-fullwidth"
+          >
+            <thead>
+              <tr>
+                <ListHeaderCell
+                  orderBy={null}
+                  setOrderBy={[MockFunction]}
+                  title="Topic Name"
+                  value="NAME"
+                >
+                  <th
+                    className="is-clickable"
+                    onClick={[Function]}
+                  >
+                    Topic Name
+                    <span
+                      className="icon is-small"
+                    >
+                      <i
+                        className="fas fa-sort"
+                      />
+                    </span>
+                  </th>
+                </ListHeaderCell>
+                <ListHeaderCell
+                  orderBy={null}
+                  setOrderBy={[MockFunction]}
+                  title="Total Partitions"
+                  value="TOTAL_PARTITIONS"
+                >
+                  <th
+                    className="is-clickable"
+                    onClick={[Function]}
+                  >
+                    Total Partitions
+                    <span
+                      className="icon is-small"
+                    >
+                      <i
+                        className="fas fa-sort"
+                      />
+                    </span>
+                  </th>
+                </ListHeaderCell>
+                <ListHeaderCell
+                  orderBy={null}
+                  setOrderBy={[MockFunction]}
+                  title="Out of sync replicas"
+                  value="OUT_OF_SYNC_REPLICAS"
+                >
+                  <th
+                    className="is-clickable"
+                    onClick={[Function]}
+                  >
+                    Out of sync replicas
+                    <span
+                      className="icon is-small"
+                    >
+                      <i
+                        className="fas fa-sort"
+                      />
+                    </span>
+                  </th>
+                </ListHeaderCell>
+                <th>
+                  Type
+                </th>
+                <th>
+                   
+                </th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td
+                  colSpan={10}
+                >
+                  No topics found
+                </td>
+              </tr>
+            </tbody>
+          </table>
+          <Pagination
+            totalPages={1}
+          >
+            <nav
+              aria-label="pagination"
+              className="pagination is-small is-right"
+              role="navigation"
+            >
+              <button
+                className="pagination-previous"
+                disabled={true}
+                type="button"
+              >
+                Previous
+              </button>
+              <button
+                className="pagination-next"
+                disabled={true}
+                type="button"
+              >
+                Next page
+              </button>
+            </nav>
+          </Pagination>
+        </div>
+      </div>
+    </List>
+  </Router>
+</StaticRouter>
+`;

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

@@ -33,8 +33,10 @@ const Details: React.FC<Props> = ({
 }) => {
   const history = useHistory();
   const { isReadOnly } = React.useContext(ClusterContext);
-  const [isDeleteTopicConfirmationVisible, setDeleteTopicConfirmationVisible] =
-    React.useState(false);
+  const [
+    isDeleteTopicConfirmationVisible,
+    setDeleteTopicConfirmationVisible,
+  ] = React.useState(false);
   const deleteTopicHandler = React.useCallback(() => {
     deleteTopic(clusterName, topicName);
     history.push(clusterTopicsPath(clusterName));

+ 3 - 2
kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/Messages.tsx

@@ -50,8 +50,9 @@ const Messages: React.FC<Props> = ({
   fetchTopicMessages,
 }) => {
   const [searchQuery, setSearchQuery] = React.useState<string>('');
-  const [searchTimestamp, setSearchTimestamp] =
-    React.useState<Date | null>(null);
+  const [searchTimestamp, setSearchTimestamp] = React.useState<Date | null>(
+    null
+  );
   const [filterProps, setFilterProps] = React.useState<FilterProps[]>([]);
   const [selectedSeekType, setSelectedSeekType] = React.useState<SeekType>(
     SeekType.OFFSET

+ 6 - 5
kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamSelect.tsx

@@ -39,11 +39,12 @@ const CustomParamSelect: React.FC<CustomParamSelectProps> = ({
     return valid || 'Custom Parameter must be unique';
   };
 
-  const onChange =
-    (inputName: string) => (event: React.ChangeEvent<HTMLSelectElement>) => {
-      trigger(inputName);
-      onNameChange(index, event.target.value);
-    };
+  const onChange = (inputName: string) => (
+    event: React.ChangeEvent<HTMLSelectElement>
+  ) => {
+    trigger(inputName);
+    onNameChange(index, event.target.value);
+  };
 
   return (
     <>

+ 7 - 5
kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParams.tsx

@@ -35,11 +35,13 @@ const CustomParams: React.FC<Props> = ({ isSubmitting, config }) => {
       )
     : {};
 
-  const [formCustomParams, setFormCustomParams] =
-    React.useState<TopicFormCustomParams>({
-      byIndex,
-      allIndexes: Object.keys(byIndex),
-    });
+  const [
+    formCustomParams,
+    setFormCustomParams,
+  ] = React.useState<TopicFormCustomParams>({
+    byIndex,
+    allIndexes: Object.keys(byIndex),
+  });
 
   const onAdd = (event: React.MouseEvent<HTMLButtonElement>) => {
     event.preventDefault();

+ 35 - 0
kafka-ui-react-app/src/components/common/Search/Search.tsx

@@ -0,0 +1,35 @@
+import React from 'react';
+import { useDebouncedCallback } from 'use-debounce';
+
+interface SearchProps {
+  handleSearch: (value: string) => void;
+  placeholder?: string;
+  value: string;
+}
+
+const Search: React.FC<SearchProps> = ({
+  handleSearch,
+  placeholder = 'Search',
+  value,
+}) => {
+  const onChange = useDebouncedCallback(
+    (e) => handleSearch(e.target.value),
+    300
+  );
+  return (
+    <p className="control has-icons-left">
+      <input
+        className="input"
+        type="text"
+        placeholder={placeholder}
+        onChange={onChange}
+        defaultValue={value}
+      />
+      <span className="icon is-small is-left">
+        <i className="fas fa-search" />
+      </span>
+    </p>
+  );
+};
+
+export default Search;

+ 35 - 0
kafka-ui-react-app/src/components/common/Search/__tests__/Search.spec.tsx

@@ -0,0 +1,35 @@
+import { shallow } from 'enzyme';
+import Search from 'components/common/Search/Search';
+import React from 'react';
+
+jest.mock('use-debounce', () => ({
+  useDebouncedCallback: (fn: (e: Event) => void) => fn,
+}));
+
+describe('Search', () => {
+  const handleSearch = jest.fn();
+  let component = shallow(
+    <Search
+      handleSearch={handleSearch}
+      value=""
+      placeholder="Search bt the Topic name"
+    />
+  );
+  it('calls handleSearch on input', () => {
+    component.find('input').simulate('change', { target: { value: 'test' } });
+    expect(handleSearch).toHaveBeenCalledTimes(1);
+  });
+
+  describe('when placeholder is provided', () => {
+    it('matches the snapshot', () => {
+      expect(component).toMatchSnapshot();
+    });
+  });
+
+  describe('when placeholder is not provided', () => {
+    component = shallow(<Search handleSearch={handleSearch} value="" />);
+    it('matches the snapshot', () => {
+      expect(component).toMatchSnapshot();
+    });
+  });
+});

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

@@ -0,0 +1,43 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Search when placeholder is not provided matches the snapshot 1`] = `
+<p
+  className="control has-icons-left"
+>
+  <input
+    className="input"
+    defaultValue=""
+    onChange={[Function]}
+    placeholder="Search"
+    type="text"
+  />
+  <span
+    className="icon is-small is-left"
+  >
+    <i
+      className="fas fa-search"
+    />
+  </span>
+</p>
+`;
+
+exports[`Search when placeholder is provided matches the snapshot 1`] = `
+<p
+  className="control has-icons-left"
+>
+  <input
+    className="input"
+    defaultValue=""
+    onChange={[Function]}
+    placeholder="Search"
+    type="text"
+  />
+  <span
+    className="icon is-small is-left"
+  >
+    <i
+      className="fas fa-search"
+    />
+  </span>
+</p>
+`;

+ 29 - 0
kafka-ui-react-app/src/components/common/table/SortableCulumnHeader/SortableColumnHeader.tsx

@@ -0,0 +1,29 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import React from 'react';
+import cx from 'classnames';
+
+export interface ListHeaderProps {
+  value: any;
+  title: string;
+  orderBy: any;
+  setOrderBy: React.Dispatch<React.SetStateAction<any>>;
+}
+
+const ListHeaderCell: React.FC<ListHeaderProps> = ({
+  value,
+  title,
+  orderBy,
+  setOrderBy,
+}) => (
+  <th
+    className={cx('is-clickable', orderBy === value && 'has-text-link-dark')}
+    onClick={() => setOrderBy(value)}
+  >
+    {title}
+    <span className="icon is-small">
+      <i className="fas fa-sort" />
+    </span>
+  </th>
+);
+
+export default ListHeaderCell;

+ 37 - 0
kafka-ui-react-app/src/components/common/table/__tests__/SortableColumnHeader.spec.tsx

@@ -0,0 +1,37 @@
+import SortableColumnHeader from 'components/common/table/SortableCulumnHeader/SortableColumnHeader';
+import { mount } from 'enzyme';
+import { TopicColumnsToSort } from 'generated-sources';
+import React from 'react';
+
+describe('ListHeader', () => {
+  const setOrderBy = jest.fn();
+  const component = mount(
+    <table>
+      <thead>
+        <tr>
+          <SortableColumnHeader
+            value={TopicColumnsToSort.NAME}
+            title="Name"
+            orderBy={null}
+            setOrderBy={setOrderBy}
+          />
+        </tr>
+      </thead>
+    </table>
+  );
+  it('matches the snapshot', () => {
+    expect(component).toMatchSnapshot();
+  });
+
+  describe('on column click', () => {
+    it('calls setOrderBy', () => {
+      component.find('th').simulate('click');
+      expect(setOrderBy).toHaveBeenCalledTimes(1);
+      expect(setOrderBy).toHaveBeenCalledWith(TopicColumnsToSort.NAME);
+    });
+
+    it('matches the snapshot', () => {
+      expect(component).toMatchSnapshot();
+    });
+  });
+});

+ 59 - 0
kafka-ui-react-app/src/components/common/table/__tests__/__snapshots__/SortableColumnHeader.spec.tsx.snap

@@ -0,0 +1,59 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ListHeader matches the snapshot 1`] = `
+<table>
+  <thead>
+    <tr>
+      <ListHeaderCell
+        orderBy={null}
+        setOrderBy={[MockFunction]}
+        title="Name"
+        value="NAME"
+      >
+        <th
+          className="is-clickable"
+          onClick={[Function]}
+        >
+          Name
+          <span
+            className="icon is-small"
+          >
+            <i
+              className="fas fa-sort"
+            />
+          </span>
+        </th>
+      </ListHeaderCell>
+    </tr>
+  </thead>
+</table>
+`;
+
+exports[`ListHeader on column click matches the snapshot 1`] = `
+<table>
+  <thead>
+    <tr>
+      <ListHeaderCell
+        orderBy={null}
+        setOrderBy={[MockFunction]}
+        title="Name"
+        value="NAME"
+      >
+        <th
+          className="is-clickable"
+          onClick={[Function]}
+        >
+          Name
+          <span
+            className="icon is-small"
+          >
+            <i
+              className="fas fa-sort"
+            />
+          </span>
+        </th>
+      </ListHeaderCell>
+    </tr>
+  </thead>
+</table>
+`;

+ 19 - 0
kafka-ui-react-app/src/redux/actions/__test__/actions.spec.ts

@@ -3,6 +3,7 @@ import {
   schemaVersionsPayload,
 } from 'redux/reducers/schemas/__test__/fixtures';
 import * as actions from 'redux/actions';
+import { TopicColumnsToSort } from 'generated-sources';
 
 describe('Actions', () => {
   describe('fetchClusterStatsAction', () => {
@@ -131,4 +132,22 @@ describe('Actions', () => {
       });
     });
   });
+
+  describe('setTopicsSearchAction', () => {
+    it('creartes SET_TOPICS_SEARCH', () => {
+      expect(actions.setTopicsSearchAction('test')).toEqual({
+        type: 'SET_TOPICS_SEARCH',
+        payload: 'test',
+      });
+    });
+  });
+
+  describe('setTopicsOrderByAction', () => {
+    it('creartes SET_TOPICS_ORDER_BY', () => {
+      expect(actions.setTopicsOrderByAction(TopicColumnsToSort.NAME)).toEqual({
+        type: 'SET_TOPICS_ORDER_BY',
+        payload: TopicColumnsToSort.NAME,
+      });
+    });
+  });
 });

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

@@ -18,6 +18,7 @@ import {
   ConsumerGroupDetails,
   SchemaSubject,
   CompatibilityLevelCompatibilityEnum,
+  TopicColumnsToSort,
   Connector,
   FullConnectorInfo,
   Connect,
@@ -233,3 +234,11 @@ export const updateConnectorConfigAction = createAsyncAction(
   'PATCH_CONNECTOR_CONFIG__SUCCESS',
   'PATCH_CONNECTOR_CONFIG__FAILURE'
 )<undefined, { connector: Connector }, { alert?: FailurePayload }>();
+
+export const setTopicsSearchAction = createAction(
+  'SET_TOPICS_SEARCH'
+)<string>();
+
+export const setTopicsOrderByAction = createAction(
+  'SET_TOPICS_ORDER_BY'
+)<TopicColumnsToSort>();

+ 26 - 25
kafka-ui-react-app/src/redux/actions/thunks/brokers.ts

@@ -6,29 +6,30 @@ import * as actions from 'redux/actions/actions';
 const apiClientConf = new Configuration(BASE_PARAMS);
 export const brokersApiClient = new BrokersApi(apiClientConf);
 
-export const fetchBrokers =
-  (clusterName: ClusterName): PromiseThunkResult =>
-  async (dispatch) => {
-    dispatch(actions.fetchBrokersAction.request());
-    try {
-      const payload = await brokersApiClient.getBrokers({ clusterName });
-      dispatch(actions.fetchBrokersAction.success(payload));
-    } catch (e) {
-      dispatch(actions.fetchBrokersAction.failure());
-    }
-  };
+export const fetchBrokers = (
+  clusterName: ClusterName
+): PromiseThunkResult => async (dispatch) => {
+  dispatch(actions.fetchBrokersAction.request());
+  try {
+    const payload = await brokersApiClient.getBrokers({ clusterName });
+    dispatch(actions.fetchBrokersAction.success(payload));
+  } catch (e) {
+    dispatch(actions.fetchBrokersAction.failure());
+  }
+};
 
-export const fetchBrokerMetrics =
-  (clusterName: ClusterName, brokerId: BrokerId): PromiseThunkResult =>
-  async (dispatch) => {
-    dispatch(actions.fetchBrokerMetricsAction.request());
-    try {
-      const payload = await brokersApiClient.getBrokersMetrics({
-        clusterName,
-        id: brokerId,
-      });
-      dispatch(actions.fetchBrokerMetricsAction.success(payload));
-    } catch (e) {
-      dispatch(actions.fetchBrokerMetricsAction.failure());
-    }
-  };
+export const fetchBrokerMetrics = (
+  clusterName: ClusterName,
+  brokerId: BrokerId
+): PromiseThunkResult => async (dispatch) => {
+  dispatch(actions.fetchBrokerMetricsAction.request());
+  try {
+    const payload = await brokersApiClient.getBrokersMetrics({
+      clusterName,
+      id: brokerId,
+    });
+    dispatch(actions.fetchBrokerMetricsAction.success(payload));
+  } catch (e) {
+    dispatch(actions.fetchBrokerMetricsAction.failure());
+  }
+};

+ 24 - 24
kafka-ui-react-app/src/redux/actions/thunks/clusters.ts

@@ -16,28 +16,28 @@ export const fetchClustersList = (): PromiseThunkResult => async (dispatch) => {
   }
 };
 
-export const fetchClusterStats =
-  (clusterName: ClusterName): PromiseThunkResult =>
-  async (dispatch) => {
-    dispatch(actions.fetchClusterStatsAction.request());
-    try {
-      const payload = await clustersApiClient.getClusterStats({ clusterName });
-      dispatch(actions.fetchClusterStatsAction.success(payload));
-    } catch (e) {
-      dispatch(actions.fetchClusterStatsAction.failure());
-    }
-  };
+export const fetchClusterStats = (
+  clusterName: ClusterName
+): PromiseThunkResult => async (dispatch) => {
+  dispatch(actions.fetchClusterStatsAction.request());
+  try {
+    const payload = await clustersApiClient.getClusterStats({ clusterName });
+    dispatch(actions.fetchClusterStatsAction.success(payload));
+  } catch (e) {
+    dispatch(actions.fetchClusterStatsAction.failure());
+  }
+};
 
-export const fetchClusterMetrics =
-  (clusterName: ClusterName): PromiseThunkResult =>
-  async (dispatch) => {
-    dispatch(actions.fetchClusterMetricsAction.request());
-    try {
-      const payload = await clustersApiClient.getClusterMetrics({
-        clusterName,
-      });
-      dispatch(actions.fetchClusterMetricsAction.success(payload));
-    } catch (e) {
-      dispatch(actions.fetchClusterMetricsAction.failure());
-    }
-  };
+export const fetchClusterMetrics = (
+  clusterName: ClusterName
+): PromiseThunkResult => async (dispatch) => {
+  dispatch(actions.fetchClusterMetricsAction.request());
+  try {
+    const payload = await clustersApiClient.getClusterMetrics({
+      clusterName,
+    });
+    dispatch(actions.fetchClusterMetricsAction.success(payload));
+  } catch (e) {
+    dispatch(actions.fetchClusterMetricsAction.failure());
+  }
+};

+ 0 - 1
kafka-ui-react-app/src/redux/actions/thunks/connectors.ts

@@ -20,7 +20,6 @@ import { getResponse } from 'lib/errorHandling';
 
 const apiClientConf = new Configuration(BASE_PARAMS);
 export const kafkaConnectApiClient = new KafkaConnectApi(apiClientConf);
-
 export const fetchConnects =
   (clusterName: ClusterName): PromiseThunkResult<void> =>
   async (dispatch) => {

+ 35 - 36
kafka-ui-react-app/src/redux/actions/thunks/consumerGroups.ts

@@ -10,40 +10,39 @@ import * as actions from 'redux/actions/actions';
 const apiClientConf = new Configuration(BASE_PARAMS);
 export const consumerGroupsApiClient = new ConsumerGroupsApi(apiClientConf);
 
-export const fetchConsumerGroupsList =
-  (clusterName: ClusterName): PromiseThunkResult =>
-  async (dispatch) => {
-    dispatch(actions.fetchConsumerGroupsAction.request());
-    try {
-      const consumerGroups = await consumerGroupsApiClient.getConsumerGroups({
-        clusterName,
-      });
-      dispatch(actions.fetchConsumerGroupsAction.success(consumerGroups));
-    } catch (e) {
-      dispatch(actions.fetchConsumerGroupsAction.failure());
-    }
-  };
+export const fetchConsumerGroupsList = (
+  clusterName: ClusterName
+): PromiseThunkResult => async (dispatch) => {
+  dispatch(actions.fetchConsumerGroupsAction.request());
+  try {
+    const consumerGroups = await consumerGroupsApiClient.getConsumerGroups({
+      clusterName,
+    });
+    dispatch(actions.fetchConsumerGroupsAction.success(consumerGroups));
+  } catch (e) {
+    dispatch(actions.fetchConsumerGroupsAction.failure());
+  }
+};
 
-export const fetchConsumerGroupDetails =
-  (
-    clusterName: ClusterName,
-    consumerGroupID: ConsumerGroupID
-  ): PromiseThunkResult =>
-  async (dispatch) => {
-    dispatch(actions.fetchConsumerGroupDetailsAction.request());
-    try {
-      const consumerGroupDetails =
-        await consumerGroupsApiClient.getConsumerGroup({
-          clusterName,
-          id: consumerGroupID,
-        });
-      dispatch(
-        actions.fetchConsumerGroupDetailsAction.success({
-          consumerGroupID,
-          details: consumerGroupDetails,
-        })
-      );
-    } catch (e) {
-      dispatch(actions.fetchConsumerGroupDetailsAction.failure());
-    }
-  };
+export const fetchConsumerGroupDetails = (
+  clusterName: ClusterName,
+  consumerGroupID: ConsumerGroupID
+): PromiseThunkResult => async (dispatch) => {
+  dispatch(actions.fetchConsumerGroupDetailsAction.request());
+  try {
+    const consumerGroupDetails = await consumerGroupsApiClient.getConsumerGroup(
+      {
+        clusterName,
+        id: consumerGroupID,
+      }
+    );
+    dispatch(
+      actions.fetchConsumerGroupDetailsAction.success({
+        consumerGroupID,
+        details: consumerGroupDetails,
+      })
+    );
+  } catch (e) {
+    dispatch(actions.fetchConsumerGroupDetailsAction.failure());
+  }
+};

+ 146 - 150
kafka-ui-react-app/src/redux/actions/thunks/schemas.ts

@@ -20,164 +20,160 @@ import { isEqual } from 'lodash';
 const apiClientConf = new Configuration(BASE_PARAMS);
 export const schemasApiClient = new SchemasApi(apiClientConf);
 
-export const fetchSchemasByClusterName =
-  (clusterName: ClusterName): PromiseThunkResult<void> =>
-  async (dispatch) => {
-    dispatch(actions.fetchSchemasByClusterNameAction.request());
-    try {
-      const schemas = await schemasApiClient.getSchemas({ clusterName });
-      dispatch(actions.fetchSchemasByClusterNameAction.success(schemas));
-    } catch (e) {
-      dispatch(actions.fetchSchemasByClusterNameAction.failure());
-    }
-  };
+export const fetchSchemasByClusterName = (
+  clusterName: ClusterName
+): PromiseThunkResult<void> => async (dispatch) => {
+  dispatch(actions.fetchSchemasByClusterNameAction.request());
+  try {
+    const schemas = await schemasApiClient.getSchemas({ clusterName });
+    dispatch(actions.fetchSchemasByClusterNameAction.success(schemas));
+  } catch (e) {
+    dispatch(actions.fetchSchemasByClusterNameAction.failure());
+  }
+};
 
-export const fetchSchemaVersions =
-  (clusterName: ClusterName, subject: SchemaName): PromiseThunkResult<void> =>
-  async (dispatch) => {
-    if (!subject) return;
-    dispatch(actions.fetchSchemaVersionsAction.request());
-    try {
-      const versions = await schemasApiClient.getAllVersionsBySubject({
-        clusterName,
-        subject,
-      });
-      dispatch(actions.fetchSchemaVersionsAction.success(versions));
-    } catch (e) {
-      dispatch(actions.fetchSchemaVersionsAction.failure());
-    }
-  };
+export const fetchSchemaVersions = (
+  clusterName: ClusterName,
+  subject: SchemaName
+): PromiseThunkResult<void> => async (dispatch) => {
+  if (!subject) return;
+  dispatch(actions.fetchSchemaVersionsAction.request());
+  try {
+    const versions = await schemasApiClient.getAllVersionsBySubject({
+      clusterName,
+      subject,
+    });
+    dispatch(actions.fetchSchemaVersionsAction.success(versions));
+  } catch (e) {
+    dispatch(actions.fetchSchemaVersionsAction.failure());
+  }
+};
 
-export const fetchGlobalSchemaCompatibilityLevel =
-  (clusterName: ClusterName): PromiseThunkResult<void> =>
-  async (dispatch) => {
-    dispatch(actions.fetchGlobalSchemaCompatibilityLevelAction.request());
-    try {
-      const result = await schemasApiClient.getGlobalSchemaCompatibilityLevel({
-        clusterName,
-      });
-      dispatch(
-        actions.fetchGlobalSchemaCompatibilityLevelAction.success(
-          result.compatibility
-        )
-      );
-    } catch (e) {
-      dispatch(actions.fetchGlobalSchemaCompatibilityLevelAction.failure());
-    }
-  };
+export const fetchGlobalSchemaCompatibilityLevel = (
+  clusterName: ClusterName
+): PromiseThunkResult<void> => async (dispatch) => {
+  dispatch(actions.fetchGlobalSchemaCompatibilityLevelAction.request());
+  try {
+    const result = await schemasApiClient.getGlobalSchemaCompatibilityLevel({
+      clusterName,
+    });
+    dispatch(
+      actions.fetchGlobalSchemaCompatibilityLevelAction.success(
+        result.compatibility
+      )
+    );
+  } catch (e) {
+    dispatch(actions.fetchGlobalSchemaCompatibilityLevelAction.failure());
+  }
+};
 
-export const updateGlobalSchemaCompatibilityLevel =
-  (
-    clusterName: ClusterName,
-    compatibilityLevel: CompatibilityLevelCompatibilityEnum
-  ): PromiseThunkResult<void> =>
-  async (dispatch) => {
-    dispatch(actions.updateGlobalSchemaCompatibilityLevelAction.request());
-    try {
-      await schemasApiClient.updateGlobalSchemaCompatibilityLevel({
-        clusterName,
-        compatibilityLevel: { compatibility: compatibilityLevel },
-      });
-      dispatch(
-        actions.updateGlobalSchemaCompatibilityLevelAction.success(
-          compatibilityLevel
-        )
-      );
-    } catch (e) {
-      dispatch(actions.updateGlobalSchemaCompatibilityLevelAction.failure());
-    }
-  };
+export const updateGlobalSchemaCompatibilityLevel = (
+  clusterName: ClusterName,
+  compatibilityLevel: CompatibilityLevelCompatibilityEnum
+): PromiseThunkResult<void> => async (dispatch) => {
+  dispatch(actions.updateGlobalSchemaCompatibilityLevelAction.request());
+  try {
+    await schemasApiClient.updateGlobalSchemaCompatibilityLevel({
+      clusterName,
+      compatibilityLevel: { compatibility: compatibilityLevel },
+    });
+    dispatch(
+      actions.updateGlobalSchemaCompatibilityLevelAction.success(
+        compatibilityLevel
+      )
+    );
+  } catch (e) {
+    dispatch(actions.updateGlobalSchemaCompatibilityLevelAction.failure());
+  }
+};
 
-export const createSchema =
-  (
-    clusterName: ClusterName,
-    newSchemaSubject: NewSchemaSubject
-  ): PromiseThunkResult =>
-  async (dispatch) => {
-    dispatch(actions.createSchemaAction.request());
-    try {
-      const schema: SchemaSubject = await schemasApiClient.createNewSchema({
+export const createSchema = (
+  clusterName: ClusterName,
+  newSchemaSubject: NewSchemaSubject
+): PromiseThunkResult => async (dispatch) => {
+  dispatch(actions.createSchemaAction.request());
+  try {
+    const schema: SchemaSubject = await schemasApiClient.createNewSchema({
+      clusterName,
+      newSchemaSubject,
+    });
+    dispatch(actions.createSchemaAction.success(schema));
+  } catch (error) {
+    const response = await getResponse(error);
+    const alert: FailurePayload = {
+      subject: ['schema', newSchemaSubject.subject].join('-'),
+      title: `Schema ${newSchemaSubject.subject}`,
+      response,
+    };
+    dispatch(actions.createSchemaAction.failure({ alert }));
+    throw error;
+  }
+};
+
+export const updateSchema = (
+  latestSchema: SchemaSubject,
+  newSchema: string,
+  newSchemaType: SchemaType,
+  newCompatibilityLevel: CompatibilityLevelCompatibilityEnum,
+  clusterName: string,
+  subject: string
+): PromiseThunkResult => async (dispatch) => {
+  dispatch(actions.updateSchemaAction.request());
+  try {
+    let schema: SchemaSubject = latestSchema;
+    if (
+      (newSchema &&
+        !isEqual(JSON.parse(latestSchema.schema), JSON.parse(newSchema))) ||
+      newSchemaType !== latestSchema.schemaType
+    ) {
+      schema = await schemasApiClient.createNewSchema({
         clusterName,
-        newSchemaSubject,
+        newSchemaSubject: {
+          ...latestSchema,
+          schema: newSchema || latestSchema.schema,
+          schemaType: newSchemaType || latestSchema.schemaType,
+        },
       });
-      dispatch(actions.createSchemaAction.success(schema));
-    } catch (error) {
-      const response = await getResponse(error);
-      const alert: FailurePayload = {
-        subject: ['schema', newSchemaSubject.subject].join('-'),
-        title: `Schema ${newSchemaSubject.subject}`,
-        response,
-      };
-      dispatch(actions.createSchemaAction.failure({ alert }));
-      throw error;
-    }
-  };
-
-export const updateSchema =
-  (
-    latestSchema: SchemaSubject,
-    newSchema: string,
-    newSchemaType: SchemaType,
-    newCompatibilityLevel: CompatibilityLevelCompatibilityEnum,
-    clusterName: string,
-    subject: string
-  ): PromiseThunkResult =>
-  async (dispatch) => {
-    dispatch(actions.updateSchemaAction.request());
-    try {
-      let schema: SchemaSubject = latestSchema;
-      if (
-        (newSchema &&
-          !isEqual(JSON.parse(latestSchema.schema), JSON.parse(newSchema))) ||
-        newSchemaType !== latestSchema.schemaType
-      ) {
-        schema = await schemasApiClient.createNewSchema({
-          clusterName,
-          newSchemaSubject: {
-            ...latestSchema,
-            schema: newSchema || latestSchema.schema,
-            schemaType: newSchemaType || latestSchema.schemaType,
-          },
-        });
-      }
-      if (newCompatibilityLevel !== latestSchema.compatibilityLevel) {
-        await schemasApiClient.updateSchemaCompatibilityLevel({
-          clusterName,
-          subject,
-          compatibilityLevel: {
-            compatibility: newCompatibilityLevel,
-          },
-        });
-      }
-      actions.updateSchemaAction.success(schema);
-    } catch (e) {
-      const response = await getResponse(e);
-      const alert: FailurePayload = {
-        subject: ['schema', subject].join('-'),
-        title: `Schema ${subject}`,
-        response,
-      };
-      dispatch(actions.updateSchemaAction.failure({ alert }));
-      throw e;
     }
-  };
-export const deleteSchema =
-  (clusterName: ClusterName, subject: string): PromiseThunkResult =>
-  async (dispatch) => {
-    dispatch(actions.deleteSchemaAction.request());
-    try {
-      await schemasApiClient.deleteSchema({
+    if (newCompatibilityLevel !== latestSchema.compatibilityLevel) {
+      await schemasApiClient.updateSchemaCompatibilityLevel({
         clusterName,
         subject,
+        compatibilityLevel: {
+          compatibility: newCompatibilityLevel,
+        },
       });
-      dispatch(actions.deleteSchemaAction.success(subject));
-    } catch (error) {
-      const response = await getResponse(error);
-      const alert: FailurePayload = {
-        subject: ['schema', subject].join('-'),
-        title: `Schema ${subject}`,
-        response,
-      };
-      dispatch(actions.deleteSchemaAction.failure({ alert }));
     }
-  };
+    actions.updateSchemaAction.success(schema);
+  } catch (e) {
+    const response = await getResponse(e);
+    const alert: FailurePayload = {
+      subject: ['schema', subject].join('-'),
+      title: `Schema ${subject}`,
+      response,
+    };
+    dispatch(actions.updateSchemaAction.failure({ alert }));
+    throw e;
+  }
+};
+export const deleteSchema = (
+  clusterName: ClusterName,
+  subject: string
+): PromiseThunkResult => async (dispatch) => {
+  dispatch(actions.deleteSchemaAction.request());
+  try {
+    await schemasApiClient.deleteSchema({
+      clusterName,
+      subject,
+    });
+    dispatch(actions.deleteSchemaAction.success(subject));
+  } catch (error) {
+    const response = await getResponse(error);
+    const alert: FailurePayload = {
+      subject: ['schema', subject].join('-'),
+      title: `Schema ${subject}`,
+      response,
+    };
+    dispatch(actions.deleteSchemaAction.failure({ alert }));
+  }
+};

+ 198 - 196
kafka-ui-react-app/src/redux/actions/thunks/topics.ts

@@ -7,6 +7,7 @@ import {
   TopicCreation,
   TopicUpdate,
   TopicConfig,
+  TopicColumnsToSort,
 } from 'generated-sources';
 import {
   PromiseThunkResult,
@@ -30,140 +31,141 @@ export interface FetchTopicsListParams {
   clusterName: ClusterName;
   page?: number;
   perPage?: number;
+  showInternal?: boolean;
+  search?: string;
+  orderBy?: TopicColumnsToSort;
 }
 
-export const fetchTopicsList =
-  (params: FetchTopicsListParams): PromiseThunkResult =>
-  async (dispatch, getState) => {
-    dispatch(actions.fetchTopicsListAction.request());
-    try {
-      const { topics, pageCount } = await topicsApiClient.getTopics(params);
-      const newState = (topics || []).reduce(
-        (memo: TopicsState, topic) => ({
-          ...memo,
-          byName: {
-            ...memo.byName,
-            [topic.name]: {
-              ...memo.byName[topic.name],
-              ...topic,
-              id: v4(),
-            },
+export const fetchTopicsList = (
+  params: FetchTopicsListParams
+): PromiseThunkResult => async (dispatch, getState) => {
+  dispatch(actions.fetchTopicsListAction.request());
+  try {
+    const { topics, pageCount } = await topicsApiClient.getTopics(params);
+    const newState = (topics || []).reduce(
+      (memo: TopicsState, topic) => ({
+        ...memo,
+        byName: {
+          ...memo.byName,
+          [topic.name]: {
+            ...memo.byName[topic.name],
+            ...topic,
+            id: v4(),
           },
-          allNames: [...memo.allNames, topic.name],
-        }),
-        {
-          ...getState().topics,
-          allNames: [],
-          totalPages: pageCount || 1,
-        }
-      );
-      dispatch(actions.fetchTopicsListAction.success(newState));
-    } catch (e) {
-      dispatch(actions.fetchTopicsListAction.failure());
-    }
-  };
+        },
+        allNames: [...memo.allNames, topic.name],
+      }),
+      {
+        ...getState().topics,
+        allNames: [],
+        totalPages: pageCount || 1,
+      }
+    );
+    dispatch(actions.fetchTopicsListAction.success(newState));
+  } catch (e) {
+    dispatch(actions.fetchTopicsListAction.failure());
+  }
+};
 
-export const fetchTopicMessages =
-  (
-    clusterName: ClusterName,
-    topicName: TopicName,
-    queryParams: Partial<TopicMessageQueryParams>
-  ): PromiseThunkResult =>
-  async (dispatch) => {
-    dispatch(actions.fetchTopicMessagesAction.request());
-    try {
-      const messages = await messagesApiClient.getTopicMessages({
-        clusterName,
-        topicName,
-        ...queryParams,
-      });
-      dispatch(actions.fetchTopicMessagesAction.success(messages));
-    } catch (e) {
-      dispatch(actions.fetchTopicMessagesAction.failure());
-    }
-  };
+export const fetchTopicMessages = (
+  clusterName: ClusterName,
+  topicName: TopicName,
+  queryParams: Partial<TopicMessageQueryParams>
+): PromiseThunkResult => async (dispatch) => {
+  dispatch(actions.fetchTopicMessagesAction.request());
+  try {
+    const messages = await messagesApiClient.getTopicMessages({
+      clusterName,
+      topicName,
+      ...queryParams,
+    });
+    dispatch(actions.fetchTopicMessagesAction.success(messages));
+  } catch (e) {
+    dispatch(actions.fetchTopicMessagesAction.failure());
+  }
+};
 
-export const clearTopicMessages =
-  (
-    clusterName: ClusterName,
-    topicName: TopicName,
-    partitions?: number[]
-  ): PromiseThunkResult =>
-  async (dispatch) => {
-    dispatch(actions.clearMessagesTopicAction.request());
-    try {
-      await messagesApiClient.deleteTopicMessages({
-        clusterName,
-        topicName,
-        partitions,
-      });
-      dispatch(actions.clearMessagesTopicAction.success(topicName));
-    } catch (e) {
-      const response = await getResponse(e);
-      const alert: FailurePayload = {
-        subject: [clusterName, topicName, partitions].join('-'),
-        title: `Clear Topic Messages`,
-        response,
-      };
-      dispatch(actions.clearMessagesTopicAction.failure({ alert }));
-    }
-  };
+export const clearTopicMessages = (
+  clusterName: ClusterName,
+  topicName: TopicName,
+  partitions?: number[]
+): PromiseThunkResult => async (dispatch) => {
+  dispatch(actions.clearMessagesTopicAction.request());
+  try {
+    await messagesApiClient.deleteTopicMessages({
+      clusterName,
+      topicName,
+      partitions,
+    });
+    dispatch(actions.clearMessagesTopicAction.success(topicName));
+  } catch (e) {
+    const response = await getResponse(e);
+    const alert: FailurePayload = {
+      subject: [clusterName, topicName, partitions].join('-'),
+      title: `Clear Topic Messages`,
+      response,
+    };
+    dispatch(actions.clearMessagesTopicAction.failure({ alert }));
+  }
+};
 
-export const fetchTopicDetails =
-  (clusterName: ClusterName, topicName: TopicName): PromiseThunkResult =>
-  async (dispatch, getState) => {
-    dispatch(actions.fetchTopicDetailsAction.request());
-    try {
-      const topicDetails = await topicsApiClient.getTopicDetails({
-        clusterName,
-        topicName,
-      });
-      const state = getState().topics;
-      const newState = {
-        ...state,
-        byName: {
-          ...state.byName,
-          [topicName]: {
-            ...state.byName[topicName],
-            ...topicDetails,
-          },
+export const fetchTopicDetails = (
+  clusterName: ClusterName,
+  topicName: TopicName
+): PromiseThunkResult => async (dispatch, getState) => {
+  dispatch(actions.fetchTopicDetailsAction.request());
+  try {
+    const topicDetails = await topicsApiClient.getTopicDetails({
+      clusterName,
+      topicName,
+    });
+    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());
-    }
-  };
+      },
+    };
+    dispatch(actions.fetchTopicDetailsAction.success(newState));
+  } catch (e) {
+    dispatch(actions.fetchTopicDetailsAction.failure());
+  }
+};
 
-export const fetchTopicConfig =
-  (clusterName: ClusterName, topicName: TopicName): PromiseThunkResult =>
-  async (dispatch, getState) => {
-    dispatch(actions.fetchTopicConfigAction.request());
-    try {
-      const config = await topicsApiClient.getTopicConfigs({
-        clusterName,
-        topicName,
-      });
+export const fetchTopicConfig = (
+  clusterName: ClusterName,
+  topicName: TopicName
+): PromiseThunkResult => async (dispatch, getState) => {
+  dispatch(actions.fetchTopicConfigAction.request());
+  try {
+    const config = await topicsApiClient.getTopicConfigs({
+      clusterName,
+      topicName,
+    });
 
-      const state = getState().topics;
-      const newState = {
-        ...state,
-        byName: {
-          ...state.byName,
-          [topicName]: {
-            ...state.byName[topicName],
-            config: config.map((inputConfig) => ({
-              ...inputConfig,
-            })),
-          },
+    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());
-    }
-  };
+    dispatch(actions.fetchTopicConfigAction.success(newState));
+  } catch (e) {
+    dispatch(actions.fetchTopicConfigAction.failure());
+  }
+};
 
 const formatTopicCreation = (form: TopicFormDataRaw): TopicCreation => {
   const {
@@ -231,84 +233,84 @@ const formatTopicUpdate = (form: TopicFormDataRaw): TopicUpdate => {
   };
 };
 
-export const createTopic =
-  (clusterName: ClusterName, form: TopicFormDataRaw): PromiseThunkResult =>
-  async (dispatch, getState) => {
-    dispatch(actions.createTopicAction.request());
-    try {
-      const topic: Topic = await topicsApiClient.createTopic({
-        clusterName,
-        topicCreation: formatTopicCreation(form),
-      });
+export const createTopic = (
+  clusterName: ClusterName,
+  form: TopicFormDataRaw
+): PromiseThunkResult => async (dispatch, getState) => {
+  dispatch(actions.createTopicAction.request());
+  try {
+    const topic: Topic = await topicsApiClient.createTopic({
+      clusterName,
+      topicCreation: formatTopicCreation(form),
+    });
 
-      const state = getState().topics;
-      const newState = {
-        ...state,
-        byName: {
-          ...state.byName,
-          [topic.name]: {
-            ...topic,
-          },
+    const state = getState().topics;
+    const newState = {
+      ...state,
+      byName: {
+        ...state.byName,
+        [topic.name]: {
+          ...topic,
         },
-        allNames: [...state.allNames, topic.name],
-      };
+      },
+      allNames: [...state.allNames, topic.name],
+    };
 
-      dispatch(actions.createTopicAction.success(newState));
-    } catch (error) {
-      const response = await getResponse(error);
-      const alert: FailurePayload = {
-        subject: ['schema', form.name].join('-'),
-        title: `Schema ${form.name}`,
-        response,
-      };
-      dispatch(actions.createTopicAction.failure({ alert }));
-    }
-  };
+    dispatch(actions.createTopicAction.success(newState));
+  } catch (error) {
+    const response = await getResponse(error);
+    const alert: FailurePayload = {
+      subject: ['schema', form.name].join('-'),
+      title: `Schema ${form.name}`,
+      response,
+    };
+    dispatch(actions.createTopicAction.failure({ alert }));
+  }
+};
 
-export const updateTopic =
-  (
-    clusterName: ClusterName,
-    topicName: TopicName,
-    form: TopicFormDataRaw
-  ): PromiseThunkResult =>
-  async (dispatch, getState) => {
-    dispatch(actions.updateTopicAction.request());
-    try {
-      const topic: Topic = await topicsApiClient.updateTopic({
-        clusterName,
-        topicName,
-        topicUpdate: formatTopicUpdate(form),
-      });
+export const updateTopic = (
+  clusterName: ClusterName,
+  topicName: TopicName,
+  form: TopicFormDataRaw
+): PromiseThunkResult => async (dispatch, getState) => {
+  dispatch(actions.updateTopicAction.request());
+  try {
+    const topic: Topic = await topicsApiClient.updateTopic({
+      clusterName,
+      topicName,
+      topicUpdate: formatTopicUpdate(form),
+    });
 
-      const state = getState().topics;
-      const newState = {
-        ...state,
-        byName: {
-          ...state.byName,
-          [topic.name]: {
-            ...state.byName[topic.name],
-            ...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());
-    }
-  };
+    dispatch(actions.updateTopicAction.success(newState));
+  } catch (e) {
+    dispatch(actions.updateTopicAction.failure());
+  }
+};
 
-export const deleteTopic =
-  (clusterName: ClusterName, topicName: TopicName): PromiseThunkResult =>
-  async (dispatch) => {
-    dispatch(actions.deleteTopicAction.request());
-    try {
-      await topicsApiClient.deleteTopic({
-        clusterName,
-        topicName,
-      });
-      dispatch(actions.deleteTopicAction.success(topicName));
-    } catch (e) {
-      dispatch(actions.deleteTopicAction.failure());
-    }
-  };
+export const deleteTopic = (
+  clusterName: ClusterName,
+  topicName: TopicName
+): PromiseThunkResult => async (dispatch) => {
+  dispatch(actions.deleteTopicAction.request());
+  try {
+    await topicsApiClient.deleteTopic({
+      clusterName,
+      topicName,
+    });
+    dispatch(actions.deleteTopicAction.success(topicName));
+  } catch (e) {
+    dispatch(actions.deleteTopicAction.failure());
+  }
+};

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

@@ -5,6 +5,7 @@ import {
   TopicConfig,
   TopicCreation,
   GetTopicMessagesRequest,
+  TopicColumnsToSort,
 } from 'generated-sources';
 
 export type TopicName = Topic['name'];
@@ -45,6 +46,8 @@ export interface TopicsState {
   allNames: TopicName[];
   totalPages: number;
   messages: TopicMessage[];
+  search: string;
+  orderBy: TopicColumnsToSort | null;
 }
 
 export type TopicFormFormattedParams = TopicCreation['configs'];

+ 40 - 11
kafka-ui-react-app/src/redux/reducers/topics/__test__/reducer.spec.ts

@@ -1,4 +1,10 @@
-import { deleteTopicAction, clearMessagesTopicAction } from 'redux/actions';
+import { TopicColumnsToSort } from 'generated-sources';
+import {
+  deleteTopicAction,
+  clearMessagesTopicAction,
+  setTopicsSearchAction,
+  setTopicsOrderByAction,
+} from 'redux/actions';
 import reducer from 'redux/reducers/topics/reducer';
 
 const topic = {
@@ -13,21 +19,44 @@ const state = {
   allNames: [topic.name],
   messages: [],
   totalPages: 1,
+  search: '',
+  orderBy: null,
 };
 
 describe('topics reducer', () => {
-  it('deletes the topic from the list on DELETE_TOPIC__SUCCESS', () => {
-    expect(reducer(state, deleteTopicAction.success(topic.name))).toEqual({
-      byName: {},
-      allNames: [],
-      messages: [],
-      totalPages: 1,
+  describe('delete topic', () => {
+    it('deletes the topic from the list on DELETE_TOPIC__SUCCESS', () => {
+      expect(reducer(state, deleteTopicAction.success(topic.name))).toEqual({
+        ...state,
+        byName: {},
+        allNames: [],
+      });
+    });
+
+    it('delete topic messages on CLEAR_TOPIC_MESSAGES__SUCCESS', () => {
+      expect(
+        reducer(state, clearMessagesTopicAction.success(topic.name))
+      ).toEqual(state);
+    });
+  });
+
+  describe('search topics', () => {
+    it('sets the search string', () => {
+      expect(reducer(state, setTopicsSearchAction('test'))).toEqual({
+        ...state,
+        search: 'test',
+      });
     });
   });
 
-  it('delete topic messages on CLEAR_TOPIC_MESSAGES__SUCCESS', () => {
-    expect(
-      reducer(state, clearMessagesTopicAction.success(topic.name))
-    ).toEqual(state);
+  describe('order topics', () => {
+    it('sets the orderBy', () => {
+      expect(
+        reducer(state, setTopicsOrderByAction(TopicColumnsToSort.NAME))
+      ).toEqual({
+        ...state,
+        orderBy: TopicColumnsToSort.NAME,
+      });
+    });
   });
 });

+ 14 - 0
kafka-ui-react-app/src/redux/reducers/topics/reducer.ts

@@ -8,6 +8,8 @@ export const initialState: TopicsState = {
   allNames: [],
   totalPages: 1,
   messages: [],
+  search: '',
+  orderBy: null,
 };
 
 const transformTopicMessages = (
@@ -59,6 +61,18 @@ const reducer = (state = initialState, action: Action): TopicsState => {
         messages: [],
       };
     }
+    case getType(actions.setTopicsSearchAction): {
+      return {
+        ...state,
+        search: action.payload,
+      };
+    }
+    case getType(actions.setTopicsOrderByAction): {
+      return {
+        ...state,
+        orderBy: action.payload,
+      };
+    }
     default:
       return state;
   }

+ 16 - 4
kafka-ui-react-app/src/redux/reducers/topics/selectors.ts

@@ -18,10 +18,12 @@ export const getTopicListTotalPages = (state: RootState) =>
   topicsState(state).totalPages;
 
 const getTopicListFetchingStatus = createFetchingSelector('GET_TOPICS');
-const getTopicDetailsFetchingStatus =
-  createFetchingSelector('GET_TOPIC_DETAILS');
-const getTopicMessagesFetchingStatus =
-  createFetchingSelector('GET_TOPIC_MESSAGES');
+const getTopicDetailsFetchingStatus = createFetchingSelector(
+  'GET_TOPIC_DETAILS'
+);
+const getTopicMessagesFetchingStatus = createFetchingSelector(
+  'GET_TOPIC_MESSAGES'
+);
 const getTopicConfigFetchingStatus = createFetchingSelector('GET_TOPIC_CONFIG');
 const getTopicCreationStatus = createFetchingSelector('POST_TOPIC');
 const getTopicUpdateStatus = createFetchingSelector('PATCH_TOPIC');
@@ -122,6 +124,16 @@ export const getTopicConfigByParamName = createSelector(
   }
 );
 
+export const getTopicsSearch = createSelector(
+  topicsState,
+  (state) => state.search
+);
+
+export const getTopicsOrderBy = createSelector(
+  topicsState,
+  (state) => state.orderBy
+);
+
 export const getIsTopicInternal = createSelector(
   getTopicByName,
   ({ internal }) => !!internal

+ 4 - 2
kafka-ui-react-app/src/redux/store/configureStore/mockStoreCreator.ts

@@ -6,7 +6,9 @@ import { RootState, Action } from 'redux/interfaces';
 const middlewares: Array<Middleware> = [thunk];
 type DispatchExts = ThunkDispatch<RootState, undefined, Action>;
 
-const mockStoreCreator: MockStoreCreator<RootState, DispatchExts> =
-  configureMockStore<RootState, DispatchExts>(middlewares);
+const mockStoreCreator: MockStoreCreator<
+  RootState,
+  DispatchExts
+> = configureMockStore<RootState, DispatchExts>(middlewares);
 
 export default mockStoreCreator();