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:
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:
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
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.
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
DashboardCommandExecutedevents, the extension records the command in the history stack. undo()pops a command from the stack and callsengine.applyUndo(command), which callscommand.undo()and replaces the layout.redo()re-executes a previously undone command viaengine.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:
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.
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, orDashboardCommandRedoneevents, the extension setsisDirtytotrue. loadLayout()replaces the engine layout and callsmarkClean().toJson()andfromJson()provide direct serialization of the current layout.fromJson()uses the scopedDashboardEditorRegistrypassed to the constructor so each component can recreate the rightPropertyCollection.
Constructor
DashboardPersistenceExtension(registry: registry);Built-in Extension: Selection
The DashboardSelectionExtension manages all editor selection state.
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
DashboardLayoutChangedevents, 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.
class DashboardAnalyticsExtension implements DashboardExtension {
static const String extensionId = 'dashboard-analytics';
}How It Works
- On
DashboardCommandExecutedevents, if the command is anAddComponentCommand, 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 Point | API | Use For |
|---|---|---|
| Component descriptors | DashboardDescriptor, ComponentDescriptor, PrimitiveComponents.descriptor | Add palette components for an app or domain |
| Property editors | initializeComponentPropertySystem(), PropertySystem.register(...) | Add custom editors/converters for PropertyCollection fields |
| Chart renderers | ChartRendererFactory().register(...) / replace(...) | Add or override chart rendering for a ChartType |
| Chart data sources | ChartDataSourceRegistry().register(...) / replace(...) | Add entity-backed or app-specific chart data loading |
| Field suggestions | ChartFieldProviderRegistry().register(...) | Suggest fields for entity-backed chart mappings |
| Entity type options | EntityTypeProviderRegistry().register(...) | Populate entity type pickers for chart configuration |
| Dashboard actions | ActionProperty, DashboardAction | Store component actions as JSON in component properties |
At app startup, initialize the built-in property and chart systems, then add your app-specific registries:
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
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
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
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:
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
- Keep extensions focused. Each extension should do one thing well. If you need multiple behaviors, use multiple extensions.
- Use unique IDs. Extension IDs must be unique per engine. Use a namespace prefix (e.g.,
lms-auto-refresh) to avoid conflicts. - Handle detach cleanly. Stop timers, close streams, and null out references in
detach(). - Do not mutate layout directly. Use the engine API (
executeCommand,replaceLayout) instead of modifying the layout observable. - Keep event handlers fast. Events are dispatched synchronously. Long-running work should be deferred to a separate async task.
Next Steps
- Architecture -- Understand the engine and event system
- Building Dashboards -- Step-by-step dashboard guide
- API Reference: Engine -- Complete engine and extension API