Repository: provectus/kafka-ui
Issue: Topic messages: Decompose component
Labels: good first issue
scope/frontend

Messages component is too big, overcomplicated and is hard to read, so it should be decomposed and refactored.

`kafka-ui-react-app/src/components/Topics/Details/Messages/Messages.tsx`

Issue comments:

Pull request review comments:
1. Use {}
2. This condition looks wrong. It was
```
{message.content && getMessageContentBody(message.content as Record<string, unknown>)}
```
you did it 
```
{content ? <> : content}
```
I would suggest moving it out of the component. It could be a separate `util` file in case it could be used in other places in advance or this file outside of the component. After that, you could use `useMemo` to calculate it once.
In case you do decomposition I would suggest describing a new component for content.
What about use [uuid](https://github.com/uuidjs/uuid#readme) lib instead of `Math.random`?
Do we plan to start using something for styles (`scss`, `Styled Components`, etc) instead of inline style?
I would suggest do not do spreading`TopicMessage` outside and just provide it to `MessageItem` as a single prop like `topic` or `topicMessage`.
Can we have a `uuid` here too?
@aMoRRoMa @whotake I think it is a wrong choise. uuid assigns new key for tag every time/every render. it is useless.
I agree that we should get rid of Math.random. But disagree of using uuid here
The key must be unique and **stable**!
From my point of view, just a `timestamp` is enough
We will update it in the separate ticket
Ok, make sense
Or get rid of this function and use 
```
<td style={{ width: 200 }}>
  {date ? format(date, 'yyyy-MM-dd HH:mm:ss') : null}
</td>
```
> In case you do decomposition I would suggest describing a new component for content.

@aMoRRoMa do you mean move the body of `getMessageContentBody` function to the new component, e.g, MessageContent?

yes

Patch that resolved the issue:
diff --git a/kafka-ui-react-app/src/components/Topics/Details/Messages/MessageItem.tsx b/kafka-ui-react-app/src/components/Topics/Details/Messages/MessageItem.tsx
new file mode 100644
index 00000000000..e168ef042a4
--- /dev/null
+++ b/kafka-ui-react-app/src/components/Topics/Details/Messages/MessageItem.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import { format } from 'date-fns';
+import JSONTree from 'react-json-tree';
+import { TopicMessage } from 'generated-sources';
+
+interface MessageItemProp {
+  partition: TopicMessage['partition'];
+  offset: TopicMessage['offset'];
+  timestamp: TopicMessage['timestamp'];
+  content: TopicMessage['content'];
+}
+
+const MessageItem: React.FC<MessageItemProp> = ({
+  partition,
+  offset,
+  timestamp,
+  content,
+}) => (
+  <tr key="{timestamp}">
+    <td style={{ width: 200 }}>
+      {timestamp ? format(timestamp, 'yyyy-MM-dd HH:mm:ss') : null}
+    </td>
+    <td style={{ width: 150 }}>{offset}</td>
+    <td style={{ width: 100 }}>{partition}</td>
+    <td key="{content}" 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',
+          }}
+        />
+      )}
+    </td>
+  </tr>
+);
+
+export default MessageItem;
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 5a6f639f691..59e8b721120 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
@@ -6,19 +6,15 @@ import {
 } from 'redux/interfaces';
 import { TopicMessage, Partition, SeekType } from 'generated-sources';
 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,
-} from 'components/Topics/shared/Form/CustomParams/CustomParamButton';
 
 import MultiSelect from 'react-multi-select-component';
 
 import * as _ from 'lodash';
 import { useDebouncedCallback } from 'use-debounce';
 import { Option } from 'react-multi-select-component/dist/lib/interfaces';
+import MessagesTable from './MessagesTable';
 
 export interface Props {
   clusterName: ClusterName;
@@ -174,42 +170,6 @@ const Messages: React.FC<Props> = ({
     fetchTopicMessages(clusterName, topicName, queryParams);
   }, [clusterName, topicName, queryParams]);
 
-  const getFormattedDate = (date: Date) => {
-    if (!date) return null;
-    return format(date, 'yyyy-MM-dd HH:mm:ss');
-  };
-
-  const getMessageContentBody = (content: Record<string, unknown>) => {
-    try {
-      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) {
-      return content;
-    }
-  };
-
   const onNext = (event: React.MouseEvent<HTMLButtonElement>) => {
     event.preventDefault();
 
@@ -236,52 +196,6 @@ const Messages: React.FC<Props> = ({
     );
   };
 
-  const getTopicMessagesTable = () => {
-    return messages.length > 0 ? (
-      <div>
-        <table className="table is-striped is-fullwidth">
-          <thead>
-            <tr>
-              <th>Timestamp</th>
-              <th>Offset</th>
-              <th>Partition</th>
-              <th>Content</th>
-            </tr>
-          </thead>
-          <tbody>
-            {messages.map((message) => (
-              <tr key={`${message.timestamp}${Math.random()}`}>
-                <td style={{ width: 200 }}>
-                  {getFormattedDate(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' }}>
-                  {message.content &&
-                    getMessageContentBody(
-                      message.content as Record<string, unknown>
-                    )}
-                </td>
-              </tr>
-            ))}
-          </tbody>
-        </table>
-        <div className="columns">
-          <div className="column is-full">
-            <CustomParamButton
-              className="is-link is-pulled-right"
-              type={CustomParamButtonType.chevronRight}
-              onClick={onNext}
-              btnText="Next"
-            />
-          </div>
-        </div>
-      </div>
-    ) : (
-      <div>No messages at selected topic</div>
-    );
-  };
-
   if (!isFetched) {
     return <PageLoader isFullHeight={false} />;
   }
@@ -366,7 +280,7 @@ const Messages: React.FC<Props> = ({
           />
         </div>
       </div>
-      {getTopicMessagesTable()}
+      <MessagesTable messages={messages} onNext={onNext} />
     </div>
   );
 };
diff --git a/kafka-ui-react-app/src/components/Topics/Details/Messages/MessagesTable.tsx b/kafka-ui-react-app/src/components/Topics/Details/Messages/MessagesTable.tsx
new file mode 100644
index 00000000000..5073673f86d
--- /dev/null
+++ b/kafka-ui-react-app/src/components/Topics/Details/Messages/MessagesTable.tsx
@@ -0,0 +1,57 @@
+import React from 'react';
+import { TopicMessage } from 'generated-sources';
+import CustomParamButton, {
+  CustomParamButtonType,
+} from '../../shared/Form/CustomParams/CustomParamButton';
+import MessageItem from './MessageItem';
+
+interface MessagesTableProp {
+  messages: TopicMessage[];
+  onNext(event: React.MouseEvent<HTMLButtonElement>): void;
+}
+
+const MessagesTable: React.FC<MessagesTableProp> = ({ messages, onNext }) => {
+  if (!messages.length) {
+    return <div>No messages at selected topic</div>;
+  }
+
+  return (
+    <div>
+      <table className="table is-striped is-fullwidth">
+        <thead>
+          <tr>
+            <th>Timestamp</th>
+            <th>Offset</th>
+            <th>Partition</th>
+            <th>Content</th>
+          </tr>
+        </thead>
+        <tbody>
+          {messages.map(
+            ({ partition, offset, timestamp, content }: TopicMessage) => (
+              <MessageItem
+                key={timestamp.toString()}
+                partition={partition}
+                offset={offset}
+                timestamp={timestamp}
+                content={content as Record<string, unknown>}
+              />
+            )
+          )}
+        </tbody>
+      </table>
+      <div className="columns">
+        <div className="column is-full">
+          <CustomParamButton
+            className="is-link is-pulled-right"
+            type={CustomParamButtonType.chevronRight}
+            onClick={onNext}
+            btnText="Next"
+          />
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default MessagesTable;
diff --git a/kafka-ui-react-app/src/redux/reducers/topics/reducer.ts b/kafka-ui-react-app/src/redux/reducers/topics/reducer.ts
index f4d2e1b8568..6a114851724 100644
--- a/kafka-ui-react-app/src/redux/reducers/topics/reducer.ts
+++ b/kafka-ui-react-app/src/redux/reducers/topics/reducer.ts
@@ -1,5 +1,5 @@
 import { v4 } from 'uuid';
-import { Topic } from 'generated-sources';
+import { Topic, TopicMessage } from 'generated-sources';
 import { Action, TopicsState } from 'redux/interfaces';
 import ActionType from 'redux/actionType';
 
@@ -41,6 +41,31 @@ const addToTopicList = (state: TopicsState, payload: Topic): TopicsState => {
   return newState;
 };
 
+const transformTopicMessages = (
+  state: TopicsState,
+  messages: TopicMessage[]
+): TopicsState => ({
+  ...state,
+  messages: messages.map((mes) => {
+    const { content } = mes;
+    let parsedContent = content;
+
+    if (content) {
+      try {
+        parsedContent =
+          typeof content !== 'object' ? JSON.parse(content) : content;
+      } catch (_) {
+        // do nothing
+      }
+    }
+
+    return {
+      ...mes,
+      content: parsedContent,
+    };
+  }),
+});
+
 const reducer = (state = initialState, action: Action): TopicsState => {
   switch (action.type) {
     case ActionType.GET_TOPICS__SUCCESS:
@@ -57,10 +82,7 @@ const reducer = (state = initialState, action: Action): TopicsState => {
         },
       };
     case ActionType.GET_TOPIC_MESSAGES__SUCCESS:
-      return {
-        ...state,
-        messages: action.payload,
-      };
+      return transformTopicMessages(state, action.payload);
     case ActionType.GET_TOPIC_CONFIG__SUCCESS:
       return {
         ...state,


Test patch: