Skip to content

Extensions

The dashboard editor uses an extension system to add cross-cutting concerns without modifying the engine core. Extensions observe engine events and can maintain their own state. This guide covers the built-in extensions, the extension interface, and how to build custom extensions.

Extension Interface

Every extension implements DashboardExtension:

dart
abstract class DashboardExtension {
  /// Unique identifier for this extension
  String get id;

  /// Called when the extension is added to an engine
  void attach(DashboardEngineApi engine);

  /// Called when the extension is removed
  void detach();

  /// Called for every event emitted by the engine
  void onEvent(DashboardEvent event);
}

The DashboardEngineApi provides access to:

dart
abstract class DashboardEngineApi {
  Observable<DashboardLayout> get layoutObservable;
  DashboardLayout get layout;
  Future<void> executeCommand(DashboardCommand command);
  Future<void> applyUndo(DashboardCommand command);
  Future<void> applyRedo(DashboardCommand command);
  void replaceLayout(DashboardLayout newLayout);
  void emitEvent(DashboardEvent event);
}

Managing Extensions

dart
final engine = DashboardEngine(initialLayout: layout, registry: registry);

// Add extensions
engine.addExtension(DashboardHistoryExtension());
engine.addExtension(myCustomExtension);

// Query extensions
final history = engine.getExtension<DashboardHistoryExtension>();
final hasHistory = engine.hasExtension('dashboard-history');
final all = engine.extensions;  // Unmodifiable list

// Remove an extension
engine.removeExtension('dashboard-history');

Attempting to add an extension with a duplicate ID throws a StateError.

Built-in Extension: History

The DashboardHistoryExtension provides undo/redo support by recording commands in a CommandHistory stack.

dart
class DashboardHistoryExtension implements DashboardExtension {
  static const String extensionId = 'dashboard-history';

  bool get canUndo;
  bool get canRedo;

  Future<void> undo();
  Future<void> redo();
  void clear();
}

How It Works

  • On DashboardCommandExecuted events, the extension records the command in the history stack.
  • undo() pops a command from the stack and calls engine.applyUndo(command), which calls command.undo() and replaces the layout.
  • redo() re-executes a previously undone command via engine.applyRedo(command).
  • The history has a configurable maximum size (default 50 commands).

Reactivity

The extension maintains an internal MobX observable _version counter that increments on every history change. This allows UI code to observe canUndo and canRedo reactively:

dart
Observer(
  builder: (_) {
    return Row(
      children: [
        IconButton(
          onPressed: history.canUndo ? () => history.undo() : null,
          icon: const Icon(Icons.undo),
        ),
        IconButton(
          onPressed: history.canRedo ? () => history.redo() : null,
          icon: const Icon(Icons.redo),
        ),
      ],
    );
  },
)

Built-in Extension: Persistence

The DashboardPersistenceExtension manages save/load operations and dirty tracking.

dart
class DashboardPersistenceExtension implements DashboardExtension {
  static const String extensionId = 'dashboard-persistence';

  DashboardPersistenceExtension({required DashboardEditorRegistry registry});

  final Observable<bool> isDirty;

  void markClean();
  void markDirty();
  Map<String, dynamic> toJson();
  void fromJson(Map<String, dynamic> json);
  void loadLayout(DashboardLayout layout);
}

How It Works

  • On DashboardCommandExecuted, DashboardCommandUndone, or DashboardCommandRedone events, the extension sets isDirty to true.
  • loadLayout() replaces the engine layout and calls markClean().
  • toJson() and fromJson() provide direct serialization of the current layout.
  • fromJson() uses the scoped DashboardEditorRegistry passed to the constructor so each component can recreate the right PropertyCollection.

Constructor

dart
DashboardPersistenceExtension(registry: registry);

Built-in Extension: Selection

The DashboardSelectionExtension manages all editor selection state.

dart
class DashboardSelectionExtension implements DashboardExtension {
  static const String extensionId = 'dashboard-selection';

  // Selected cell
  final Observable<String?> selectedCellRowId;
  final Observable<String?> selectedCellColumnId;

  // Config panel
  final Observable<bool> showConfigPanel;
  final Observable<DashboardRightPanelMode> rightPanelMode;

  // Hover state
  final Observable<String?> hoveredRowId;
  final Observable<String?> hoveredCellRowId;
  final Observable<String?> hoveredCellColumnId;

  // Drag state
  final Observable<String?> dragOverRowId;
  final Observable<String?> dragOverColumnId;

  // Live resize state
  final ObservableMap<String, double> liveRowHeightPx;
  final ObservableMap<String, int> liveColumnFlex;

  // Selection methods
  void selectCell(String rowId, String columnId);
  void selectCellOnly(String rowId, String columnId);
  void selectCellWithComponent(String rowId, String columnId);
  void selectRow(String rowId);
  void selectDashboard();
  void selectComponent(String? componentId);
  void clearSelection();

  // Panel methods
  void openConfigPanel();
  void closeConfigPanel();

  // Queries
  ComponentInstance? get selectedCellComponent;
  DashboardRow? get selectedRow;
  DashboardColumn? get selectedColumn;
  bool get selectedCellHasComponent;
  bool get isDashboardSelected;
}

How It Works

  • On DashboardLayoutChanged events, the extension validates that selected rows and columns still exist. If the selected row or column was deleted, selection is cleared.
  • Drag-over state and live resize state are reset on every layout change.
  • The selection supports a two-click pattern: first click selects the cell, second click opens the config panel.

Built-in Extension: Analytics

The DashboardAnalyticsExtension tracks editor actions for analytics reporting.

dart
class DashboardAnalyticsExtension implements DashboardExtension {
  static const String extensionId = 'dashboard-analytics';
}

How It Works

  • On DashboardCommandExecuted events, if the command is an AddComponentCommand, the built-in extension emits a lightweight debug log with the component key.
  • The extension is intentionally small. Add your own analytics extension when you need production telemetry or audit events.

Other Extension Points

The dashboard editor also exposes extension points outside the engine extension API:

Extension PointAPIUse For
Component descriptorsDashboardDescriptor, ComponentDescriptor, PrimitiveComponents.descriptorAdd palette components for an app or domain
Property editorsinitializeComponentPropertySystem(), PropertySystem.register(...)Add custom editors/converters for PropertyCollection fields
Chart renderersChartRendererFactory().register(...) / replace(...)Add or override chart rendering for a ChartType
Chart data sourcesChartDataSourceRegistry().register(...) / replace(...)Add entity-backed or app-specific chart data loading
Field suggestionsChartFieldProviderRegistry().register(...)Suggest fields for entity-backed chart mappings
Entity type optionsEntityTypeProviderRegistry().register(...)Populate entity type pickers for chart configuration
Dashboard actionsActionProperty, DashboardActionStore component actions as JSON in component properties

At app startup, initialize the built-in property and chart systems, then add your app-specific registries:

dart
void main() {
  initializeComponentPropertySystem();
  initializeChartSystem();

  ChartDataSourceRegistry().register(
    ChartDataSourceType.entity,
    MyEntityChartDataSource(),
  );
  ChartFieldProviderRegistry().register(MyEntityFieldProvider());
  EntityTypeProviderRegistry().register(MyEntityTypeProvider());

  runApp(MyApp());
}

Building a Custom Extension

Here is a complete example of a custom extension that auto-refreshes dashboard data at a configurable interval.

Step 1: Implement the Interface

dart
class AutoRefreshExtension implements DashboardExtension {
  static const String extensionId = 'auto-refresh';

  final Duration interval;
  final Future<void> Function() onRefresh;

  DashboardEngineApi? _engine;
  bool _active = false;

  AutoRefreshExtension({
    this.interval = const Duration(seconds: 30),
    required this.onRefresh,
  });

  @override
  String get id => extensionId;

  @override
  void attach(DashboardEngineApi engine) {
    _engine = engine;
    _startRefreshLoop();
  }

  @override
  void detach() {
    _active = false;
    _engine = null;
  }

  @override
  void onEvent(DashboardEvent event) {
    // This extension does not react to events.
    // It runs its own timer-based refresh loop.
  }

  void _startRefreshLoop() async {
    _active = true;

    while (_active) {
      await Future.delayed(interval);
      if (!_active) break;

      try {
        await onRefresh();
      } catch (e) {
        // Log error but continue refreshing
        debugPrint('AutoRefreshExtension: refresh failed: $e');
      }
    }
  }

  /// Pause auto-refresh
  void pause() {
    _active = false;
  }

  /// Resume auto-refresh
  void resume() {
    if (!_active && _engine != null) {
      _startRefreshLoop();
    }
  }
}

Step 2: Register with the Engine

dart
final engine = DashboardEngine(initialLayout: layout, registry: registry);

engine.addExtension(AutoRefreshExtension(
  interval: const Duration(seconds: 60),
  onRefresh: () async {
    // Refresh all data stores
    for (final store in dataStores) {
      await store.refresh(
        sourceType: 'api',
        sourceData: {'endpoint': '/api/dashboard/summary', 'method': 'GET'},
      );
    }
  },
));

Step 3: Control from the UI

dart
final autoRefresh = engine.getExtension<AutoRefreshExtension>();

// Pause when user is editing
autoRefresh?.pause();

// Resume when editing is done
autoRefresh?.resume();

LMS Example: Activity Logger Extension

An extension that logs all dashboard editing activity for audit purposes:

dart
class ActivityLoggerExtension implements DashboardExtension {
  static const String extensionId = 'activity-logger';

  final void Function(String action, Map<String, dynamic> details) log;

  ActivityLoggerExtension({required this.log});

  @override
  String get id => extensionId;

  @override
  void attach(DashboardEngineApi engine) {}

  @override
  void detach() {}

  @override
  void onEvent(DashboardEvent event) {
    if (event is DashboardCommandExecuted) {
      log('command_executed', {
        'command': event.command.description,
        'timestamp': DateTime.now().toIso8601String(),
      });
    }
    if (event is DashboardCommandUndone) {
      log('command_undone', {
        'command': event.command.description,
        'timestamp': DateTime.now().toIso8601String(),
      });
    }
  }
}

// Usage
engine.addExtension(ActivityLoggerExtension(
  log: (action, details) {
    auditService.log(
      userId: currentUser.id,
      module: 'dashboard-editor',
      action: action,
      details: details,
    );
  },
));

Extension Best Practices

  1. Keep extensions focused. Each extension should do one thing well. If you need multiple behaviors, use multiple extensions.
  2. Use unique IDs. Extension IDs must be unique per engine. Use a namespace prefix (e.g., lms-auto-refresh) to avoid conflicts.
  3. Handle detach cleanly. Stop timers, close streams, and null out references in detach().
  4. Do not mutate layout directly. Use the engine API (executeCommand, replaceLayout) instead of modifying the layout observable.
  5. Keep event handlers fast. Events are dispatched synchronously. Long-running work should be deferred to a separate async task.

Next Steps