mirror of https://github.com/grafana/grafana
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 testspull/83608/head
parent
393b12f49f
commit
04539ffccb
@ -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 }; |
||||
} |
||||
@ -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`, |
||||
}), |
||||
}; |
||||
}; |
||||
Loading…
Reference in new issue