Building a Custom Filter or Control-Panel Component for Apache Superset

Building a Custom Filter or Control-Panel Component for Apache Superset
Two different things get called a “Superset custom filter”, and they are built in completely different places.The first is a control: a widget in the Explore control panel that configures a single chart. Metrics, group-by, colour scheme, a threshold slider. Controls belong to the chart plugin and are declared in controlPanel.ts.

The second is a native filter: a widget in the dashboard filter bar that pushes a value into every chart in scope. Select, range, time range, time column. Native filters belong to the dashboard, not to any chart, and are implemented inside the Superset frontend.

There is a third thing that sits between them: a chart that acts as a filter, so that clicking a bar or a region filters the rest of the dashboard. That is cross-filtering, and it is something a plugin can do on its own.

This guide covers all three, in order of how much of Superset you have to touch. Most requirements are satisfied by the first, which needs no changes to Superset at all.

Part 1: Custom controls inside a plugin

A control panel is data, not code: a ControlPanelConfig object listing sections, each with rows of controls. Each control has a type naming a React component that Superset already ships, plus configuration. You can go a long way by composing those components well.

The control types you already have

@superset-ui/chart-controls exposes the components Superset’s own charts use. The ones that cover most needs:

type What it renders Typical use
SelectControl Single or multi select, optionally free-form Pick a column, a mode, an aggregation
TextControl Text or number input Thresholds, labels, limits
TextAreaControl Multi-line text, optional code highlighting Custom SQL, JSON, templates
CheckboxControl Boolean toggle Show legend, stack bars
SliderControl Numeric slider Opacity, radius, bucket count
RadioButtonControl Mutually exclusive options Chart orientation, sort order
ColorPickerControl Single colour Highlight colour
ColorSchemeControl Registered categorical scheme Series colours
BoundsControl Min and max pair Axis bounds
MetricsControl Saved or ad-hoc metrics Any measure
AdhocFilterControl Filter builder Where clauses
CollectionControl Repeating group of sub-controls Multiple thresholds, multiple series

You reference them by string in the type field. The shared, pre-configured versions for metrics, group-by, filters and row limits live in sharedControls; spread one and override the label and description rather than configuring from scratch.

Dynamic visibility

Controls can appear and disappear based on other controls. The visibility function receives the current control state and returns a boolean:

typescript
[
  {
    name: 'threshold_mode',
    config: {
      type: 'RadioButtonControl',
      label: t('Threshold'),
      default: 'none',
      options: [
        ['none', t('None')],
        ['fixed', t('Fixed value')],
        ['percentile', t('Percentile')],
      ],
      renderTrigger: true,
    },
  },
],
[
  {
    name: 'threshold_value',
    config: {
      type: 'TextControl',
      label: t('Threshold value'),
      isFloat: true,
      default: 0,
      renderTrigger: true,
      visibility: ({ controls }) => controls?.threshold_mode?.value === 'fixed',
    },
  },
],
[
  {
    name: 'threshold_percentile',
    config: {
      type: 'SliderControl',
      label: t('Percentile'),
      min: 1,
      max: 99,
      default: 90,
      renderTrigger: true,
      visibility: ({ controls }) => controls?.threshold_mode?.value === 'percentile',
    },
  },
],

This pattern replaces a great deal of what people initially think needs a custom component. A control panel that shows only the relevant options feels bespoke without being bespoke.

Options that depend on the dataset

Controls can derive their options from the dataset and the rest of the form through mapStateToProps. The canonical example is a select whose choices are the dataset’s columns filtered by type:

typescript
{
  name: 'geo_column',
  config: {
    type: 'SelectControl',
    label: t('Geometry column'),
    description: t('A column holding WKT or GeoJSON'),
    mapStateToProps: ({ datasource }) => ({
      choices: (datasource?.columns ?? [])
        .filter(col => /geom|geo|wkt|shape/i.test(col.column_name))
        .map(col => [col.column_name, col.verbose_name || col.column_name]),
    }),
    validators: [validateNonEmpty],
  },
}

mapStateToProps runs whenever the Explore state changes, so keep it cheap.

Validation

validators is an array of functions that take the value and return an error string or nothing. validateNonEmpty, validateInteger and validateNumber are provided; writing your own is one function:

typescript
const validateRange = (value: unknown) => {
  const n = Number(value);
  return n >= 0 && n <= 100 ? false : t('Must be between 0 and 100');
};

Explore shows the error under the control and disables the Update button until it clears. That is a better experience than letting a bad value through and handling it in transformProps.

Repeating groups

CollectionControl renders a list of sub-control groups the user can add to and remove from. It is how you get “add another threshold band” without writing a component. Each item is an object with the sub-control values, and it arrives in transformProps as an array.

If a requirement survives all of the above, you genuinely need a new component, and that means Part 3.

Building this control for more than one dashboard?

A one-off control panel is a weekend project; one that survives Superset upgrades and gets reused across dashboards is not. A Plugin Scoping Call scopes that difference before you commit engineering time.

Book a Plugin Scoping Call

Part 2: Making a chart act as a filter

A chart that filters the dashboard when the user clicks it is the most requested “custom filter” and needs no changes to Superset. It needs three things in the plugin.

First, declare the behaviour in index.ts:

typescript
behaviors: [Behavior.InteractiveChart],

Second, in transformProps, pass through the hooks Superset gives you:

typescript
const { hooks, filterState } = chartProps;
return {
  // ...other props
  setDataMask: hooks.setDataMask,
  selectedValues: filterState?.value ?? [],
  emitCrossFilters: formData.emitCrossFilters ?? false,
};

Third, in the component, call setDataMask when the user selects something, and clear it when they deselect:

typescript
function handleSelect(column: string, value: string | number, currentlySelected: boolean) {
  if (!emitCrossFilters) return;
  if (currentlySelected) {
    setDataMask({ extraFormData: { filters: [] }, filterState: { value: null } });
    return;
  }
  setDataMask({
    extraFormData: {
      filters: [{ col: column, op: 'IN', val: [value] }],
    },
    filterState: { value: [value], selectedValues: [value] },
  });
}

extraFormData.filters is what other charts receive as an additional where clause. filterState.value is what Superset uses to remember the selection, show it in the filter indicator, and hand it back to you as selectedValues so you can highlight the selected element. Set both, always; setting one leaves the dashboard confused.

The emitCrossFilters form-data flag is set by the dashboard’s cross-filter toggle, so respect it. Users expect to be able to turn cross-filtering off per dashboard.

Test this on a dashboard with at least two charts, not in Explore. Cross-filters do not exist outside a dashboard, and the failure modes (stale data in other charts, a selection that will not clear) only appear there.

Part 3: A genuinely new control type or native filter

Sometimes the requirement is a widget Superset does not have: a hierarchical tree picker for an org structure, a map-drawn bounding box, a date range with fiscal-quarter presets, a filter fed by an external service. Superset’s plugin API does not let a chart plugin register new control component types or new native filter types. Both are registries populated inside the Superset frontend, so both require a change to your Superset fork.

That is not a reason to avoid it. It is a reason to do it in a way that keeps the fork thin.

A new control component

  • Put the component in its own package, exactly as you would a chart plugin, so that it is versioned, tested and built independently. It receives value, onChange, and the config fields you declare, and it renders inside Explore’s control frame.
  • In the fork, register it in the control map. In 4.x that map lives in the Explore controls module (superset-frontend/src/explore/components/controls/index.js). One import and one entry:
javascript
import OrgTreeControl from 'superset-control-org-tree';
// ...
const controlMap = {
  // existing controls
  OrgTreeControl,
};
  • Reference it from any plugin by type: 'OrgTreeControl'.

Your fork diff is two lines. When Superset moves the control map, which it does occasionally, the rebase is a two-line conflict.

A new native filter type

Native filters are filter plugins in superset-frontend/src/filters, each with a control panel config, a buildQuery (for filters that need to fetch their own options) and a component that calls setDataMask just like a cross-filtering chart. Start by copying the select filter, which is the most complete example, and register the new plugin in the native filter preset alongside the built-ins.

The component contract is the same setDataMask call from Part 2. A native filter is, in effect, a chart that has no visualisation and lives in the filter bar. If you got a chart filtering the dashboard, you already know how to write one.

Keep the same discipline: the filter code in its own package, the registration in the fork.

When to say no

A new control or filter type is justified when the widget will be used across several charts or dashboards. For a one-off, restructure the requirement: a SelectControl with mapStateToProps and a CollectionControl cover more than people expect, and they upgrade for free.

Frequently Asked Questions

Can I add a custom control to a Superset chart plugin without forking Superset?

Yes, and that is the right default. A plugin’s control panel is built from the control types Superset already ships, and mapStateToProps gives them dataset-aware options, conditional visibility and validation. A separate plugin package survives upgrades; a fork turns every future Superset release into a merge.

How do I make one chart filter another dashboard?

Use cross-filters rather than building anything. A chart emits the clicked value as a filter that other charts on the dashboard respond to, which covers the majority of requests that arrive worded as “can this chart be a filter”. Reach for a custom native filter type only when the interaction genuinely cannot be expressed that way.

When is a genuinely new control type justified?

When the widget will be used across several charts or dashboards. For a one-off, restructure the requirement instead: SelectControl with mapStateToProps and CollectionControl cover more ground than people expect, and they inherit improvements from upstream for free. A bespoke control is a maintenance commitment at every upgrade.

Where does control state live?

In the form data that the control panel produces and buildQuery consumes, which is what makes a control’s value part of the saved chart and of the query it generates. Keeping state there, rather than in component-local state, is what lets a chart be saved, shared, embedded and rendered by the reports worker with the same result.

Build It With Andolasoft

Extending Superset beyond charts and filters follows the same rule of thin forks and separate packages. We wrote about that from a different angle in adding collaboration features to Apache Superset, and the mechanics of the plugin package itself are in the build guide. The wider service is on the Superset plugin development page.

If you are weighing a plugin-only approach against a fork for a specific requirement, that is exactly the question our Plugin Scoping Call answers: free, 45 minutes, and you leave with a written recommendation and estimate. If you would rather describe the requirement first, talk to us.