Repository: VolkovLabs/volkovlabs-dynamictext-panel (ts)

Issue:
Allow panel to display background color based on a data field
The old singlevalue panel was able to display some text and to color the background based on the results returned in the query. So it was it possible to display a temperature panel with a blue to red background colour, depending on how hot it was.

I have managed to simulate this functionality by configuring my sql query to return a #xxxxx color in a colume and using that, but it would be better if the panel did this natively.

I'd like to suggest that it be possible to do the same with the dynamic text module.


Pull request description:
Add status field
1. Resolves #163.

Content example:
```html
<div style="background-color: {{statusColor}}">
  {{Status}}
</div>
```

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f2bda6c..a8426ec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@
 
 - Update ESLint configuration (#192)
 - Increase Tests Coverage (#194)
+- Add status field (#196)
 
 ## 4.0.0 (2023-06-26)
 
diff --git a/provisioning/dashboards/panels.json b/provisioning/dashboards/panels.json
index 68aea20..1b35fa2 100644
--- a/provisioning/dashboards/panels.json
+++ b/provisioning/dashboards/panels.json
@@ -32,6 +32,28 @@
         "type": "grafana",
         "uid": "grafana"
       },
+      "fieldConfig": {
+        "defaults": {
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green",
+                "value": null
+              },
+              {
+                "color": "#EAB839",
+                "value": 10
+              },
+              {
+                "color": "red",
+                "value": 50
+              }
+            ]
+          }
+        },
+        "overrides": []
+      },
       "gridPos": {
         "h": 5,
         "w": 8,
@@ -40,7 +62,7 @@
       },
       "id": 19,
       "options": {
-        "content": "{{variable \"test\"}}",
+        "content": "<div style=\"background-color: {{statusColor}}\">\n  {{A-series}}\n</div>",
         "defaultContent": "The query didn't return any results.",
         "editor": {
           "format": "auto",
@@ -48,8 +70,9 @@
           "language": "markdown"
         },
         "editors": [],
-        "everyRow": false,
+        "everyRow": true,
         "helpers": "",
+        "status": "A-series",
         "styles": ""
       },
       "repeat": "test",
@@ -71,6 +94,24 @@
         "type": "marcusolsson-static-datasource",
         "uid": "U0HP2Rv4z"
       },
+      "fieldConfig": {
+        "defaults": {
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green",
+                "value": null
+              },
+              {
+                "color": "red",
+                "value": 80
+              }
+            ]
+          }
+        },
+        "overrides": []
+      },
       "gridPos": {
         "h": 2,
         "w": 12,
@@ -121,6 +162,24 @@
         "type": "marcusolsson-static-datasource",
         "uid": "U0HP2Rv4z"
       },
+      "fieldConfig": {
+        "defaults": {
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green",
+                "value": null
+              },
+              {
+                "color": "red",
+                "value": 80
+              }
+            ]
+          }
+        },
+        "overrides": []
+      },
       "gridPos": {
         "h": 2,
         "w": 12,
@@ -158,6 +217,24 @@
         "type": "marcusolsson-static-datasource",
         "uid": "U0HP2Rv4z"
       },
+      "fieldConfig": {
+        "defaults": {
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green",
+                "value": null
+              },
+              {
+                "color": "red",
+                "value": 80
+              }
+            ]
+          }
+        },
+        "overrides": []
+      },
       "gridPos": {
         "h": 5,
         "w": 12,
@@ -226,6 +303,24 @@
         "type": "marcusolsson-static-datasource",
         "uid": "U0HP2Rv4z"
       },
+      "fieldConfig": {
+        "defaults": {
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green",
+                "value": null
+              },
+              {
+                "color": "red",
+                "value": 80
+              }
+            ]
+          }
+        },
+        "overrides": []
+      },
       "gridPos": {
         "h": 12,
         "w": 6,
@@ -296,6 +391,24 @@
         "type": "grafana",
         "uid": "grafana"
       },
+      "fieldConfig": {
+        "defaults": {
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green",
+                "value": null
+              },
+              {
+                "color": "red",
+                "value": 80
+              }
+            ]
+          }
+        },
+        "overrides": []
+      },
       "gridPos": {
         "h": 12,
         "w": 6,
@@ -332,6 +445,24 @@
         "type": "marcusolsson-static-datasource",
         "uid": "U0HP2Rv4z"
       },
+      "fieldConfig": {
+        "defaults": {
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green",
+                "value": null
+              },
+              {
+                "color": "red",
+                "value": 80
+              }
+            ]
+          }
+        },
+        "overrides": []
+      },
       "gridPos": {
         "h": 4,
         "w": 12,
@@ -382,6 +513,24 @@
         "type": "marcusolsson-static-datasource",
         "uid": "U0HP2Rv4z"
       },
+      "fieldConfig": {
+        "defaults": {
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green",
+                "value": null
+              },
+              {
+                "color": "red",
+                "value": 80
+              }
+            ]
+          }
+        },
+        "overrides": []
+      },
       "gridPos": {
         "h": 13,
         "w": 12,
@@ -484,6 +633,24 @@
         "type": "marcusolsson-static-datasource",
         "uid": "U0HP2Rv4z"
       },
+      "fieldConfig": {
+        "defaults": {
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green",
+                "value": null
+              },
+              {
+                "color": "red",
+                "value": 80
+              }
+            ]
+          }
+        },
+        "overrides": []
+      },
       "gridPos": {
         "h": 9,
         "w": 12,
@@ -554,6 +721,24 @@
         "type": "volkovlabs-rss-datasource",
         "uid": "rZAdZdf82"
       },
+      "fieldConfig": {
+        "defaults": {
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green",
+                "value": null
+              },
+              {
+                "color": "red",
+                "value": 80
+              }
+            ]
+          }
+        },
+        "overrides": []
+      },
       "gridPos": {
         "h": 12,
         "w": 12,
@@ -611,6 +796,24 @@
         "type": "volkovlabs-rss-datasource",
         "uid": "rZAdZdf7a"
       },
+      "fieldConfig": {
+        "defaults": {
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green",
+                "value": null
+              },
+              {
+                "color": "red",
+                "value": 80
+              }
+            ]
+          }
+        },
+        "overrides": []
+      },
       "gridPos": {
         "h": 9,
         "w": 12,
diff --git a/src/components/Text/Text.tsx b/src/components/Text/Text.tsx
index 8d7ff60..c36a732 100644
--- a/src/components/Text/Text.tsx
+++ b/src/components/Text/Text.tsx
@@ -6,7 +6,7 @@ import { Alert, useStyles2 } from '@grafana/ui';
 import { TestIds } from '../../constants';
 import { generateHtml } from '../../helpers';
 import { Styles } from '../../styles';
-import { TextOptions } from '../../types';
+import { PanelOptions } from '../../types';
 
 /**
  * Properties
@@ -15,9 +15,9 @@ export interface Props {
   /**
    * Options
    *
-   * @type {TextOptions}
+   * @type {PanelOptions}
    */
-  options: TextOptions;
+  options: PanelOptions;
 
   /**
    * Frame
@@ -111,12 +111,20 @@ export const Text: React.FC<Props> = ({ options, frame, timeRange, timeZone, rep
         /**
          * Frame returned
          */
-        const data = frame.fields.reduce((out, { config, name, values }) => {
-          values.toArray().forEach((v, i) => {
-            out[i] = { ...out[i], [config.displayName || name]: v };
+        const data = frame.fields.reduce((acc, { config, name, values, display }) => {
+          values.toArray().forEach((value, i) => {
+            /**
+             * Status Color
+             */
+            const statusColor = options.status === name ? display?.(value).color : undefined;
+
+            /**
+             * Set Value and Status Color
+             */
+            acc[i] = { ...acc[i], [config.displayName || name]: value, statusColor };
           });
 
-          return out;
+          return acc;
         }, [] as Array<Record<string, any>>);
 
         if (options.everyRow) {
@@ -155,7 +163,15 @@ export const Text: React.FC<Props> = ({ options, frame, timeRange, timeZone, rep
         unsubscribeFn();
       }
     };
-  }, [frame?.fields, frame?.length, getHtml, options.content, options.defaultContent, options.everyRow]);
+  }, [
+    frame?.fields,
+    frame?.length,
+    getHtml,
+    options.content,
+    options.defaultContent,
+    options.everyRow,
+    options.status,
+  ]);
 
   if (error) {
     return (
diff --git a/src/components/TextPanel/TextPanel.tsx b/src/components/TextPanel/TextPanel.tsx
index 637807b..97d23a0 100644
--- a/src/components/TextPanel/TextPanel.tsx
+++ b/src/components/TextPanel/TextPanel.tsx
@@ -1,16 +1,16 @@
-import React, { useState } from 'react';
+import React, { useCallback, useMemo, useState } from 'react';
 import { css, cx } from '@emotion/css';
 import { PanelProps, SelectableValue } from '@grafana/data';
 import { Select, useStyles2 } from '@grafana/ui';
 import { TestIds } from '../../constants';
 import { Styles } from '../../styles';
-import { TextOptions } from '../../types';
+import { PanelOptions } from '../../types';
 import { Text } from '../Text';
 
 /**
  * Properties
  */
-interface Props extends PanelProps<TextOptions> {}
+interface Props extends PanelProps<PanelOptions> {}
 
 /**
  * Panel
@@ -38,18 +38,25 @@ export const TextPanel: React.FC<Props> = ({
   /**
    * Change Frame
    */
-  const onChangeFrame = (selectableValue: SelectableValue<string>) => {
-    const index = data.series.findIndex((frame) => frame.refId === selectableValue.value);
-    setFrameIndex(index);
-  };
+  const onChangeFrame = useCallback(
+    (selectableValue: SelectableValue<string>) => {
+      const index = data.series.findIndex((frame) => frame.refId === selectableValue.value);
+      setFrameIndex(index);
+    },
+    [data.series]
+  );
 
   /**
    * Frames
    */
-  const selectableFrames = data.series.map((frame) => ({
-    label: frame.name,
-    value: frame.refId,
-  }));
+  const selectableFrames = useMemo(
+    () =>
+      data.series.map((frame) => ({
+        label: frame.name,
+        value: frame.refId,
+      })),
+    [data.series]
+  );
 
   /**
    * Selected Frame
diff --git a/src/constants/default.ts b/src/constants/default.ts
index 4f60bd4..567a8b7 100644
--- a/src/constants/default.ts
+++ b/src/constants/default.ts
@@ -1,10 +1,10 @@
-import { TextOptions } from '../types';
+import { PanelOptions } from '../types';
 import { CodeLanguage, Format } from './editor';
 
 /**
  * Default Options
  */
-export const DefaultOptions: TextOptions = {
+export const DefaultOptions: PanelOptions = {
   content: '```json\n{{{json @root}}}\n```',
   defaultContent: "The query didn't return any results.",
   editor: { height: 200, format: Format.AUTO, language: CodeLanguage.MARKDOWN },
@@ -12,4 +12,5 @@ export const DefaultOptions: TextOptions = {
   everyRow: true,
   helpers: '',
   styles: '',
+  status: '',
 };
diff --git a/src/module.ts b/src/module.ts
index f664e32..d6c660c 100644
--- a/src/module.ts
+++ b/src/module.ts
@@ -1,107 +1,130 @@
-import { PanelPlugin } from '@grafana/data';
+import { Field, FieldConfigProperty, FieldType, PanelPlugin } from '@grafana/data';
 import { HelpersEditor, StylesEditor, TextEditor, TextPanel } from './components';
 import { CodeLanguageOptions, DefaultOptions, EditorsOptions, EveryRowOptions, FormatOptions } from './constants';
-import { EditorType, TextOptions } from './types';
+import { EditorType, PanelOptions } from './types';
 
 /**
  * Panel Plugin
  */
-export const plugin = new PanelPlugin<TextOptions>(TextPanel).setNoPadding().setPanelOptions((builder) => {
-  builder
-    .addRadio({
-      path: 'everyRow',
-      name: 'Render template',
-      settings: {
-        options: EveryRowOptions,
-      },
-      defaultValue: DefaultOptions.everyRow,
-    })
-    .addMultiSelect({
-      path: 'editors',
-      name: 'Select Editors to display. Editors with updated values always displayed.',
-      settings: {
-        options: EditorsOptions as any,
-      },
-      defaultValue: DefaultOptions.editors,
-    });
+export const plugin = new PanelPlugin<PanelOptions>(TextPanel)
+  .setNoPadding()
+  .useFieldConfig({
+    disableStandardOptions: [
+      FieldConfigProperty.Unit,
+      FieldConfigProperty.Color,
+      FieldConfigProperty.Min,
+      FieldConfigProperty.Max,
+      FieldConfigProperty.Decimals,
+      FieldConfigProperty.DisplayName,
+      FieldConfigProperty.NoValue,
+      FieldConfigProperty.Links,
+      FieldConfigProperty.Mappings,
+    ],
+  })
+  .setPanelOptions((builder) => {
+    builder
+      .addRadio({
+        path: 'everyRow',
+        name: 'Render template',
+        settings: {
+          options: EveryRowOptions,
+        },
+        defaultValue: DefaultOptions.everyRow,
+      })
+      .addMultiSelect({
+        path: 'editors',
+        name: 'Select Editors to display. Editors with updated values always displayed.',
+        settings: {
+          options: EditorsOptions as any,
+        },
+        defaultValue: DefaultOptions.editors,
+      })
+      .addFieldNamePicker({
+        path: 'status',
+        name: 'Field with status value. To be used to get statusColor based on thresholds.',
+        settings: {
+          filter: (f: Field) => f.type === FieldType.number,
+          noFieldsMessage: 'No number fields found',
+        },
+      });
 
-  /**
-   * Editor
-   */
-  builder
-    .addRadio({
-      path: 'editor.language',
-      name: 'Primary Content Language',
-      description: 'Used for formatting and suggestions.',
-      settings: {
-        options: CodeLanguageOptions,
-      },
-      defaultValue: DefaultOptions.editor.language,
-      category: ['Editor'],
-    })
-    .addRadio({
-      path: 'editor.format',
-      name: 'Formatting',
-      settings: {
-        options: FormatOptions,
-      },
-      defaultValue: DefaultOptions.editor.format,
-      category: ['Editor'],
-    })
-    .addSliderInput({
-      path: 'editor.height',
-      name: 'Height, px',
-      defaultValue: DefaultOptions.editor.height,
-      settings: {
-        min: 100,
-        max: 2000,
-      },
-      category: ['Editor'],
-    });
+    /**
+     * Editor
+     */
+    builder
+      .addRadio({
+        path: 'editor.language',
+        name: 'Primary Content Language',
+        description: 'Used for formatting and suggestions.',
+        settings: {
+          options: CodeLanguageOptions,
+        },
+        defaultValue: DefaultOptions.editor.language,
+        category: ['Editor'],
+      })
+      .addRadio({
+        path: 'editor.format',
+        name: 'Formatting',
+        settings: {
+          options: FormatOptions,
+        },
+        defaultValue: DefaultOptions.editor.format,
+        category: ['Editor'],
+      })
+      .addSliderInput({
+        path: 'editor.height',
+        name: 'Height, px',
+        defaultValue: DefaultOptions.editor.height,
+        settings: {
+          min: 100,
+          max: 2000,
+        },
+        category: ['Editor'],
+      });
 
-  /**
-   * Content
-   */
-  builder
-    .addCustomEditor({
-      id: 'content',
-      path: 'content',
-      name: 'Content',
-      defaultValue: DefaultOptions.content,
-      editor: TextEditor,
-      category: ['Content'],
-    })
-    .addCustomEditor({
-      id: 'defaultContent',
-      path: 'defaultContent',
-      name: 'Default Content',
-      description: 'Displayed when query result is empty.',
-      defaultValue: DefaultOptions.defaultContent,
-      editor: TextEditor,
-      category: ['Content'],
-      showIf: (config) =>
-        config.editors.includes(EditorType.DEFAULT) || config.defaultContent !== DefaultOptions.defaultContent,
-    })
-    .addCustomEditor({
-      id: 'helpers',
-      path: 'helpers',
-      name: 'JavaScript Code',
-      description: 'Allows to add Handlebars Helpers and event handlers.',
-      defaultValue: DefaultOptions.helpers,
-      editor: HelpersEditor,
-      category: ['Content'],
-      showIf: (config) => config.editors.includes(EditorType.HELPERS) || config.helpers !== DefaultOptions.helpers,
-    })
-    .addCustomEditor({
-      id: 'styles',
-      path: 'styles',
-      name: 'CSS Styles',
-      description: 'Allows to add styles. Use & {} for parent style.',
-      defaultValue: DefaultOptions.styles,
-      editor: StylesEditor,
-      category: ['Content'],
-      showIf: (config) => config.editors.includes(EditorType.STYLES) || config.styles !== DefaultOptions.styles,
-    });
+    /**
+     * Content
+     */
+    builder
+      .addCustomEditor({
+        id: 'content',
+        path: 'content',
+        name: 'Content',
+        defaultValue: DefaultOptions.content,
+        editor: TextEditor,
+        category: ['Content'],
+      })
+      .addCustomEditor({
+        id: 'defaultContent',
+        path: 'defaultContent',
+        name: 'Default Content',
+        description: 'Displayed when query result is empty.',
+        defaultValue: DefaultOptions.defaultContent,
+        editor: TextEditor,
+        category: ['Content'],
+        showIf: (config) =>
+          config.editors.includes(EditorType.DEFAULT) || config.defaultContent !== DefaultOptions.defaultContent,
+      })
+      .addCustomEditor({
+        id: 'helpers',
+        path: 'helpers',
+        name: 'JavaScript Code',
+        description: 'Allows to add Handlebars Helpers and event handlers.',
+        defaultValue: DefaultOptions.helpers,
+        editor: HelpersEditor,
+        category: ['Content'],
+        showIf: (config) => config.editors.includes(EditorType.HELPERS) || config.helpers !== DefaultOptions.helpers,
+      })
+      .addCustomEditor({
+        id: 'styles',
+        path: 'styles',
+        name: 'CSS Styles',
+        description: 'Allows to add styles. Use & {} for parent style.',
+        defaultValue: DefaultOptions.styles,
+        editor: StylesEditor,
+        category: ['Content'],
+        showIf: (config) => config.editors.includes(EditorType.STYLES) || config.styles !== DefaultOptions.styles,
+      });
 
-  return builder;
-});
+    return builder;
+  });
diff --git a/src/types/panel.ts b/src/types/panel.ts
index 775c623..c55689e 100644
--- a/src/types/panel.ts
+++ b/src/types/panel.ts
@@ -10,9 +10,9 @@ export enum EditorType {
 }
 
 /**
- * Options
+ * Panel Options
  */
-export interface TextOptions {
+export interface PanelOptions {
   /**
    * Content
    *
@@ -61,4 +61,11 @@ export interface TextOptions {
    * @type {string}
    */
   styles: string;
+
+  /**
+   * Status
+   *
+   * @type {string};
+   */
+  status: string;
 }


Test patch:
diff --git a/src/components/Text/Text.test.tsx b/src/components/Text/Text.test.tsx
index dc0b1e1..fb35367 100644
--- a/src/components/Text/Text.test.tsx
+++ b/src/components/Text/Text.test.tsx
@@ -1,5 +1,5 @@
 import React from 'react';
-import { FieldType } from '@grafana/data';
+import { FieldType, toDataFrame } from '@grafana/data';
 import { render, screen } from '@testing-library/react';
 import { DefaultOptions, TestIds } from '../../constants';
 import { Props, Text } from './Text';
@@ -58,6 +58,40 @@ describe('<Text />', () => {
     expect(screen.getByTestId(TestIds.text.content)).toHaveStyle({ color: 'red' });
   });
 
+  it('Should apply status field', async () => {
+    const replaceVariables = jest.fn((str: string) => str);
+    const dataFrame = toDataFrame({
+      fields: [
+        {
+          name: 'value',
+          type: FieldType.number,
+          display: (value: number) => ({ color: value > 80 ? 'red' : 'green' }),
+          values: [80, 90],
+        },
+      ],
+    });
+    const props: Props = {
+      frame: dataFrame,
+      options: {
+        ...DefaultOptions,
+        status: 'value',
+        content: '<div style="background-color: {{statusColor}};" data-testid="status">{{status}}</div>',
+        defaultContent: 'Test default content',
+        everyRow: true,
+      },
+      timeRange: {} as any,
+      timeZone: '',
+      replaceVariables,
+      eventBus: {} as any,
+    };
+
+    render(<Text {...props} />);
+
+    const statuses = screen.getAllByTestId('status');
+    expect(statuses[0]).toHaveStyle({ backgroundColor: 'green' });
+    expect(statuses[1]).toHaveStyle({ backgroundColor: 'red' });
+  });
+
   /**
    * Render content twice
    */
diff --git a/src/module.test.ts b/src/module.test.ts
index 019ca0d..990a315 100644
--- a/src/module.test.ts
+++ b/src/module.test.ts
@@ -1,8 +1,13 @@
-import { PanelPlugin } from '@grafana/data';
+import { Field, FieldType, PanelPlugin } from '@grafana/data';
 import { DefaultOptions } from './constants';
-import { EditorType, TextOptions } from './types';
+import { EditorType, PanelOptions } from './types';
 import { plugin } from './module';
 
+/**
+ * Test Field
+ */
+type TestField = Pick<Field, 'name' | 'type'>;
+
 /*
  Plugin
  */
@@ -12,6 +17,7 @@ describe('plugin', () => {
    */
   const builder: any = {
     addCustomEditor: jest.fn().mockImplementation(() => builder),
+    addFieldNamePicker: jest.fn().mockImplementation(() => builder),
     addRadio: jest.fn().mockImplementation(() => builder),
     addSliderInput: jest.fn().mockImplementation(() => builder),
     addMultiSelect: jest.fn().mockImplementation(() => builder),
@@ -46,7 +52,7 @@ describe('plugin', () => {
      * @param config
      * @param result
      */
-    const addInputImplementation = (config: Partial<TextOptions>, result: string[]) => (input: any) => {
+    const addInputImplementation = (config: Partial<PanelOptions>, result: string[]) => (input: any) => {
       if (input.showIf) {
         if (input.showIf(config)) {
           result.push(input.path);
@@ -105,4 +111,29 @@ describe('plugin', () => {
       expect(shownOptionsPaths).toEqual(expect.arrayContaining(['styles']));
     });
   });
+
+  describe('Settings', () => {
+    const addFieldNameImplementation =
+      (optionPath: string, allFields: TestField[], shownFields: TestField[]) => (input: any) => {
+        if (optionPath === input.path) {
+          const fields = allFields.filter(input.settings.filter);
+          shownFields.push(...fields);
+        }
+
+        return builder;
+      };
+
+    it('Should return only number fields for status', () => {
+      const fields: TestField[] = [
+        { name: 'string', type: FieldType.string },
+        { name: 'number', type: FieldType.number },
+      ];
+      const shownFields: TestField[] = [];
+
+      builder.addFieldNamePicker.mockImplementation(addFieldNameImplementation('status', fields, shownFields));
+      plugin['optionsSupplier'](builder);
+
+      expect(shownFields).toEqual([{ name: 'number', type: FieldType.number }]);
+    });
+  });
 });