diff --git a/packages/grafana-data/src/panel/PanelPlugin.test.tsx b/packages/grafana-data/src/panel/PanelPlugin.test.tsx
index 50801dd8a84..59d8bf9d75b 100644
--- a/packages/grafana-data/src/panel/PanelPlugin.test.tsx
+++ b/packages/grafana-data/src/panel/PanelPlugin.test.tsx
@@ -5,6 +5,7 @@ import {
standardFieldConfigEditorRegistry,
} from '../field/standardFieldConfigEditorRegistry';
import { FieldConfigProperty, FieldConfigPropertyItem } from '../types/fieldOverrides';
+import { PanelMigrationModel } from '../types/panel';
import { PanelOptionsEditorBuilder } from '../utils/OptionsUIBuilders';
import { PanelPlugin } from './PanelPlugin';
@@ -308,4 +309,178 @@ describe('PanelPlugin', () => {
});
});
});
+
+ describe('setMigrationHandler', () => {
+ it('should handle synchronous migrations', () => {
+ const panel = new PanelPlugin(() =>
Panel
);
+ const mockMigration = () => ({
+ newOption: 'migrated',
+ });
+
+ panel.setMigrationHandler(mockMigration);
+
+ const migrationModel: PanelMigrationModel = {
+ id: 1,
+ type: 'test-panel',
+ options: { oldOption: 'value' },
+ fieldConfig: { defaults: {}, overrides: [] },
+ pluginVersion: '1.0.0',
+ };
+
+ expect(panel.onPanelMigration).toBeDefined();
+ expect(panel.onPanelMigration!(migrationModel)).toEqual({
+ newOption: 'migrated',
+ });
+ });
+
+ it('should handle async migrations', async () => {
+ const panel = new PanelPlugin(() => Panel
);
+ const mockAsyncMigration = async () => {
+ return Promise.resolve({
+ newOption: 'async-migrated',
+ });
+ };
+
+ panel.setMigrationHandler(mockAsyncMigration);
+
+ const migrationModel: PanelMigrationModel = {
+ id: 1,
+ type: 'test-panel',
+ options: { oldOption: 'value' },
+ fieldConfig: { defaults: {}, overrides: [] },
+ };
+
+ const result = await panel.onPanelMigration!(migrationModel);
+ expect(result).toEqual({
+ newOption: 'async-migrated',
+ });
+ });
+
+ it('should handle complex panel migrations with advanced transformations', () => {
+ const panel = new PanelPlugin(() => Panel
);
+
+ const mockMigration = (model: PanelMigrationModel) => {
+ const { options, fieldConfig, title, id, type, pluginVersion } = model;
+
+ //notice many of these migrations don't make sense in real code but are here
+ //to make sure the attributes of the PanelMigrationModel are used and tested
+ const baseMigration = {
+ ...options,
+ display: {
+ ...options.display,
+ mode: options.display?.type === 'legacy' ? 'modern' : options.display?.mode,
+ title: title?.toLowerCase() ?? 'untitled',
+ panelId: `${type}-${id}`,
+ },
+ thresholds: options.thresholds?.map((t: { value: string | number; color: string }) => ({
+ ...t,
+ value: typeof t.value === 'string' ? parseInt(t.value, 10) : t.value,
+ // Use fieldConfig defaults for threshold colors if available
+ color: fieldConfig.defaults?.color ?? t.color,
+ })),
+ metadata: {
+ migrationVersion: pluginVersion ? `${pluginVersion} -> 2.0.0` : '2.0.0',
+ migratedFields: Object.keys(fieldConfig.defaults ?? {}),
+ overrideCount: fieldConfig.overrides?.length ?? 0,
+ },
+ // Merge custom field defaults into options
+ customDefaults: fieldConfig.defaults?.custom ?? {},
+ // Transform overrides into a map
+ overrideMap: fieldConfig.overrides?.reduce(
+ (acc, override) => ({
+ ...acc,
+ [override.matcher.id]: override.properties,
+ }),
+ {}
+ ),
+ };
+
+ // Apply panel type specific migrations
+ if (type.includes('visualization')) {
+ return {
+ ...baseMigration,
+ visualizationSpecific: {
+ enhanced: true,
+ legacyFormat: false,
+ },
+ };
+ }
+
+ return baseMigration;
+ };
+
+ panel.setMigrationHandler(mockMigration);
+
+ const complexModel: PanelMigrationModel = {
+ id: 123,
+ type: 'visualization-panel',
+ title: 'Complex METRICS',
+ pluginVersion: '1.0.0',
+ options: {
+ display: {
+ type: 'legacy',
+ showHeader: true,
+ },
+ thresholds: [
+ { value: '90', color: 'red' },
+ { value: '50', color: 'yellow' },
+ ],
+ queries: ['A', 'B'],
+ },
+ fieldConfig: {
+ defaults: {
+ color: { mode: 'thresholds' },
+ custom: {
+ lineWidth: 1,
+ fillOpacity: 0.5,
+ },
+ mappings: [],
+ },
+ overrides: [
+ {
+ matcher: { id: 'byName', options: 'cpu' },
+ properties: [{ id: 'color', value: 'red' }],
+ },
+ {
+ matcher: { id: 'byValue', options: 'memory' },
+ properties: [{ id: 'unit', value: 'bytes' }],
+ },
+ ],
+ },
+ };
+
+ const result = panel.onPanelMigration!(complexModel);
+
+ expect(result).toMatchObject({
+ display: {
+ mode: 'modern',
+ showHeader: true,
+ title: 'complex metrics',
+ panelId: 'visualization-panel-123',
+ },
+ thresholds: [
+ { value: 90, color: { mode: 'thresholds' } },
+ { value: 50, color: { mode: 'thresholds' } },
+ ],
+ queries: ['A', 'B'],
+ metadata: {
+ migrationVersion: '1.0.0 -> 2.0.0',
+ migratedFields: ['color', 'custom', 'mappings'],
+ overrideCount: 2,
+ },
+ customDefaults: {
+ lineWidth: 1,
+ fillOpacity: 0.5,
+ },
+ overrideMap: {
+ byName: [{ id: 'color', value: 'red' }],
+ byValue: [{ id: 'unit', value: 'bytes' }],
+ },
+ visualizationSpecific: {
+ enhanced: true,
+ legacyFormat: false,
+ },
+ });
+ });
+ });
});
diff --git a/packages/grafana-data/src/types/panel.ts b/packages/grafana-data/src/types/panel.ts
index 7cfe9263ce3..91083493c83 100644
--- a/packages/grafana-data/src/types/panel.ts
+++ b/packages/grafana-data/src/types/panel.ts
@@ -134,12 +134,32 @@ export interface PanelEditorProps {
data?: PanelData;
}
+/**
+ * This type mirrors the required properties from PanelModel needed for migration handlers.
+ *
+ * By maintaining a separate type definition, we ensure that changes to PanelModel
+ * that would break third-party migration handlers are caught at compile time,
+ * rather than failing silently when third-party code attempts to use an incompatible panel.
+ *
+ * TOptions must be any to follow the same pattern as PanelModel
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export interface PanelMigrationModel {
+ id: number;
+ type: string;
+ title?: string;
+ options: TOptions;
+ fieldConfig: PanelModel['fieldConfig'];
+ pluginVersion?: string;
+ targets?: PanelModel['targets'];
+}
+
/**
* Called when a panel is first loaded with current panel model to migrate panel options if needed.
* Can return panel options, or a Promise that resolves to panel options for async migrations
*/
export type PanelMigrationHandler = (
- panel: PanelModel
+ panel: PanelMigrationModel
) => Partial | Promise>;
/**