From 8b50c60342324d1fbbeebf2e780581eb1cd7e4fc Mon Sep 17 00:00:00 2001
From: Ryan McKinley <ryantxu@gmail.com>
Date: Thu, 5 Jan 2023 02:08:00 -0800
Subject: [PATCH] TextPanel: Refactor to functional component (#60885)

---
 public/app/plugins/panel/text/TextPanel.tsx   | 185 +++++++-----------
 .../plugins/panel/text/TextPanelEditor.tsx    |   4 +-
 public/app/plugins/panel/text/module.tsx      |   2 -
 3 files changed, 78 insertions(+), 113 deletions(-)

diff --git a/public/app/plugins/panel/text/TextPanel.tsx b/public/app/plugins/panel/text/TextPanel.tsx
index cf7a265057f..b3bca99353f 100644
--- a/public/app/plugins/panel/text/TextPanel.tsx
+++ b/public/app/plugins/panel/text/TextPanel.tsx
@@ -1,127 +1,94 @@
-// Libraries
 import { css, cx } from '@emotion/css';
 import DangerouslySetHtmlContent from 'dangerously-set-html-content';
-import { debounce } from 'lodash';
-import React, { PureComponent } from 'react';
+import React, { useState } from 'react';
+import { useDebounce } from 'react-use';
 
-import { GrafanaTheme2, PanelProps, renderTextPanelMarkdown, textUtil } from '@grafana/data';
-// Utils
-import { CustomScrollbar, CodeEditor, stylesFactory, ThemeContext } from '@grafana/ui';
+import { GrafanaTheme2, PanelProps, renderTextPanelMarkdown, textUtil, InterpolateFunction } from '@grafana/data';
+import { CustomScrollbar, CodeEditor, useStyles2 } from '@grafana/ui';
 import config from 'app/core/config';
 
-// Types
 import { defaultCodeOptions, PanelOptions, TextMode } from './models.gen';
 
 export interface Props extends PanelProps<PanelOptions> {}
 
-interface State {
-  html: string;
-}
+export function TextPanel(props: Props) {
+  const styles = useStyles2(getStyles);
+  const [processed, setProcessed] = useState<PanelOptions>({
+    mode: props.options.mode,
+    content: processContent(props.options, props.replaceVariables, config.disableSanitizeHtml),
+  });
 
-export class TextPanel extends PureComponent<Props, State> {
-  declare context: React.ContextType<typeof ThemeContext>;
-  static contextType = ThemeContext;
-
-  constructor(props: Props) {
-    super(props);
-
-    this.state = {
-      html: this.processContent(props.options),
-    };
-  }
-
-  updateHTML = debounce(() => {
-    const html = this.processContent(this.props.options);
-    if (html !== this.state.html) {
-      this.setState({ html });
-    }
-  }, 150);
-
-  componentDidUpdate(prevProps: Props) {
-    // Since any change could be referenced in a template variable,
-    // This needs to process every time (with debounce)
-    this.updateHTML();
-  }
-
-  prepareHTML(html: string): string {
-    const result = this.interpolateString(html);
-    return config.disableSanitizeHtml ? result : this.sanitizeString(result);
-  }
-
-  prepareMarkdown(content: string): string {
-    // Always interpolate variables before converting to markdown
-    // because `marked` replaces '{' and '}' in URLs with '%7B' and '%7D'
-    // See https://marked.js.org/demo
-    let result = this.interpolateString(content);
-
-    if (config.disableSanitizeHtml) {
-      result = renderTextPanelMarkdown(result, {
-        noSanitize: true,
-      });
-      return result;
-    }
-
-    result = renderTextPanelMarkdown(result);
-    return this.sanitizeString(result);
-  }
-
-  interpolateString(content: string): string {
-    const { replaceVariables, options } = this.props;
-    return replaceVariables(content, {}, options.code?.language === 'json' ? 'json' : 'html');
-  }
-
-  sanitizeString(content: string): string {
-    return textUtil.sanitizeTextPanelContent(content);
-  }
-
-  processContent(options: PanelOptions): string {
-    const { mode, content } = options;
-
-    if (!content) {
-      return '';
-    }
-
-    if (mode === TextMode.HTML) {
-      return this.prepareHTML(content);
-    } else if (mode === TextMode.Code) {
-      return this.interpolateString(content);
-    }
-
-    return this.prepareMarkdown(content);
-  }
-
-  render() {
-    const { html } = this.state;
-    const { options } = this.props;
-    const styles = getStyles(this.context);
-
-    if (options.mode === TextMode.Code) {
-      const { width, height } = this.props;
-      const code = options.code ?? defaultCodeOptions;
-      return (
-        <CodeEditor
-          key={`${code.showLineNumbers}/${code.showMiniMap}`} // will reinit-on change
-          value={html}
-          language={code.language ?? defaultCodeOptions.language!}
-          width={width}
-          height={height}
-          containerStyles={styles.codeEditorContainer}
-          showMiniMap={code.showMiniMap}
-          showLineNumbers={code.showLineNumbers}
-          readOnly={true} // future
-        />
-      );
-    }
+  useDebounce(
+    () => {
+      const { options, replaceVariables } = props;
+      const content = processContent(options, replaceVariables, config.disableSanitizeHtml);
+      if (content !== processed.content || options.mode !== processed.mode) {
+        setProcessed({
+          mode: options.mode,
+          content,
+        });
+      }
+    },
+    100,
+    [props]
+  );
 
+  if (processed.mode === TextMode.Code) {
+    const code = props.options.code ?? defaultCodeOptions;
     return (
-      <CustomScrollbar autoHeightMin="100%">
-        <DangerouslySetHtmlContent html={html} className={styles.markdown} data-testid="TextPanel-converted-content" />
-      </CustomScrollbar>
+      <CodeEditor
+        key={`${code.showLineNumbers}/${code.showMiniMap}`} // will reinit-on change
+        value={processed.content}
+        language={code.language ?? defaultCodeOptions.language!}
+        width={props.width}
+        height={props.height}
+        containerStyles={styles.codeEditorContainer}
+        showMiniMap={code.showMiniMap}
+        showLineNumbers={code.showLineNumbers}
+        readOnly={true} // future
+      />
     );
   }
+
+  return (
+    <CustomScrollbar autoHeightMin="100%">
+      <DangerouslySetHtmlContent
+        html={processed.content}
+        className={styles.markdown}
+        data-testid="TextPanel-converted-content"
+      />
+    </CustomScrollbar>
+  );
 }
 
-const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
+function processContent(options: PanelOptions, interpolate: InterpolateFunction, disableSanitizeHtml: boolean): string {
+  let { mode, content } = options;
+  if (!content) {
+    return '';
+  }
+
+  content = interpolate(content, {}, options.code?.language === 'json' ? 'json' : 'html');
+
+  switch (mode) {
+    case TextMode.Code:
+      break; // nothing
+    case TextMode.HTML:
+      if (!disableSanitizeHtml) {
+        content = textUtil.sanitizeTextPanelContent(content);
+      }
+      break;
+    case TextMode.Markdown:
+    default:
+      // default to markdown
+      content = renderTextPanelMarkdown(content, {
+        noSanitize: disableSanitizeHtml,
+      });
+  }
+
+  return content;
+}
+
+const getStyles = (theme: GrafanaTheme2) => ({
   codeEditorContainer: css`
     .monaco-editor .margin,
     .monaco-editor-background {
@@ -134,4 +101,4 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
       height: 100%;
     `
   ),
-}));
+});
diff --git a/public/app/plugins/panel/text/TextPanelEditor.tsx b/public/app/plugins/panel/text/TextPanelEditor.tsx
index 8a899ac9b0c..5a3d2417022 100644
--- a/public/app/plugins/panel/text/TextPanelEditor.tsx
+++ b/public/app/plugins/panel/text/TextPanelEditor.tsx
@@ -1,5 +1,5 @@
 import { css, cx } from '@emotion/css';
-import React, { FC, useMemo } from 'react';
+import React, { useMemo } from 'react';
 import AutoSizer from 'react-virtualized-auto-sizer';
 
 import { GrafanaTheme2, StandardEditorProps } from '@grafana/data';
@@ -12,7 +12,7 @@ import {
 
 import { PanelOptions, TextMode } from './models.gen';
 
-export const TextPanelEditor: FC<StandardEditorProps<string, any, PanelOptions>> = ({ value, onChange, context }) => {
+export const TextPanelEditor = ({ value, onChange, context }: StandardEditorProps<string, any, PanelOptions>) => {
   const language = useMemo(() => context.options?.mode ?? TextMode.Markdown, [context]);
   const styles = useStyles2(getStyles);
 
diff --git a/public/app/plugins/panel/text/module.tsx b/public/app/plugins/panel/text/module.tsx
index 05a14f7b45a..90535a49a3b 100644
--- a/public/app/plugins/panel/text/module.tsx
+++ b/public/app/plugins/panel/text/module.tsx
@@ -11,7 +11,6 @@ export const plugin = new PanelPlugin<PanelOptions>(TextPanel)
       .addRadio({
         path: 'mode',
         name: 'Mode',
-        description: 'text mode of the panel',
         settings: {
           options: [
             { value: TextMode.Markdown, label: 'Markdown' },
@@ -49,7 +48,6 @@ export const plugin = new PanelPlugin<PanelOptions>(TextPanel)
         id: 'content',
         path: 'content',
         name: 'Content',
-        description: 'Content of the panel',
         editor: TextPanelEditor,
         defaultValue: defaultPanelOptions.content,
       });