Repository: provectus/kafka-ui
Issue: Display topic message content in a tree view
Labels: type/enhancement
scope/frontend

If topic message content is a valid JSON it should be displayed as a tree with expandable nodes.

Issue comments:

Pull request review comments:

Patch that resolved the issue:
diff --git a/kafka-ui-react-app/package-lock.json b/kafka-ui-react-app/package-lock.json
index 80d728a361d..bc28dec3291 100644
--- a/kafka-ui-react-app/package-lock.json
+++ b/kafka-ui-react-app/package-lock.json
@@ -1803,6 +1803,11 @@
         "@babel/types": "^7.3.0"
       }
     },
+    "@types/base16": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@types/base16/-/base16-1.0.2.tgz",
+      "integrity": "sha512-oYO/U4VD1DavwrKuCSQWdLG+5K22SLPem2OQaHmFcQuwHoVeGC+JGVRji2MUqZUAIQZHEonOeVfAX09hYiLsdg=="
+    },
     "@types/classnames": {
       "version": "2.2.9",
       "resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.9.tgz",
@@ -1902,8 +1907,15 @@
     "@types/lodash": {
       "version": "4.14.149",
       "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.149.tgz",
-      "integrity": "sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ==",
-      "dev": true
+      "integrity": "sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ=="
+    },
+    "@types/lodash.curry": {
+      "version": "4.1.6",
+      "resolved": "https://registry.npmjs.org/@types/lodash.curry/-/lodash.curry-4.1.6.tgz",
+      "integrity": "sha512-x3ctCcmOYqRrihNNnQJW6fe/yZFCgnrIa6p80AiPQRO8Jis29bBdy1dEw1FwngoF/mCZa3Bx+33fUZvOEE635Q==",
+      "requires": {
+        "@types/lodash": "*"
+      }
     },
     "@types/minimatch": {
       "version": "3.0.3",
@@ -3393,6 +3405,11 @@
         }
       }
     },
+    "base16": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/base16/-/base16-1.0.0.tgz",
+      "integrity": "sha1-4pf2DX7BAUp6lxo568ipjAtoHnA="
+    },
     "base64-js": {
       "version": "1.3.1",
       "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz",
@@ -4346,7 +4363,6 @@
       "version": "3.1.2",
       "resolved": "https://registry.npmjs.org/color/-/color-3.1.2.tgz",
       "integrity": "sha512-vXTJhHebByxZn3lDvDJYw4lR5+uB3vuoHsuYA5AKuxRVn5wzzIfQKGLBmgdVRHKTJYeK5rvJcHnrd0Li49CFpg==",
-      "dev": true,
       "requires": {
         "color-convert": "^1.9.1",
         "color-string": "^1.5.2"
@@ -4356,7 +4372,6 @@
       "version": "1.9.3",
       "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
       "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
-      "dev": true,
       "requires": {
         "color-name": "1.1.3"
       }
@@ -4364,14 +4379,12 @@
     "color-name": {
       "version": "1.1.3",
       "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
-      "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
-      "dev": true
+      "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
     },
     "color-string": {
       "version": "1.5.3",
       "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.3.tgz",
       "integrity": "sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw==",
-      "dev": true,
       "requires": {
         "color-name": "^1.0.0",
         "simple-swizzle": "^0.2.2"
@@ -10560,6 +10573,11 @@
       "integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=",
       "dev": true
     },
+    "lodash.curry": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/lodash.curry/-/lodash.curry-4.1.1.tgz",
+      "integrity": "sha1-JI42By7ekGUB11lmIAqG2riyMXA="
+    },
     "lodash.memoize": {
       "version": "4.1.2",
       "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@@ -14055,6 +14073,26 @@
         "whatwg-fetch": "^3.0.0"
       }
     },
+    "react-base16-styling": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/react-base16-styling/-/react-base16-styling-0.8.0.tgz",
+      "integrity": "sha512-ElvciPaL4xpWh7ISX7ugkNS/dvoh7DpVMp4t93ngnEsS2LkMd8Gu+cDDOLis2rj4889CNK662UdjOfv3wvZg9w==",
+      "requires": {
+        "@types/base16": "^1.0.2",
+        "@types/lodash.curry": "^4.1.6",
+        "base16": "^1.0.0",
+        "color": "^3.1.2",
+        "csstype": "^3.0.2",
+        "lodash.curry": "^4.1.1"
+      },
+      "dependencies": {
+        "csstype": {
+          "version": "3.0.4",
+          "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.4.tgz",
+          "integrity": "sha512-xc8DUsCLmjvCfoD7LTGE0ou2MIWLx0K9RCZwSHMOdynqRsP4MtUcLeqh1HcQ2dInwDTqn+3CE0/FZh1et+p4jA=="
+        }
+      }
+    },
     "react-datepicker": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-3.0.0.tgz",
@@ -14344,6 +14382,16 @@
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.12.0.tgz",
       "integrity": "sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q=="
     },
+    "react-json-tree": {
+      "version": "0.13.0",
+      "resolved": "https://registry.npmjs.org/react-json-tree/-/react-json-tree-0.13.0.tgz",
+      "integrity": "sha512-FPJUQzYWi7pvBUAnMd9ENOnAUT2mXLhe01VUbPfKH9Q4gk4FQ0fpS1e1WZ3o7g6zfYJpYJeBTo1WwlMHlMlZOw==",
+      "requires": {
+        "@types/prop-types": "^15.7.3",
+        "prop-types": "^15.7.2",
+        "react-base16-styling": "^0.8.0"
+      }
+    },
     "react-multi-select-component": {
       "version": "2.0.12",
       "resolved": "https://registry.npmjs.org/react-multi-select-component/-/react-multi-select-component-2.0.12.tgz",
@@ -16076,7 +16124,6 @@
       "version": "0.2.2",
       "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
       "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=",
-      "dev": true,
       "requires": {
         "is-arrayish": "^0.3.1"
       },
@@ -16084,8 +16131,7 @@
         "is-arrayish": {
           "version": "0.3.2",
           "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
-          "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
-          "dev": true
+          "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="
         }
       }
     },
diff --git a/kafka-ui-react-app/package.json b/kafka-ui-react-app/package.json
index 63dd7934c1f..43e31221fba 100644
--- a/kafka-ui-react-app/package.json
+++ b/kafka-ui-react-app/package.json
@@ -16,6 +16,7 @@
     "react-datepicker": "^3.0.0",
     "react-dom": "^16.12.0",
     "react-hook-form": "^4.5.5",
+    "react-json-tree": "^0.13.0",
     "react-multi-select-component": "^2.0.12",
     "react-redux": "^7.1.3",
     "react-router-dom": "^5.1.2",
@@ -75,6 +76,7 @@
     "@types/redux-thunk": "^2.1.0",
     "@typescript-eslint/eslint-plugin": "^2.27.0",
     "@typescript-eslint/parser": "^2.27.0",
+    "dotenv": "^8.2.0",
     "eslint": "^6.8.0",
     "eslint-config-airbnb": "^18.1.0",
     "eslint-config-prettier": "^6.10.1",
@@ -90,8 +92,7 @@
     "node-sass": "^4.13.1",
     "prettier": "^2.0.4",
     "react-scripts": "3.4.0",
-    "typescript": "~3.7.4",
-    "dotenv": "^8.2.0"
+    "typescript": "~3.7.4"
   },
   "proxy": "http://localhost:8080"
 }
diff --git a/kafka-ui-react-app/src/components/Topics/Details/Messages/Messages.tsx b/kafka-ui-react-app/src/components/Topics/Details/Messages/Messages.tsx
index a218d17edf2..99ea852b258 100644
--- a/kafka-ui-react-app/src/components/Topics/Details/Messages/Messages.tsx
+++ b/kafka-ui-react-app/src/components/Topics/Details/Messages/Messages.tsx
@@ -11,7 +11,7 @@ import {
 import PageLoader from 'components/common/PageLoader/PageLoader';
 import { format } from 'date-fns';
 import DatePicker from 'react-datepicker';
-
+import JSONTree from 'react-json-tree';
 import 'react-datepicker/dist/react-datepicker.css';
 import CustomParamButton, {
   CustomParamButtonType,
@@ -176,34 +176,35 @@ const Messages: React.FC<Props> = ({
     return format(Date.parse(timestamp), 'yyyy-MM-dd HH:mm:ss');
   };
 
-  const getMessageContentHeaders = React.useMemo(() => {
-    const message = messages[0];
-    const headers: JSX.Element[] = [];
-    try {
-      const content =
-        typeof message.content !== 'object'
-          ? JSON.parse(message.content)
-          : message.content;
-      Object.keys(content).forEach((k) =>
-        headers.push(<th key={Math.random()}>{`content.${k}`}</th>)
-      );
-    } catch (e) {
-      headers.push(<th>Content</th>);
-    }
-    return headers;
-  }, [messages]);
-
   const getMessageContentBody = (content: any) => {
-    const columns: JSX.Element[] = [];
     try {
-      const c = typeof content !== 'object' ? JSON.parse(content) : content;
-      Object.values(c).map((v) =>
-        columns.push(<td key={Math.random()}>{JSON.stringify(v)}</td>)
+      const contentObj =
+        typeof content !== 'object' ? JSON.parse(content) : content;
+      return (
+        <JSONTree
+          data={contentObj}
+          hideRoot
+          invertTheme={false}
+          theme={{
+            tree: ({ style }) => ({
+              style: {
+                ...style,
+                backgroundColor: undefined,
+                marginLeft: 0,
+                marginTop: 0,
+              },
+            }),
+            value: ({ style }) => ({
+              style: { ...style, marginLeft: 0 },
+            }),
+            base0D: '#3273dc',
+            base0B: '#363636',
+          }}
+        />
       );
     } catch (e) {
-      columns.push(<td>{content}</td>);
+      return content;
     }
-    return columns;
   };
 
   const onNext = (event: React.MouseEvent<HTMLButtonElement>) => {
@@ -241,16 +242,20 @@ const Messages: React.FC<Props> = ({
               <th>Timestamp</th>
               <th>Offset</th>
               <th>Partition</th>
-              {getMessageContentHeaders}
+              <th>Content</th>
             </tr>
           </thead>
           <tbody>
             {messages.map((message) => (
               <tr key={`${message.timestamp}${Math.random()}`}>
-                <td>{getTimestampDate(message.timestamp)}</td>
-                <td>{message.offset}</td>
-                <td>{message.partition}</td>
-                {getMessageContentBody(message.content)}
+                <td style={{ width: 200 }}>
+                  {getTimestampDate(message.timestamp)}
+                </td>
+                <td style={{ width: 150 }}>{message.offset}</td>
+                <td style={{ width: 100 }}>{message.partition}</td>
+                <td key={Math.random()} style={{ wordBreak: 'break-word' }}>
+                  {getMessageContentBody(message.content)}
+                </td>
               </tr>
             ))}
           </tbody>


Test patch: