Scenes: Add 'Import from library' functionality (#83498)

* wip

* tests + refactor ad panel func

* Add row functionality

* update row state only when there are children

* Add new row + copy paste panels

* Add library panel functionality

* tests

* PR mods

* reafctor + tests

* reafctor

* fix test

* refactor

* fix bug on cancelling lib widget

* dashboard now saves with lib panel widget

* add lib panels widget works in rows as well

* split add lib panel func to another PR

* Add library panel functionality

* refactor

* take panelKey into account when getting next panel id in dashboard

* fix tests
This commit is contained in:
Victor Marin 2024-02-28 16:41:12 +02:00 committed by GitHub
parent 393b12f49f
commit 04539ffccb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 578 additions and 1 deletions

View File

@ -0,0 +1,252 @@
import { SceneGridItem, SceneGridLayout, SceneGridRow, SceneTimeRange } from '@grafana/scenes';
import { LibraryPanel } from '@grafana/schema/dist/esm/index.gen';
import { DashboardScene } from '../scene/DashboardScene';
import { activateFullSceneTree } from '../utils/test-utils';
import { AddLibraryPanelWidget } from './AddLibraryPanelWidget';
import { LibraryVizPanel } from './LibraryVizPanel';
describe('AddLibraryPanelWidget', () => {
let dashboard: DashboardScene;
let addLibPanelWidget: AddLibraryPanelWidget;
const mockEvent = {
preventDefault: jest.fn(),
} as unknown as React.MouseEvent<HTMLButtonElement>;
beforeEach(async () => {
const result = await buildTestScene();
dashboard = result.dashboard;
addLibPanelWidget = result.addLibPanelWidget;
});
it('should return the dashboard', () => {
expect(addLibPanelWidget.getDashboard()).toBe(dashboard);
});
it('should cancel adding a lib panel', () => {
addLibPanelWidget.onCancelAddPanel(mockEvent);
const body = dashboard.state.body as SceneGridLayout;
expect(body.state.children.length).toBe(0);
});
it('should cancel lib panel at correct position', () => {
const anotherLibPanelWidget = new AddLibraryPanelWidget({ key: 'panel-2' });
const body = dashboard.state.body as SceneGridLayout;
body.setState({
children: [
...body.state.children,
new SceneGridItem({
key: 'griditem-2',
x: 0,
y: 0,
width: 10,
height: 12,
body: anotherLibPanelWidget,
}),
],
});
dashboard.setState({ body });
anotherLibPanelWidget.onCancelAddPanel(mockEvent);
const gridItem = body.state.children[0] as SceneGridItem;
expect(body.state.children.length).toBe(1);
expect(gridItem.state.body!.state.key).toBe(addLibPanelWidget.state.key);
});
it('should cancel lib panel inside a row child', () => {
const anotherLibPanelWidget = new AddLibraryPanelWidget({ key: 'panel-2' });
dashboard.setState({
body: new SceneGridLayout({
children: [
new SceneGridRow({
key: 'panel-2',
children: [
new SceneGridItem({
key: 'griditem-2',
x: 0,
y: 0,
width: 10,
height: 12,
body: anotherLibPanelWidget,
}),
],
}),
],
}),
});
const body = dashboard.state.body as SceneGridLayout;
anotherLibPanelWidget.onCancelAddPanel(mockEvent);
const gridRow = body.state.children[0] as SceneGridRow;
expect(body.state.children.length).toBe(1);
expect(gridRow.state.children.length).toBe(0);
});
it('should add library panel from menu', () => {
const panelInfo: LibraryPanel = {
uid: 'uid',
model: {
type: 'timeseries',
},
name: 'name',
version: 1,
type: 'timeseries',
};
const body = dashboard.state.body as SceneGridLayout;
const gridItem = body.state.children[0] as SceneGridItem;
expect(gridItem.state.body!).toBeInstanceOf(AddLibraryPanelWidget);
addLibPanelWidget.onAddLibraryPanel(panelInfo);
expect(body.state.children.length).toBe(1);
expect(gridItem.state.body!).toBeInstanceOf(LibraryVizPanel);
expect((gridItem.state.body! as LibraryVizPanel).state.panelKey).toBe(addLibPanelWidget.state.key);
});
it('should add a lib panel at correct position', () => {
const anotherLibPanelWidget = new AddLibraryPanelWidget({ key: 'panel-2' });
const body = dashboard.state.body as SceneGridLayout;
body.setState({
children: [
...body.state.children,
new SceneGridItem({
key: 'griditem-2',
x: 0,
y: 0,
width: 10,
height: 12,
body: anotherLibPanelWidget,
}),
],
});
dashboard.setState({ body });
const panelInfo: LibraryPanel = {
uid: 'uid',
model: {
type: 'timeseries',
},
name: 'name',
version: 1,
type: 'timeseries',
};
anotherLibPanelWidget.onAddLibraryPanel(panelInfo);
const gridItemOne = body.state.children[0] as SceneGridItem;
const gridItemTwo = body.state.children[1] as SceneGridItem;
expect(body.state.children.length).toBe(2);
expect(gridItemOne.state.body!).toBeInstanceOf(AddLibraryPanelWidget);
expect((gridItemTwo.state.body! as LibraryVizPanel).state.panelKey).toBe(anotherLibPanelWidget.state.key);
});
it('should add library panel from menu to a row child', () => {
const anotherLibPanelWidget = new AddLibraryPanelWidget({ key: 'panel-2' });
dashboard.setState({
body: new SceneGridLayout({
children: [
new SceneGridRow({
key: 'panel-2',
children: [
new SceneGridItem({
key: 'griditem-2',
x: 0,
y: 0,
width: 10,
height: 12,
body: anotherLibPanelWidget,
}),
],
}),
],
}),
});
const panelInfo: LibraryPanel = {
uid: 'uid',
model: {
type: 'timeseries',
},
name: 'name',
version: 1,
type: 'timeseries',
};
const body = dashboard.state.body as SceneGridLayout;
anotherLibPanelWidget.onAddLibraryPanel(panelInfo);
const gridRow = body.state.children[0] as SceneGridRow;
const gridItem = gridRow.state.children[0] as SceneGridItem;
expect(body.state.children.length).toBe(1);
expect(gridItem.state.body!).toBeInstanceOf(LibraryVizPanel);
expect((gridItem.state.body! as LibraryVizPanel).state.panelKey).toBe(anotherLibPanelWidget.state.key);
});
it('should throw error if adding lib panel in a layout that is not SceneGridLayout', () => {
dashboard.setState({
body: undefined,
});
expect(() => addLibPanelWidget.onAddLibraryPanel({} as LibraryPanel)).toThrow(
'Trying to add a library panel in a layout that is not SceneGridLayout'
);
});
it('should throw error if removing the library panel widget in a layout that is not SceneGridLayout', () => {
dashboard.setState({
body: undefined,
});
expect(() => addLibPanelWidget.onCancelAddPanel(mockEvent)).toThrow(
'Trying to remove the library panel widget in a layout that is not SceneGridLayout'
);
});
});
async function buildTestScene() {
const addLibPanelWidget = new AddLibraryPanelWidget({ key: 'panel-1' });
const dashboard = new DashboardScene({
$timeRange: new SceneTimeRange({}),
title: 'hello',
uid: 'dash-1',
version: 4,
meta: {
canEdit: true,
},
body: new SceneGridLayout({
children: [
new SceneGridItem({
key: 'griditem-1',
x: 0,
y: 0,
width: 10,
height: 12,
body: addLibPanelWidget,
}),
],
}),
});
activateFullSceneTree(dashboard);
await new Promise((r) => setTimeout(r, 1));
dashboard.onEnterEditMode();
return { dashboard, addLibPanelWidget };
}

View File

@ -0,0 +1,169 @@
import { css, cx, keyframes } from '@emotion/css';
import React from 'react';
import tinycolor from 'tinycolor2';
import { GrafanaTheme2 } from '@grafana/data';
import {
SceneComponentProps,
SceneGridItem,
SceneGridLayout,
SceneGridRow,
SceneObjectBase,
SceneObjectState,
} from '@grafana/scenes';
import { LibraryPanel } from '@grafana/schema';
import { IconButton, useStyles2 } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import {
LibraryPanelsSearch,
LibraryPanelsSearchVariant,
} from 'app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch';
import { getDashboardSceneFor } from '../utils/utils';
import { DashboardScene } from './DashboardScene';
import { LibraryVizPanel } from './LibraryVizPanel';
export interface AddLibraryPanelWidgetState extends SceneObjectState {
key: string;
}
export class AddLibraryPanelWidget extends SceneObjectBase<AddLibraryPanelWidgetState> {
public constructor(state: AddLibraryPanelWidgetState) {
super({
...state,
});
}
private get _dashboard(): DashboardScene {
return getDashboardSceneFor(this);
}
public getDashboard(): DashboardScene {
return this._dashboard;
}
public onCancelAddPanel = (evt: React.MouseEvent<HTMLButtonElement>) => {
evt.preventDefault();
if (!(this._dashboard.state.body instanceof SceneGridLayout)) {
throw new Error('Trying to remove the library panel widget in a layout that is not SceneGridLayout');
}
const sceneGridLayout = this._dashboard.state.body;
const children = [];
for (const child of sceneGridLayout.state.children) {
if (child.state.key !== this.parent?.state.key) {
children.push(child);
}
if (child instanceof SceneGridRow) {
const rowChildren = [];
for (const rowChild of child.state.children) {
if (rowChild instanceof SceneGridItem && rowChild.state.key !== this.parent?.state.key) {
rowChildren.push(rowChild);
}
}
child.setState({ children: rowChildren });
}
}
sceneGridLayout.setState({ children });
};
public onAddLibraryPanel = (panelInfo: LibraryPanel) => {
if (!(this._dashboard.state.body instanceof SceneGridLayout)) {
throw new Error('Trying to add a library panel in a layout that is not SceneGridLayout');
}
const body = new LibraryVizPanel({
title: 'Panel Title',
uid: panelInfo.uid,
name: panelInfo.name,
panelKey: this.state.key,
});
if (this.parent instanceof SceneGridItem) {
this.parent.setState({ body });
}
};
static Component = ({ model }: SceneComponentProps<AddLibraryPanelWidget>) => {
const dashboard = model.getDashboard();
const styles = useStyles2(getStyles);
return (
<div className={styles.wrapper}>
<div className={cx('panel-container', styles.callToAction)}>
<div className={cx(styles.headerRow, `grid-drag-handle-${dashboard.state.body.state.key}`)}>
<span>
<Trans i18nKey="library-panel.add-widget.title">Add panel from panel library</Trans>
</span>
<div className="flex-grow-1" />
<IconButton
aria-label="Close 'Add Panel' widget"
name="times"
onClick={model.onCancelAddPanel}
tooltip="Close widget"
/>
</div>
<LibraryPanelsSearch
onClick={model.onAddLibraryPanel}
variant={LibraryPanelsSearchVariant.Tight}
showPanelFilter
/>
</div>
</div>
);
};
}
const getStyles = (theme: GrafanaTheme2) => {
const pulsate = keyframes({
'0%': {
boxShadow: `0 0 0 2px ${theme.colors.background.canvas}, 0 0 0px 4px ${theme.colors.primary.main}`,
},
'50%': {
boxShadow: `0 0 0 2px ${theme.components.dashboard.background}, 0 0 0px 4px ${tinycolor(theme.colors.primary.main)
.darken(20)
.toHexString()}`,
},
'100%': {
boxShadow: `0 0 0 2px ${theme.components.dashboard.background}, 0 0 0px 4px ${theme.colors.primary.main}`,
},
});
return {
// wrapper is used to make sure box-shadow animation isn't cut off in dashboard page
wrapper: css({
height: '100%',
paddingTop: `${theme.spacing(0.5)}`,
}),
headerRow: css({
display: 'flex',
alignItems: 'center',
height: '38px',
flexShrink: 0,
width: '100%',
fontSize: theme.typography.fontSize,
fontWeight: theme.typography.fontWeightMedium,
paddingLeft: `${theme.spacing(1)}`,
transition: 'background-color 0.1s ease-in-out',
cursor: 'move',
'&:hover': {
background: `${theme.colors.background.secondary}`,
},
}),
callToAction: css({
overflow: 'hidden',
outline: '2px dotted transparent',
outlineOffset: '2px',
boxShadow: '0 0 0 2px black, 0 0 0px 4px #1f60c4',
animation: `${pulsate} 2s ease infinite`,
}),
};
};

View File

@ -264,6 +264,17 @@ describe('DashboardScene', () => {
expect(gridItem.state.y).toBe(0);
expect(scene.state.hasCopiedPanel).toBe(false);
});
it('Should create a new add library panel widget', () => {
scene.onCreateLibPanelWidget();
const body = scene.state.body as SceneGridLayout;
const gridItem = body.state.children[0] as SceneGridItem;
expect(body.state.children.length).toBe(5);
expect(gridItem.state.body!.state.key).toBe('panel-5');
expect(gridItem.state.y).toBe(0);
});
});
});

View File

@ -59,6 +59,7 @@ import {
isPanelClone,
} from '../utils/utils';
import { AddLibraryPanelWidget } from './AddLibraryPanelWidget';
import { DashboardControls } from './DashboardControls';
import { DashboardSceneUrlSync } from './DashboardSceneUrlSync';
import { PanelRepeaterGridItem } from './PanelRepeaterGridItem';
@ -612,6 +613,29 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
locationService.partial({ editview: 'settings' });
};
public onCreateLibPanelWidget() {
if (!(this.state.body instanceof SceneGridLayout)) {
throw new Error('Trying to add a panel in a layout that is not SceneGridLayout');
}
const sceneGridLayout = this.state.body;
const panelId = dashboardSceneGraph.getNextPanelId(this);
const newGridItem = new SceneGridItem({
height: NEW_PANEL_HEIGHT,
width: NEW_PANEL_WIDTH,
x: 0,
y: 0,
body: new AddLibraryPanelWidget({ key: getVizPanelKeyForPanelId(panelId) }),
key: `grid-item-${panelId}`,
});
sceneGridLayout.setState({
children: [newGridItem, ...sceneGridLayout.state.children],
});
}
public onCreateNewRow() {
const row = getDefaultRow(this);

View File

@ -18,6 +18,7 @@ describe('NavToolbarActions', () => {
expect(screen.queryByLabelText('Add visualization')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Add row')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Paste panel')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Add library panel')).not.toBeInTheDocument();
expect(await screen.findByText('Edit')).toBeInTheDocument();
expect(await screen.findByText('Share')).toBeInTheDocument();
});
@ -32,6 +33,7 @@ describe('NavToolbarActions', () => {
expect(await screen.findByLabelText('Add visualization')).toBeInTheDocument();
expect(await screen.findByLabelText('Add row')).toBeInTheDocument();
expect(await screen.findByLabelText('Paste panel')).toBeInTheDocument();
expect(await screen.findByLabelText('Add library panel')).toBeInTheDocument();
expect(screen.queryByText('Edit')).not.toBeInTheDocument();
expect(screen.queryByText('Share')).not.toBeInTheDocument();
});

View File

@ -71,6 +71,22 @@ export function ToolbarActions({ dashboard }: Props) {
),
});
toolbarActions.push({
group: 'icon-actions',
condition: isEditing && !editview && !isViewingPanel && !isEditingPanel,
render: () => (
<ToolbarButton
key="add-library-panel"
tooltip={'Add library panel'}
icon="library-panel"
onClick={() => {
dashboard.onCreateLibPanelWidget();
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_library_panel' });
}}
/>
),
});
toolbarActions.push({
group: 'icon-actions',
condition: isEditing && !editview && !isViewingPanel && !isEditingPanel,

View File

@ -42,11 +42,13 @@ 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 { AddLibraryPanelWidget } from '../scene/AddLibraryPanelWidget';
import { LibraryVizPanel } from '../scene/LibraryVizPanel';
import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { NEW_LINK } from '../settings/links/utils';
import { getQueryRunnerFor } from '../utils/utils';
import { getQueryRunnerFor, getVizPanelKeyForPanelId } from '../utils/utils';
import { buildNewDashboardSaveModel } from './buildNewDashboardSaveModel';
import { GRAFANA_DATASOURCE_REF } from './const';
@ -58,6 +60,8 @@ import {
createSceneVariableFromVariableModel,
transformSaveModelToScene,
convertOldSnapshotToScenesSnapshot,
buildGridItemForLibPanel,
buildGridItemForLibraryPanelWidget,
} from './transformSaveModelToScene';
describe('transformSaveModelToScene', () => {
@ -453,6 +457,37 @@ describe('transformSaveModelToScene', () => {
expect(runner.state.cacheTimeout).toBe('10');
expect(runner.state.queryCachingTTL).toBe(200000);
});
it('should convert saved lib widget to AddLibraryPanelWidget', () => {
const panel = {
id: 10,
type: 'add-library-panel',
};
const gridItem = buildGridItemForLibraryPanelWidget(new PanelModel(panel))!;
const libPanelWidget = gridItem.state.body as AddLibraryPanelWidget;
expect(libPanelWidget.state.key).toEqual(getVizPanelKeyForPanelId(panel.id));
});
it('should convert saved lib panel to LibraryVizPanel', () => {
const panel = {
title: 'Panel',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
transparent: true,
libraryPanel: {
uid: '123',
name: 'My Panel',
folderUid: '456',
},
};
const gridItem = buildGridItemForLibPanel(new PanelModel(panel))!;
const libVizPanel = gridItem.state.body as LibraryVizPanel;
expect(libVizPanel.state.uid).toEqual(panel.libraryPanel.uid);
expect(libVizPanel.state.name).toEqual(panel.libraryPanel.name);
expect(libVizPanel.state.title).toEqual(panel.title);
});
});
describe('when creating variables objects', () => {

View File

@ -34,6 +34,7 @@ import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { trackDashboardLoaded } from 'app/features/dashboard/utils/tracking';
import { DashboardDTO } from 'app/types';
import { AddLibraryPanelWidget } from '../scene/AddLibraryPanelWidget';
import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer';
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
import { DashboardControls } from '../scene/DashboardControls';
@ -110,6 +111,12 @@ export function createSceneObjectsForPanels(oldPanels: PanelModel[]): SceneGridI
currentRowPanels = [];
}
}
} else if (panel.type === 'add-library-panel') {
const gridItem = buildGridItemForLibraryPanelWidget(panel);
if (gridItem) {
panels.push(gridItem);
}
} else if (panel.libraryPanel?.uid && !('model' in panel.libraryPanel)) {
const gridItem = buildGridItemForLibPanel(panel);
if (gridItem) {
@ -399,6 +406,24 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode
}
}
export function buildGridItemForLibraryPanelWidget(panel: PanelModel) {
if (panel.type !== 'add-library-panel') {
return null;
}
const body = new AddLibraryPanelWidget({
key: getVizPanelKeyForPanelId(panel.id),
});
return new SceneGridItem({
body,
y: panel.gridPos.y,
x: panel.gridPos.x,
width: panel.gridPos.w,
height: panel.gridPos.h,
});
}
export function buildGridItemForLibPanel(panel: PanelModel) {
if (!panel.libraryPanel) {
return null;

View File

@ -41,6 +41,7 @@ import snapshotableDashboardJson from './testfiles/snapshotable_dashboard.json';
import snapshotableWithRowsDashboardJson from './testfiles/snapshotable_with_rows.json';
import {
buildGridItemForLibPanel,
buildGridItemForLibraryPanelWidget,
buildGridItemForPanel,
transformSaveModelToScene,
} from './transformSaveModelToScene';
@ -351,6 +352,30 @@ describe('transformSceneToSaveModel', () => {
expect(result.transformations).toBeUndefined();
expect(result.fieldConfig).toBeUndefined();
});
it('given a library panel widget', () => {
const panel = buildGridItemFromPanelSchema({
id: 4,
gridPos: {
h: 8,
w: 12,
x: 0,
y: 0,
},
type: 'add-library-panel',
});
const result = gridItemToPanel(panel);
expect(result.id).toBe(4);
expect(result.gridPos).toEqual({
h: 8,
w: 12,
x: 0,
y: 0,
});
expect(result.type).toBe('add-library-panel');
});
});
describe('Annotations', () => {
@ -897,6 +922,9 @@ describe('transformSceneToSaveModel', () => {
export function buildGridItemFromPanelSchema(panel: Partial<Panel>): SceneGridItemLike {
if (panel.libraryPanel) {
return buildGridItemForLibPanel(new PanelModel(panel))!;
} else if (panel.type === 'add-library-panel') {
return buildGridItemForLibraryPanelWidget(new PanelModel(panel))!;
}
return buildGridItemForPanel(new PanelModel(panel));
}

View File

@ -33,6 +33,7 @@ import { getPanelDataFrames } from 'app/features/dashboard/components/HelpWizard
import { DASHBOARD_SCHEMA_VERSION } from 'app/features/dashboard/state/DashboardMigrator';
import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types';
import { AddLibraryPanelWidget } from '../scene/AddLibraryPanelWidget';
import { DashboardScene } from '../scene/DashboardScene';
import { LibraryVizPanel } from '../scene/LibraryVizPanel';
import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem';
@ -167,6 +168,20 @@ export function gridItemToPanel(gridItem: SceneGridItemLike, isSnapshot = false)
} as Panel;
}
// Handle library panel widget as well and exit early
if (gridItem.state.body instanceof AddLibraryPanelWidget) {
x = gridItem.state.x ?? 0;
y = gridItem.state.y ?? 0;
w = gridItem.state.width ?? 0;
h = gridItem.state.height ?? 0;
return {
id: getPanelIdForVizPanel(gridItem.state.body),
type: 'add-library-panel',
gridPos: { x, y, w, h },
};
}
if (!(gridItem.state.body instanceof VizPanel)) {
throw new Error('SceneGridItem body expected to be VizPanel');
}