Files
grafana/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts
Dominik Prokop 0122f7ccad DashboardScene: Support dashboard links (#77855)
* MenuItem: Allow react node as label

* LinkButton: Expose ButtonLinkProps

* Typecheck fix

* DashboardLinks: Refactor and use LinkButton and menu

* DashbaordLinks scene object

* Use flex layout for dashboard controls

* Update public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* fix keepTime and includeVars

* Add ellipsis to menu item label and description

* Use DashboardLink type from grafana/schema

* Update dashboard scene controls layout

* Fix e2e

* Test fix

* Bring back keyboard navigation

* Remove unused code

* One more fix

---------

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>
Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
2023-11-15 17:49:51 +02:00

795 lines
25 KiB
TypeScript

import {
LoadingState,
ConstantVariableModel,
CustomVariableModel,
DataSourceVariableModel,
QueryVariableModel,
IntervalVariableModel,
TypedVariableModel,
} from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { config } from '@grafana/runtime';
import {
AdHocFilterSet,
behaviors,
CustomVariable,
DataSourceVariable,
QueryVariable,
SceneDataLayerControls,
SceneDataLayers,
SceneDataTransformer,
SceneGridItem,
SceneGridLayout,
SceneGridRow,
VizPanel,
} from '@grafana/scenes';
import { DashboardCursorSync, defaultDashboard, Panel, RowPanel, VariableType } from '@grafana/schema';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { createPanelSaveModel } from 'app/features/dashboard/state/__fixtures__/dashboardFixtures';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types';
import { DashboardDataDTO } from 'app/types';
import { DashboardControls } from '../scene/DashboardControls';
import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { ShareQueryDataProvider } from '../scene/ShareQueryDataProvider';
import { getQueryRunnerFor } from '../utils/utils';
import dashboard_to_load1 from './testfiles/dashboard_to_load1.json';
import repeatingRowsAndPanelsDashboardJson from './testfiles/repeating_rows_and_panels.json';
import {
createDashboardSceneFromDashboardModel,
buildGridItemForPanel,
createSceneVariableFromVariableModel,
transformSaveModelToScene,
} from './transformSaveModelToScene';
describe('transformSaveModelToScene', () => {
describe('when creating dashboard scene', () => {
it('should initialize the DashboardScene with the model state', () => {
const dash = {
...defaultDashboard,
title: 'test',
uid: 'test-uid',
time: { from: 'now-10h', to: 'now' },
weekStart: 'saturday',
fiscalYearStartMonth: 2,
timezone: 'America/New_York',
templating: {
list: [
{
hide: 2,
name: 'constant',
skipUrlSync: false,
type: 'constant' as VariableType,
query: 'test',
id: 'constant',
global: false,
index: 3,
state: LoadingState.Done,
error: null,
description: '',
datasource: null,
},
{
hide: 2,
name: 'CoolFilters',
type: 'adhoc' as VariableType,
datasource: { uid: 'gdev-prometheus', type: 'prometheus' },
id: 'adhoc',
global: false,
skipUrlSync: false,
index: 3,
state: LoadingState.Done,
error: null,
description: '',
},
],
},
};
const oldModel = new DashboardModel(dash);
const scene = createDashboardSceneFromDashboardModel(oldModel);
expect(scene.state.title).toBe('test');
expect(scene.state.uid).toBe('test-uid');
expect(scene.state?.$timeRange?.state.value.raw).toEqual(dash.time);
expect(scene.state?.$timeRange?.state.fiscalYearStartMonth).toEqual(2);
expect(scene.state?.$timeRange?.state.timeZone).toEqual('America/New_York');
expect(scene.state?.$timeRange?.state.weekStart).toEqual('saturday');
expect(scene.state?.$variables?.state.variables).toHaveLength(1);
expect(scene.state.controls).toBeDefined();
expect(scene.state.controls![0]).toBeInstanceOf(DashboardControls);
expect((scene.state.controls![0] as DashboardControls).state.variableControls[1]).toBeInstanceOf(AdHocFilterSet);
expect(
((scene.state.controls![0] as DashboardControls).state.variableControls[1] as AdHocFilterSet).state.name
).toBe('CoolFilters');
});
it('should apply cursor sync behavior', () => {
const dash = {
...defaultDashboard,
graphTooltip: DashboardCursorSync.Crosshair,
};
const oldModel = new DashboardModel(dash);
const scene = createDashboardSceneFromDashboardModel(oldModel);
expect(scene.state.$behaviors).toHaveLength(2);
expect(scene.state.$behaviors![1]).toBeInstanceOf(behaviors.CursorSync);
expect((scene.state.$behaviors![1] as behaviors.CursorSync).state.sync).toEqual(DashboardCursorSync.Crosshair);
});
});
describe('when organizing panels as scene children', () => {
it('should create panels within collapsed rows', () => {
const panel = createPanelSaveModel({
title: 'test',
gridPos: { x: 1, y: 0, w: 12, h: 8 },
}) as Panel;
const row = createPanelSaveModel({
title: 'test',
type: 'row',
gridPos: { x: 0, y: 0, w: 12, h: 1 },
collapsed: true,
panels: [panel],
}) as unknown as RowPanel;
const dashboard = {
...defaultDashboard,
panels: [row],
};
const oldModel = new DashboardModel(dashboard);
const scene = createDashboardSceneFromDashboardModel(oldModel);
const body = scene.state.body as SceneGridLayout;
expect(body.state.children).toHaveLength(1);
const rowScene = body.state.children[0] as SceneGridRow;
expect(rowScene).toBeInstanceOf(SceneGridRow);
expect(rowScene.state.title).toEqual(row.title);
expect(rowScene.state.y).toEqual(row.gridPos!.y);
expect(rowScene.state.isCollapsed).toEqual(row.collapsed);
expect(rowScene.state.children).toHaveLength(1);
expect(rowScene.state.children[0]).toBeInstanceOf(SceneGridItem);
});
it('should create panels within expanded row', () => {
const panelOutOfRow = createPanelSaveModel({
title: 'Out of a row',
gridPos: {
h: 8,
w: 12,
x: 0,
y: 0,
},
});
const rowWithPanel = createPanelSaveModel({
title: 'Row with panel',
type: 'row',
id: 10,
collapsed: false,
gridPos: {
h: 1,
w: 24,
x: 0,
y: 8,
},
// This panels array is not used if the row is not collapsed
panels: [],
});
const panelInRow = createPanelSaveModel({
gridPos: {
h: 8,
w: 12,
x: 0,
y: 9,
},
title: 'In row 1',
});
const emptyRow = createPanelSaveModel({
collapsed: false,
gridPos: {
h: 1,
w: 24,
x: 0,
y: 17,
},
// This panels array is not used if the row is not collapsed
panels: [],
title: 'Empty row',
type: 'row',
});
const dashboard = {
...defaultDashboard,
panels: [panelOutOfRow, rowWithPanel, panelInRow, emptyRow],
};
const oldModel = new DashboardModel(dashboard);
const scene = createDashboardSceneFromDashboardModel(oldModel);
const body = scene.state.body as SceneGridLayout;
expect(body.state.children).toHaveLength(3);
expect(body).toBeInstanceOf(SceneGridLayout);
// Panel out of row
expect(body.state.children[0]).toBeInstanceOf(SceneGridItem);
const panelOutOfRowVizPanel = body.state.children[0] as SceneGridItem;
expect((panelOutOfRowVizPanel.state.body as VizPanel)?.state.title).toBe(panelOutOfRow.title);
// Row with panel
expect(body.state.children[1]).toBeInstanceOf(SceneGridRow);
const rowWithPanelsScene = body.state.children[1] as SceneGridRow;
expect(rowWithPanelsScene.state.title).toBe(rowWithPanel.title);
expect(rowWithPanelsScene.state.key).toBe('panel-10');
expect(rowWithPanelsScene.state.children).toHaveLength(1);
// Panel within row
expect(rowWithPanelsScene.state.children[0]).toBeInstanceOf(SceneGridItem);
const panelInRowVizPanel = rowWithPanelsScene.state.children[0] as SceneGridItem;
expect((panelInRowVizPanel.state.body as VizPanel).state.title).toBe(panelInRow.title);
// Empty row
expect(body.state.children[2]).toBeInstanceOf(SceneGridRow);
const emptyRowScene = body.state.children[2] as SceneGridRow;
expect(emptyRowScene.state.title).toBe(emptyRow.title);
expect(emptyRowScene.state.children).toHaveLength(0);
});
});
describe('when creating viz panel objects', () => {
it('should initalize the VizPanel scene object state', () => {
const panel = {
title: 'test',
type: 'test-plugin',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
maxDataPoints: 100,
options: {
fieldOptions: {
defaults: {
unit: 'none',
decimals: 2,
},
overrides: [],
},
},
fieldConfig: {
defaults: {
unit: 'none',
},
overrides: [],
},
pluginVersion: '1.0.0',
transformations: [
{
id: 'reduce',
options: {
reducers: [
{
id: 'mean',
},
],
},
},
],
targets: [
{
refId: 'A',
queryType: 'randomWalk',
},
],
};
const { gridItem, vizPanel } = buildGridItemForTest(panel);
expect(gridItem.state.x).toEqual(0);
expect(gridItem.state.y).toEqual(0);
expect(gridItem.state.width).toEqual(12);
expect(gridItem.state.height).toEqual(8);
expect(vizPanel.state.title).toBe('test');
expect(vizPanel.state.pluginId).toBe('test-plugin');
expect(vizPanel.state.options).toEqual(panel.options);
expect(vizPanel.state.fieldConfig).toEqual(panel.fieldConfig);
expect(vizPanel.state.pluginVersion).toBe('1.0.0');
const queryRunner = getQueryRunnerFor(vizPanel)!;
expect(queryRunner.state.queries).toEqual(panel.targets);
expect(queryRunner.state.maxDataPoints).toEqual(100);
expect(queryRunner.state.maxDataPointsFromWidth).toEqual(true);
expect((vizPanel.state.$data as SceneDataTransformer)?.state.transformations).toEqual(panel.transformations);
});
it('should initalize the VizPanel without title and transparent true', () => {
const panel = {
title: '',
type: 'test-plugin',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
transparent: true,
};
const { vizPanel } = buildGridItemForTest(panel);
expect(vizPanel.state.displayMode).toEqual('transparent');
expect(vizPanel.state.hoverHeader).toEqual(true);
});
it('should set PanelTimeRange when timeFrom or timeShift is present', () => {
const panel = {
type: 'test-plugin',
timeFrom: '2h',
timeShift: '1d',
};
const { vizPanel } = buildGridItemForTest(panel);
const timeRange = vizPanel.state.$timeRange as PanelTimeRange;
expect(timeRange).toBeInstanceOf(PanelTimeRange);
expect(timeRange.state.timeFrom).toBe('2h');
expect(timeRange.state.timeShift).toBe('1d');
});
it('should handle a dashboard query data source', () => {
const panel = {
title: '',
type: 'test-plugin',
datasource: { uid: SHARED_DASHBOARD_QUERY, type: DASHBOARD_DATASOURCE_PLUGIN_ID },
gridPos: { x: 0, y: 0, w: 12, h: 8 },
transparent: true,
targets: [{ refId: 'A', panelId: 10 }],
};
const { vizPanel } = buildGridItemForTest(panel);
expect(vizPanel.state.$data).toBeInstanceOf(ShareQueryDataProvider);
});
it('should not set SceneQueryRunner for plugins with skipDataQuery', () => {
const panel = {
title: '',
type: 'text-plugin-34',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
transparent: true,
targets: [{ refId: 'A' }],
};
config.panels['text-plugin-34'] = getPanelPlugin({
skipDataQuery: true,
}).meta;
const { vizPanel } = buildGridItemForTest(panel);
expect(vizPanel.state.$data).toBeUndefined();
});
it('When repeat is set should build PanelRepeaterGridItem', () => {
const panel = {
title: '',
type: 'text-plugin-34',
gridPos: { x: 0, y: 0, w: 8, h: 8 },
repeat: 'server',
repeatDirection: 'v',
maxPerRow: 8,
};
const gridItem = buildGridItemForPanel(new PanelModel(panel));
const repeater = gridItem as PanelRepeaterGridItem;
expect(repeater.state.maxPerRow).toBe(8);
expect(repeater.state.variableName).toBe('server');
expect(repeater.state.width).toBe(8);
expect(repeater.state.height).toBe(8);
expect(repeater.state.repeatDirection).toBe('v');
expect(repeater.state.maxPerRow).toBe(8);
});
});
describe('when creating variables objects', () => {
it('should migrate custom variable', () => {
const variable: CustomVariableModel = {
current: {
selected: false,
text: 'a',
value: 'a',
},
hide: 0,
includeAll: false,
multi: false,
name: 'query0',
options: [
{
selected: true,
text: 'a',
value: 'a',
},
{
selected: false,
text: 'b',
value: 'b',
},
{
selected: false,
text: 'c',
value: 'c',
},
{
selected: false,
text: 'd',
value: 'd',
},
],
query: 'a,b,c,d',
skipUrlSync: false,
type: 'custom',
rootStateKey: 'N4XLmH5Vz',
id: 'query0',
global: false,
index: 0,
state: LoadingState.Done,
error: null,
description: null,
allValue: null,
};
const migrated = createSceneVariableFromVariableModel(variable);
const { key, ...rest } = migrated.state;
expect(migrated).toBeInstanceOf(CustomVariable);
expect(rest).toEqual({
allValue: undefined,
defaultToAll: false,
description: null,
includeAll: false,
isMulti: false,
label: undefined,
name: 'query0',
options: [],
query: 'a,b,c,d',
skipUrlSync: false,
text: 'a',
type: 'custom',
value: 'a',
hide: 0,
});
});
it('should migrate query variable', () => {
const variable: QueryVariableModel = {
allValue: null,
current: {
text: 'America',
value: 'America',
selected: false,
},
datasource: {
uid: 'P15396BDD62B2BE29',
type: 'influxdb',
},
definition: '',
hide: 0,
includeAll: false,
label: 'Datacenter',
multi: false,
name: 'datacenter',
options: [
{
text: 'America',
value: 'America',
selected: true,
},
{
text: 'Africa',
value: 'Africa',
selected: false,
},
{
text: 'Asia',
value: 'Asia',
selected: false,
},
{
text: 'Europe',
value: 'Europe',
selected: false,
},
],
query: 'SHOW TAG VALUES WITH KEY = "datacenter" ',
refresh: 1,
regex: '',
skipUrlSync: false,
sort: 0,
type: 'query',
rootStateKey: '000000002',
id: 'datacenter',
global: false,
index: 0,
state: LoadingState.Done,
error: null,
description: null,
};
const migrated = createSceneVariableFromVariableModel(variable);
const { key, ...rest } = migrated.state;
expect(migrated).toBeInstanceOf(QueryVariable);
expect(rest).toEqual({
allValue: undefined,
datasource: {
type: 'influxdb',
uid: 'P15396BDD62B2BE29',
},
defaultToAll: false,
description: null,
includeAll: false,
isMulti: false,
label: 'Datacenter',
name: 'datacenter',
options: [],
query: 'SHOW TAG VALUES WITH KEY = "datacenter" ',
refresh: 1,
regex: '',
skipUrlSync: false,
sort: 0,
text: 'America',
type: 'query',
value: 'America',
hide: 0,
});
});
it('should migrate datasource variable', () => {
const variable: DataSourceVariableModel = {
id: 'query1',
rootStateKey: 'N4XLmH5Vz',
name: 'query1',
type: 'datasource',
global: false,
index: 1,
hide: 0,
skipUrlSync: false,
state: LoadingState.Done,
error: null,
description: null,
current: {
value: ['gdev-prometheus', 'gdev-slow-prometheus'],
text: ['gdev-prometheus', 'gdev-slow-prometheus'],
selected: true,
},
regex: '/^gdev/',
options: [
{
text: 'All',
value: '$__all',
selected: false,
},
{
text: 'gdev-prometheus',
value: 'gdev-prometheus',
selected: true,
},
{
text: 'gdev-slow-prometheus',
value: 'gdev-slow-prometheus',
selected: false,
},
],
query: 'prometheus',
multi: true,
includeAll: true,
refresh: 1,
allValue: 'Custom all',
};
const migrated = createSceneVariableFromVariableModel(variable);
const { key, ...rest } = migrated.state;
expect(migrated).toBeInstanceOf(DataSourceVariable);
expect(rest).toEqual({
allValue: 'Custom all',
defaultToAll: true,
includeAll: true,
label: undefined,
name: 'query1',
options: [],
pluginId: 'prometheus',
regex: '/^gdev/',
skipUrlSync: false,
text: ['gdev-prometheus', 'gdev-slow-prometheus'],
type: 'datasource',
value: ['gdev-prometheus', 'gdev-slow-prometheus'],
isMulti: true,
description: null,
hide: 0,
});
});
it('should migrate constant variable', () => {
const variable: ConstantVariableModel = {
hide: 2,
label: 'constant',
name: 'constant',
skipUrlSync: false,
type: 'constant',
rootStateKey: 'N4XLmH5Vz',
current: {
selected: true,
text: 'test',
value: 'test',
},
options: [
{
selected: true,
text: 'test',
value: 'test',
},
],
query: 'test',
id: 'constant',
global: false,
index: 3,
state: LoadingState.Done,
error: null,
description: null,
};
const migrated = createSceneVariableFromVariableModel(variable);
const { key, ...rest } = migrated.state;
expect(rest).toEqual({
description: null,
hide: 2,
label: 'constant',
name: 'constant',
skipUrlSync: true,
type: 'constant',
value: 'test',
});
});
it('should migrate interval variable', () => {
const variable: IntervalVariableModel = {
name: 'intervalVar',
label: 'Interval Label',
type: 'interval',
rootStateKey: 'N4XLmH5Vz',
auto: false,
refresh: 2,
auto_count: 30,
auto_min: '10s',
current: {
selected: true,
text: '1m',
value: '1m',
},
options: [
{
selected: true,
text: '1m',
value: '1m',
},
],
query: '1m, 5m, 15m, 30m, 1h, 6h, 12h, 1d, 7d, 14d, 30d',
id: 'intervalVar',
global: false,
index: 4,
hide: 0,
skipUrlSync: false,
state: LoadingState.Done,
error: null,
description: null,
};
const migrated = createSceneVariableFromVariableModel(variable);
const { key, ...rest } = migrated.state;
expect(rest).toEqual({
label: 'Interval Label',
autoEnabled: false,
autoMinInterval: '10s',
autoStepCount: 30,
description: null,
refresh: 2,
intervals: ['1m', '5m', '15m', '30m', '1h', '6h', '12h', '1d', '7d', '14d', '30d'],
hide: 0,
name: 'intervalVar',
skipUrlSync: false,
type: 'interval',
value: '1m',
});
});
it.each(['textbox', 'system'])('should throw for unsupported (yet) variables', (type) => {
const variable = {
name: 'query0',
type: type as VariableType,
};
expect(() => createSceneVariableFromVariableModel(variable as TypedVariableModel)).toThrow();
});
});
describe('Repeating rows', () => {
it('Should build correct scene model', () => {
const scene = transformSaveModelToScene({ dashboard: repeatingRowsAndPanelsDashboardJson as any, meta: {} });
const body = scene.state.body as SceneGridLayout;
const row2 = body.state.children[1] as SceneGridRow;
expect(row2.state.$behaviors?.[0]).toBeInstanceOf(RowRepeaterBehavior);
const repeatBehavior = row2.state.$behaviors?.[0] as RowRepeaterBehavior;
expect(repeatBehavior.state.variableName).toBe('server');
const lastRow = body.state.children[body.state.children.length - 1] as SceneGridRow;
expect(lastRow.state.isCollapsed).toBe(true);
});
});
describe('Annotation queries', () => {
it('Should build correct scene model', () => {
const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
expect(scene.state.$data).toBeInstanceOf(SceneDataLayers);
expect((scene.state.controls![0] as DashboardControls)!.state.variableControls[2]).toBeInstanceOf(
SceneDataLayerControls
);
const dataLayers = scene.state.$data as SceneDataLayers;
expect(dataLayers.state.layers).toHaveLength(4);
expect(dataLayers.state.layers[0].state.name).toBe('Annotations & Alerts');
expect(dataLayers.state.layers[0].state.isEnabled).toBe(true);
expect(dataLayers.state.layers[0].state.isHidden).toBe(false);
expect(dataLayers.state.layers[1].state.name).toBe('Enabled');
expect(dataLayers.state.layers[1].state.isEnabled).toBe(true);
expect(dataLayers.state.layers[1].state.isHidden).toBe(false);
expect(dataLayers.state.layers[2].state.name).toBe('Disabled');
expect(dataLayers.state.layers[2].state.isEnabled).toBe(false);
expect(dataLayers.state.layers[2].state.isHidden).toBe(false);
expect(dataLayers.state.layers[3].state.name).toBe('Hidden');
expect(dataLayers.state.layers[3].state.isEnabled).toBe(true);
expect(dataLayers.state.layers[3].state.isHidden).toBe(true);
});
});
describe('Alerting data layer', () => {
it('Should add alert states data layer if unified alerting enabled', () => {
config.unifiedAlertingEnabled = true;
const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
expect(scene.state.$data).toBeInstanceOf(SceneDataLayers);
expect((scene.state.controls![0] as DashboardControls)!.state.variableControls[2]).toBeInstanceOf(
SceneDataLayerControls
);
const dataLayers = scene.state.$data as SceneDataLayers;
expect(dataLayers.state.layers).toHaveLength(5);
expect(dataLayers.state.layers[4].state.name).toBe('Alert States');
});
it('Should add alert states data layer if any panel has a legacy alert defined', () => {
config.unifiedAlertingEnabled = false;
const dashboard = { ...dashboard_to_load1 } as unknown as DashboardDataDTO;
dashboard.panels![0].alert = {};
const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
expect(scene.state.$data).toBeInstanceOf(SceneDataLayers);
expect((scene.state.controls![0] as DashboardControls)!.state.variableControls[2]).toBeInstanceOf(
SceneDataLayerControls
);
const dataLayers = scene.state.$data as SceneDataLayers;
expect(dataLayers.state.layers).toHaveLength(5);
expect(dataLayers.state.layers[4].state.name).toBe('Alert States');
});
});
});
function buildGridItemForTest(saveModel: Partial<Panel>): { gridItem: SceneGridItem; vizPanel: VizPanel } {
const gridItem = buildGridItemForPanel(new PanelModel(saveModel));
if (gridItem instanceof SceneGridItem) {
return { gridItem, vizPanel: gridItem.state.body as VizPanel };
}
throw new Error('buildGridItemForPanel to return SceneGridItem');
}