Преглед на файлове

#300 Refactor Topic Message pages (#302)

* [CHORE] Update deps. Remove unused

* [CHORE] Cleanup thunk specs.

* [CHORE] Add react-outside-click hook

* [CHORE] Adds Dropdown component

* [CHORE] Adds Dynamic Text Button component

* [CHORE] Refactor useDataSaver hook

* [CHORE] Cleanup

* Refactor topic messages view

* Refactor topic messages view

* Update actions
Oleg Shur преди 4 години
родител
ревизия
398a5be1c9
променени са 31 файла, в които са добавени 680 реда и са изтрити 309 реда
  1. 1 1
      .github/workflows/frontend.yaml
  2. 26 61
      kafka-ui-react-app/package-lock.json
  3. 5 6
      kafka-ui-react-app/package.json
  4. 7 1
      kafka-ui-react-app/sonar-project.properties
  5. 26 28
      kafka-ui-react-app/src/components/Schemas/Details/LatestVersionItem.tsx
  6. 1 1
      kafka-ui-react-app/src/components/Topics/List/List.tsx
  7. 14 9
      kafka-ui-react-app/src/components/Topics/List/ListItem.tsx
  8. 1 1
      kafka-ui-react-app/src/components/Topics/List/__tests__/ListItem.spec.tsx
  9. 40 10
      kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/MessageItem.tsx
  10. 4 3
      kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/MessagesTable.tsx
  11. 2 2
      kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/__test__/MessageItem.spec.tsx
  12. 1 5
      kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/__test__/Messages.spec.tsx
  13. 54 0
      kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/__test__/__snapshots__/MessageItem.spec.tsx.snap
  14. 6 3
      kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/__test__/__snapshots__/MessagesTable.spec.tsx.snap
  15. 2 2
      kafka-ui-react-app/src/components/Topics/Topic/Details/Settings/Settings.tsx
  16. 45 0
      kafka-ui-react-app/src/components/common/Dropdown/Dropdown.tsx
  17. 5 0
      kafka-ui-react-app/src/components/common/Dropdown/DropdownDivider.tsx
  18. 28 0
      kafka-ui-react-app/src/components/common/Dropdown/DropdownItem.tsx
  19. 79 0
      kafka-ui-react-app/src/components/common/Dropdown/__tests__/Dropdown.spec.tsx
  20. 23 0
      kafka-ui-react-app/src/components/common/Dropdown/__tests__/DropdownItem.spec.tsx
  21. 81 0
      kafka-ui-react-app/src/components/common/Dropdown/__tests__/__snapshots__/Dropdown.spec.tsx.snap
  22. 17 0
      kafka-ui-react-app/src/components/common/Dropdown/__tests__/__snapshots__/DropdownItem.spec.tsx.snap
  23. 43 0
      kafka-ui-react-app/src/components/common/DynamicTextButton/DynamicTextButton.tsx
  24. 31 0
      kafka-ui-react-app/src/components/common/DynamicTextButton/__tests__/DynamicTextButton.spec.tsx
  25. 0 44
      kafka-ui-react-app/src/components/common/JSONViewer/DynamicButton.tsx
  26. 4 45
      kafka-ui-react-app/src/components/common/JSONViewer/JSONViewer.tsx
  27. 0 24
      kafka-ui-react-app/src/components/common/JSONViewer/__tests__/DynamicButton.spec.tsx
  28. 55 19
      kafka-ui-react-app/src/lib/hooks/__tests__/useDataSaver.spec.tsx
  29. 18 12
      kafka-ui-react-app/src/lib/hooks/useDataSaver.tsx
  30. 1 32
      kafka-ui-react-app/src/redux/actions/__test__/thunks/schemas.spec.ts
  31. 60 0
      kafka-ui-react-app/src/redux/actions/__test__/thunks/topics.spec.ts

+ 1 - 1
.github/workflows/frontend.yaml

@@ -42,7 +42,7 @@ jobs:
     - name: Tests
       run: |
         cd kafka-ui-react-app/
-        npm run test
+        npm run test:CI
     - name: SonarCloud Scan
       uses: workshur/sonarcloud-github-action@improved_basedir
       with:

+ 26 - 61
kafka-ui-react-app/package-lock.json

@@ -3816,6 +3816,11 @@
         }
       }
     },
+    "@rooks/use-outside-click-ref": {
+      "version": "4.10.0",
+      "resolved": "https://registry.npmjs.org/@rooks/use-outside-click-ref/-/use-outside-click-ref-4.10.0.tgz",
+      "integrity": "sha512-nLhcHaNySs5usbd05zQ0ORtClYh2xNlzVHFzC2pDzNCfff61BxdOA4DKv71FyBwkeEaualWbYnKqFEF/QrbC2A=="
+    },
     "@sinonjs/commons": {
       "version": "1.8.2",
       "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.2.tgz",
@@ -3975,22 +3980,6 @@
         "loader-utils": "^2.0.0"
       }
     },
-    "@testing-library/dom": {
-      "version": "7.30.0",
-      "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.30.0.tgz",
-      "integrity": "sha512-v4GzWtltaiDE0yRikLlcLAfEiiK8+ptu6OuuIebm9GdC2XlZTNDPGEfM2UkEtnH7hr9TRq2sivT5EA9P1Oy7bw==",
-      "dev": true,
-      "requires": {
-        "@babel/code-frame": "^7.10.4",
-        "@babel/runtime": "^7.12.5",
-        "@types/aria-query": "^4.2.0",
-        "aria-query": "^4.2.2",
-        "chalk": "^4.1.0",
-        "dom-accessibility-api": "^0.5.4",
-        "lz-string": "^1.4.4",
-        "pretty-format": "^26.6.2"
-      }
-    },
     "@testing-library/jest-dom": {
       "version": "5.11.9",
       "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.11.9.tgz",
@@ -4065,34 +4054,12 @@
         }
       }
     },
-    "@testing-library/react": {
-      "version": "11.2.5",
-      "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-11.2.5.tgz",
-      "integrity": "sha512-yEx7oIa/UWLe2F2dqK0FtMF9sJWNXD+2PPtp39BvE0Kh9MJ9Kl0HrZAgEuhUJR+Lx8Di6Xz+rKwSdEPY2UV8ZQ==",
-      "dev": true,
-      "requires": {
-        "@babel/runtime": "^7.12.5",
-        "@testing-library/dom": "^7.28.1"
-      }
-    },
-    "@testing-library/user-event": {
-      "version": "7.2.1",
-      "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-7.2.1.tgz",
-      "integrity": "sha512-oZ0Ib5I4Z2pUEcoo95cT1cr6slco9WY7yiPpG+RGNkj8YcYgJnM7pXmYmorNOReh8MIGcKSqXyeGjxnr8YiZbA==",
-      "dev": true
-    },
     "@types/anymatch": {
       "version": "1.3.1",
       "resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-1.3.1.tgz",
       "integrity": "sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA==",
       "dev": true
     },
-    "@types/aria-query": {
-      "version": "4.2.1",
-      "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.1.tgz",
-      "integrity": "sha512-S6oPal772qJZHoRZLFc/XoZW2gFvwXusYUmXPXkgxJLuEk2vOt7jc4Yo6z/vtI0EBkbPBVrJJ0B+prLIKiWqHg==",
-      "dev": true
-    },
     "@types/babel__core": {
       "version": "7.1.13",
       "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.13.tgz",
@@ -8518,12 +8485,6 @@
         "esutils": "^2.0.2"
       }
     },
-    "dom-accessibility-api": {
-      "version": "0.5.4",
-      "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.4.tgz",
-      "integrity": "sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ==",
-      "dev": true
-    },
     "dom-converter": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
@@ -11418,9 +11379,9 @@
       "dev": true
     },
     "husky": {
-      "version": "5.1.3",
-      "resolved": "https://registry.npmjs.org/husky/-/husky-5.1.3.tgz",
-      "integrity": "sha512-fbNJ+Gz5wx2LIBtMweJNY1D7Uc8p1XERi5KNRMccwfQA+rXlxWNSdUxswo0gT8XqxywTIw7Ywm/F4v/O35RdMg==",
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/husky/-/husky-5.2.0.tgz",
+      "integrity": "sha512-AM8T/auHXRBxlrfPVLKP6jt49GCM2Zz47m8G3FOMsLmTv8Dj/fKVWE0Rh2d4Qrvmy131xEsdQnb3OXRib67PGg==",
       "dev": true
     },
     "iconv-lite": {
@@ -11468,11 +11429,6 @@
       "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==",
       "dev": true
     },
-    "immer": {
-      "version": "8.0.2",
-      "resolved": "https://registry.npmjs.org/immer/-/immer-8.0.2.tgz",
-      "integrity": "sha512-PC9UlH8GYfBCoTbPbDEULuXhdmr21+tlv10IzA9Eycpi2Qrgas0j8pUt8Z2ZxVJ/OHIzQq4W8AWymMGkCJplBA=="
-    },
     "import-cwd": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz",
@@ -13361,6 +13317,15 @@
         }
       }
     },
+    "jest-sonar-reporter": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/jest-sonar-reporter/-/jest-sonar-reporter-2.0.0.tgz",
+      "integrity": "sha512-ZervDCgEX5gdUbdtWsjdipLN3bKJwpxbvhkYNXTAYvAckCihobSLr9OT/IuyNIRT1EZMDDwR6DroWtrq+IL64w==",
+      "dev": true,
+      "requires": {
+        "xml": "^1.0.1"
+      }
+    },
     "jest-util": {
       "version": "26.6.2",
       "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-26.6.2.tgz",
@@ -14037,12 +14002,6 @@
         "yallist": "^4.0.0"
       }
     },
-    "lz-string": {
-      "version": "1.4.4",
-      "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
-      "integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=",
-      "dev": true
-    },
     "magic-string": {
       "version": "0.25.7",
       "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz",
@@ -17493,9 +17452,9 @@
       "dev": true
     },
     "react-hook-form": {
-      "version": "6.15.4",
-      "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-6.15.4.tgz",
-      "integrity": "sha512-K+Sw33DtTMengs8OdqFJI3glzNl1wBzSefD/ksQw/hJf9CnOHQAU6qy82eOrh0IRNt2G53sjr7qnnw1JDjvx1w=="
+      "version": "6.15.5",
+      "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-6.15.5.tgz",
+      "integrity": "sha512-so2jEPYKdVk1olMo+HQ9D9n1hVzaPPFO4wsjgSeZ964R7q7CHsYRbVF0PGBi83FcycA5482WHflasdwLIUVENg=="
     },
     "react-is": {
       "version": "17.0.1",
@@ -22692,6 +22651,12 @@
       "integrity": "sha512-Qm8k8ojNQIMx7S+Zp8u/uHOx7Qazv3Yv4q68MiWWWOJhiwG5W3x7iqmRtJo8xxrciZUY4vRxUTJCKuRnF28ZZw==",
       "dev": true
     },
+    "xml": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
+      "integrity": "sha1-eLpyAgApxbyHuKgaPPzXS0ovweU=",
+      "dev": true
+    },
     "xml-name-validator": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz",

+ 5 - 6
kafka-ui-react-app/package.json

@@ -4,20 +4,20 @@
   "private": true,
   "dependencies": {
     "@hookform/error-message": "0.0.5",
+    "@rooks/use-outside-click-ref": "^4.10.0",
     "bulma": "^0.9.2",
     "bulma-switch": "^2.0.0",
     "classnames": "^2.2.6",
     "date-fns": "^2.19.0",
     "eslint-import-resolver-node": "^0.3.4",
     "eslint-import-resolver-typescript": "^2.4.0",
-    "immer": "^8.0.2",
     "lodash": "^4.17.21",
     "node-fetch": "^2.6.1",
     "pretty-ms": "^7.0.1",
     "react": "^17.0.1",
     "react-datepicker": "^3.6.0",
     "react-dom": "^17.0.1",
-    "react-hook-form": "^6.15.4",
+    "react-hook-form": "^6.15.5",
     "react-json-tree": "^0.15.0",
     "react-multi-select-component": "^3.1.6",
     "react-redux": "^7.2.2",
@@ -44,7 +44,7 @@
     "lint": "eslint --ext .tsx,.ts src/",
     "lint:fix": "eslint --ext .tsx,.ts src/ --fix",
     "test": "react-scripts test",
-    "test:CI": "CI=true npm test --watchAll=false",
+    "test:CI": "CI=true npm test  -- --coverage --ci --testResultsProcessor=\"jest-sonar-reporter\" --watchAll=false",
     "eject": "react-scripts eject",
     "tsc": "tsc",
     "prepare": "cd .. && husky install kafka-ui-react-app/.husky",
@@ -69,8 +69,6 @@
     "@jest/types": "^26.6.2",
     "@openapitools/openapi-generator-cli": "^2.2.2",
     "@testing-library/jest-dom": "^5.11.9",
-    "@testing-library/react": "^11.2.5",
-    "@testing-library/user-event": "^7.1.2",
     "@types/classnames": "^2.2.11",
     "@types/enzyme": "^3.10.8",
     "@types/jest": "^26.0.21",
@@ -103,7 +101,8 @@
     "eslint-plugin-react-hooks": "^4.2.0",
     "esprint": "^2.0.0",
     "fetch-mock-jest": "^1.5.1",
-    "husky": "^5.1.3",
+    "husky": "^5.2.0",
+    "jest-sonar-reporter": "^2.0.0",
     "lint-staged": "^10.5.4",
     "node-sass": "^5.0.0",
     "prettier": "^2.2.1",

+ 7 - 1
kafka-ui-react-app/sonar-project.properties

@@ -1,4 +1,10 @@
 sonar.projectKey=provectus_kafka-ui_frontend
 sonar.organization=provectus
 
-sonar.sources=./src
+sonar.sources=.
+sonar.exclusions="**/__test?__/**"
+
+sonar.typescript.lcov.reportPaths=./coverage/lcov.info
+sonar.testExecutionReportPaths=./test-report.xml
+
+sonar.sourceEncoding=UTF-8

+ 26 - 28
kafka-ui-react-app/src/components/Schemas/Details/LatestVersionItem.tsx

@@ -8,36 +8,34 @@ interface LatestVersionProps {
 
 const LatestVersionItem: React.FC<LatestVersionProps> = ({
   schema: { id, subject, schema, compatibilityLevel },
-}) => {
-  return (
-    <div className="tile is-ancestor mt-1">
-      <div className="tile is-4 is-parent">
-        <div className="tile is-child">
-          <table className="table is-fullwidth">
-            <tbody>
-              <tr>
-                <td>ID</td>
-                <td>{id}</td>
-              </tr>
-              <tr>
-                <td>Subject</td>
-                <td>{subject}</td>
-              </tr>
-              <tr>
-                <td>Compatibility</td>
-                <td>{compatibilityLevel}</td>
-              </tr>
-            </tbody>
-          </table>
-        </div>
+}) => (
+  <div className="tile is-ancestor mt-1">
+    <div className="tile is-4 is-parent">
+      <div className="tile is-child">
+        <table className="table is-fullwidth">
+          <tbody>
+            <tr>
+              <td>ID</td>
+              <td>{id}</td>
+            </tr>
+            <tr>
+              <td>Subject</td>
+              <td>{subject}</td>
+            </tr>
+            <tr>
+              <td>Compatibility</td>
+              <td>{compatibilityLevel}</td>
+            </tr>
+          </tbody>
+        </table>
       </div>
-      <div className="tile is-parent">
-        <div className="tile is-child box py-1">
-          <JSONViewer data={JSON.parse(schema)} />
-        </div>
+    </div>
+    <div className="tile is-parent">
+      <div className="tile is-child box py-1">
+        <JSONViewer data={JSON.parse(schema)} />
       </div>
     </div>
-  );
-};
+  </div>
+);
 
 export default LatestVersionItem;

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

@@ -81,7 +81,7 @@ const List: React.FC<Props> = ({
         <PageLoader />
       ) : (
         <div className="box">
-          <table className="table is-striped is-fullwidth">
+          <table className="table is-fullwidth">
             <thead>
               <tr>
                 <th>Topic Name</th>

+ 14 - 9
kafka-ui-react-app/src/components/Topics/List/ListItem.tsx

@@ -6,6 +6,8 @@ import {
   TopicName,
   TopicWithDetailedInfo,
 } from 'redux/interfaces';
+import DropdownItem from 'components/common/Dropdown/DropdownItem';
+import Dropdown from 'components/common/Dropdown/Dropdown';
 
 interface ListItemProps {
   topic: TopicWithDetailedInfo;
@@ -54,16 +56,19 @@ const ListItem: React.FC<ListItemProps> = ({
           {internal ? 'Internal' : 'External'}
         </div>
       </td>
-      <td>
-        <button
-          type="button"
-          className="is-small button is-danger"
-          onClick={deleteTopicHandler}
+      <td className="has-text-right">
+        <Dropdown
+          label={
+            <span className="icon">
+              <i className="fas fa-cog" />
+            </span>
+          }
+          right
         >
-          <span className="icon is-small">
-            <i className="far fa-trash-alt" />
-          </span>
-        </button>
+          <DropdownItem onClick={deleteTopicHandler}>
+            <span className="has-text-danger">Delete Topic</span>
+          </DropdownItem>
+        </Dropdown>
       </td>
     </tr>
   );

+ 1 - 1
kafka-ui-react-app/src/components/Topics/List/__tests__/ListItem.spec.tsx

@@ -14,7 +14,7 @@ describe('ListItem', () => {
         clusterName={clustterName}
       />
     );
-    component.find('button').simulate('click');
+    component.find('DropdownItem').simulate('click');
     expect(mockDelete).toBeCalledTimes(1);
     expect(mockDelete).toBeCalledWith(clustterName, topic.name);
   });

+ 40 - 10
kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/MessageItem.tsx

@@ -2,6 +2,10 @@ import React from 'react';
 import { format } from 'date-fns';
 import { TopicMessage } from 'generated-sources';
 import JSONViewer from 'components/common/JSONViewer/JSONViewer';
+import { isObject } from 'lodash';
+import Dropdown from 'components/common/Dropdown/Dropdown';
+import DropdownItem from 'components/common/Dropdown/DropdownItem';
+import useDataSaver from 'lib/hooks/useDataSaver';
 
 export interface MessageItemProp {
   partition: TopicMessage['partition'];
@@ -15,15 +19,41 @@ const MessageItem: React.FC<MessageItemProp> = ({
   offset,
   timestamp,
   content,
-}) => (
-  <tr>
-    <td style={{ width: 200 }}>{format(timestamp, 'yyyy-MM-dd HH:mm:ss')}</td>
-    <td style={{ width: 150 }}>{offset}</td>
-    <td style={{ width: 100 }}>{partition}</td>
-    <td style={{ wordBreak: 'break-word' }}>
-      {content && <JSONViewer data={content as Record<string, string>} />}
-    </td>
-  </tr>
-);
+}) => {
+  const { copyToClipboard, saveFile } = useDataSaver(
+    'topic-message',
+    (content as Record<string, string>) || ''
+  );
+
+  return (
+    <tr>
+      <td style={{ width: 200 }}>{format(timestamp, 'yyyy-MM-dd HH:mm:ss')}</td>
+      <td style={{ width: 150 }}>{offset}</td>
+      <td style={{ width: 100 }}>{partition}</td>
+      <td style={{ wordBreak: 'break-word' }}>
+        {isObject(content) ? (
+          <JSONViewer data={content as Record<string, string>} />
+        ) : (
+          content
+        )}
+      </td>
+      <td className="has-text-right">
+        <Dropdown
+          label={
+            <span className="icon">
+              <i className="fas fa-cog" />
+            </span>
+          }
+          right
+        >
+          <DropdownItem onClick={copyToClipboard}>
+            Copy to clipboard
+          </DropdownItem>
+          <DropdownItem onClick={saveFile}>Save as a file</DropdownItem>
+        </Dropdown>
+      </td>
+    </tr>
+  );
+};
 
 export default MessageItem;

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

@@ -15,24 +15,25 @@ const MessagesTable: React.FC<MessagesTableProp> = ({ messages, onNext }) => {
 
   return (
     <>
-      <table className="table is-striped is-fullwidth">
+      <table className="table is-fullwidth">
         <thead>
           <tr>
             <th>Timestamp</th>
             <th>Offset</th>
             <th>Partition</th>
             <th>Content</th>
+            <th> </th>
           </tr>
         </thead>
         <tbody>
           {messages.map(
             ({ partition, offset, timestamp, content }: TopicMessage) => (
               <MessageItem
-                key={`message-${timestamp.getTime()}`}
+                key={`message-${timestamp.getTime()}-${offset}`}
                 partition={partition}
                 offset={offset}
                 timestamp={timestamp}
-                content={content as { [key: string]: string }}
+                content={content}
               />
             )
           )}

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

@@ -13,7 +13,7 @@ describe('MessageItem', () => {
       const wrapper = shallow(<MessageItem {...messages[0]} />);
 
       expect(wrapper.find('tr').length).toEqual(1);
-      expect(wrapper.find('td').length).toEqual(4);
+      expect(wrapper.find('td').length).toEqual(5);
       expect(wrapper.find('JSONViewer').length).toEqual(1);
     });
 
@@ -27,7 +27,7 @@ describe('MessageItem', () => {
       const wrapper = shallow(<MessageItem {...messages[1]} />);
 
       expect(wrapper.find('tr').length).toEqual(1);
-      expect(wrapper.find('td').length).toEqual(4);
+      expect(wrapper.find('td').length).toEqual(5);
       expect(wrapper.find('JSONViewer').length).toEqual(0);
     });
 

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

@@ -63,11 +63,7 @@ describe('Messages', () => {
           })
         );
         it('renders table', () => {
-          expect(
-            messagesWrapper.exists(
-              '[className="table is-striped is-fullwidth"]'
-            )
-          ).toBeTruthy();
+          expect(messagesWrapper.exists('.table.is-fullwidth')).toBeTruthy();
         });
         it('renders JSONTree', () => {
           expect(messagesWrapper.find('JSONTree').length).toEqual(1);

+ 54 - 0
kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/__test__/__snapshots__/MessageItem.spec.tsx.snap

@@ -45,6 +45,33 @@ exports[`MessageItem when content is defined matches snapshot 1`] = `
       }
     />
   </td>
+  <td
+    className="has-text-right"
+  >
+    <Dropdown
+      label={
+        <span
+          className="icon"
+        >
+          <i
+            className="fas fa-cog"
+          />
+        </span>
+      }
+      right={true}
+    >
+      <DropdownItem
+        onClick={[Function]}
+      >
+        Copy to clipboard
+      </DropdownItem>
+      <DropdownItem
+        onClick={[Function]}
+      >
+        Save as a file
+      </DropdownItem>
+    </Dropdown>
+  </td>
 </tr>
 `;
 
@@ -84,5 +111,32 @@ exports[`MessageItem when content is undefined matches snapshot 1`] = `
       }
     }
   />
+  <td
+    className="has-text-right"
+  >
+    <Dropdown
+      label={
+        <span
+          className="icon"
+        >
+          <i
+            className="fas fa-cog"
+          />
+        </span>
+      }
+      right={true}
+    >
+      <DropdownItem
+        onClick={[Function]}
+      >
+        Copy to clipboard
+      </DropdownItem>
+      <DropdownItem
+        onClick={[Function]}
+      >
+        Save as a file
+      </DropdownItem>
+    </Dropdown>
+  </td>
 </tr>
 `;

+ 6 - 3
kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/__test__/__snapshots__/MessagesTable.spec.tsx.snap

@@ -3,7 +3,7 @@
 exports[`MessagesTable when topic contains messages matches snapshot 1`] = `
 <Fragment>
   <table
-    className="table is-striped is-fullwidth"
+    className="table is-fullwidth"
   >
     <thead>
       <tr>
@@ -19,6 +19,9 @@ exports[`MessagesTable when topic contains messages matches snapshot 1`] = `
         <th>
           Content
         </th>
+        <th>
+           
+        </th>
       </tr>
     </thead>
     <tbody>
@@ -29,13 +32,13 @@ exports[`MessagesTable when topic contains messages matches snapshot 1`] = `
             "key": "val",
           }
         }
-        key="message-802310400000"
+        key="message-802310400000-2"
         offset={2}
         partition={1}
         timestamp={1995-06-05T00:00:00.000Z}
       />
       <MessageItem
-        key="message-1596585600000"
+        key="message-1596585600000-20"
         offset={20}
         partition={2}
         timestamp={2020-08-05T00:00:00.000Z}

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

@@ -30,7 +30,7 @@ const ConfigListItem: React.FC<ListItemProps> = ({
   );
 };
 
-const Sertings: React.FC<Props> = ({
+const Settings: React.FC<Props> = ({
   clusterName,
   topicName,
   isFetched,
@@ -65,4 +65,4 @@ const Sertings: React.FC<Props> = ({
   );
 };
 
-export default Sertings;
+export default Settings;

+ 45 - 0
kafka-ui-react-app/src/components/common/Dropdown/Dropdown.tsx

@@ -0,0 +1,45 @@
+import useOutsideClickRef from '@rooks/use-outside-click-ref';
+import cx from 'classnames';
+import React, { useCallback, useMemo, useState } from 'react';
+
+export interface DropdownProps {
+  label: React.ReactNode;
+  right?: boolean;
+  up?: boolean;
+}
+
+const Dropdown: React.FC<DropdownProps> = ({ label, right, up, children }) => {
+  const [active, setActive] = useState<boolean>(false);
+  const [wrapperRef] = useOutsideClickRef(() => setActive(false));
+  const onClick = useCallback(() => setActive(!active), [active]);
+
+  const classNames = useMemo(
+    () =>
+      cx('dropdown', {
+        'is-active': active,
+        'is-right': right,
+        'is-up': up,
+      }),
+    [active]
+  );
+  return (
+    <div className={classNames} ref={wrapperRef}>
+      <div className="dropdown-trigger">
+        <button
+          type="button"
+          className="button is-small"
+          aria-haspopup="true"
+          aria-controls="dropdown-menu"
+          onClick={onClick}
+        >
+          {label}
+        </button>
+      </div>
+      <div className="dropdown-menu" id="dropdown-menu" role="menu">
+        <div className="dropdown-content has-text-left">{children}</div>
+      </div>
+    </div>
+  );
+};
+
+export default Dropdown;

+ 5 - 0
kafka-ui-react-app/src/components/common/Dropdown/DropdownDivider.tsx

@@ -0,0 +1,5 @@
+import React from 'react';
+
+const DropdownDivider: React.FC = () => <hr className="dropdown-divider" />;
+
+export default DropdownDivider;

+ 28 - 0
kafka-ui-react-app/src/components/common/Dropdown/DropdownItem.tsx

@@ -0,0 +1,28 @@
+import React, { useCallback } from 'react';
+
+export interface DropdownItemProps {
+  onClick(): void;
+}
+
+const DropdownItem: React.FC<DropdownItemProps> = ({ onClick, children }) => {
+  const onClickHandler = useCallback(
+    (e: React.MouseEvent) => {
+      e.preventDefault();
+      onClick();
+    },
+    [onClick]
+  );
+  return (
+    <a
+      href="#end"
+      onClick={onClickHandler}
+      className="dropdown-item is-link"
+      role="menuitem"
+      type="button"
+    >
+      {children}
+    </a>
+  );
+};
+
+export default DropdownItem;

+ 79 - 0
kafka-ui-react-app/src/components/common/Dropdown/__tests__/Dropdown.spec.tsx

@@ -0,0 +1,79 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import Dropdown, { DropdownProps } from '../Dropdown';
+import DropdownItem from '../DropdownItem';
+import DropdownDivider from '../DropdownDivider';
+
+const dummyLable = 'My Test Label';
+const dummyChildren = (
+  <>
+    <DropdownItem onClick={jest.fn()}>Child 1</DropdownItem>
+    <DropdownItem onClick={jest.fn()}>Child 2</DropdownItem>
+    <DropdownDivider />
+    <DropdownItem onClick={jest.fn()}>Child 3</DropdownItem>
+  </>
+);
+
+describe('Dropdown', () => {
+  const setupWrapper = (
+    props: Partial<DropdownProps> = {},
+    children: React.ReactNode = undefined
+  ) => (
+    <Dropdown label={dummyLable} {...props}>
+      {children}
+    </Dropdown>
+  );
+
+  it('renders Dropdown with initial props', () => {
+    const wrapper = mount(setupWrapper());
+    expect(wrapper.exists('.dropdown')).toBeTruthy();
+
+    expect(wrapper.exists('.dropdown.is-active')).toBeFalsy();
+    expect(wrapper.exists('.dropdown.is-right')).toBeFalsy();
+    expect(wrapper.exists('.dropdown.is-up')).toBeFalsy();
+
+    expect(wrapper.exists('.dropdown-trigger')).toBeTruthy();
+    expect(wrapper.find('.dropdown-trigger').text()).toEqual(dummyLable);
+    expect(wrapper.exists('.dropdown-content')).toBeTruthy();
+    expect(wrapper.find('.dropdown-content').text()).toEqual('');
+  });
+
+  it('renders custom children', () => {
+    const wrapper = mount(setupWrapper({}, dummyChildren));
+    expect(wrapper.exists('.dropdown-content')).toBeTruthy();
+    expect(wrapper.find('.dropdown-item').length).toEqual(3);
+    expect(wrapper.find('.dropdown-divider').length).toEqual(1);
+  });
+
+  it('renders dropdown with a right-aligned menu', () => {
+    const wrapper = mount(setupWrapper({ right: true }));
+    expect(wrapper.exists('.dropdown.is-right')).toBeTruthy();
+  });
+
+  it('renders dropdown with a popup menu', () => {
+    const wrapper = mount(setupWrapper({ up: true }));
+    expect(wrapper.exists('.dropdown.is-up')).toBeTruthy();
+  });
+
+  it('handles click', () => {
+    const wrapper = mount(setupWrapper());
+    expect(wrapper.exists('button')).toBeTruthy();
+    expect(wrapper.exists('.dropdown.is-active')).toBeFalsy();
+
+    wrapper.find('button').simulate('click');
+    expect(wrapper.exists('.dropdown.is-active')).toBeTruthy();
+  });
+
+  it('matches snapshot', () => {
+    const wrapper = mount(
+      setupWrapper(
+        {
+          right: true,
+          up: true,
+        },
+        dummyChildren
+      )
+    );
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 23 - 0
kafka-ui-react-app/src/components/common/Dropdown/__tests__/DropdownItem.spec.tsx

@@ -0,0 +1,23 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import DropdownItem from '../DropdownItem';
+
+const onClick = jest.fn();
+
+describe('DropdownItem', () => {
+  it('matches snapshot', () => {
+    const wrapper = mount(
+      <DropdownItem onClick={jest.fn()}>Item 1</DropdownItem>
+    );
+    expect(onClick).not.toHaveBeenCalled();
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('handles Click', () => {
+    const wrapper = mount(
+      <DropdownItem onClick={onClick}>Item 1</DropdownItem>
+    );
+    wrapper.simulate('click');
+    expect(onClick).toHaveBeenCalled();
+  });
+});

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

@@ -0,0 +1,81 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Dropdown matches snapshot 1`] = `
+<Dropdown
+  label="My Test Label"
+  right={true}
+  up={true}
+>
+  <div
+    className="dropdown is-right is-up"
+  >
+    <div
+      className="dropdown-trigger"
+    >
+      <button
+        aria-controls="dropdown-menu"
+        aria-haspopup="true"
+        className="button is-small"
+        onClick={[Function]}
+        type="button"
+      >
+        My Test Label
+      </button>
+    </div>
+    <div
+      className="dropdown-menu"
+      id="dropdown-menu"
+      role="menu"
+    >
+      <div
+        className="dropdown-content has-text-left"
+      >
+        <DropdownItem
+          onClick={[MockFunction]}
+        >
+          <a
+            className="dropdown-item is-link"
+            href="#end"
+            onClick={[Function]}
+            role="menuitem"
+            type="button"
+          >
+            Child 1
+          </a>
+        </DropdownItem>
+        <DropdownItem
+          onClick={[MockFunction]}
+        >
+          <a
+            className="dropdown-item is-link"
+            href="#end"
+            onClick={[Function]}
+            role="menuitem"
+            type="button"
+          >
+            Child 2
+          </a>
+        </DropdownItem>
+        <DropdownDivider>
+          <hr
+            className="dropdown-divider"
+          />
+        </DropdownDivider>
+        <DropdownItem
+          onClick={[MockFunction]}
+        >
+          <a
+            className="dropdown-item is-link"
+            href="#end"
+            onClick={[Function]}
+            role="menuitem"
+            type="button"
+          >
+            Child 3
+          </a>
+        </DropdownItem>
+      </div>
+    </div>
+  </div>
+</Dropdown>
+`;

+ 17 - 0
kafka-ui-react-app/src/components/common/Dropdown/__tests__/__snapshots__/DropdownItem.spec.tsx.snap

@@ -0,0 +1,17 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DropdownItem matches snapshot 1`] = `
+<DropdownItem
+  onClick={[MockFunction]}
+>
+  <a
+    className="dropdown-item is-link"
+    href="#end"
+    onClick={[Function]}
+    role="menuitem"
+    type="button"
+  >
+    Item 1
+  </a>
+</DropdownItem>
+`;

+ 43 - 0
kafka-ui-react-app/src/components/common/DynamicTextButton/DynamicTextButton.tsx

@@ -0,0 +1,43 @@
+import React, { useCallback } from 'react';
+import cx from 'classnames';
+
+interface DynamicTextButtonProps {
+  onClick(): void;
+  className?: string;
+  title: string;
+  delay?: number;
+  render(clicked: boolean): React.ReactNode;
+}
+
+const DynamicTextButton: React.FC<DynamicTextButtonProps> = ({
+  onClick,
+  className,
+  title,
+  render,
+  delay = 3000,
+}) => {
+  const [clicked, setClicked] = React.useState(false);
+
+  let timeout: number;
+
+  const clickHandler = useCallback(() => {
+    onClick();
+    setClicked(true);
+    timeout = window.setTimeout(() => setClicked(false), delay);
+  }, []);
+
+  React.useEffect(() => () => window.clearTimeout(timeout), []);
+
+  return (
+    <button
+      className={cx('button', className)}
+      title={title}
+      type="button"
+      onClick={clickHandler}
+    >
+      {render(clicked)}
+    </button>
+  );
+};
+
+export default DynamicTextButton;

+ 31 - 0
kafka-ui-react-app/src/components/common/DynamicTextButton/__tests__/DynamicTextButton.spec.tsx

@@ -0,0 +1,31 @@
+import React from 'react';
+import { mount, shallow } from 'enzyme';
+import DynamicTextButton from '../DynamicTextButton';
+
+describe('DynamicButton', () => {
+  const mockCallback = jest.fn();
+  it('exectutes callback', () => {
+    const component = shallow(
+      <DynamicTextButton
+        onClick={mockCallback}
+        title="title"
+        render={() => 'text'}
+      />
+    );
+    component.simulate('click');
+    expect(mockCallback).toBeCalled();
+  });
+
+  it('changes the text', () => {
+    const component = mount(
+      <DynamicTextButton
+        onClick={mockCallback}
+        title="title"
+        render={(clicked) => (clicked ? 'active' : 'default')}
+      />
+    );
+    expect(component.text()).toEqual('default');
+    component.simulate('click');
+    expect(component.text()).toEqual('active');
+  });
+});

+ 0 - 44
kafka-ui-react-app/src/components/common/JSONViewer/DynamicButton.tsx

@@ -1,44 +0,0 @@
-import React from 'react';
-
-interface ButtonProps {
-  callback: () => void;
-  classes?: string;
-  title: string;
-  style?: { [key: string]: string | number };
-  text: {
-    default: string;
-    dynamic: string;
-  };
-}
-
-const DynamicButton: React.FC<ButtonProps> = ({
-  callback,
-  classes,
-  title,
-  style,
-  text,
-  children,
-}) => {
-  const [buttonText, setButtonText] = React.useState(text.default);
-  let timeout: number;
-  const clickHandler = () => {
-    callback();
-    setButtonText(text.dynamic);
-    timeout = window.setTimeout(() => setButtonText(text.default), 3000);
-  };
-  React.useEffect(() => () => window.clearTimeout(timeout), [callback]);
-  return (
-    <button
-      className={classes}
-      title={title}
-      type="button"
-      style={style}
-      onClick={clickHandler}
-    >
-      {children}
-      <span>{buttonText}</span>
-    </button>
-  );
-};
-
-export default DynamicButton;

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

@@ -1,54 +1,13 @@
 import React from 'react';
 import JSONTree from 'react-json-tree';
-import useDataSaver from 'lib/hooks/useDataSaver';
 import theme from './themes/google';
-import DynamicButton from './DynamicButton';
 
 interface JSONViewerProps {
-  data: {
-    [key: string]: string;
-  };
+  data: Record<string, string>;
 }
 
-const JSONViewer: React.FC<JSONViewerProps> = ({ data }) => {
-  const { copyToClipboard, saveFile } = useDataSaver();
-  const copyButtonHandler = () => {
-    copyToClipboard(JSON.stringify(data));
-  };
-  const buttonClasses = 'button is-link is-outlined is-small is-centered';
-  return (
-    <div>
-      <JSONTree
-        data={data}
-        theme={theme}
-        shouldExpandNode={() => true}
-        hideRoot
-      />
-      <div className="field has-addons is-justify-content-flex-end">
-        <DynamicButton
-          callback={copyButtonHandler}
-          classes={`${buttonClasses} mr-1`}
-          title="Copy the message to the clipboard"
-          text={{ default: 'Copy', dynamic: 'Copied!' }}
-        >
-          <span className="icon">
-            <i className="far fa-clipboard" />
-          </span>
-        </DynamicButton>
-        <button
-          className={buttonClasses}
-          title="Download the message as a .json/.txt file"
-          type="button"
-          onClick={() => saveFile(JSON.stringify(data), `topic-message`)}
-        >
-          <span className="icon">
-            <i className="fas fa-file-download" />
-          </span>
-          <span>Save</span>
-        </button>
-      </div>
-    </div>
-  );
-};
+const JSONViewer: React.FC<JSONViewerProps> = ({ data }) => (
+  <JSONTree data={data} theme={theme} shouldExpandNode={() => true} hideRoot />
+);
 
 export default JSONViewer;

+ 0 - 24
kafka-ui-react-app/src/components/common/JSONViewer/__tests__/DynamicButton.spec.tsx

@@ -1,24 +0,0 @@
-import { mount, shallow } from 'enzyme';
-import React from 'react';
-import DynamicButton from '../DynamicButton';
-
-describe('DynamicButton', () => {
-  const mockCallback = jest.fn();
-  const text = { default: 'DefaultText', dynamic: 'DynamicText' };
-  it('exectutes callback', () => {
-    const component = shallow(
-      <DynamicButton callback={mockCallback} title="title" text={text} />
-    );
-    component.simulate('click');
-    expect(mockCallback).toBeCalled();
-  });
-
-  it('changes the text', () => {
-    const component = mount(
-      <DynamicButton callback={mockCallback} title="title" text={text} />
-    );
-    expect(component.text()).toEqual(text.default);
-    component.simulate('click');
-    expect(component.text()).toEqual(text.dynamic);
-  });
-});

+ 55 - 19
kafka-ui-react-app/src/lib/hooks/__tests__/useDataSaver.spec.tsx

@@ -4,23 +4,59 @@ describe('useDataSaver hook', () => {
   const content = {
     title: 'title',
   };
-  it('downloads the file', () => {
-    const link: HTMLAnchorElement = document.createElement('a');
-    link.click = jest.fn();
-    const mockCreate = jest
-      .spyOn(document, 'createElement')
-      .mockImplementation(() => link);
-    const { saveFile } = useDataSaver();
-    saveFile(JSON.stringify(content), 'fileName');
-
-    expect(mockCreate).toHaveBeenCalledTimes(1);
-    expect(link.download).toEqual('fileName.json');
-    expect(link.href).toEqual(
-      `data:text/json;charset=utf-8,${encodeURIComponent(
-        JSON.stringify(content)
-      )}`
-    );
-    expect(link.click).toHaveBeenCalledTimes(1);
+  describe('Save as file', () => {
+    beforeAll(() => {
+      jest.useFakeTimers('modern');
+      jest.setSystemTime(new Date('Wed Mar 24 2021 03:19:56 GMT-0700'));
+    });
+
+    afterAll(() => jest.useRealTimers());
+
+    it('downloads json file', () => {
+      const link: HTMLAnchorElement = document.createElement('a');
+      link.click = jest.fn();
+
+      const mockCreate = jest
+        .spyOn(document, 'createElement')
+        .mockImplementation(() => link);
+
+      const { saveFile } = useDataSaver('message', content);
+      saveFile();
+
+      expect(mockCreate).toHaveBeenCalledTimes(1);
+      expect(link.download).toEqual('message_1616581196000.json');
+      expect(link.href).toEqual(
+        `data:text/json;charset=utf-8,${encodeURIComponent(
+          JSON.stringify(content)
+        )}`
+      );
+      expect(link.click).toHaveBeenCalledTimes(1);
+
+      mockCreate.mockRestore();
+    });
+
+    it('downloads txt file', () => {
+      const link: HTMLAnchorElement = document.createElement('a');
+      link.click = jest.fn();
+
+      const mockCreate = jest
+        .spyOn(document, 'createElement')
+        .mockImplementation(() => link);
+
+      const { saveFile } = useDataSaver('message', 'content');
+      saveFile();
+
+      expect(mockCreate).toHaveBeenCalledTimes(1);
+      expect(link.download).toEqual('message_1616581196000.txt');
+      expect(link.href).toEqual(
+        `data:text/json;charset=utf-8,${encodeURIComponent(
+          JSON.stringify('content')
+        )}`
+      );
+      expect(link.click).toHaveBeenCalledTimes(1);
+
+      mockCreate.mockRestore();
+    });
   });
 
   it('copies the data to the clipboard', () => {
@@ -30,8 +66,8 @@ describe('useDataSaver hook', () => {
       },
     });
     jest.spyOn(navigator.clipboard, 'writeText');
-    const { copyToClipboard } = useDataSaver();
-    copyToClipboard(JSON.stringify(content));
+    const { copyToClipboard } = useDataSaver('topic', content);
+    copyToClipboard();
 
     expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
       JSON.stringify(content)

+ 18 - 12
kafka-ui-react-app/src/lib/hooks/useDataSaver.tsx

@@ -1,21 +1,27 @@
-const useDataSaver = () => {
-  const copyToClipboard = (content: string) => {
-    if (navigator.clipboard) navigator.clipboard.writeText(content);
-  };
+import { isObject } from 'lodash';
 
-  const saveFile = (content: string, fileName: string) => {
-    let extension = 'json';
-    try {
-      JSON.parse(content);
-    } catch (e) {
-      extension = 'txt';
+const useDataSaver = (
+  subject: string,
+  data: Record<string, string> | string
+) => {
+  const copyToClipboard = () => {
+    if (navigator.clipboard) {
+      const str = JSON.stringify(data);
+      navigator.clipboard.writeText(str);
     }
+  };
+
+  const saveFile = () => {
+    const extension = isObject(data) ? 'json' : 'txt';
     const dataStr = `data:text/json;charset=utf-8,${encodeURIComponent(
-      content
+      JSON.stringify(data)
     )}`;
     const downloadAnchorNode = document.createElement('a');
     downloadAnchorNode.setAttribute('href', dataStr);
-    downloadAnchorNode.setAttribute('download', `${fileName}.${extension}`);
+    downloadAnchorNode.setAttribute(
+      'download',
+      `${subject}_${new Date().getTime()}.${extension}`
+    );
     document.body.appendChild(downloadAnchorNode);
     downloadAnchorNode.click();
     downloadAnchorNode.remove();

+ 1 - 32
kafka-ui-react-app/src/redux/actions/__test__/thunks.spec.ts → kafka-ui-react-app/src/redux/actions/__test__/thunks/schemas.spec.ts

@@ -9,7 +9,7 @@ import { RootState, Action } from 'redux/interfaces';
 import * as actions from 'redux/actions/actions';
 import * as thunks from 'redux/actions/thunks';
 import * as schemaFixtures from 'redux/reducers/schemas/__test__/fixtures';
-import * as fixtures from './fixtures';
+import * as fixtures from '../fixtures';
 
 const middlewares: Array<Middleware> = [thunk];
 type DispatchExts = ThunkDispatch<RootState, undefined, Action>;
@@ -22,7 +22,6 @@ const mockStoreCreator: MockStoreCreator<
 const store: MockStoreEnhanced<RootState, DispatchExts> = mockStoreCreator();
 
 const clusterName = 'local';
-const topicName = 'localTopic';
 const subject = 'test';
 
 describe('Thunks', () => {
@@ -138,34 +137,4 @@ describe('Thunks', () => {
       }
     });
   });
-
-  describe('deleteTopis', () => {
-    it('creates DELETE_TOPIC__SUCCESS when deleting existing topic', async () => {
-      fetchMock.deleteOnce(
-        `/api/clusters/${clusterName}/topics/${topicName}`,
-        200
-      );
-      await store.dispatch(thunks.deleteTopic(clusterName, topicName));
-      expect(store.getActions()).toEqual([
-        actions.deleteTopicAction.request(),
-        actions.deleteTopicAction.success(topicName),
-      ]);
-    });
-
-    it('creates DELETE_TOPIC__FAILURE when deleting existing topic', async () => {
-      fetchMock.postOnce(
-        `/api/clusters/${clusterName}/topics/${topicName}`,
-        404
-      );
-      try {
-        await store.dispatch(thunks.deleteTopic(clusterName, topicName));
-      } catch (error) {
-        expect(error.status).toEqual(404);
-        expect(store.getActions()).toEqual([
-          actions.deleteTopicAction.request(),
-          actions.deleteTopicAction.failure(),
-        ]);
-      }
-    });
-  });
 });

+ 60 - 0
kafka-ui-react-app/src/redux/actions/__test__/thunks/topics.spec.ts

@@ -0,0 +1,60 @@
+import configureMockStore, {
+  MockStoreCreator,
+  MockStoreEnhanced,
+} from 'redux-mock-store';
+import thunk, { ThunkDispatch } from 'redux-thunk';
+import fetchMock from 'fetch-mock-jest';
+import { Middleware } from 'redux';
+import { RootState, Action } from 'redux/interfaces';
+import * as actions from 'redux/actions/actions';
+import * as thunks from 'redux/actions/thunks';
+
+const middlewares: Array<Middleware> = [thunk];
+type DispatchExts = ThunkDispatch<RootState, undefined, Action>;
+
+const mockStoreCreator: MockStoreCreator<
+  RootState,
+  DispatchExts
+> = configureMockStore<RootState, DispatchExts>(middlewares);
+
+const store: MockStoreEnhanced<RootState, DispatchExts> = mockStoreCreator();
+
+const clusterName = 'local';
+const topicName = 'localTopic';
+
+describe('Thunks', () => {
+  afterEach(() => {
+    fetchMock.restore();
+    store.clearActions();
+  });
+
+  describe('deleteTopis', () => {
+    it('creates DELETE_TOPIC__SUCCESS when deleting existing topic', async () => {
+      fetchMock.deleteOnce(
+        `/api/clusters/${clusterName}/topics/${topicName}`,
+        200
+      );
+      await store.dispatch(thunks.deleteTopic(clusterName, topicName));
+      expect(store.getActions()).toEqual([
+        actions.deleteTopicAction.request(),
+        actions.deleteTopicAction.success(topicName),
+      ]);
+    });
+
+    it('creates DELETE_TOPIC__FAILURE when deleting existing topic', async () => {
+      fetchMock.deleteOnce(
+        `/api/clusters/${clusterName}/topics/${topicName}`,
+        404
+      );
+      try {
+        await store.dispatch(thunks.deleteTopic(clusterName, topicName));
+      } catch (error) {
+        expect(error.status).toEqual(404);
+        expect(store.getActions()).toEqual([
+          actions.deleteTopicAction.request(),
+          actions.deleteTopicAction.failure(),
+        ]);
+      }
+    });
+  });
+});