Repository: provectus/kafka-ui
Issue: Topic message : json export feature
Labels: type/enhancement
good first issue
scope/frontend

Hi, 

I like the tree presentation for topic message.
It would be also really nice to have a way to copy the message content in json format (so we can export it somewhere else and work on it)
For now, copying from the UI is not usable as it does not let to export real json.

Version used : 0.0.9

Issue comments:

Pull request review comments:
Is there a way to avoid custom css?
Can we wrap this to the hook? It might be useful for schema registry
It is important to reduce custom stuff. Let's use native title
tip: `Record<string, string>`
You can use Bulma helpers https://bulma.io/documentation/helpers/flexbox-helpers/#justify-content 
Same here
What is this div for?
Yep, got rid of it

Patch that resolved the issue:
diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/MessageItem.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/MessageItem.tsx
index 0f7b13de23c..a541a725e3a 100644
--- a/kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/MessageItem.tsx
+++ b/kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/MessageItem.tsx
@@ -21,7 +21,7 @@ const MessageItem: React.FC<MessageItemProp> = ({
     <td style={{ width: 150 }}>{offset}</td>
     <td style={{ width: 100 }}>{partition}</td>
     <td style={{ wordBreak: 'break-word' }}>
-      {content && <JSONViewer data={content as { [key: string]: string }} />}
+      {content && <JSONViewer data={content as Record<string, string>} />}
     </td>
   </tr>
 );
diff --git a/kafka-ui-react-app/src/components/common/JSONViewer/DynamicButton.tsx b/kafka-ui-react-app/src/components/common/JSONViewer/DynamicButton.tsx
new file mode 100644
index 00000000000..a72592d5e92
--- /dev/null
+++ b/kafka-ui-react-app/src/components/common/JSONViewer/DynamicButton.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+
+interface ButtonProps {
+  callback: () => void;
+  classes?: string;
+  title: string;
+  style?: { [key: string]: string | number };
+  text: {
+    default: string;
+    dynamic: string;
+  };
+}
+
+const DynamicButton: React.FC<ButtonProps> = ({
+  callback,
+  classes,
+  title,
+  style,
+  text,
+  children,
+}) => {
+  const [buttonText, setButtonText] = React.useState(text.default);
+  let timeout: number;
+  const clickHandler = () => {
+    callback();
+    setButtonText(text.dynamic);
+    timeout = window.setTimeout(() => setButtonText(text.default), 3000);
+  };
+  React.useEffect(() => () => window.clearTimeout(timeout), [callback]);
+  return (
+    <button
+      className={classes}
+      title={title}
+      type="button"
+      style={style}
+      onClick={clickHandler}
+    >
+      {children}
+      <span>{buttonText}</span>
+    </button>
+  );
+};
+
+export default DynamicButton;
diff --git a/kafka-ui-react-app/src/components/common/JSONViewer/JSONViewer.tsx b/kafka-ui-react-app/src/components/common/JSONViewer/JSONViewer.tsx
index 6627e2d072e..68d38a1909a 100644
--- a/kafka-ui-react-app/src/components/common/JSONViewer/JSONViewer.tsx
+++ b/kafka-ui-react-app/src/components/common/JSONViewer/JSONViewer.tsx
@@ -1,6 +1,8 @@
 import React from 'react';
 import JSONTree from 'react-json-tree';
+import useDataSaver from 'lib/hooks/useDataSaver';
 import theme from './themes/google';
+import DynamicButton from './DynamicButton';
 
 interface JSONViewerProps {
   data: {
@@ -8,8 +10,45 @@ interface JSONViewerProps {
   };
 }
 
-const JSONViewer: React.FC<JSONViewerProps> = ({ data }) => (
-  <JSONTree data={data} theme={theme} shouldExpandNode={() => true} hideRoot />
-);
+const JSONViewer: React.FC<JSONViewerProps> = ({ data }) => {
+  const { copyToClipboard, saveFile } = useDataSaver();
+  const copyButtonHandler = () => {
+    copyToClipboard(JSON.stringify(data));
+  };
+  const buttonClasses = 'button is-link is-outlined is-small is-centered';
+  return (
+    <div>
+      <JSONTree
+        data={data}
+        theme={theme}
+        shouldExpandNode={() => true}
+        hideRoot
+      />
+      <div className="field has-addons is-justify-content-flex-end">
+        <DynamicButton
+          callback={copyButtonHandler}
+          classes={`${buttonClasses} mr-1`}
+          title="Copy the message to the clipboard"
+          text={{ default: 'Copy', dynamic: 'Copied!' }}
+        >
+          <span className="icon">
+            <i className="far fa-clipboard" />
+          </span>
+        </DynamicButton>
+        <button
+          className={buttonClasses}
+          title="Download the message as a .json/.txt file"
+          type="button"
+          onClick={() => saveFile(JSON.stringify(data), `topic-message`)}
+        >
+          <span className="icon">
+            <i className="fas fa-file-download" />
+          </span>
+          <span>Save</span>
+        </button>
+      </div>
+    </div>
+  );
+};
 
 export default JSONViewer;
diff --git a/kafka-ui-react-app/src/components/common/JSONViewer/__tests__/DynamicButton.spec.tsx b/kafka-ui-react-app/src/components/common/JSONViewer/__tests__/DynamicButton.spec.tsx
new file mode 100644
index 00000000000..2e6ef202e3b
--- /dev/null
+++ b/kafka-ui-react-app/src/components/common/JSONViewer/__tests__/DynamicButton.spec.tsx
@@ -0,0 +1,24 @@
+import { mount, shallow } from 'enzyme';
+import React from 'react';
+import DynamicButton from '../DynamicButton';
+
+describe('DynamicButton', () => {
+  const mockCallback = jest.fn();
+  const text = { default: 'DefaultText', dynamic: 'DynamicText' };
+  it('exectutes callback', () => {
+    const component = shallow(
+      <DynamicButton callback={mockCallback} title="title" text={text} />
+    );
+    component.simulate('click');
+    expect(mockCallback).toBeCalled();
+  });
+
+  it('changes the text', () => {
+    const component = mount(
+      <DynamicButton callback={mockCallback} title="title" text={text} />
+    );
+    expect(component.text()).toEqual(text.default);
+    component.simulate('click');
+    expect(component.text()).toEqual(text.dynamic);
+  });
+});
diff --git a/kafka-ui-react-app/src/lib/hooks/__tests__/useDataSaver.spec.tsx b/kafka-ui-react-app/src/lib/hooks/__tests__/useDataSaver.spec.tsx
new file mode 100644
index 00000000000..4897bafccb1
--- /dev/null
+++ b/kafka-ui-react-app/src/lib/hooks/__tests__/useDataSaver.spec.tsx
@@ -0,0 +1,40 @@
+import useDataSaver from '../useDataSaver';
+
+describe('useDataSaver hook', () => {
+  const content = {
+    title: 'title',
+  };
+  it('downloads the file', () => {
+    const link: HTMLAnchorElement = document.createElement('a');
+    link.click = jest.fn();
+    const mockCreate = jest
+      .spyOn(document, 'createElement')
+      .mockImplementation(() => link);
+    const { saveFile } = useDataSaver();
+    saveFile(JSON.stringify(content), 'fileName');
+
+    expect(mockCreate).toHaveBeenCalledTimes(1);
+    expect(link.download).toEqual('fileName.json');
+    expect(link.href).toEqual(
+      `data:text/json;charset=utf-8,${encodeURIComponent(
+        JSON.stringify(content)
+      )}`
+    );
+    expect(link.click).toHaveBeenCalledTimes(1);
+  });
+
+  it('copies the data to the clipboard', () => {
+    Object.assign(navigator, {
+      clipboard: {
+        writeText: jest.fn(),
+      },
+    });
+    jest.spyOn(navigator.clipboard, 'writeText');
+    const { copyToClipboard } = useDataSaver();
+    copyToClipboard(JSON.stringify(content));
+
+    expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
+      JSON.stringify(content)
+    );
+  });
+});
diff --git a/kafka-ui-react-app/src/lib/hooks/useDataSaver.tsx b/kafka-ui-react-app/src/lib/hooks/useDataSaver.tsx
new file mode 100644
index 00000000000..3fc23a168ad
--- /dev/null
+++ b/kafka-ui-react-app/src/lib/hooks/useDataSaver.tsx
@@ -0,0 +1,27 @@
+const useDataSaver = () => {
+  const copyToClipboard = (content: string) => {
+    if (navigator.clipboard) navigator.clipboard.writeText(content);
+  };
+
+  const saveFile = (content: string, fileName: string) => {
+    let extension = 'json';
+    try {
+      JSON.parse(content);
+    } catch (e) {
+      extension = 'txt';
+    }
+    const dataStr = `data:text/json;charset=utf-8,${encodeURIComponent(
+      content
+    )}`;
+    const downloadAnchorNode = document.createElement('a');
+    downloadAnchorNode.setAttribute('href', dataStr);
+    downloadAnchorNode.setAttribute('download', `${fileName}.${extension}`);
+    document.body.appendChild(downloadAnchorNode);
+    downloadAnchorNode.click();
+    downloadAnchorNode.remove();
+  };
+
+  return { copyToClipboard, saveFile };
+};
+
+export default useDataSaver;


Test patch: