Files
superset2/CONTROL_PANEL_MIGRATION_AGENT.md
Evan Rusackas a5bc492a95 feat: Migrate Sankey and Treemap control panels to React architecture
- Created SankeyControlPanelSimple.tsx with React-based controls
- Created TreemapControlPanelSimple.tsx with tab-based layout
- Both follow established patterns from Pie and Funnel migrations
- Added special handling for single-column selection in Sankey
- Updated migration agent with new patterns and common issues
- All charts compile successfully with webpack dev server

Key improvements:
- Direct React components instead of config objects
- Full TypeScript support with proper types
- Tab-based organization (Data/Customize)
- Proper safety checks and validation
- Consistent with modern control panel architecture

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-18 16:48:57 -07:00

22 KiB

Control Panel Migration Agent

A comprehensive guide for migrating Apache Superset control panels from the legacy config-based approach to the new React-based approach.

Overview

This migration transforms control panels from complex string-referenced configurations (controlPanelSections/controlSetRows) to pure React components. The new approach provides:

  • Direct React components instead of config objects
  • Full TypeScript support with proper type safety
  • Simplified architecture with no JSON intermediary
  • Better developer experience with IDE autocomplete and refactoring support

Architecture Comparison

Legacy Architecture

// Old approach: Config-based with string references and config objects
const config: ControlPanelConfig = {
  controlPanelSections: [
    {
      label: t('Query'),
      expanded: true,
      controlSetRows: [
        [GroupByControl()], // String reference
        [MetricControl()],  // String reference
        [
          {
            name: 'show_labels',
            config: {
              type: 'CheckboxControl',
              label: t('Show Labels'),
              renderTrigger: true,
              default: true,
            },
          },
        ], // Config object
      ],
    },
  ],
};

New React-Based Architecture

// New approach: Pure React components with direct JSX
export const PieControlPanel: FC<PieControlPanelProps> = ({ ... }) => {
  return (
    <div>
      <DndColumnSelect
        value={formValues.groupby || []}
        onChange={handleChange('groupby')}
        // ... other props
      />
      <CheckboxControl
        label={t('Show Labels')}
        value={formValues.show_labels ?? true}
        onChange={handleChange('show_labels')}
        renderTrigger
      />
    </div>
  );
};

// Mark as modern panel
(PieControlPanel as any).isModernPanel = true;

Migration Steps

1. File Structure

Keep the existing file location but change from config to React component:

  • controlPanel.ts[ChartName]ControlPanelSimple.tsx
  • The file remains in the same directory as the original

2. Update Imports

From (Legacy):

import {
  ControlPanelConfig,
  sharedControls,
  GroupByControl,
  MetricControl
} from '@superset-ui/chart-controls';

To (Modern):

import { FC, useState } from 'react';
import { t } from '@superset-ui/core';
import { Tabs } from 'antd';
import {
  ColorSchemeControl,
  D3_FORMAT_OPTIONS,
  D3_TIME_FORMAT_OPTIONS,
  D3_FORMAT_DOCS,
  D3_NUMBER_FORMAT_DESCRIPTION_VALUES_TEXT,
} from '@superset-ui/chart-controls';

// Direct component imports
import { DndColumnSelect } from '../../../../src/explore/components/controls/DndColumnSelectControl/DndColumnSelect';
import { DndMetricSelect } from '../../../../src/explore/components/controls/DndColumnSelectControl/DndMetricSelect';
import { DndFilterSelect } from '../../../../src/explore/components/controls/DndColumnSelectControl/DndFilterSelect';
import TextControl from '../../../../src/explore/components/controls/TextControl';
import CheckboxControl from '../../../../src/explore/components/controls/CheckboxControl';
import SliderControl from '../../../../src/explore/components/controls/SliderControl';
import SelectControl from '../../../../src/explore/components/controls/SelectControl';
import CurrencyControl from '../../../../src/explore/components/controls/CurrencyControl';
import ControlHeader from '../../../../src/explore/components/ControlHeader';
import Control from '../../../../src/explore/components/Control';

3. Component Structure Template

Create a React component with this structure:

interface [ChartName]ControlPanelProps {
  onChange?: (field: string, value: any) => void;
  value?: Record<string, any>;
  datasource?: any;
  actions?: any;
  controls?: any;
  form_data?: any;
}

export const [ChartName]ControlPanel: FC<[ChartName]ControlPanelProps> = ({
  onChange,
  value,
  datasource,
  form_data,
  actions,
  controls,
}) => {
  // Safety checks for datasource
  if (!datasource || !form_data) {
    return <div>Loading control panel...</div>;
  }

  // Ensure safe data structures
  const safeColumns = Array.isArray(datasource?.columns) ? datasource.columns : [];
  const safeMetrics = Array.isArray(datasource?.metrics) ? datasource.metrics : [];

  // Helper for control changes
  const handleChange = (field: string) => (val: any) => {
    if (actions?.setControlValue) {
      actions.setControlValue(field, val);
    } else if (onChange) {
      onChange(field, val);
    }
  };

  // Get form values
  const formValues = form_data || value || {};

  // Tab state (if using tabs)
  const [activeTab, setActiveTab] = useState('data');

  // Component implementation here...

  return (
    <div style={{ padding: '16px' }}>
      {/* Your controls here */}
    </div>
  );
};

// CRITICAL: Mark as modern panel
([ChartName]ControlPanel as any).isModernPanel = true;

// Export wrapper config for compatibility
const config = {
  controlPanelSections: [
    {
      label: null,
      expanded: true,
      controlSetRows: [[[ChartName]ControlPanel as any]],
    },
  ],
  controlOverrides: {
    // Move all defaults here
    field_name: {
      default: defaultValue,
      label: t('Field Label'),
      renderTrigger: true,
    },
  },
};

export default config;

4. Control Mapping Reference

String References → React Components

Legacy String Modern React Component Notes
['groupby'] <DndColumnSelect ... /> Multi-select columns
['metric'] <DndMetricSelect ... /> Single metric select
['metrics'] <DndMetricSelect ... /> Multi metric select
['adhoc_filters'] <DndFilterSelect ... /> Advanced filters
['row_limit'] <TextControl isInt ... /> Numeric input
['color_scheme'] ColorSchemeControl() with Control wrapper Special handling needed

Config Objects → React Components

Legacy Config Modern Component Example
{ type: 'TextControl', ... } <TextControl ... /> <TextControl value={val} onChange={fn} />
{ type: 'CheckboxControl', ... } <CheckboxControl ... /> <CheckboxControl label="..." value={val} />
{ type: 'SelectControl', ... } <SelectControl ... /> <SelectControl choices={[...]} value={val} />
{ type: 'SliderControl', ... } <SliderControl ... /> <SliderControl {...{min: 0, max: 100}} />

5. Props Mapping Guide

Legacy Config Property Modern React Prop Notes
label label prop OR <ControlHeader> Use ControlHeader for tooltips
description description prop OR <ControlHeader> ControlHeader for complex descriptions
default Move to controlOverrides Don't set as component prop
renderTrigger: true renderTrigger prop Controls instant chart updates
visibility Conditional rendering {condition && <Control />}
choices choices prop For SelectControl
min/max/step Spread object {...{ min: 10, max: 100, step: 1 }}

6. Implementing Tabbed Layout

Most modern control panels use a Data/Customize tab structure:

const [activeTab, setActiveTab] = useState('data');

const dataTabContent = (
  <div>
    {/* Query-related controls: columns, metrics, filters, row limit */}
    <div style={{ marginBottom: 16 }}>
      <ControlHeader
        label={t('Group by')}
        description={t('Columns to group by')}
        hovered
      />
      <DndColumnSelect
        value={formValues.groupby || []}
        onChange={handleChange('groupby')}
        options={safeColumns}
        name="groupby"
        label=""  // Avoid duplicate labels
        multi
        canDelete
        ghostButtonText={t('Add dimension')}
        type="DndColumnSelect"
        actions={actions}
      />
    </div>
    {/* More data controls... */}
  </div>
);

const customizeTabContent = (
  <div>
    {/* Styling and display controls */}
    <div style={{ marginBottom: 24 }}>
      <h4>{t('Chart Options')}</h4>
      {/* Styling controls... */}
    </div>
  </div>
);

const tabItems = [
  { key: 'data', label: t('Data'), children: dataTabContent },
  { key: 'customize', label: t('Customize'), children: customizeTabContent },
];

return (
  <div style={{ padding: '16px' }}>
    <Tabs
      activeKey={activeTab}
      onChange={setActiveTab}
      items={tabItems}
      size="large"
    />
  </div>
);

7. Common Control Patterns

Drag-and-Drop Controls

{/* Group By - Column Selection */}
<DndColumnSelect
  value={formValues.groupby || []}
  onChange={handleChange('groupby')}
  options={safeColumns}
  name="groupby"
  label=""  // Empty to avoid duplicate with ControlHeader
  multi
  canDelete
  ghostButtonText={t('Add dimension')}
  type="DndColumnSelect"
  actions={actions}
/>

{/* Metric Selection */}
<DndMetricSelect
  value={formValues.metric}
  onChange={handleChange('metric')}
  datasource={safeDataSource}
  name="metric"
  label=""
  multi={false}
  savedMetrics={safeMetrics}
/>

{/* Filters */}
<DndFilterSelect
  value={formValues.adhoc_filters || []}
  onChange={handleChange('adhoc_filters')}
  datasource={safeDataSource}
  columns={safeColumns}
  formData={formValues}
  name="adhoc_filters"
  savedMetrics={safeMetrics}
  selectedMetrics={formValues.metric ? [formValues.metric] : []}
  type="DndFilterSelect"
  actions={actions}
/>

Color Scheme Control (Special Case)

{/* Color Scheme requires special Control wrapper */}
{(() => {
  const colorSchemeControl = ColorSchemeControl();
  const { hidden, ...cleanConfig } = colorSchemeControl.config || {};
  return (
    <Control
      {...cleanConfig}
      name="color_scheme"
      value={formValues.color_scheme}
      actions={{
        ...actions,
        setControlValue: (field: string, val: any) => {
          handleChange('color_scheme')(val);
        },
      }}
      renderTrigger
    />
  );
})()}

Basic Controls

{/* Text Input */}
<TextControl
  value={formValues.row_limit}
  onChange={handleChange('row_limit')}
  isInt
  placeholder="100"
  controlId="row_limit"
/>

{/* Checkbox */}
<CheckboxControl
  label={t('Show Labels')}
  description={t('Whether to display the labels.')}
  value={formValues.show_labels ?? true}
  onChange={handleChange('show_labels')}
  renderTrigger
  hovered
/>

{/* Select Dropdown */}
<SelectControl
  label={t('Label Type')}
  description={t('What should be shown on the label?')}
  value={formValues.label_type || 'key'}
  onChange={handleChange('label_type')}
  choices={[
    ['key', t('Category Name')],
    ['value', t('Value')],
    ['percent', t('Percentage')],
  ]}
  clearable={false}
  renderTrigger
  hovered
/>

{/* Slider */}
<SliderControl
  value={formValues.outerRadius || 70}
  onChange={handleChange('outerRadius')}
  {...{ min: 10, max: 100, step: 1 }}
/>

Control Headers with Tooltips

<ControlHeader
  label={t('Percentage threshold')}
  description={t('Minimum threshold in percentage points for showing labels.')}
  renderTrigger
  hovered
/>

Conditional Rendering

{/* Show control only when condition is met */}
{formValues.show_labels && (
  <CheckboxControl
    label={t('Put labels outside')}
    description={t('Put the labels outside of the pie?')}
    value={formValues.labels_outside ?? true}
    onChange={handleChange('labels_outside')}
    renderTrigger
    hovered
  />
)}

{/* Nested conditional rendering */}
{formValues.label_type === 'template' && (
  <TextControl
    value={formValues.label_template || ''}
    onChange={handleChange('label_template')}
    placeholder="{name}: {value}"
    controlId="label_template"
  />
)}

8. Section Organization

Use HTML headers and spacing for logical groupings:

{/* Chart Options Section */}
<div style={{ marginBottom: 24 }}>
  <h4>{t('Chart Options')}</h4>

  {/* Controls for this section */}
  <div style={{ marginBottom: 16 }}>
    {/* Individual control */}
  </div>
</div>

{/* Labels Section */}
<div style={{ marginBottom: 24 }}>
  <h4>{t('Labels')}</h4>

  {/* Label-related controls */}
</div>

9. Control Defaults in controlOverrides

Move all default values to the controlOverrides section:

const config = {
  controlPanelSections: [
    {
      label: null,
      expanded: true,
      controlSetRows: [[PieControlPanel as any]],
    },
  ],
  controlOverrides: {
    groupby: {
      default: [],
      label: t('Group by'),
    },
    metric: {
      default: null,
      label: t('Metric'),
    },
    show_labels: {
      default: true,
      label: t('Show labels'),
      renderTrigger: true,
    },
    color_scheme: {
      default: 'supersetColors',
      label: t('Color scheme'),
      renderTrigger: true,
    },
    // ... all other defaults
  },
};

10. Chart Plugin Integration

Update the chart plugin to use the new control panel:

// In your chart's index.ts file
import controlPanel from './PieControlPanelSimple'; // New React-based panel

export default class EchartsPieChartPlugin extends EchartsChartPlugin {
  constructor() {
    super({
      controlPanel,
      // ... other config
    });
  }
}

Testing Your Migration

1. Visual Validation

  • All controls render properly in the UI
  • Tab navigation works (if using tabs)
  • Control layout matches the original
  • Conditional controls show/hide correctly

2. Functional Testing

  • Control changes update the chart immediately (if renderTrigger: true)
  • Form values persist when switching between tabs
  • Drag-and-drop controls work with datasource
  • Error states display appropriately
  • Default values apply correctly

3. Integration Testing

  • Control panel works in Explore view
  • Values save correctly when creating charts
  • Dashboard filters work with the controls
  • Chart reloading preserves control values

Common Issues & Solutions

Issue: Double Labels on Controls

Problem: Control shows both ControlHeader label and control's built-in label Solution: Set label="" on the control when using ControlHeader:

<ControlHeader label={t('Group by')} />
<DndColumnSelect
  label=""  // Empty to prevent duplicate
  // ... other props
/>

Issue: Slider Min/Max Not Working

Problem: Slider doesn't respect min/max values Solution: Use spread operator with object literal:

<SliderControl
  value={formValues.outerRadius || 70}
  onChange={handleChange('outerRadius')}
  {...{ min: 10, max: 100, step: 1 }}  // Use spread with object
/>

Issue: Controls Not Triggering Chart Updates

Problem: Chart doesn't refresh when controls change Solution: Ensure renderTrigger is set where needed:

<CheckboxControl
  // ... other props
  renderTrigger  // Add this for instant updates
/>

Issue: "Cannot read properties of undefined"

Problem: Attempting to access undefined datasource or form_data Solution: Add safety checks and fallbacks:

// Safety checks at component start
if (!datasource || !form_data) {
  return <div>Loading control panel...</div>;
}

// Safe array access
const safeColumns = Array.isArray(datasource?.columns) ? datasource.columns : [];

Issue: Color Scheme Control Not Working

Problem: ColorSchemeControl doesn't integrate properly Solution: Use the special Control wrapper pattern:

{(() => {
  const colorSchemeControl = ColorSchemeControl();
  const { hidden, ...cleanConfig } = colorSchemeControl.config || {};
  return (
    <Control
      {...cleanConfig}
      name="color_scheme"
      value={formValues.color_scheme}
      actions={{
        ...actions,
        setControlValue: (field: string, val: any) => {
          handleChange('color_scheme')(val);
        },
      }}
      renderTrigger
    />
  );
})()}

Migration Checklist

Pre-Migration

  • Identify all controls in the legacy control panel
  • Note any conditional control visibility rules
  • Check for custom control configurations
  • Understand the chart's specific requirements

During Migration

  • Create new [ChartName]ControlPanelSimple.tsx file
  • Implement component structure with proper interface
  • Map all legacy controls to React components
  • Add safety checks for datasource/form_data
  • Implement tab structure (Data/Customize)
  • Add all control defaults to controlOverrides
  • Mark component as modern with isModernPanel = true
  • Update chart plugin to import new control panel

Post-Migration Testing

  • Test all control interactions
  • Verify chart updates on control changes
  • Check conditional control visibility
  • Validate default values
  • Test with different datasources
  • Run pre-commit hooks: pre-commit run
  • Test in Explore and Dashboard contexts

Advanced Patterns

Dynamic Control Visibility

// Show additional controls based on current selection
{formValues.chart_type === 'pie' && (
  <div>
    {/* Pie-specific controls */}
    <CheckboxControl
      label={t('Show as Donut')}
      value={formValues.donut ?? false}
      onChange={handleChange('donut')}
    />

    {formValues.donut && (
      <SliderControl
        value={formValues.innerRadius || 30}
        onChange={handleChange('innerRadius')}
        {...{ min: 0, max: 100, step: 1 }}
      />
    )}
  </div>
)}

Complex Control Groups

{/* Side-by-side controls */}
<div style={{ display: 'flex', gap: 16 }}>
  <div style={{ flex: 1 }}>
    <ControlHeader label={t('Min Value')} />
    <TextControl
      value={formValues.min_value}
      onChange={handleChange('min_value')}
      isFloat
    />
  </div>
  <div style={{ flex: 1 }}>
    <ControlHeader label={t('Max Value')} />
    <TextControl
      value={formValues.max_value}
      onChange={handleChange('max_value')}
      isFloat
    />
  </div>
</div>

Custom Validation

// Add validation logic to handleChange
const handleChange = (field: string) => (val: any) => {
  // Custom validation
  if (field === 'row_limit' && val && val < 1) {
    console.warn('Row limit must be positive');
    return;
  }

  if (actions?.setControlValue) {
    actions.setControlValue(field, val);
  } else if (onChange) {
    onChange(field, val);
  }
};

Additional Migration Patterns

Single Column Selection

When a control expects a single column value (not an array):

// For Sankey source/target columns
const handleSingleColumnChange = (field: string) => (val: any) => {
  const singleValue = Array.isArray(val) ? val[0] : val;
  actions.setControlValue(field, singleValue);
};

// Usage
<DndColumnSelect
  value={formValues.source ? [formValues.source] : []}
  onChange={handleSingleColumnChange('source')}
  options={safeColumns}
  multi={false}
/>

Required Field Validation

For controls that must have values:

import { validateNonEmpty } from '@superset-ui/core';

// In controlOverrides
source: {
  validators: [validateNonEmpty],
  label: t('Source Column'),
},

Chart Type Descriptions

Add helpful descriptions at the top of control panels:

<div style={{ marginBottom: 16, padding: '12px', borderRadius: '4px' }}>
  <div style={{ fontSize: '16px', fontWeight: 500, marginBottom: '8px' }}>
    {t('Sankey Diagram')}
  </div>
  <div style={{ fontSize: '12px', opacity: 0.65 }}>
    {t('Visualize flow between different entities')}
  </div>
</div>

Common Migration Issues & Solutions

Issue 1: "Cannot read properties of undefined (reading 'map')"

Problem: DndColumnSelect crashes when datasource is undefined Solution: Pass options={datasource?.columns || []} instead of datasource={datasource}

Issue 2: Tabs import error ("Element type is invalid")

Problem: Runtime error when loading control panel Solution: Import from 'antd' directly: import { Tabs } from 'antd'; (NOT from '@superset-ui/core')

Issue 3: React hooks error

Problem: "React Hook 'useState' is called conditionally" Solution: Always declare state hooks before any conditional returns:

const [activeTab, setActiveTab] = useState('data'); // FIRST
if (!datasource) return <div>Loading...</div>;      // THEN conditions

Issue 4: ESLint color literal warnings

Problem: theme-colors/no-literal-colors ESLint rule Solution: Use opacity instead of color literals:

// Bad: style={{ color: '#666' }}
// Good: style={{ opacity: 0.65 }}

Issue 5: Single value vs array handling

Problem: Some controls expect single values but DndColumnSelect returns arrays Solution: See "Single Column Selection" pattern above

Issue 6: antd import warnings

Problem: "'antd' should be listed in the project's dependencies" Solution: Use SKIP=eslint-frontend when committing if antd is already available

Reference Implementation

The Pie chart control panel migration (PieControlPanelSimple.tsx) serves as the definitive reference implementation showing:

  • Complete tab-based layout (Data/Customize)
  • All major control types (DndColumnSelect, CheckboxControl, SelectControl, SliderControl, etc.)
  • Conditional control rendering
  • Proper safety checks and error handling
  • Color scheme integration
  • Control grouping and organization
  • Modern React patterns and TypeScript usage

Study this implementation for best practices and patterns that can be applied to any chart control panel migration.

Summary

The new React-based control panel approach provides:

  1. Better Developer Experience - Direct React components with TypeScript
  2. Improved Maintainability - Clear component structure and patterns
  3. Enhanced Flexibility - Easy conditional rendering and dynamic controls
  4. Type Safety - Full TypeScript support with proper interfaces
  5. Simplified Architecture - No complex config intermediaries

The migration process involves converting string references and config objects to direct React components, implementing proper safety checks, and organizing controls in a logical tab-based structure. The key is to maintain compatibility with the existing Superset infrastructure while providing a more modern and maintainable development experience.