Browse Source

Schema Registry Views (#195)

* Schema Registry index page https://github.com/provectus/kafka-ui/pull/183

* Schema Registry show page https://github.com/provectus/kafka-ui/pull/196

* Specs https://github.com/provectus/kafka-ui/pull/208

* New JsonViewer common component
Oleg Shur 4 years ago
parent
commit
3bc9447cc7
49 changed files with 2092 additions and 83 deletions
  1. 2 0
      docker/kafka-ui.yaml
  2. 47 30
      kafka-ui-react-app/package-lock.json
  3. 2 1
      kafka-ui-react-app/package.json
  4. 7 2
      kafka-ui-react-app/src/components/App.tsx
  5. 8 0
      kafka-ui-react-app/src/components/Nav/ClusterMenu.tsx
  6. 0 0
      kafka-ui-react-app/src/components/Nav/NavContainer.ts
  7. 110 0
      kafka-ui-react-app/src/components/Schemas/Details/Details.tsx
  8. 39 0
      kafka-ui-react-app/src/components/Schemas/Details/DetailsContainer.ts
  9. 43 0
      kafka-ui-react-app/src/components/Schemas/Details/LatestVersionItem.tsx
  10. 23 0
      kafka-ui-react-app/src/components/Schemas/Details/SchemaVersion.tsx
  11. 108 0
      kafka-ui-react-app/src/components/Schemas/Details/__test__/Details.spec.tsx
  12. 18 0
      kafka-ui-react-app/src/components/Schemas/Details/__test__/LatestVersionItem.spec.tsx
  13. 17 0
      kafka-ui-react-app/src/components/Schemas/Details/__test__/SchemaVersion.spec.tsx
  14. 461 0
      kafka-ui-react-app/src/components/Schemas/Details/__test__/__snapshots__/Details.spec.tsx.snap
  15. 69 0
      kafka-ui-react-app/src/components/Schemas/Details/__test__/__snapshots__/LatestVersionItem.spec.tsx.snap
  16. 31 0
      kafka-ui-react-app/src/components/Schemas/Details/__test__/__snapshots__/SchemaVersion.spec.tsx.snap
  17. 29 0
      kafka-ui-react-app/src/components/Schemas/Details/__test__/fixtures.ts
  18. 34 0
      kafka-ui-react-app/src/components/Schemas/List/List.tsx
  19. 11 0
      kafka-ui-react-app/src/components/Schemas/List/ListContainer.tsx
  20. 32 0
      kafka-ui-react-app/src/components/Schemas/List/ListItem.tsx
  21. 56 0
      kafka-ui-react-app/src/components/Schemas/List/__test__/List.spec.tsx
  22. 26 0
      kafka-ui-react-app/src/components/Schemas/List/__test__/ListItem.spec.tsx
  23. 102 0
      kafka-ui-react-app/src/components/Schemas/List/__test__/__snapshots__/List.spec.tsx.snap
  24. 94 0
      kafka-ui-react-app/src/components/Schemas/List/__test__/__snapshots__/ListItem.spec.tsx.snap
  25. 28 0
      kafka-ui-react-app/src/components/Schemas/List/__test__/fixtures.ts
  26. 43 0
      kafka-ui-react-app/src/components/Schemas/Schemas.tsx
  27. 32 0
      kafka-ui-react-app/src/components/Schemas/SchemasContainer.tsx
  28. 86 0
      kafka-ui-react-app/src/components/Schemas/__test__/Schemas.spec.tsx
  29. 18 0
      kafka-ui-react-app/src/components/Schemas/__test__/__snapshots__/Schemas.spec.tsx.snap
  30. 2 23
      kafka-ui-react-app/src/components/Topics/Details/Messages/MessageItem.tsx
  31. 1 1
      kafka-ui-react-app/src/components/Topics/Details/Messages/MessagesTable.tsx
  32. 2 2
      kafka-ui-react-app/src/components/Topics/Details/Messages/__test__/MessageItem.spec.tsx
  33. 1 23
      kafka-ui-react-app/src/components/Topics/Details/Messages/__test__/__snapshots__/MessageItem.spec.tsx.snap
  34. 15 0
      kafka-ui-react-app/src/components/common/JSONViewer/JSONViewer.tsx
  35. 20 0
      kafka-ui-react-app/src/components/common/JSONViewer/themes/google.ts
  36. 2 0
      kafka-ui-react-app/src/lib/paths.ts
  37. 50 0
      kafka-ui-react-app/src/redux/actions/__test__/actions.spec.ts
  38. 56 0
      kafka-ui-react-app/src/redux/actions/__test__/thunks.spec.ts
  39. 13 0
      kafka-ui-react-app/src/redux/actions/actions.ts
  40. 31 1
      kafka-ui-react-app/src/redux/actions/thunks.ts
  41. 3 0
      kafka-ui-react-app/src/redux/interfaces/index.ts
  42. 9 0
      kafka-ui-react-app/src/redux/interfaces/schema.ts
  43. 2 0
      kafka-ui-react-app/src/redux/reducers/index.ts
  44. 58 0
      kafka-ui-react-app/src/redux/reducers/schemas/__test__/__snapshots__/reducer.spec.ts.snap
  45. 54 0
      kafka-ui-react-app/src/redux/reducers/schemas/__test__/fixtures.ts
  46. 39 0
      kafka-ui-react-app/src/redux/reducers/schemas/__test__/reducer.spec.ts
  47. 64 0
      kafka-ui-react-app/src/redux/reducers/schemas/__test__/selectors.spec.ts
  48. 46 0
      kafka-ui-react-app/src/redux/reducers/schemas/reducer.ts
  49. 48 0
      kafka-ui-react-app/src/redux/reducers/schemas/selectors.ts

+ 2 - 0
docker/kafka-ui.yaml

@@ -30,6 +30,8 @@ services:
     environment:
     environment:
       ZOOKEEPER_CLIENT_PORT: 2181
       ZOOKEEPER_CLIENT_PORT: 2181
       ZOOKEEPER_TICK_TIME: 2000
       ZOOKEEPER_TICK_TIME: 2000
+    ports:
+      - 2181:2181
 
 
   kafka0:
   kafka0:
     image: confluentinc/cp-kafka:5.1.0
     image: confluentinc/cp-kafka:5.1.0

+ 47 - 30
kafka-ui-react-app/package-lock.json

@@ -2725,12 +2725,14 @@
     "@types/node": {
     "@types/node": {
       "version": "12.20.2",
       "version": "12.20.2",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.2.tgz",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.2.tgz",
-      "integrity": "sha512-djoyN0pvTje9Lpu25ZwZwlLQICPiuv42omiydLhl7om+og1RhQboGmar12KaKls8soTUQ893TuWCrlyt8B1pVg=="
+      "integrity": "sha512-djoyN0pvTje9Lpu25ZwZwlLQICPiuv42omiydLhl7om+og1RhQboGmar12KaKls8soTUQ893TuWCrlyt8B1pVg==",
+      "dev": true
     },
     },
     "@types/node-fetch": {
     "@types/node-fetch": {
       "version": "2.5.8",
       "version": "2.5.8",
       "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.8.tgz",
       "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.8.tgz",
       "integrity": "sha512-fbjI6ja0N5ZA8TV53RUqzsKNkl9fv8Oj3T7zxW7FGv1GSH7gwJaNF8dzCjrqKaxKeUpTz4yT1DaJFq/omNpGfw==",
       "integrity": "sha512-fbjI6ja0N5ZA8TV53RUqzsKNkl9fv8Oj3T7zxW7FGv1GSH7gwJaNF8dzCjrqKaxKeUpTz4yT1DaJFq/omNpGfw==",
+      "dev": true,
       "requires": {
       "requires": {
         "@types/node": "*",
         "@types/node": "*",
         "form-data": "^3.0.0"
         "form-data": "^3.0.0"
@@ -2740,6 +2742,7 @@
           "version": "3.0.1",
           "version": "3.0.1",
           "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
           "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
           "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==",
           "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==",
+          "dev": true,
           "requires": {
           "requires": {
             "asynckit": "^0.4.0",
             "asynckit": "^0.4.0",
             "combined-stream": "^1.0.8",
             "combined-stream": "^1.0.8",
@@ -4214,7 +4217,8 @@
     "asynckit": {
     "asynckit": {
       "version": "0.4.0",
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
       "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
-      "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
+      "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
+      "dev": true
     },
     },
     "at-least-node": {
     "at-least-node": {
       "version": "1.0.0",
       "version": "1.0.0",
@@ -5693,6 +5697,7 @@
       "version": "1.0.8",
       "version": "1.0.8",
       "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
       "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
       "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
       "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "dev": true,
       "requires": {
       "requires": {
         "delayed-stream": "~1.0.0"
         "delayed-stream": "~1.0.0"
       }
       }
@@ -6643,7 +6648,8 @@
     "delayed-stream": {
     "delayed-stream": {
       "version": "1.0.0",
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
       "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
-      "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
+      "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
+      "dev": true
     },
     },
     "delegates": {
     "delegates": {
       "version": "1.0.0",
       "version": "1.0.0",
@@ -9332,19 +9338,6 @@
       "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==",
       "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==",
       "dev": true
       "dev": true
     },
     },
-    "history": {
-      "version": "4.10.1",
-      "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz",
-      "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==",
-      "requires": {
-        "@babel/runtime": "^7.1.2",
-        "loose-envify": "^1.2.0",
-        "resolve-pathname": "^3.0.0",
-        "tiny-invariant": "^1.0.2",
-        "tiny-warning": "^1.0.0",
-        "value-equal": "^1.0.1"
-      }
-    },
     "hmac-drbg": {
     "hmac-drbg": {
       "version": "1.0.1",
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
       "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
@@ -12576,12 +12569,14 @@
     "mime-db": {
     "mime-db": {
       "version": "1.45.0",
       "version": "1.45.0",
       "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.45.0.tgz",
       "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.45.0.tgz",
-      "integrity": "sha512-CkqLUxUk15hofLoLyljJSrukZi8mAtgd+yE5uO4tqRZsdsAJKv0O+rFMhVDRJgozy+yG6md5KwuXhD4ocIoP+w=="
+      "integrity": "sha512-CkqLUxUk15hofLoLyljJSrukZi8mAtgd+yE5uO4tqRZsdsAJKv0O+rFMhVDRJgozy+yG6md5KwuXhD4ocIoP+w==",
+      "dev": true
     },
     },
     "mime-types": {
     "mime-types": {
       "version": "2.1.28",
       "version": "2.1.28",
       "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.28.tgz",
       "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.28.tgz",
       "integrity": "sha512-0TO2yJ5YHYr7M2zzT7gDU1tbwHxEUWBCLt0lscSNpcdAfFyJOVEpRYNS7EXVcTLNj/25QO8gulHC5JtTzSE2UQ==",
       "integrity": "sha512-0TO2yJ5YHYr7M2zzT7gDU1tbwHxEUWBCLt0lscSNpcdAfFyJOVEpRYNS7EXVcTLNj/25QO8gulHC5JtTzSE2UQ==",
+      "dev": true,
       "requires": {
       "requires": {
         "mime-db": "1.45.0"
         "mime-db": "1.45.0"
       }
       }
@@ -13570,9 +13565,9 @@
       }
       }
     },
     },
     "open": {
     "open": {
-      "version": "7.4.1",
-      "resolved": "https://registry.npmjs.org/open/-/open-7.4.1.tgz",
-      "integrity": "sha512-Pxv+fKRsd/Ozflgn2Gjev1HZveJJeKR6hKKmdaImJMuEZ6htAvCTbcMABJo+qevlAelTLCrEK3YTKZ9fVTcSPw==",
+      "version": "7.4.2",
+      "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
+      "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
       "dev": true,
       "dev": true,
       "requires": {
       "requires": {
         "is-docker": "^2.0.0",
         "is-docker": "^2.0.0",
@@ -15601,9 +15596,9 @@
       }
       }
     },
     },
     "react-dev-utils": {
     "react-dev-utils": {
-      "version": "11.0.2",
-      "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.2.tgz",
-      "integrity": "sha512-xG7GlMoYkrgc2M1kDCHKRywXMDbFnjOB+/VzpytQyYBusEzR8NlGTMmUbvN86k94yyKu5XReHB8eZC2JZrNchQ==",
+      "version": "11.0.3",
+      "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.3.tgz",
+      "integrity": "sha512-4lEA5gF4OHrcJLMUV1t+4XbNDiJbsAWCH5Z2uqlTqW6dD7Cf5nEASkeXrCI/Mz83sI2o527oBIFKVMXtRf1Vtg==",
       "dev": true,
       "dev": true,
       "requires": {
       "requires": {
         "@babel/code-frame": "7.10.4",
         "@babel/code-frame": "7.10.4",
@@ -15619,7 +15614,7 @@
         "global-modules": "2.0.0",
         "global-modules": "2.0.0",
         "globby": "11.0.1",
         "globby": "11.0.1",
         "gzip-size": "5.1.1",
         "gzip-size": "5.1.1",
-        "immer": "7.0.9",
+        "immer": "8.0.1",
         "is-root": "2.1.0",
         "is-root": "2.1.0",
         "loader-utils": "2.0.0",
         "loader-utils": "2.0.0",
         "open": "^7.0.2",
         "open": "^7.0.2",
@@ -15732,12 +15727,6 @@
           "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
           "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
           "dev": true
           "dev": true
         },
         },
-        "immer": {
-          "version": "7.0.9",
-          "resolved": "https://registry.npmjs.org/immer/-/immer-7.0.9.tgz",
-          "integrity": "sha512-Vs/gxoM4DqNAYR7pugIxi0Xc8XAun/uy7AQu4fLLqaTBHxjOP9pJ266Q9MWA/ly4z6rAFZbvViOtihxUZ7O28A==",
-          "dev": true
-        },
         "locate-path": {
         "locate-path": {
           "version": "5.0.0",
           "version": "5.0.0",
           "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
           "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@@ -15894,6 +15883,19 @@
         "tiny-warning": "^1.0.0"
         "tiny-warning": "^1.0.0"
       },
       },
       "dependencies": {
       "dependencies": {
+        "history": {
+          "version": "4.10.1",
+          "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz",
+          "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==",
+          "requires": {
+            "@babel/runtime": "^7.1.2",
+            "loose-envify": "^1.2.0",
+            "resolve-pathname": "^3.0.0",
+            "tiny-invariant": "^1.0.2",
+            "tiny-warning": "^1.0.0",
+            "value-equal": "^1.0.1"
+          }
+        },
         "react-is": {
         "react-is": {
           "version": "16.13.1",
           "version": "16.13.1",
           "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
           "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -15913,6 +15915,21 @@
         "react-router": "5.2.0",
         "react-router": "5.2.0",
         "tiny-invariant": "^1.0.2",
         "tiny-invariant": "^1.0.2",
         "tiny-warning": "^1.0.0"
         "tiny-warning": "^1.0.0"
+      },
+      "dependencies": {
+        "history": {
+          "version": "4.10.1",
+          "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz",
+          "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==",
+          "requires": {
+            "@babel/runtime": "^7.1.2",
+            "loose-envify": "^1.2.0",
+            "resolve-pathname": "^3.0.0",
+            "tiny-invariant": "^1.0.2",
+            "tiny-warning": "^1.0.0",
+            "value-equal": "^1.0.1"
+          }
+        }
       }
       }
     },
     },
     "react-scripts": {
     "react-scripts": {

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

@@ -4,7 +4,6 @@
   "private": true,
   "private": true,
   "dependencies": {
   "dependencies": {
     "@hookform/error-message": "0.0.5",
     "@hookform/error-message": "0.0.5",
-    "@types/node-fetch": "^2.5.8",
     "bulma": "^0.9.2",
     "bulma": "^0.9.2",
     "bulma-switch": "^2.0.0",
     "bulma-switch": "^2.0.0",
     "classnames": "^2.2.6",
     "classnames": "^2.2.6",
@@ -22,6 +21,7 @@
     "react-json-tree": "^0.13.0",
     "react-json-tree": "^0.13.0",
     "react-multi-select-component": "^2.0.14",
     "react-multi-select-component": "^2.0.14",
     "react-redux": "^7.2.2",
     "react-redux": "^7.2.2",
+    "react-router": "^5.2.0",
     "react-router-dom": "^5.2.0",
     "react-router-dom": "^5.2.0",
     "redux": "^4.0.5",
     "redux": "^4.0.5",
     "redux-thunk": "^2.3.0",
     "redux-thunk": "^2.3.0",
@@ -77,6 +77,7 @@
     "@types/jest": "^26.0.20",
     "@types/jest": "^26.0.20",
     "@types/lodash": "^4.14.165",
     "@types/lodash": "^4.14.165",
     "@types/node": "^12.20.2",
     "@types/node": "^12.20.2",
+    "@types/node-fetch": "^2.5.8",
     "@types/react": "^17.0.2",
     "@types/react": "^17.0.2",
     "@types/react-datepicker": "^3.1.1",
     "@types/react-datepicker": "^3.1.1",
     "@types/react-dom": "^17.0.1",
     "@types/react-dom": "^17.0.1",

+ 7 - 2
kafka-ui-react-app/src/components/App.tsx

@@ -3,10 +3,11 @@ import { Switch, Route, Redirect } from 'react-router-dom';
 import './App.scss';
 import './App.scss';
 import BrokersContainer from './Brokers/BrokersContainer';
 import BrokersContainer from './Brokers/BrokersContainer';
 import TopicsContainer from './Topics/TopicsContainer';
 import TopicsContainer from './Topics/TopicsContainer';
-import NavConatiner from './Nav/NavConatiner';
+import NavContainer from './Nav/NavContainer';
 import PageLoader from './common/PageLoader/PageLoader';
 import PageLoader from './common/PageLoader/PageLoader';
 import Dashboard from './Dashboard/Dashboard';
 import Dashboard from './Dashboard/Dashboard';
 import ConsumersGroupsContainer from './ConsumerGroups/ConsumersGroupsContainer';
 import ConsumersGroupsContainer from './ConsumerGroups/ConsumersGroupsContainer';
+import SchemasContainer from './Schemas/SchemasContainer';
 
 
 interface AppProps {
 interface AppProps {
   isClusterListFetched: boolean;
   isClusterListFetched: boolean;
@@ -35,7 +36,7 @@ const App: React.FC<AppProps> = ({
         </div>
         </div>
       </nav>
       </nav>
       <main className="Layout__container">
       <main className="Layout__container">
-        <NavConatiner className="Layout__navbar" />
+        <NavContainer className="Layout__navbar" />
         {isClusterListFetched ? (
         {isClusterListFetched ? (
           <Switch>
           <Switch>
             <Route
             <Route
@@ -55,6 +56,10 @@ const App: React.FC<AppProps> = ({
               path="/ui/clusters/:clusterName/consumer-groups"
               path="/ui/clusters/:clusterName/consumer-groups"
               component={ConsumersGroupsContainer}
               component={ConsumersGroupsContainer}
             />
             />
+            <Route
+              path="/ui/clusters/:clusterName/schemas"
+              component={SchemasContainer}
+            />
             <Redirect
             <Redirect
               from="/ui/clusters/:clusterName"
               from="/ui/clusters/:clusterName"
               to="/ui/clusters/:clusterName/brokers"
               to="/ui/clusters/:clusterName/brokers"

+ 8 - 0
kafka-ui-react-app/src/components/Nav/ClusterMenu.tsx

@@ -4,6 +4,7 @@ import {
   clusterBrokersPath,
   clusterBrokersPath,
   clusterTopicsPath,
   clusterTopicsPath,
   clusterConsumerGroupsPath,
   clusterConsumerGroupsPath,
+  clusterSchemasPath,
 } from 'lib/paths';
 } from 'lib/paths';
 import { Cluster, ServerStatus } from 'generated-sources';
 import { Cluster, ServerStatus } from 'generated-sources';
 
 
@@ -85,6 +86,13 @@ const ClusterMenu: React.FC<Props> = ({ cluster }) => (
         >
         >
           Consumers
           Consumers
         </NavLink>
         </NavLink>
+        <NavLink
+          to={clusterSchemasPath(cluster.name)}
+          activeClassName="is-active"
+          title="Schema Registry"
+        >
+          Schema Registry
+        </NavLink>
       </ul>
       </ul>
     </li>
     </li>
   </ul>
   </ul>

+ 0 - 0
kafka-ui-react-app/src/components/Nav/NavConatiner.ts → kafka-ui-react-app/src/components/Nav/NavContainer.ts


+ 110 - 0
kafka-ui-react-app/src/components/Schemas/Details/Details.tsx

@@ -0,0 +1,110 @@
+import React from 'react';
+import { SchemaSubject } from 'generated-sources';
+import { ClusterName, SchemaName } from 'redux/interfaces';
+import { clusterSchemasPath } from 'lib/paths';
+import Breadcrumb from '../../common/Breadcrumb/Breadcrumb';
+import SchemaVersion from './SchemaVersion';
+import LatestVersionItem from './LatestVersionItem';
+import PageLoader from '../../common/PageLoader/PageLoader';
+
+export interface DetailsProps {
+  schema: SchemaSubject;
+  clusterName: ClusterName;
+  versions: SchemaSubject[];
+  isFetched: boolean;
+  fetchSchemaVersions: (
+    clusterName: ClusterName,
+    schemaName: SchemaName
+  ) => void;
+}
+
+const Details: React.FC<DetailsProps> = ({
+  schema,
+  clusterName,
+  fetchSchemaVersions,
+  versions,
+  isFetched,
+}) => {
+  React.useEffect(() => {
+    fetchSchemaVersions(clusterName, schema.subject as SchemaName);
+  }, [fetchSchemaVersions, clusterName]);
+  return (
+    <div className="section">
+      <div className="level">
+        <Breadcrumb
+          links={[
+            {
+              href: clusterSchemasPath(clusterName),
+              label: 'Schema Registry',
+            },
+          ]}
+        >
+          {schema.subject}
+        </Breadcrumb>
+      </div>
+      <div className="box">
+        <div className="level">
+          <div className="level-left">
+            <div className="level-item">
+              <div className="mr-1">
+                <b>Latest Version</b>
+              </div>
+              <div className="tag is-info is-light" title="Version">
+                #{schema.version}
+              </div>
+            </div>
+          </div>
+          <div className="level-right">
+            <button
+              className="button is-primary is-small level-item"
+              type="button"
+              title="in development"
+              disabled
+            >
+              Create Schema
+            </button>
+            <button
+              className="button is-warning is-small level-item"
+              type="button"
+              title="in development"
+              disabled
+            >
+              Update Schema
+            </button>
+            <button
+              className="button is-danger is-small level-item"
+              type="button"
+              title="in development"
+              disabled
+            >
+              Delete
+            </button>
+          </div>
+        </div>
+        <LatestVersionItem schema={schema} />
+      </div>
+      {isFetched ? (
+        <div className="box">
+          <table className="table is-striped is-fullwidth">
+            <thead>
+              <tr>
+                <th>Version</th>
+                <th>ID</th>
+                <th>Schema</th>
+              </tr>
+            </thead>
+            <tbody>
+              {versions.map((version) => (
+                <SchemaVersion key={version.id} version={version} />
+              ))}
+            </tbody>
+          </table>
+        </div>
+      ) : (
+        <PageLoader />
+      )}
+    </div>
+  );
+};
+
+export default Details;

+ 39 - 0
kafka-ui-react-app/src/components/Schemas/Details/DetailsContainer.ts

@@ -0,0 +1,39 @@
+import { connect } from 'react-redux';
+import { ClusterName, RootState } from 'redux/interfaces';
+import { RouteComponentProps, withRouter } from 'react-router-dom';
+import {
+  getIsSchemaVersionFetched,
+  getSchema,
+  getSortedSchemaVersions,
+} from 'redux/reducers/schemas/selectors';
+import { fetchSchemaVersions } from 'redux/actions';
+import Details from './Details';
+
+interface RouteProps {
+  clusterName: ClusterName;
+  subject: string;
+}
+
+type OwnProps = RouteComponentProps<RouteProps>;
+
+const mapStateToProps = (
+  state: RootState,
+  {
+    match: {
+      params: { clusterName, subject },
+    },
+  }: OwnProps
+) => ({
+  schema: getSchema(state, subject),
+  versions: getSortedSchemaVersions(state),
+  isFetched: getIsSchemaVersionFetched(state),
+  clusterName,
+});
+
+const mapDispatchToProps = {
+  fetchSchemaVersions,
+};
+
+export default withRouter(
+  connect(mapStateToProps, mapDispatchToProps)(Details)
+);

+ 43 - 0
kafka-ui-react-app/src/components/Schemas/Details/LatestVersionItem.tsx

@@ -0,0 +1,43 @@
+import React from 'react';
+import { SchemaSubject } from 'generated-sources';
+import JSONViewer from 'components/common/JSONViewer/JSONViewer';
+
+interface LatestVersionProps {
+  schema: SchemaSubject;
+}
+
+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>
+      <div className="tile is-parent">
+        <div className="tile is-child box py-1">
+          <JSONViewer data={JSON.parse(schema as string)} />
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default LatestVersionItem;

+ 23 - 0
kafka-ui-react-app/src/components/Schemas/Details/SchemaVersion.tsx

@@ -0,0 +1,23 @@
+import React from 'react';
+import { SchemaSubject } from 'generated-sources';
+import JSONViewer from 'components/common/JSONViewer/JSONViewer';
+
+interface SchemaVersionProps {
+  version: SchemaSubject;
+}
+
+const SchemaVersion: React.FC<SchemaVersionProps> = ({
+  version: { version, id, schema },
+}) => {
+  return (
+    <tr>
+      <td>{version}</td>
+      <td>{id}</td>
+      <td className="py-0">
+        <JSONViewer data={JSON.parse(schema as string)} />
+      </td>
+    </tr>
+  );
+};
+
+export default SchemaVersion;

+ 108 - 0
kafka-ui-react-app/src/components/Schemas/Details/__test__/Details.spec.tsx

@@ -0,0 +1,108 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+import { shallow } from 'enzyme';
+import configureStore from 'redux/store/configureStore';
+import DetailsContainer from '../DetailsContainer';
+import Details, { DetailsProps } from '../Details';
+import { schema, versions } from './fixtures';
+
+describe('Details', () => {
+  describe('Container', () => {
+    const store = configureStore();
+
+    it('renders view', () => {
+      const component = shallow(
+        <Provider store={store}>
+          <DetailsContainer />
+        </Provider>
+      );
+
+      expect(component.exists()).toBeTruthy();
+    });
+  });
+
+  describe('View', () => {
+    const setupWrapper = (props: Partial<DetailsProps> = {}) => (
+      <Details
+        schema={schema}
+        clusterName="Test cluster"
+        fetchSchemaVersions={jest.fn()}
+        isFetched
+        versions={[]}
+        {...props}
+      />
+    );
+    describe('Initial state', () => {
+      let useEffect: jest.SpyInstance<
+        void,
+        [effect: React.EffectCallback, deps?: React.DependencyList | undefined]
+      >;
+      let wrapper;
+      const mockedFn = jest.fn();
+
+      const mockedUseEffect = () => {
+        useEffect.mockImplementationOnce(mockedFn);
+      };
+
+      beforeEach(() => {
+        useEffect = jest.spyOn(React, 'useEffect');
+        mockedUseEffect();
+
+        wrapper = shallow(setupWrapper({ fetchSchemaVersions: mockedFn }));
+      });
+
+      it('should call fetchSchemaVersions every render', () => {
+        expect(mockedFn).toHaveBeenCalled();
+      });
+
+      it('matches snapshot', () => {
+        expect(
+          shallow(setupWrapper({ fetchSchemaVersions: mockedFn }))
+        ).toMatchSnapshot();
+      });
+    });
+
+    describe('when page with schema versions is loading', () => {
+      const wrapper = shallow(setupWrapper({ isFetched: false }));
+
+      it('renders PageLoader', () => {
+        expect(wrapper.exists('PageLoader')).toBeTruthy();
+      });
+
+      it('matches snapshot', () => {
+        expect(shallow(setupWrapper({ isFetched: false }))).toMatchSnapshot();
+      });
+    });
+
+    describe('when page with schema versions loaded', () => {
+      describe('when versions are empty', () => {
+        it('renders table heading without SchemaVersion', () => {
+          const wrapper = shallow(setupWrapper());
+          expect(wrapper.exists('LatestVersionItem')).toBeTruthy();
+          expect(wrapper.exists('button')).toBeTruthy();
+          expect(wrapper.exists('thead')).toBeTruthy();
+          expect(wrapper.exists('SchemaVersion')).toBeFalsy();
+        });
+
+        it('matches snapshot', () => {
+          expect(shallow(setupWrapper())).toMatchSnapshot();
+        });
+      });
+
+      describe('when schema has versions', () => {
+        const wrapper = shallow(setupWrapper({ versions }));
+
+        it('renders table heading with SchemaVersion', () => {
+          expect(wrapper.exists('LatestVersionItem')).toBeTruthy();
+          expect(wrapper.exists('button')).toBeTruthy();
+          expect(wrapper.exists('thead')).toBeTruthy();
+          expect(wrapper.find('SchemaVersion').length).toEqual(2);
+        });
+
+        it('matches snapshot', () => {
+          expect(shallow(setupWrapper({ versions }))).toMatchSnapshot();
+        });
+      });
+    });
+  });
+});

+ 18 - 0
kafka-ui-react-app/src/components/Schemas/Details/__test__/LatestVersionItem.spec.tsx

@@ -0,0 +1,18 @@
+import React from 'react';
+import { mount, shallow } from 'enzyme';
+import { schema } from './fixtures';
+import LatestVersionItem from '../LatestVersionItem';
+
+describe('LatestVersionItem', () => {
+  it('renders latest version of schema', () => {
+    const wrapper = mount(<LatestVersionItem schema={schema} />);
+
+    expect(wrapper.find('table').length).toEqual(1);
+    expect(wrapper.find('td').at(1).text()).toEqual('1');
+    expect(wrapper.exists('JSONViewer')).toBeTruthy();
+  });
+
+  it('matches snapshot', () => {
+    expect(shallow(<LatestVersionItem schema={schema} />)).toMatchSnapshot();
+  });
+});

+ 17 - 0
kafka-ui-react-app/src/components/Schemas/Details/__test__/SchemaVersion.spec.tsx

@@ -0,0 +1,17 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import SchemaVersion from '../SchemaVersion';
+import { versions } from './fixtures';
+
+describe('SchemaVersion', () => {
+  it('renders versions', () => {
+    const wrapper = shallow(<SchemaVersion version={versions[0]} />);
+
+    expect(wrapper.find('td').length).toEqual(3);
+    expect(wrapper.exists('JSONViewer')).toBeTruthy();
+  });
+
+  it('matches snapshot', () => {
+    expect(shallow(<SchemaVersion version={versions[0]} />)).toMatchSnapshot();
+  });
+});

+ 461 - 0
kafka-ui-react-app/src/components/Schemas/Details/__test__/__snapshots__/Details.spec.tsx.snap

@@ -0,0 +1,461 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Details View Initial state matches snapshot 1`] = `
+<div
+  className="section"
+>
+  <div
+    className="level"
+  >
+    <Breadcrumb
+      links={
+        Array [
+          Object {
+            "href": "/ui/clusters/Test cluster/schemas",
+            "label": "Schema Registry",
+          },
+        ]
+      }
+    >
+      test
+    </Breadcrumb>
+  </div>
+  <div
+    className="box"
+  >
+    <div
+      className="level"
+    >
+      <div
+        className="level-left"
+      >
+        <div
+          className="level-item"
+        >
+          <div
+            className="mr-1"
+          >
+            <b>
+              Latest Version
+            </b>
+          </div>
+          <div
+            className="tag is-info is-light"
+            title="Version"
+          >
+            #
+            1
+          </div>
+        </div>
+      </div>
+      <div
+        className="level-right"
+      >
+        <button
+          className="button is-primary is-small level-item"
+          disabled={true}
+          title="in development"
+          type="button"
+        >
+          Create Schema
+        </button>
+        <button
+          className="button is-warning is-small level-item"
+          disabled={true}
+          title="in development"
+          type="button"
+        >
+          Update Schema
+        </button>
+        <button
+          className="button is-danger is-small level-item"
+          disabled={true}
+          title="in development"
+          type="button"
+        >
+          Delete
+        </button>
+      </div>
+    </div>
+    <LatestVersionItem
+      schema={
+        Object {
+          "compatibilityLevel": "BACKWARD",
+          "id": 1,
+          "schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord1\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
+          "subject": "test",
+          "version": "1",
+        }
+      }
+    />
+  </div>
+  <div
+    className="box"
+  >
+    <table
+      className="table is-striped is-fullwidth"
+    >
+      <thead>
+        <tr>
+          <th>
+            Version
+          </th>
+          <th>
+            ID
+          </th>
+          <th>
+            Schema
+          </th>
+        </tr>
+      </thead>
+      <tbody />
+    </table>
+  </div>
+</div>
+`;
+
+exports[`Details View when page with schema versions is loading matches snapshot 1`] = `
+<div
+  className="section"
+>
+  <div
+    className="level"
+  >
+    <Breadcrumb
+      links={
+        Array [
+          Object {
+            "href": "/ui/clusters/Test cluster/schemas",
+            "label": "Schema Registry",
+          },
+        ]
+      }
+    >
+      test
+    </Breadcrumb>
+  </div>
+  <div
+    className="box"
+  >
+    <div
+      className="level"
+    >
+      <div
+        className="level-left"
+      >
+        <div
+          className="level-item"
+        >
+          <div
+            className="mr-1"
+          >
+            <b>
+              Latest Version
+            </b>
+          </div>
+          <div
+            className="tag is-info is-light"
+            title="Version"
+          >
+            #
+            1
+          </div>
+        </div>
+      </div>
+      <div
+        className="level-right"
+      >
+        <button
+          className="button is-primary is-small level-item"
+          disabled={true}
+          title="in development"
+          type="button"
+        >
+          Create Schema
+        </button>
+        <button
+          className="button is-warning is-small level-item"
+          disabled={true}
+          title="in development"
+          type="button"
+        >
+          Update Schema
+        </button>
+        <button
+          className="button is-danger is-small level-item"
+          disabled={true}
+          title="in development"
+          type="button"
+        >
+          Delete
+        </button>
+      </div>
+    </div>
+    <LatestVersionItem
+      schema={
+        Object {
+          "compatibilityLevel": "BACKWARD",
+          "id": 1,
+          "schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord1\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
+          "subject": "test",
+          "version": "1",
+        }
+      }
+    />
+  </div>
+  <PageLoader />
+</div>
+`;
+
+exports[`Details View when page with schema versions loaded when schema has versions matches snapshot 1`] = `
+<div
+  className="section"
+>
+  <div
+    className="level"
+  >
+    <Breadcrumb
+      links={
+        Array [
+          Object {
+            "href": "/ui/clusters/Test cluster/schemas",
+            "label": "Schema Registry",
+          },
+        ]
+      }
+    >
+      test
+    </Breadcrumb>
+  </div>
+  <div
+    className="box"
+  >
+    <div
+      className="level"
+    >
+      <div
+        className="level-left"
+      >
+        <div
+          className="level-item"
+        >
+          <div
+            className="mr-1"
+          >
+            <b>
+              Latest Version
+            </b>
+          </div>
+          <div
+            className="tag is-info is-light"
+            title="Version"
+          >
+            #
+            1
+          </div>
+        </div>
+      </div>
+      <div
+        className="level-right"
+      >
+        <button
+          className="button is-primary is-small level-item"
+          disabled={true}
+          title="in development"
+          type="button"
+        >
+          Create Schema
+        </button>
+        <button
+          className="button is-warning is-small level-item"
+          disabled={true}
+          title="in development"
+          type="button"
+        >
+          Update Schema
+        </button>
+        <button
+          className="button is-danger is-small level-item"
+          disabled={true}
+          title="in development"
+          type="button"
+        >
+          Delete
+        </button>
+      </div>
+    </div>
+    <LatestVersionItem
+      schema={
+        Object {
+          "compatibilityLevel": "BACKWARD",
+          "id": 1,
+          "schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord1\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
+          "subject": "test",
+          "version": "1",
+        }
+      }
+    />
+  </div>
+  <div
+    className="box"
+  >
+    <table
+      className="table is-striped is-fullwidth"
+    >
+      <thead>
+        <tr>
+          <th>
+            Version
+          </th>
+          <th>
+            ID
+          </th>
+          <th>
+            Schema
+          </th>
+        </tr>
+      </thead>
+      <tbody>
+        <SchemaVersion
+          key="1"
+          version={
+            Object {
+              "compatibilityLevel": "BACKWARD",
+              "id": 1,
+              "schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord1\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
+              "subject": "test",
+              "version": "1",
+            }
+          }
+        />
+        <SchemaVersion
+          key="2"
+          version={
+            Object {
+              "compatibilityLevel": "BACKWARD",
+              "id": 2,
+              "schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord2\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
+              "subject": "test",
+              "version": "2",
+            }
+          }
+        />
+      </tbody>
+    </table>
+  </div>
+</div>
+`;
+
+exports[`Details View when page with schema versions loaded when versions are empty matches snapshot 1`] = `
+<div
+  className="section"
+>
+  <div
+    className="level"
+  >
+    <Breadcrumb
+      links={
+        Array [
+          Object {
+            "href": "/ui/clusters/Test cluster/schemas",
+            "label": "Schema Registry",
+          },
+        ]
+      }
+    >
+      test
+    </Breadcrumb>
+  </div>
+  <div
+    className="box"
+  >
+    <div
+      className="level"
+    >
+      <div
+        className="level-left"
+      >
+        <div
+          className="level-item"
+        >
+          <div
+            className="mr-1"
+          >
+            <b>
+              Latest Version
+            </b>
+          </div>
+          <div
+            className="tag is-info is-light"
+            title="Version"
+          >
+            #
+            1
+          </div>
+        </div>
+      </div>
+      <div
+        className="level-right"
+      >
+        <button
+          className="button is-primary is-small level-item"
+          disabled={true}
+          title="in development"
+          type="button"
+        >
+          Create Schema
+        </button>
+        <button
+          className="button is-warning is-small level-item"
+          disabled={true}
+          title="in development"
+          type="button"
+        >
+          Update Schema
+        </button>
+        <button
+          className="button is-danger is-small level-item"
+          disabled={true}
+          title="in development"
+          type="button"
+        >
+          Delete
+        </button>
+      </div>
+    </div>
+    <LatestVersionItem
+      schema={
+        Object {
+          "compatibilityLevel": "BACKWARD",
+          "id": 1,
+          "schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord1\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
+          "subject": "test",
+          "version": "1",
+        }
+      }
+    />
+  </div>
+  <div
+    className="box"
+  >
+    <table
+      className="table is-striped is-fullwidth"
+    >
+      <thead>
+        <tr>
+          <th>
+            Version
+          </th>
+          <th>
+            ID
+          </th>
+          <th>
+            Schema
+          </th>
+        </tr>
+      </thead>
+      <tbody />
+    </table>
+  </div>
+</div>
+`;

+ 69 - 0
kafka-ui-react-app/src/components/Schemas/Details/__test__/__snapshots__/LatestVersionItem.spec.tsx.snap

@@ -0,0 +1,69 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`LatestVersionItem matches snapshot 1`] = `
+<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>
+              1
+            </td>
+          </tr>
+          <tr>
+            <td>
+              Subject
+            </td>
+            <td>
+              test
+            </td>
+          </tr>
+          <tr>
+            <td>
+              Compatibility
+            </td>
+            <td>
+              BACKWARD
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+  </div>
+  <div
+    className="tile is-parent"
+  >
+    <div
+      className="tile is-child box py-1"
+    >
+      <JSONViewer
+        data={
+          Object {
+            "fields": Array [
+              Object {
+                "name": "id",
+                "type": "long",
+              },
+            ],
+            "name": "MyRecord1",
+            "namespace": "com.mycompany",
+            "type": "record",
+          }
+        }
+      />
+    </div>
+  </div>
+</div>
+`;

+ 31 - 0
kafka-ui-react-app/src/components/Schemas/Details/__test__/__snapshots__/SchemaVersion.spec.tsx.snap

@@ -0,0 +1,31 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SchemaVersion matches snapshot 1`] = `
+<tr>
+  <td>
+    1
+  </td>
+  <td>
+    1
+  </td>
+  <td
+    className="py-0"
+  >
+    <JSONViewer
+      data={
+        Object {
+          "fields": Array [
+            Object {
+              "name": "id",
+              "type": "long",
+            },
+          ],
+          "name": "MyRecord1",
+          "namespace": "com.mycompany",
+          "type": "record",
+        }
+      }
+    />
+  </td>
+</tr>
+`;

+ 29 - 0
kafka-ui-react-app/src/components/Schemas/Details/__test__/fixtures.ts

@@ -0,0 +1,29 @@
+import { SchemaSubject } from 'generated-sources';
+
+export const schema: SchemaSubject = {
+  subject: 'test',
+  version: '1',
+  id: 1,
+  schema:
+    '{"type":"record","name":"MyRecord1","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
+  compatibilityLevel: 'BACKWARD',
+};
+
+export const versions: SchemaSubject[] = [
+  {
+    subject: 'test',
+    version: '1',
+    id: 1,
+    schema:
+      '{"type":"record","name":"MyRecord1","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
+    compatibilityLevel: 'BACKWARD',
+  },
+  {
+    subject: 'test',
+    version: '2',
+    id: 2,
+    schema:
+      '{"type":"record","name":"MyRecord2","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
+    compatibilityLevel: 'BACKWARD',
+  },
+];

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

@@ -0,0 +1,34 @@
+import React from 'react';
+import { SchemaSubject } from 'generated-sources';
+import Breadcrumb from '../../common/Breadcrumb/Breadcrumb';
+import ListItem from './ListItem';
+
+export interface ListProps {
+  schemas: SchemaSubject[];
+}
+
+const List: React.FC<ListProps> = ({ schemas }) => {
+  return (
+    <div className="section">
+      <Breadcrumb>Schema Registry</Breadcrumb>
+      <div className="box">
+        <table className="table is-striped is-fullwidth">
+          <thead>
+            <tr>
+              <th>Schema Name</th>
+              <th>Version</th>
+              <th>Compatibility</th>
+            </tr>
+          </thead>
+          <tbody>
+            {schemas.map((subject) => (
+              <ListItem key={subject.id} subject={subject} />
+            ))}
+          </tbody>
+        </table>
+      </div>
+    </div>
+  );
+};
+
+export default List;

+ 11 - 0
kafka-ui-react-app/src/components/Schemas/List/ListContainer.tsx

@@ -0,0 +1,11 @@
+import { connect } from 'react-redux';
+import { RootState } from 'redux/interfaces';
+import { withRouter } from 'react-router-dom';
+import { getSchemaList } from 'redux/reducers/schemas/selectors';
+import List from './List';
+
+const mapStateToProps = (state: RootState) => ({
+  schemas: getSchemaList(state),
+});
+
+export default withRouter(connect(mapStateToProps)(List));

+ 32 - 0
kafka-ui-react-app/src/components/Schemas/List/ListItem.tsx

@@ -0,0 +1,32 @@
+import React from 'react';
+import { SchemaSubject } from 'generated-sources';
+import { NavLink } from 'react-router-dom';
+
+export interface ListItemProps {
+  subject: SchemaSubject;
+}
+
+const ListItem: React.FC<ListItemProps> = ({
+  subject: { subject, version, compatibilityLevel },
+}) => {
+  return (
+    <tr>
+      <td>
+        <NavLink
+          exact
+          to={`schemas/${subject}/latest`}
+          activeClassName="is-active"
+          className="title is-6"
+        >
+          {subject}
+        </NavLink>
+      </td>
+      <td>{version}</td>
+      <td>
+        <span className="tag is-link">{compatibilityLevel}</span>
+      </td>
+    </tr>
+  );
+};
+
+export default ListItem;

+ 56 - 0
kafka-ui-react-app/src/components/Schemas/List/__test__/List.spec.tsx

@@ -0,0 +1,56 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+import { shallow } from 'enzyme';
+import configureStore from 'redux/store/configureStore';
+import ListContainer from '../ListContainer';
+import List, { ListProps } from '../List';
+import { schemas } from './fixtures';
+
+describe('List', () => {
+  describe('Container', () => {
+    const store = configureStore();
+
+    it('renders view', () => {
+      const component = shallow(
+        <Provider store={store}>
+          <ListContainer />
+        </Provider>
+      );
+
+      expect(component.exists()).toBeTruthy();
+    });
+  });
+
+  describe('View', () => {
+    const setupWrapper = (props: Partial<ListProps> = {}) => (
+      <List schemas={[]} {...props} />
+    );
+
+    describe('without schemas', () => {
+      it('renders table heading without ListItem', () => {
+        const wrapper = shallow(setupWrapper());
+        expect(wrapper.exists('Breadcrumb')).toBeTruthy();
+        expect(wrapper.exists('thead')).toBeTruthy();
+        expect(wrapper.exists('ListItem')).toBeFalsy();
+      });
+
+      it('matches snapshot', () => {
+        expect(shallow(setupWrapper())).toMatchSnapshot();
+      });
+    });
+
+    describe('with schemas', () => {
+      const wrapper = shallow(setupWrapper({ schemas }));
+
+      it('renders table heading with ListItem', () => {
+        expect(wrapper.exists('Breadcrumb')).toBeTruthy();
+        expect(wrapper.exists('thead')).toBeTruthy();
+        expect(wrapper.find('ListItem').length).toEqual(3);
+      });
+
+      it('matches snapshot', () => {
+        expect(shallow(setupWrapper({ schemas }))).toMatchSnapshot();
+      });
+    });
+  });
+});

+ 26 - 0
kafka-ui-react-app/src/components/Schemas/List/__test__/ListItem.spec.tsx

@@ -0,0 +1,26 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import { BrowserRouter as Router } from 'react-router-dom';
+import { schemas } from './fixtures';
+import ListItem from '../ListItem';
+
+describe('ListItem', () => {
+  const wrapper = mount(
+    <Router>
+      <table>
+        <tbody>
+          <ListItem subject={schemas[0]} />
+        </tbody>
+      </table>
+    </Router>
+  );
+
+  it('renders schemas', () => {
+    expect(wrapper.find('NavLink').length).toEqual(1);
+    expect(wrapper.find('td').length).toEqual(3);
+  });
+
+  it('matches snapshot', () => {
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 102 - 0
kafka-ui-react-app/src/components/Schemas/List/__test__/__snapshots__/List.spec.tsx.snap

@@ -0,0 +1,102 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`List View with schemas matches snapshot 1`] = `
+<div
+  className="section"
+>
+  <Breadcrumb>
+    Schema Registry
+  </Breadcrumb>
+  <div
+    className="box"
+  >
+    <table
+      className="table is-striped is-fullwidth"
+    >
+      <thead>
+        <tr>
+          <th>
+            Schema Name
+          </th>
+          <th>
+            Version
+          </th>
+          <th>
+            Compatibility
+          </th>
+        </tr>
+      </thead>
+      <tbody>
+        <ListItem
+          key="1"
+          subject={
+            Object {
+              "compatibilityLevel": "BACKWARD",
+              "id": 1,
+              "schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord1\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
+              "subject": "test",
+              "version": "1",
+            }
+          }
+        />
+        <ListItem
+          key="1"
+          subject={
+            Object {
+              "compatibilityLevel": "BACKWARD",
+              "id": 1,
+              "schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord2\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
+              "subject": "test2",
+              "version": "1",
+            }
+          }
+        />
+        <ListItem
+          key="1"
+          subject={
+            Object {
+              "compatibilityLevel": "BACKWARD",
+              "id": 1,
+              "schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord3\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
+              "subject": "test3",
+              "version": "1",
+            }
+          }
+        />
+      </tbody>
+    </table>
+  </div>
+</div>
+`;
+
+exports[`List View without schemas matches snapshot 1`] = `
+<div
+  className="section"
+>
+  <Breadcrumb>
+    Schema Registry
+  </Breadcrumb>
+  <div
+    className="box"
+  >
+    <table
+      className="table is-striped is-fullwidth"
+    >
+      <thead>
+        <tr>
+          <th>
+            Schema Name
+          </th>
+          <th>
+            Version
+          </th>
+          <th>
+            Compatibility
+          </th>
+        </tr>
+      </thead>
+      <tbody />
+    </table>
+  </div>
+</div>
+`;

+ 94 - 0
kafka-ui-react-app/src/components/Schemas/List/__test__/__snapshots__/ListItem.spec.tsx.snap

@@ -0,0 +1,94 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ListItem matches snapshot 1`] = `
+<BrowserRouter>
+  <Router
+    history={
+      Object {
+        "action": "POP",
+        "block": [Function],
+        "createHref": [Function],
+        "go": [Function],
+        "goBack": [Function],
+        "goForward": [Function],
+        "length": 1,
+        "listen": [Function],
+        "location": Object {
+          "hash": "",
+          "pathname": "/",
+          "search": "",
+          "state": undefined,
+        },
+        "push": [Function],
+        "replace": [Function],
+      }
+    }
+  >
+    <table>
+      <tbody>
+        <ListItem
+          subject={
+            Object {
+              "compatibilityLevel": "BACKWARD",
+              "id": 1,
+              "schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord1\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
+              "subject": "test",
+              "version": "1",
+            }
+          }
+        >
+          <tr>
+            <td>
+              <NavLink
+                activeClassName="is-active"
+                className="title is-6"
+                exact={true}
+                to="schemas/test/latest"
+              >
+                <Link
+                  aria-current={null}
+                  className="title is-6"
+                  to={
+                    Object {
+                      "hash": "",
+                      "pathname": "/schemas/test/latest",
+                      "search": "",
+                      "state": null,
+                    }
+                  }
+                >
+                  <LinkAnchor
+                    aria-current={null}
+                    className="title is-6"
+                    href="/schemas/test/latest"
+                    navigate={[Function]}
+                  >
+                    <a
+                      aria-current={null}
+                      className="title is-6"
+                      href="/schemas/test/latest"
+                      onClick={[Function]}
+                    >
+                      test
+                    </a>
+                  </LinkAnchor>
+                </Link>
+              </NavLink>
+            </td>
+            <td>
+              1
+            </td>
+            <td>
+              <span
+                className="tag is-link"
+              >
+                BACKWARD
+              </span>
+            </td>
+          </tr>
+        </ListItem>
+      </tbody>
+    </table>
+  </Router>
+</BrowserRouter>
+`;

+ 28 - 0
kafka-ui-react-app/src/components/Schemas/List/__test__/fixtures.ts

@@ -0,0 +1,28 @@
+import { SchemaSubject } from 'generated-sources';
+
+export const schemas: SchemaSubject[] = [
+  {
+    subject: 'test',
+    version: '1',
+    id: 1,
+    schema:
+      '{"type":"record","name":"MyRecord1","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
+    compatibilityLevel: 'BACKWARD',
+  },
+  {
+    subject: 'test2',
+    version: '1',
+    id: 1,
+    schema:
+      '{"type":"record","name":"MyRecord2","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
+    compatibilityLevel: 'BACKWARD',
+  },
+  {
+    subject: 'test3',
+    version: '1',
+    id: 1,
+    schema:
+      '{"type":"record","name":"MyRecord3","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
+    compatibilityLevel: 'BACKWARD',
+  },
+];

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

@@ -0,0 +1,43 @@
+import React from 'react';
+import { ClusterName } from 'redux/interfaces';
+import { Switch, Route } from 'react-router-dom';
+import PageLoader from 'components/common/PageLoader/PageLoader';
+import ListContainer from './List/ListContainer';
+import DetailsContainer from './Details/DetailsContainer';
+
+export interface SchemasProps {
+  isFetched: boolean;
+  clusterName: ClusterName;
+  fetchSchemasByClusterName: (clusterName: ClusterName) => void;
+}
+
+const Schemas: React.FC<SchemasProps> = ({
+  isFetched,
+  fetchSchemasByClusterName,
+  clusterName,
+}) => {
+  React.useEffect(() => {
+    fetchSchemasByClusterName(clusterName);
+  }, [fetchSchemasByClusterName, clusterName]);
+
+  if (isFetched) {
+    return (
+      <Switch>
+        <Route
+          exact
+          path="/ui/clusters/:clusterName/schemas"
+          component={ListContainer}
+        />
+        <Route
+          exact
+          path="/ui/clusters/:clusterName/schemas/:subject/latest"
+          component={DetailsContainer}
+        />
+      </Switch>
+    );
+  }
+
+  return <PageLoader />;
+};
+
+export default Schemas;

+ 32 - 0
kafka-ui-react-app/src/components/Schemas/SchemasContainer.tsx

@@ -0,0 +1,32 @@
+import { connect } from 'react-redux';
+import { ClusterName, RootState } from 'redux/interfaces';
+import { fetchSchemasByClusterName } from 'redux/actions';
+import { getIsSchemaListFetched } from 'redux/reducers/schemas/selectors';
+import { RouteComponentProps, withRouter } from 'react-router-dom';
+import Schemas from './Schemas';
+
+interface RouteProps {
+  clusterName: ClusterName;
+}
+
+type OwnProps = RouteComponentProps<RouteProps>;
+
+const mapStateToProps = (
+  state: RootState,
+  {
+    match: {
+      params: { clusterName },
+    },
+  }: OwnProps
+) => ({
+  isFetched: getIsSchemaListFetched(state),
+  clusterName,
+});
+
+const mapDispatchToProps = {
+  fetchSchemasByClusterName,
+};
+
+export default withRouter(
+  connect(mapStateToProps, mapDispatchToProps)(Schemas)
+);

+ 86 - 0
kafka-ui-react-app/src/components/Schemas/__test__/Schemas.spec.tsx

@@ -0,0 +1,86 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+import { shallow } from 'enzyme';
+import configureStore from 'redux/store/configureStore';
+import { StaticRouter } from 'react-router-dom';
+import { match } from 'react-router';
+import { ClusterName } from 'redux/interfaces';
+import Schemas, { SchemasProps } from '../Schemas';
+import SchemasContainer from '../SchemasContainer';
+
+describe('Schemas', () => {
+  const pathname = `/ui/clusters/clusterName/schemas`;
+
+  describe('Container', () => {
+    const store = configureStore();
+
+    it('renders view', () => {
+      const component = shallow(
+        <Provider store={store}>
+          <StaticRouter location={{ pathname }} context={{}}>
+            <SchemasContainer />
+          </StaticRouter>
+        </Provider>
+      );
+
+      expect(component.exists()).toBeTruthy();
+    });
+
+    describe('View', () => {
+      const setupWrapper = (props: Partial<SchemasProps> = {}) => (
+        <Schemas
+          isFetched
+          clusterName="Test"
+          fetchSchemasByClusterName={jest.fn()}
+          {...props}
+        />
+      );
+      describe('Initial state', () => {
+        let useEffect: jest.SpyInstance<
+          void,
+          [
+            effect: React.EffectCallback,
+            deps?: React.DependencyList | undefined
+          ]
+        >;
+        let wrapper;
+        const mockedFn = jest.fn();
+
+        const mockedUseEffect = () => {
+          useEffect.mockImplementationOnce(mockedFn);
+        };
+
+        beforeEach(() => {
+          useEffect = jest.spyOn(React, 'useEffect');
+          mockedUseEffect();
+
+          wrapper = shallow(
+            setupWrapper({ fetchSchemasByClusterName: mockedFn })
+          );
+        });
+
+        it('should call fetchSchemasByClusterName every render', () => {
+          expect(mockedFn).toHaveBeenCalled();
+        });
+
+        it('matches snapshot', () => {
+          expect(
+            shallow(setupWrapper({ fetchSchemasByClusterName: mockedFn }))
+          ).toMatchSnapshot();
+        });
+      });
+
+      describe('when page is loading', () => {
+        const wrapper = shallow(setupWrapper({ isFetched: false }));
+
+        it('renders PageLoader', () => {
+          expect(wrapper.exists('PageLoader')).toBeTruthy();
+        });
+
+        it('matches snapshot', () => {
+          expect(shallow(setupWrapper({ isFetched: false }))).toMatchSnapshot();
+        });
+      });
+    });
+  });
+});

+ 18 - 0
kafka-ui-react-app/src/components/Schemas/__test__/__snapshots__/Schemas.spec.tsx.snap

@@ -0,0 +1,18 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Schemas Container View Initial state matches snapshot 1`] = `
+<Switch>
+  <Route
+    component={[Function]}
+    exact={true}
+    path="/ui/clusters/:clusterName/schemas"
+  />
+  <Route
+    component={[Function]}
+    exact={true}
+    path="/ui/clusters/:clusterName/schemas/:subject/latest"
+  />
+</Switch>
+`;
+
+exports[`Schemas Container View when page is loading matches snapshot 1`] = `<PageLoader />`;

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

@@ -1,7 +1,7 @@
 import React from 'react';
 import React from 'react';
 import { format } from 'date-fns';
 import { format } from 'date-fns';
-import JSONTree from 'react-json-tree';
 import { TopicMessage } from 'generated-sources';
 import { TopicMessage } from 'generated-sources';
+import JSONViewer from 'components/common/JSONViewer/JSONViewer';
 
 
 export interface MessageItemProp {
 export interface MessageItemProp {
   partition: TopicMessage['partition'];
   partition: TopicMessage['partition'];
@@ -21,28 +21,7 @@ const MessageItem: React.FC<MessageItemProp> = ({
     <td style={{ width: 150 }}>{offset}</td>
     <td style={{ width: 150 }}>{offset}</td>
     <td style={{ width: 100 }}>{partition}</td>
     <td style={{ width: 100 }}>{partition}</td>
     <td style={{ wordBreak: 'break-word' }}>
     <td style={{ wordBreak: 'break-word' }}>
-      {content && (
-        <JSONTree
-          data={content}
-          hideRoot
-          invertTheme={false}
-          theme={{
-            tree: ({ style }) => ({
-              style: {
-                ...style,
-                backgroundColor: undefined,
-                marginLeft: 0,
-                marginTop: 0,
-              },
-            }),
-            value: ({ style }) => ({
-              style: { ...style, marginLeft: 0 },
-            }),
-            base0D: '#3273dc',
-            base0B: '#363636',
-          }}
-        />
-      )}
+      {content && <JSONViewer data={content as { [key: string]: string }} />}
     </td>
     </td>
   </tr>
   </tr>
 );
 );

+ 1 - 1
kafka-ui-react-app/src/components/Topics/Details/Messages/MessagesTable.tsx

@@ -32,7 +32,7 @@ const MessagesTable: React.FC<MessagesTableProp> = ({ messages, onNext }) => {
                 partition={partition}
                 partition={partition}
                 offset={offset}
                 offset={offset}
                 timestamp={timestamp}
                 timestamp={timestamp}
-                content={content as Record<string, unknown>}
+                content={content as { [key: string]: string }}
               />
               />
             )
             )
           )}
           )}

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

@@ -14,7 +14,7 @@ describe('MessageItem', () => {
 
 
       expect(wrapper.find('tr').length).toEqual(1);
       expect(wrapper.find('tr').length).toEqual(1);
       expect(wrapper.find('td').length).toEqual(4);
       expect(wrapper.find('td').length).toEqual(4);
-      expect(wrapper.find('JSONTree').length).toEqual(1);
+      expect(wrapper.find('JSONViewer').length).toEqual(1);
     });
     });
 
 
     it('matches snapshot', () => {
     it('matches snapshot', () => {
@@ -28,7 +28,7 @@ describe('MessageItem', () => {
 
 
       expect(wrapper.find('tr').length).toEqual(1);
       expect(wrapper.find('tr').length).toEqual(1);
       expect(wrapper.find('td').length).toEqual(4);
       expect(wrapper.find('td').length).toEqual(4);
-      expect(wrapper.find('JSONTree').length).toEqual(0);
+      expect(wrapper.find('JSONViewer').length).toEqual(0);
     });
     });
 
 
     it('matches snapshot', () => {
     it('matches snapshot', () => {

+ 1 - 23
kafka-ui-react-app/src/components/Topics/Details/Messages/__test__/__snapshots__/MessageItem.spec.tsx.snap

@@ -36,35 +36,13 @@ exports[`MessageItem when content is defined matches snapshot 1`] = `
       }
       }
     }
     }
   >
   >
-    <JSONTree
-      collectionLimit={50}
+    <JSONViewer
       data={
       data={
         Object {
         Object {
           "foo": "bar",
           "foo": "bar",
           "key": "val",
           "key": "val",
         }
         }
       }
       }
-      getItemString={[Function]}
-      hideRoot={true}
-      invertTheme={false}
-      isCustomNode={[Function]}
-      keyPath={
-        Array [
-          "root",
-        ]
-      }
-      labelRenderer={[Function]}
-      postprocessValue={[Function]}
-      shouldExpandNode={[Function]}
-      theme={
-        Object {
-          "base0B": "#363636",
-          "base0D": "#3273dc",
-          "tree": [Function],
-          "value": [Function],
-        }
-      }
-      valueRenderer={[Function]}
     />
     />
   </td>
   </td>
 </tr>
 </tr>

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

@@ -0,0 +1,15 @@
+import React from 'react';
+import JSONTree from 'react-json-tree';
+import theme from './themes/google';
+
+interface JSONViewerProps {
+  data: {
+    [key: string]: string;
+  };
+}
+
+const JSONViewer: React.FC<JSONViewerProps> = ({ data }) => (
+  <JSONTree data={data} theme={theme} shouldExpandNode={() => true} hideRoot />
+);
+
+export default JSONViewer;

+ 20 - 0
kafka-ui-react-app/src/components/common/JSONViewer/themes/google.ts

@@ -0,0 +1,20 @@
+export default {
+  scheme: 'google',
+  author: 'seth wright (http://sethawright.com)',
+  base00: '#1d1f21',
+  base01: '#282a2e',
+  base02: '#373b41',
+  base03: '#969896',
+  base04: '#b4b7b4',
+  base05: '#c5c8c6',
+  base06: '#e0e0e0',
+  base07: '#ffffff',
+  base08: '#CC342B',
+  base09: '#F96A38',
+  base0A: '#FBA922',
+  base0B: '#198844',
+  base0C: '#3971ED',
+  base0D: '#3971ED',
+  base0E: '#A36AC7',
+  base0F: '#3971ED',
+};

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

@@ -10,6 +10,8 @@ export const clusterTopicNewPath = (clusterName: ClusterName) =>
   `${clusterPath(clusterName)}/topics/new`;
   `${clusterPath(clusterName)}/topics/new`;
 export const clusterConsumerGroupsPath = (clusterName: ClusterName) =>
 export const clusterConsumerGroupsPath = (clusterName: ClusterName) =>
   `${clusterPath(clusterName)}/consumer-groups`;
   `${clusterPath(clusterName)}/consumer-groups`;
+export const clusterSchemasPath = (clusterName: ClusterName) =>
+  `${clusterPath(clusterName)}/schemas`;
 
 
 export const clusterTopicPath = (
 export const clusterTopicPath = (
   clusterName: ClusterName,
   clusterName: ClusterName,

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

@@ -1,3 +1,7 @@
+import {
+  clusterSchemasPayload,
+  schemaVersionsPayload,
+} from 'redux/reducers/schemas/__test__/fixtures';
 import * as actions from '../actions';
 import * as actions from '../actions';
 
 
 describe('Actions', () => {
 describe('Actions', () => {
@@ -25,4 +29,50 @@ describe('Actions', () => {
       });
       });
     });
     });
   });
   });
+
+  describe('fetchSchemasByClusterNameAction', () => {
+    it('creates a REQUEST action', () => {
+      expect(actions.fetchSchemasByClusterNameAction.request()).toEqual({
+        type: 'GET_CLUSTER_SCHEMAS__REQUEST',
+      });
+    });
+
+    it('creates a SUCCESS action', () => {
+      expect(
+        actions.fetchSchemasByClusterNameAction.success(clusterSchemasPayload)
+      ).toEqual({
+        type: 'GET_CLUSTER_SCHEMAS__SUCCESS',
+        payload: clusterSchemasPayload,
+      });
+    });
+
+    it('creates a FAILURE action', () => {
+      expect(actions.fetchSchemasByClusterNameAction.failure()).toEqual({
+        type: 'GET_CLUSTER_SCHEMAS__FAILURE',
+      });
+    });
+  });
+
+  describe('fetchSchemaVersionsAction', () => {
+    it('creates a REQUEST action', () => {
+      expect(actions.fetchSchemaVersionsAction.request()).toEqual({
+        type: 'GET_SCHEMA_VERSIONS__REQUEST',
+      });
+    });
+
+    it('creates a SUCCESS action', () => {
+      expect(
+        actions.fetchSchemaVersionsAction.success(schemaVersionsPayload)
+      ).toEqual({
+        type: 'GET_SCHEMA_VERSIONS__SUCCESS',
+        payload: schemaVersionsPayload,
+      });
+    });
+
+    it('creates a FAILURE action', () => {
+      expect(actions.fetchSchemaVersionsAction.failure()).toEqual({
+        type: 'GET_SCHEMA_VERSIONS__FAILURE',
+      });
+    });
+  });
 });
 });

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

@@ -8,6 +8,7 @@ import { Middleware } from 'redux';
 import { RootState, Action } from 'redux/interfaces';
 import { RootState, Action } from 'redux/interfaces';
 import * as actions from 'redux/actions/actions';
 import * as actions from 'redux/actions/actions';
 import * as thunks from 'redux/actions/thunks';
 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];
 const middlewares: Array<Middleware> = [thunk];
@@ -21,6 +22,7 @@ const mockStoreCreator: MockStoreCreator<
 const store: MockStoreEnhanced<RootState, DispatchExts> = mockStoreCreator();
 const store: MockStoreEnhanced<RootState, DispatchExts> = mockStoreCreator();
 
 
 const clusterName = 'local';
 const clusterName = 'local';
+const subject = 'test';
 
 
 describe('Thunks', () => {
 describe('Thunks', () => {
   afterEach(() => {
   afterEach(() => {
@@ -49,4 +51,58 @@ describe('Thunks', () => {
       ]);
       ]);
     });
     });
   });
   });
+
+  describe('fetchSchemasByClusterName', () => {
+    it('creates GET_CLUSTER_SCHEMAS__SUCCESS when fetching cluster schemas', async () => {
+      fetchMock.getOnce(`/api/clusters/${clusterName}/schemas`, {
+        body: schemaFixtures.clusterSchemasPayload,
+      });
+      await store.dispatch(thunks.fetchSchemasByClusterName(clusterName));
+      expect(store.getActions()).toEqual([
+        actions.fetchSchemasByClusterNameAction.request(),
+        actions.fetchSchemasByClusterNameAction.success(
+          schemaFixtures.clusterSchemasPayload
+        ),
+      ]);
+    });
+
+    it('creates GET_CLUSTER_SCHEMAS__FAILURE when fetching cluster schemas', async () => {
+      fetchMock.getOnce(`/api/clusters/${clusterName}/schemas`, 404);
+      await store.dispatch(thunks.fetchSchemasByClusterName(clusterName));
+      expect(store.getActions()).toEqual([
+        actions.fetchSchemasByClusterNameAction.request(),
+        actions.fetchSchemasByClusterNameAction.failure(),
+      ]);
+    });
+  });
+
+  describe('fetchSchemaVersions', () => {
+    it('creates GET_SCHEMA_VERSIONS__SUCCESS when fetching schema versions', async () => {
+      fetchMock.getOnce(
+        `/api/clusters/${clusterName}/schemas/${subject}/versions`,
+        {
+          body: schemaFixtures.schemaVersionsPayload,
+        }
+      );
+      await store.dispatch(thunks.fetchSchemaVersions(clusterName, subject));
+      expect(store.getActions()).toEqual([
+        actions.fetchSchemaVersionsAction.request(),
+        actions.fetchSchemaVersionsAction.success(
+          schemaFixtures.schemaVersionsPayload
+        ),
+      ]);
+    });
+
+    it('creates GET_SCHEMA_VERSIONS__FAILURE when fetching schema versions', async () => {
+      fetchMock.getOnce(
+        `/api/clusters/${clusterName}/schemas/${subject}/versions`,
+        404
+      );
+      await store.dispatch(thunks.fetchSchemaVersions(clusterName, subject));
+      expect(store.getActions()).toEqual([
+        actions.fetchSchemaVersionsAction.request(),
+        actions.fetchSchemaVersionsAction.failure(),
+      ]);
+    });
+  });
 });
 });

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

@@ -13,6 +13,7 @@ import {
   TopicMessage,
   TopicMessage,
   ConsumerGroup,
   ConsumerGroup,
   ConsumerGroupDetails,
   ConsumerGroupDetails,
+  SchemaSubject,
 } from 'generated-sources';
 } from 'generated-sources';
 
 
 export const fetchClusterStatsAction = createAsyncAction(
 export const fetchClusterStatsAction = createAsyncAction(
@@ -96,3 +97,15 @@ export const fetchConsumerGroupDetailsAction = createAsyncAction(
   { consumerGroupID: ConsumerGroupID; details: ConsumerGroupDetails },
   { consumerGroupID: ConsumerGroupID; details: ConsumerGroupDetails },
   undefined
   undefined
 >();
 >();
+
+export const fetchSchemasByClusterNameAction = createAsyncAction(
+  'GET_CLUSTER_SCHEMAS__REQUEST',
+  'GET_CLUSTER_SCHEMAS__SUCCESS',
+  'GET_CLUSTER_SCHEMAS__FAILURE'
+)<undefined, SchemaSubject[], undefined>();
+
+export const fetchSchemaVersionsAction = createAsyncAction(
+  'GET_SCHEMA_VERSIONS__REQUEST',
+  'GET_SCHEMA_VERSIONS__SUCCESS',
+  'GET_SCHEMA_VERSIONS__FAILURE'
+)<undefined, SchemaSubject[], undefined>();

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

@@ -15,13 +15,14 @@ import {
   TopicMessageQueryParams,
   TopicMessageQueryParams,
   TopicFormFormattedParams,
   TopicFormFormattedParams,
   TopicFormDataRaw,
   TopicFormDataRaw,
+  SchemaName,
 } from 'redux/interfaces';
 } from 'redux/interfaces';
 
 
 import { BASE_PARAMS } from 'lib/constants';
 import { BASE_PARAMS } from 'lib/constants';
 import * as actions from './actions';
 import * as actions from './actions';
 
 
 const apiClientConf = new Configuration(BASE_PARAMS);
 const apiClientConf = new Configuration(BASE_PARAMS);
-const apiClient = new ApiClustersApi(apiClientConf);
+export const apiClient = new ApiClustersApi(apiClientConf);
 
 
 export const fetchClustersList = (): PromiseThunkResult => async (dispatch) => {
 export const fetchClustersList = (): PromiseThunkResult => async (dispatch) => {
   dispatch(actions.fetchClusterListAction.request());
   dispatch(actions.fetchClusterListAction.request());
@@ -250,3 +251,32 @@ export const fetchConsumerGroupDetails = (
     dispatch(actions.fetchConsumerGroupDetailsAction.failure());
     dispatch(actions.fetchConsumerGroupDetailsAction.failure());
   }
   }
 };
 };
+
+export const fetchSchemasByClusterName = (
+  clusterName: ClusterName
+): PromiseThunkResult<void> => async (dispatch) => {
+  dispatch(actions.fetchSchemasByClusterNameAction.request());
+  try {
+    const schemas = await apiClient.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 apiClient.getAllVersionsBySubject({
+      clusterName,
+      subject,
+    });
+    dispatch(actions.fetchSchemaVersionsAction.success(versions));
+  } catch (e) {
+    dispatch(actions.fetchSchemaVersionsAction.failure());
+  }
+};

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

@@ -8,11 +8,13 @@ import { ClusterState } from './cluster';
 import { BrokersState } from './broker';
 import { BrokersState } from './broker';
 import { LoaderState } from './loader';
 import { LoaderState } from './loader';
 import { ConsumerGroupsState } from './consumerGroup';
 import { ConsumerGroupsState } from './consumerGroup';
+import { SchemasState } from './schema';
 
 
 export * from './topic';
 export * from './topic';
 export * from './cluster';
 export * from './cluster';
 export * from './broker';
 export * from './broker';
 export * from './consumerGroup';
 export * from './consumerGroup';
+export * from './schema';
 export * from './loader';
 export * from './loader';
 
 
 export interface RootState {
 export interface RootState {
@@ -20,6 +22,7 @@ export interface RootState {
   clusters: ClusterState;
   clusters: ClusterState;
   brokers: BrokersState;
   brokers: BrokersState;
   consumerGroups: ConsumerGroupsState;
   consumerGroups: ConsumerGroupsState;
+  schemas: SchemasState;
   loader: LoaderState;
   loader: LoaderState;
 }
 }
 
 

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

@@ -0,0 +1,9 @@
+import { SchemaSubject } from 'generated-sources';
+
+export type SchemaName = string;
+
+export interface SchemasState {
+  byName: { [subject: string]: SchemaSubject };
+  allNames: SchemaName[];
+  currentSchemaVersions: SchemaSubject[];
+}

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

@@ -4,6 +4,7 @@ import topics from './topics/reducer';
 import clusters from './clusters/reducer';
 import clusters from './clusters/reducer';
 import brokers from './brokers/reducer';
 import brokers from './brokers/reducer';
 import consumerGroups from './consumerGroups/reducer';
 import consumerGroups from './consumerGroups/reducer';
+import schemas from './schemas/reducer';
 import loader from './loader/reducer';
 import loader from './loader/reducer';
 
 
 export default combineReducers<RootState>({
 export default combineReducers<RootState>({
@@ -11,5 +12,6 @@ export default combineReducers<RootState>({
   clusters,
   clusters,
   brokers,
   brokers,
   consumerGroups,
   consumerGroups,
+  schemas,
   loader,
   loader,
 });
 });

+ 58 - 0
kafka-ui-react-app/src/redux/reducers/schemas/__test__/__snapshots__/reducer.spec.ts.snap

@@ -0,0 +1,58 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Schemas reducer reacts on GET_CLUSTER_SCHEMAS__SUCCESS and returns payload 1`] = `
+Object {
+  "allNames": Array [
+    "test2",
+    "test3",
+    "test",
+  ],
+  "byName": Object {
+    "test": Object {
+      "compatibilityLevel": "BACKWARD",
+      "id": 2,
+      "schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord2\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
+      "subject": "test",
+      "version": "2",
+    },
+    "test2": Object {
+      "compatibilityLevel": "BACKWARD",
+      "id": 4,
+      "schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord4\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
+      "subject": "test2",
+      "version": "3",
+    },
+    "test3": Object {
+      "compatibilityLevel": "BACKWARD",
+      "id": 5,
+      "schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
+      "subject": "test3",
+      "version": "1",
+    },
+  },
+  "currentSchemaVersions": Array [],
+}
+`;
+
+exports[`Schemas reducer reacts on GET_SCHEMA_VERSIONS__SUCCESS and returns payload 1`] = `
+Object {
+  "allNames": Array [],
+  "byName": Object {},
+  "currentSchemaVersions": Array [
+    Object {
+      "compatibilityLevel": "BACKWARD",
+      "id": 1,
+      "schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord1\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
+      "subject": "test",
+      "version": "1",
+    },
+    Object {
+      "compatibilityLevel": "BACKWARD",
+      "id": 2,
+      "schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord2\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
+      "subject": "test",
+      "version": "2",
+    },
+  ],
+}
+`;

+ 54 - 0
kafka-ui-react-app/src/redux/reducers/schemas/__test__/fixtures.ts

@@ -0,0 +1,54 @@
+import { SchemasState } from 'redux/interfaces';
+import { SchemaSubject } from 'generated-sources';
+
+export const initialState: SchemasState = {
+  byName: {},
+  allNames: [],
+  currentSchemaVersions: [],
+};
+
+export const clusterSchemasPayload: SchemaSubject[] = [
+  {
+    subject: 'test2',
+    version: '3',
+    id: 4,
+    schema:
+      '{"type":"record","name":"MyRecord4","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
+    compatibilityLevel: 'BACKWARD',
+  },
+  {
+    subject: 'test3',
+    version: '1',
+    id: 5,
+    schema:
+      '{"type":"record","name":"MyRecord","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
+    compatibilityLevel: 'BACKWARD',
+  },
+  {
+    subject: 'test',
+    version: '2',
+    id: 2,
+    schema:
+      '{"type":"record","name":"MyRecord2","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
+    compatibilityLevel: 'BACKWARD',
+  },
+];
+
+export const schemaVersionsPayload: SchemaSubject[] = [
+  {
+    subject: 'test',
+    version: '1',
+    id: 1,
+    schema:
+      '{"type":"record","name":"MyRecord1","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
+    compatibilityLevel: 'BACKWARD',
+  },
+  {
+    subject: 'test',
+    version: '2',
+    id: 2,
+    schema:
+      '{"type":"record","name":"MyRecord2","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
+    compatibilityLevel: 'BACKWARD',
+  },
+];

+ 39 - 0
kafka-ui-react-app/src/redux/reducers/schemas/__test__/reducer.spec.ts

@@ -0,0 +1,39 @@
+import {
+  fetchSchemasByClusterNameAction,
+  fetchSchemaVersionsAction,
+} from 'redux/actions';
+import reducer from 'redux/reducers/schemas/reducer';
+import {
+  clusterSchemasPayload,
+  initialState,
+  schemaVersionsPayload,
+} from './fixtures';
+
+describe('Schemas reducer', () => {
+  it('returns the initial state', () => {
+    expect(
+      reducer(undefined, fetchSchemasByClusterNameAction.request())
+    ).toEqual(initialState);
+    expect(reducer(undefined, fetchSchemaVersionsAction.request())).toEqual(
+      initialState
+    );
+  });
+
+  it('reacts on GET_CLUSTER_SCHEMAS__SUCCESS and returns payload', () => {
+    expect(
+      reducer(
+        undefined,
+        fetchSchemasByClusterNameAction.success(clusterSchemasPayload)
+      )
+    ).toMatchSnapshot();
+  });
+
+  it('reacts on GET_SCHEMA_VERSIONS__SUCCESS and returns payload', () => {
+    expect(
+      reducer(
+        undefined,
+        fetchSchemaVersionsAction.success(schemaVersionsPayload)
+      )
+    ).toMatchSnapshot();
+  });
+});

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

@@ -0,0 +1,64 @@
+import {
+  fetchSchemasByClusterNameAction,
+  fetchSchemaVersionsAction,
+} from 'redux/actions';
+import configureStore from 'redux/store/configureStore';
+import * as selectors from '../selectors';
+import { clusterSchemasPayload, schemaVersionsPayload } from './fixtures';
+
+const store = configureStore();
+
+describe('Schemas selectors', () => {
+  describe('Initial state', () => {
+    it('returns fetch status', () => {
+      expect(selectors.getIsSchemaListFetched(store.getState())).toBeFalsy();
+      expect(selectors.getIsSchemaVersionFetched(store.getState())).toBeFalsy();
+    });
+
+    it('returns schema list', () => {
+      expect(selectors.getSchemaList(store.getState())).toEqual([]);
+    });
+
+    it('returns undefined schema', () => {
+      expect(selectors.getSchema(store.getState(), ' ')).toBeUndefined();
+    });
+
+    it('returns sorted versions of schema', () => {
+      expect(selectors.getSortedSchemaVersions(store.getState())).toEqual([]);
+    });
+  });
+
+  describe('state', () => {
+    beforeAll(() => {
+      store.dispatch(
+        fetchSchemasByClusterNameAction.success(clusterSchemasPayload)
+      );
+      store.dispatch(fetchSchemaVersionsAction.success(schemaVersionsPayload));
+    });
+
+    it('returns fetch status', () => {
+      expect(selectors.getIsSchemaListFetched(store.getState())).toBeTruthy();
+      expect(
+        selectors.getIsSchemaVersionFetched(store.getState())
+      ).toBeTruthy();
+    });
+
+    it('returns schema list', () => {
+      expect(selectors.getSchemaList(store.getState())).toEqual(
+        clusterSchemasPayload
+      );
+    });
+
+    it('returns schema', () => {
+      expect(selectors.getSchema(store.getState(), 'test2')).toEqual(
+        clusterSchemasPayload[0]
+      );
+    });
+
+    it('returns sorted versions of schema', () => {
+      expect(selectors.getSortedSchemaVersions(store.getState())).toEqual(
+        schemaVersionsPayload
+      );
+    });
+  });
+});

+ 46 - 0
kafka-ui-react-app/src/redux/reducers/schemas/reducer.ts

@@ -0,0 +1,46 @@
+import { SchemaSubject } from 'generated-sources';
+import { Action, SchemasState } from 'redux/interfaces';
+
+export const initialState: SchemasState = {
+  byName: {},
+  allNames: [],
+  currentSchemaVersions: [],
+};
+
+const updateSchemaList = (
+  state: SchemasState,
+  payload: SchemaSubject[]
+): SchemasState => {
+  const initialMemo: SchemasState = {
+    ...state,
+    allNames: [],
+  };
+
+  return payload.reduce((memo: SchemasState, schema) => {
+    if (!schema.subject) return memo;
+    return {
+      ...memo,
+      byName: {
+        ...memo.byName,
+        [schema.subject]: {
+          ...memo.byName[schema.subject],
+          ...schema,
+        },
+      },
+      allNames: [...memo.allNames, schema.subject],
+    };
+  }, initialMemo);
+};
+
+const reducer = (state = initialState, action: Action): SchemasState => {
+  switch (action.type) {
+    case 'GET_CLUSTER_SCHEMAS__SUCCESS':
+      return updateSchemaList(state, action.payload);
+    case 'GET_SCHEMA_VERSIONS__SUCCESS':
+      return { ...state, currentSchemaVersions: action.payload };
+    default:
+      return state;
+  }
+};
+
+export default reducer;

+ 48 - 0
kafka-ui-react-app/src/redux/reducers/schemas/selectors.ts

@@ -0,0 +1,48 @@
+import { createSelector } from 'reselect';
+import { RootState, SchemasState } from 'redux/interfaces';
+import { createFetchingSelector } from 'redux/reducers/loader/selectors';
+
+const schemasState = ({ schemas }: RootState): SchemasState => schemas;
+
+const getAllNames = (state: RootState) => schemasState(state).allNames;
+const getSchemaMap = (state: RootState) => schemasState(state).byName;
+
+const getSchemaListFetchingStatus = createFetchingSelector(
+  'GET_CLUSTER_SCHEMAS'
+);
+
+const getSchemaVersionsFetchingStatus = createFetchingSelector(
+  'GET_SCHEMA_VERSIONS'
+);
+
+export const getIsSchemaListFetched = createSelector(
+  getSchemaListFetchingStatus,
+  (status) => status === 'fetched'
+);
+
+export const getIsSchemaVersionFetched = createSelector(
+  getSchemaVersionsFetchingStatus,
+  (status) => status === 'fetched'
+);
+
+export const getSchemaList = createSelector(
+  getIsSchemaListFetched,
+  getAllNames,
+  getSchemaMap,
+  (isFetched, allNames, byName) =>
+    isFetched ? allNames.map((subject) => byName[subject]) : []
+);
+
+const getSchemaName = (_: RootState, subject: string) => subject;
+
+export const getSchema = createSelector(
+  getSchemaMap,
+  getSchemaName,
+  (schemas, subject) => schemas[subject]
+);
+
+export const getSortedSchemaVersions = createSelector(
+  schemasState,
+  ({ currentSchemaVersions }) =>
+    currentSchemaVersions.sort((a, b) => a.id - b.id)
+);