How to configure dimensions for your panel plugin

In this post, I want to share a pattern that I’m using for almost all my panel plugins. I call it panel dimensions.

I’m using the word “dimension” here to refer the semantic use of a collection of data. For example, a set of numbers can be used to both determine the size or the position of a visual element. In this case, “size” and “position” would be dimensions used by the visualization.

If our visualization was a function, the dimensions would be the list of argument it accepts. In pseudo-code, it could look something like this:

scatterPlot(x: number[], y: number[], size: number[], label: string[])

Now that we know what our visualization needs, we need to figure out how to map a query response from a data source to each of these dimensions. Since users can build their own queries in Grafana, the query result can include fields that aren’t even used by our visualization.

I’ve found two predominant approaches for selecting the data frame fields to use for each dimension: by type or by name.

Select dimensions by field type

// Select field by type.
const valueField = frame.fields.find((field) => field.type === FieldType.number);

If your visualization only uses one field for each type, most likely you should select the field by type. In fact, this is what many of the built-in visualizations do for visualizing time series. This works because any frame that has a field of type time and number is effectively a time series.

If your visualization requires multiple fields of the same type, selecting by type means that you need to rely on the order of the fields. If your dimensions have inherent order, such as start and end time, this can still work. But if the frame contains fields for both number of pageviews and unique visitors, your users need to resort to documentation to know in which order they should be in.

Select dimensions by field name

// Select field by name.
const valueField = frame.fields.find((field) => field.name === options.valueField);

If we instead rely on the field name, you can select the field you want no matter how the user contructed the frame. However, hard-coding the expected field name, again, means that the user needs to read the docs to know what do alias the field names to. Instead you should make the name configurable by the user. That way the user can select the field to use for which dimension.

The hybrid approach

Both of these approaches have their pros and cons. Selecting by type means that the visualization “just works” when the user selects it, with minimal configuration. When you do need the extra configuration however, selecting by field name is going to be more flexible.

Instead of choosing between them, I’ve found that combining them lets you address the downsides with each of them.

  • Select fields by type to provide a sensible default configuration.
  • Allow user to select by name for when the defaults don’t work for them.

The following example first checks if the user has configured options.textField. If they have, select field by name. If not, select by type.

// Select by type or name.
const textField = options.textField
  ? frame.fields.find((f) => f.name === options.textField)
  : frame.fields.find((f) => f.type === FieldType.string);

For an example of how this would look like, here are the panel options from the Treemap panel. In this example, the data frame contains four fields: slug and orgSlug (text), and downloads and popularity (number).

Since the slug field is the first field in the frame, it’s selected by default to use as the Label by dimension, so we don’t need to set it explicitly.

How are you configuring your panel plugins? Do you have a use case where this wouldn’t work? Let me know!

Appendix

To support this pattern, I’ve built a custom options editor that you can use in your panel to select field names from the query result.

Here’s how you’d use it:

module.ts

      .addCustomEditor({
        id: 'sizeByField',
        path: 'sizeByField',
        name: 'Size by',
        description: 'Field to use for size. Defaults to the first numeric field.',
        editor: FieldSelectEditor,
        category: ['Dimensions'],
        settings: {
          filterByType: [FieldType.number],
        },
      })

And here’s the source code:

FieldSelectEditor.tsx

import React from "react";
import { StandardEditorProps, FieldType } from "@grafana/data";
import { MultiSelect, Select } from "@grafana/ui";

interface Settings {
  filterByType: FieldType[];
  multi: boolean;
}

interface Props
  extends StandardEditorProps<string | string[] | null, Settings> {}

/**
 * FieldSelectEditor populates a Select with the names of the fields returned by
 * the query.
 *
 * Requires Grafana >=7.0.3. For more information, refer to the following
 * pull request:
 *
 * https://github.com/grafana/grafana/pull/24829
 */
export const FieldSelectEditor: React.FC<Props> = ({
  item,
  value,
  onChange,
  context,
}) => {
  if (context.data && context.data.length > 0) {
    const options = context.data
      .flatMap((frame) => frame.fields)
      .filter((field) =>
        item.settings?.filterByType
          ? item.settings?.filterByType.some((_) => field.type === _)
          : true
      )
      .map((field) => ({
        label: field.name,
        value: field.name,
      }));

    if (item.settings?.multi) {
      return (
        <MultiSelect
          isClearable
          isLoading={false}
          value={value as string[]}
          onChange={(e) => onChange(e.map((_) => _.value!))}
          options={options}
        />
      );
    } else {
      return (
        <Select
          isClearable
          isLoading={false}
          value={value as string | null}
          onChange={(e) => {
            onChange(e?.value);
          }}
          options={options}
        />
      );
    }
  }

  return <Select onChange={() => {}} disabled={true} />;
};

Update (2022-02-21):

Since Grafana 8.1, you can use the built-in field name picker instead of a custom editor. It works very much the same as the FieldSelectEditor. Here’s the same example from the post, using addFieldNamePicker:

      .addFieldNamePicker({
        path: 'sizeByField',
        name: 'Size by',
        description: 'Field to use for size. Defaults to the first numeric field.',
        category: ['Dimensions'],
        settings: {
          filter: (f: Field) => f.type === FieldType.number,
        },
      })
1 Like