Dashboard scenes: Editing library panels. (#83223)

* wip

* Refactor find panel by key

* clean up lint, make isLoading optional

* change library panel so that the dashboard key is attached to the panel instead of the library panel

* do not reload everything when the library panel is already loaded

* Progress on library panel options in options pane

* We can skip building the edit scene until we have the library panel loaded

* undo changes to findLibraryPanelbyKey, changes not necessary when the panel has the findable id instead of the library panel

* fix undo

* make sure the save model gets the id from the panel and not the library panel

* remove non necessary links and data providers from dummy loading panel

* change library panel so that the dashboard key is attached to the panel instead of the library panel

* make sure the save model gets the id from the panel and not the library panel

* do not reload everything when the library panel is already loaded

* Fix merge issue

* Clean up

* lint cleanup

* wip saving

* working save

* use title from panel model

* move library panel api functions

* fix issue from merge

* Add confirm save modal. Update library panel to response from save request. Add library panel information box to panel options

* Better naming

* Remove library panel from viz panel state, use sourcePanel.parent instead. Fix edited by time formatting

* Add tests for editing library panels

* implement changed from review feedback

* minor refactor from feedback
This commit is contained in:
Oscar Kilhed
2024-03-11 20:48:27 +01:00
committed by GitHub
parent efbcd53119
commit 0b2640e9ff
16 changed files with 596 additions and 81 deletions

View File

@@ -0,0 +1,58 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2, dateTimeFormat } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { LibraryVizPanel } from '../scene/LibraryVizPanel';
interface Props {
libraryPanel: LibraryVizPanel;
}
export const LibraryVizPanelInfo = ({ libraryPanel }: Props) => {
const styles = useStyles2(getStyles);
const libraryPanelState = libraryPanel.useState();
const tz = libraryPanelState.$timeRange?.getTimeZone();
const meta = libraryPanelState._loadedPanel?.meta;
if (!meta) {
return null;
}
return (
<div className={styles.info}>
<div className={styles.libraryPanelInfo}>
{`Used on ${meta.connectedDashboards} `}
{meta.connectedDashboards === 1 ? 'dashboard' : 'dashboards'}
</div>
<div className={styles.libraryPanelInfo}>
{dateTimeFormat(meta.updated, { format: 'L', timeZone: tz })} by
{meta.updatedBy.avatarUrl && (
<img className={styles.userAvatar} src={meta.updatedBy.avatarUrl} alt={`Avatar for ${meta.updatedBy.name}`} />
)}
{meta.updatedBy.name}
</div>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
info: css({
lineHeight: 1,
}),
libraryPanelInfo: css({
color: theme.colors.text.secondary,
fontSize: theme.typography.bodySmall.fontSize,
}),
userAvatar: css({
borderRadius: theme.shape.radius.circle,
boxSizing: 'content-box',
width: '22px',
height: '22px',
paddingLeft: theme.spacing(1),
paddingRight: theme.spacing(1),
}),
};
};

View File

@@ -1,7 +1,10 @@
import { PanelPlugin, PanelPluginMeta, PluginType } from '@grafana/data';
import { SceneGridItem, SceneGridLayout, VizPanel } from '@grafana/scenes';
import * as libAPI from 'app/features/library-panels/state/api';
import { DashboardScene } from '../scene/DashboardScene';
import { LibraryVizPanel } from '../scene/LibraryVizPanel';
import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel';
import { activateFullSceneTree } from '../utils/test-utils';
import { buildPanelEditScene } from './PanelEditor';
@@ -56,6 +59,64 @@ describe('PanelEditor', () => {
});
});
describe('Handling library panels', () => {
it('should call the api with the updated panel', async () => {
pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true });
const panel = new VizPanel({
key: 'panel-1',
pluginId: 'text',
});
const libraryPanelModel = {
title: 'title',
uid: 'uid',
name: 'libraryPanelName',
model: vizPanelToPanel(panel),
type: 'panel',
version: 1,
};
const libraryPanel = new LibraryVizPanel({
isLoaded: true,
title: libraryPanelModel.title,
uid: libraryPanelModel.uid,
name: libraryPanelModel.name,
panelKey: panel.state.key!,
panel: panel,
_loadedPanel: libraryPanelModel,
});
const apiCall = jest
.spyOn(libAPI, 'updateLibraryVizPanel')
.mockResolvedValue({ type: 'panel', ...libAPI.libraryVizPanelToSaveModel(libraryPanel), version: 2 });
const editScene = buildPanelEditScene(panel);
const gridItem = new SceneGridItem({ body: libraryPanel });
const scene = new DashboardScene({
editPanel: editScene,
isEditing: true,
body: new SceneGridLayout({
children: [gridItem],
}),
});
activateFullSceneTree(scene);
editScene.state.vizManager.state.panel.setState({ title: 'changed title' });
(editScene.state.vizManager.state.sourcePanel.resolve().parent as LibraryVizPanel).setState({
name: 'changed name',
});
editScene.state.vizManager.commitChanges();
const calledWith = apiCall.mock.calls[0][0].state;
expect(calledWith.panel?.state.title).toBe('changed title');
expect(calledWith.name).toBe('changed name');
await new Promise(process.nextTick); // Wait for mock api to return and update the library panel
expect((gridItem.state.body as LibraryVizPanel).state._loadedPanel?.version).toBe(2);
});
});
describe('PanelDataPane', () => {
it('should not exist if panel is skipDataQuery', () => {
pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true });

View File

@@ -4,8 +4,9 @@ import { NavIndex } from '@grafana/data';
import { config, locationService } from '@grafana/runtime';
import { SceneGridItem, SceneGridLayout, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
import { LibraryVizPanel } from '../scene/LibraryVizPanel';
import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem';
import { getPanelIdForVizPanel, getDashboardSceneFor } from '../utils/utils';
import { getDashboardSceneFor, getPanelIdForVizPanel } from '../utils/utils';
import { PanelDataPane } from './PanelDataPane/PanelDataPane';
import { PanelEditorRenderer } from './PanelEditorRenderer';
@@ -18,6 +19,7 @@ export interface PanelEditorState extends SceneObjectState {
optionsPane: PanelOptionsPane;
dataPane?: PanelDataPane;
vizManager: VizPanelManager;
showLibraryPanelSaveModal?: boolean;
}
export class PanelEditor extends SceneObjectBase<PanelEditorState> {
@@ -105,7 +107,10 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
const normalToRepeat = !this._initialRepeatOptions.repeat && panelManager.state.repeat;
const repeatToNormal = this._initialRepeatOptions.repeat && !panelManager.state.repeat;
if (sourcePanelParent instanceof SceneGridItem) {
if (sourcePanelParent instanceof LibraryVizPanel) {
// Library panels handled separately
return;
} else if (sourcePanelParent instanceof SceneGridItem) {
if (normalToRepeat) {
this.replaceSceneGridItemWithPanelRepeater(sourcePanelParent);
} else {
@@ -197,6 +202,19 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
height,
});
}
public onSaveLibraryPanel = () => {
this.setState({ showLibraryPanelSaveModal: true });
};
public onConfirmSaveLibraryPanel = () => {
this.state.vizManager.commitChanges();
locationService.partial({ editPanel: null });
};
public onDismissLibraryPanelModal = () => {
this.setState({ showLibraryPanelSaveModal: false });
};
}
export function buildPanelEditScene(panel: VizPanel): PanelEditor {

View File

@@ -6,9 +6,10 @@ import { SceneComponentProps } from '@grafana/scenes';
import { Button, ToolbarButton, useStyles2 } from '@grafana/ui';
import { NavToolbarActions } from '../scene/NavToolbarActions';
import { getDashboardSceneFor } from '../utils/utils';
import { getDashboardSceneFor, getLibraryPanel } from '../utils/utils';
import { PanelEditor } from './PanelEditor';
import { SaveLibraryVizPanelModal } from './SaveLibraryVizPanelModal';
import { useSnappingSplitter } from './splitter/useSnappingSplitter';
export function PanelEditorRenderer({ model }: SceneComponentProps<PanelEditor>) {
@@ -57,7 +58,9 @@ export function PanelEditorRenderer({ model }: SceneComponentProps<PanelEditor>)
function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
const dashboard = getDashboardSceneFor(model);
const { vizManager, dataPane } = model.useState();
const { vizManager, dataPane, showLibraryPanelSaveModal } = model.useState();
const { sourcePanel } = vizManager.useState();
const libraryPanel = getLibraryPanel(sourcePanel.resolve());
const { controls } = dashboard.useState();
const styles = useStyles2(getStyles);
@@ -82,6 +85,14 @@ function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
<div {...primaryProps}>
<vizManager.Component model={vizManager} />
</div>
{showLibraryPanelSaveModal && libraryPanel && (
<SaveLibraryVizPanelModal
libraryPanel={libraryPanel}
onDismiss={model.onDismissLibraryPanelModal}
onConfirm={model.onConfirmSaveLibraryPanel}
onDiscard={model.onDiscard}
></SaveLibraryVizPanelModal>
)}
{dataPane && (
<>
<div {...splitterProps} />

View File

@@ -0,0 +1,63 @@
import { act, fireEvent, render } from '@testing-library/react';
import React from 'react';
import { SceneGridItem, VizPanel } from '@grafana/scenes';
import { OptionFilter } from 'app/features/dashboard/components/PanelEditor/OptionsPaneOptions';
import { LibraryVizPanel } from '../scene/LibraryVizPanel';
import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel';
import { PanelOptions } from './PanelOptions';
import { VizPanelManager } from './VizPanelManager';
jest.mock('react-router-dom', () => ({
useLocation: () => ({
pathname: '',
}),
}));
describe('PanelOptions', () => {
it('gets library panel options when the editing a library panel', async () => {
const panel = new VizPanel({
key: 'panel-1',
pluginId: 'text',
});
const libraryPanelModel = {
title: 'title',
uid: 'uid',
name: 'libraryPanelName',
model: vizPanelToPanel(panel),
type: 'panel',
version: 1,
};
const libraryPanel = new LibraryVizPanel({
isLoaded: true,
title: libraryPanelModel.title,
uid: libraryPanelModel.uid,
name: libraryPanelModel.name,
panelKey: panel.state.key!,
panel: panel,
_loadedPanel: libraryPanelModel,
});
new SceneGridItem({ body: libraryPanel });
const panelManger = VizPanelManager.createFor(panel);
const panelOptions = (
<PanelOptions vizManager={panelManger} searchQuery="" listMode={OptionFilter.All}></PanelOptions>
);
const r = render(panelOptions);
const input = await r.findByTestId('library panel name input');
await act(async () => {
fireEvent.blur(input, { target: { value: 'new library panel name' } });
});
expect((panelManger.state.sourcePanel.resolve().parent as LibraryVizPanel).state.name).toBe(
'new library panel name'
);
});
});

View File

@@ -4,7 +4,12 @@ import { sceneGraph } from '@grafana/scenes';
import { OptionFilter, renderSearchHits } from 'app/features/dashboard/components/PanelEditor/OptionsPaneOptions';
import { getFieldOverrideCategories } from 'app/features/dashboard/components/PanelEditor/getFieldOverrideElements';
import { getPanelFrameCategory2 } from 'app/features/dashboard/components/PanelEditor/getPanelFrameOptions';
import { getVisualizationOptions2 } from 'app/features/dashboard/components/PanelEditor/getVisualizationOptions';
import {
getLibraryVizPanelOptionsCategory,
getVisualizationOptions2,
} from 'app/features/dashboard/components/PanelEditor/getVisualizationOptions';
import { LibraryVizPanel } from '../scene/LibraryVizPanel';
import { VizPanelManager } from './VizPanelManager';
@@ -15,7 +20,8 @@ interface Props {
}
export const PanelOptions = React.memo<Props>(({ vizManager, searchQuery, listMode }) => {
const { panel, repeat } = vizManager.useState();
const { panel, sourcePanel, repeat } = vizManager.useState();
const parent = sourcePanel.resolve().parent;
const { data } = sceneGraph.getData(panel).useState();
const { options, fieldConfig } = panel.useState();
@@ -40,6 +46,13 @@ export const PanelOptions = React.memo<Props>(({ vizManager, searchQuery, listMo
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [panel, options, fieldConfig]);
const libraryPanelOptions = useMemo(() => {
if (parent instanceof LibraryVizPanel) {
return getLibraryVizPanelOptionsCategory(parent);
}
return;
}, [parent]);
const justOverrides = useMemo(
() =>
getFieldOverrideCategories(
@@ -62,11 +75,19 @@ export const PanelOptions = React.memo<Props>(({ vizManager, searchQuery, listMo
if (isSearching) {
mainBoxElements.push(
renderSearchHits([panelFrameOptions, ...(visualizationOptions ?? [])], justOverrides, searchQuery)
renderSearchHits(
[panelFrameOptions, ...(libraryPanelOptions ? [libraryPanelOptions] : []), ...(visualizationOptions ?? [])],
justOverrides,
searchQuery
)
);
} else {
switch (listMode) {
case OptionFilter.All:
if (libraryPanelOptions) {
// Library Panel options first
mainBoxElements.push(libraryPanelOptions.render());
}
mainBoxElements.push(panelFrameOptions.render());
for (const item of visualizationOptions ?? []) {

View File

@@ -0,0 +1,107 @@
import React, { useCallback, useState } from 'react';
import { useAsync, useDebounce } from 'react-use';
import { Button, Icon, Input, Modal, useStyles2 } from '@grafana/ui';
import { getConnectedDashboards } from 'app/features/library-panels/state/api';
import { getModalStyles } from 'app/features/library-panels/styles';
import { LibraryVizPanel } from '../scene/LibraryVizPanel';
interface Props {
libraryPanel: LibraryVizPanel;
isUnsavedPrompt?: boolean;
onConfirm: () => void;
onDismiss: () => void;
onDiscard: () => void;
}
export const SaveLibraryVizPanelModal = ({ libraryPanel, isUnsavedPrompt, onDismiss, onConfirm, onDiscard }: Props) => {
const [searchString, setSearchString] = useState('');
const dashState = useAsync(async () => {
const searchHits = await getConnectedDashboards(libraryPanel.state.uid);
if (searchHits.length > 0) {
return searchHits.map((dash) => dash.title);
}
return [];
}, [libraryPanel.state.uid]);
const [filteredDashboards, setFilteredDashboards] = useState<string[]>([]);
useDebounce(
() => {
if (!dashState.value) {
return setFilteredDashboards([]);
}
return setFilteredDashboards(
dashState.value.filter((dashName) => dashName.toLowerCase().includes(searchString.toLowerCase()))
);
},
300,
[dashState.value, searchString]
);
const styles = useStyles2(getModalStyles);
const discardAndClose = useCallback(() => {
onDiscard();
}, [onDiscard]);
const title = isUnsavedPrompt ? 'Unsaved library panel changes' : 'Save library panel';
return (
<Modal title={title} icon="save" onDismiss={onDismiss} isOpen={true}>
<div>
<p className={styles.textInfo}>
{'This update will affect '}
<strong>
{libraryPanel.state._loadedPanel?.meta?.connectedDashboards}{' '}
{libraryPanel.state._loadedPanel?.meta?.connectedDashboards === 1 ? 'dashboard' : 'dashboards'}.
</strong>
The following dashboards using the panel will be affected:
</p>
<Input
className={styles.dashboardSearch}
prefix={<Icon name="search" />}
placeholder="Search affected dashboards"
value={searchString}
onChange={(e) => setSearchString(e.currentTarget.value)}
/>
{dashState.loading ? (
<p>Loading connected dashboards...</p>
) : (
<table className={styles.myTable}>
<thead>
<tr>
<th>Dashboard name</th>
</tr>
</thead>
<tbody>
{filteredDashboards.map((dashName, i) => (
<tr key={`dashrow-${i}`}>
<td>{dashName}</td>
</tr>
))}
</tbody>
</table>
)}
<Modal.ButtonRow>
<Button variant="secondary" onClick={onDismiss} fill="outline">
Cancel
</Button>
{isUnsavedPrompt && (
<Button variant="destructive" onClick={discardAndClose}>
Discard
</Button>
)}
<Button
onClick={() => {
onConfirm();
}}
>
Update all
</Button>
</Modal.ButtonRow>
</div>
</Modal>
);
};

View File

@@ -2,15 +2,18 @@ import { map, of } from 'rxjs';
import { DataQueryRequest, DataSourceApi, DataSourceInstanceSettings, LoadingState, PanelData } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { SceneQueryRunner, VizPanel } from '@grafana/scenes';
import { SceneGridItem, SceneQueryRunner, VizPanel } from '@grafana/scenes';
import { DataQuery, DataSourceJsonData, DataSourceRef } from '@grafana/schema';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { InspectTab } from 'app/features/inspector/types';
import * as libAPI from 'app/features/library-panels/state/api';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types';
import { LibraryVizPanel } from '../scene/LibraryVizPanel';
import { PanelTimeRange, PanelTimeRangeState } from '../scene/PanelTimeRange';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel';
import { DashboardModelCompatibilityWrapper } from '../utils/DashboardModelCompatibilityWrapper';
import { findVizPanelByKey } from '../utils/utils';
@@ -208,6 +211,47 @@ describe('VizPanelManager', () => {
});
});
describe('library panels', () => {
it('saves library panels on commit', () => {
const panel = new VizPanel({
key: 'panel-1',
pluginId: 'text',
});
const libraryPanelModel = {
title: 'title',
uid: 'uid',
name: 'libraryPanelName',
model: vizPanelToPanel(panel),
type: 'panel',
version: 1,
};
const libraryPanel = new LibraryVizPanel({
isLoaded: true,
title: libraryPanelModel.title,
uid: libraryPanelModel.uid,
name: libraryPanelModel.name,
panelKey: panel.state.key!,
panel: panel,
_loadedPanel: libraryPanelModel,
});
new SceneGridItem({ body: libraryPanel });
const panelManager = VizPanelManager.createFor(panel);
const apiCall = jest
.spyOn(libAPI, 'updateLibraryVizPanel')
.mockResolvedValue({ type: 'panel', ...libAPI.libraryVizPanelToSaveModel(libraryPanel) });
panelManager.state.panel.setState({ title: 'new title' });
panelManager.commitChanges();
expect(apiCall.mock.calls[0][0].state.panel?.state.title).toBe('new title');
});
});
describe('query options', () => {
beforeEach(() => {
store.setObject.mockClear();

View File

@@ -13,28 +13,30 @@ import {
} from '@grafana/data';
import { config, getDataSourceSrv, locationService } from '@grafana/runtime';
import {
SceneObjectState,
VizPanel,
SceneObjectBase,
SceneComponentProps,
sceneUtils,
DeepPartial,
SceneQueryRunner,
sceneGraph,
SceneDataTransformer,
PanelBuilders,
SceneComponentProps,
SceneDataTransformer,
SceneGridItem,
SceneObjectBase,
SceneObjectRef,
SceneObjectState,
SceneQueryRunner,
VizPanel,
sceneGraph,
sceneUtils,
} from '@grafana/scenes';
import { DataQuery, DataTransformerConfig, Panel } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
import { getPluginVersion } from 'app/features/dashboard/state/PanelModel';
import { getLastUsedDatasourceFromStorage } from 'app/features/dashboard/utils/dashboard';
import { storeLastUsedDataSourceInLocalStorage } from 'app/features/datasources/components/picker/utils';
import { updateLibraryVizPanel } from 'app/features/library-panels/state/api';
import { updateQueries } from 'app/features/query/state/updateQueries';
import { GrafanaQuery } from 'app/plugins/datasource/grafana/types';
import { QueryGroupOptions } from 'app/types';
import { LibraryVizPanel } from '../scene/LibraryVizPanel';
import { PanelRepeaterGridItem, RepeatDirection } from '../scene/PanelRepeaterGridItem';
import { PanelTimeRange, PanelTimeRangeState } from '../scene/PanelTimeRange';
import { gridItemToPanel } from '../serialization/transformSceneToSaveModel';
@@ -346,6 +348,24 @@ export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> {
}),
});
}
if (sourcePanel.parent instanceof LibraryVizPanel) {
if (sourcePanel.parent.parent instanceof SceneGridItem) {
const newLibPanel = sourcePanel.parent.clone({
panel: this.state.panel.clone({
$data: this.state.$data?.clone(),
}),
});
sourcePanel.parent.parent.setState({
body: newLibPanel,
});
updateLibraryVizPanel(newLibPanel!).then((p) => {
if (sourcePanel.parent instanceof LibraryVizPanel) {
newLibPanel.setPanelFromLibPanel(p);
}
});
}
}
}
/**

View File

@@ -14,7 +14,7 @@ import appEvents from 'app/core/app_events';
import { PanelInspectDrawer } from '../inspect/PanelInspectDrawer';
import { buildPanelEditScene } from '../panel-edit/PanelEditor';
import { createDashboardEditViewFor } from '../settings/utils';
import { findVizPanelByKey, getDashboardSceneFor, isLibraryPanelChild, isPanelClone } from '../utils/utils';
import { findVizPanelByKey, getDashboardSceneFor, getLibraryPanel, isPanelClone } from '../utils/utils';
import { DashboardScene, DashboardSceneState } from './DashboardScene';
import { LibraryVizPanel } from './LibraryVizPanel';
@@ -66,7 +66,7 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
return;
}
if (isLibraryPanelChild(panel)) {
if (getLibraryPanel(panel)) {
this._handleLibraryPanel(panel, (p) => {
if (p.state.key === undefined) {
// Inspect drawer require a panel key to be set
@@ -105,7 +105,7 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
return;
}
if (isLibraryPanelChild(panel)) {
if (getLibraryPanel(panel)) {
this._handleLibraryPanel(panel, (p) => this._buildLibraryPanelViewScene(p));
return;
}
@@ -119,6 +119,7 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
if (typeof values.editPanel === 'string') {
const panel = findVizPanelByKey(this._scene, values.editPanel);
if (!panel) {
console.warn(`Panel ${values.editPanel} not found`);
return;
}
@@ -126,10 +127,11 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
if (!isEditing) {
this._scene.onEnterEditMode();
}
if (isLibraryPanelChild(panel)) {
if (getLibraryPanel(panel)) {
this._handleLibraryPanel(panel, (p) => {
this._scene.setState({ editPanel: buildPanelEditScene(p) });
});
return;
}
update.editPanel = buildPanelEditScene(panel);
} else if (editPanel && values.editPanel === null) {

View File

@@ -51,12 +51,7 @@ export class LibraryVizPanel extends SceneObjectBase<LibraryVizPanelState> {
}
};
private async loadLibraryPanelFromPanelModel() {
let vizPanel = this.state.panel!;
try {
const libPanel = await getLibraryPanel(this.state.uid, true);
public setPanelFromLibPanel(libPanel: LibraryPanel) {
if (this.state._loadedPanel?.version === libPanel.version) {
return;
}
@@ -64,7 +59,7 @@ export class LibraryVizPanel extends SceneObjectBase<LibraryVizPanelState> {
const libPanelModel = new PanelModel(libPanel.model);
const vizPanelState: VizPanelState = {
title: this.state.title,
title: libPanelModel.title,
key: this.state.panelKey,
options: libPanelModel.options ?? {},
fieldConfig: libPanelModel.fieldConfig,
@@ -108,7 +103,15 @@ export class LibraryVizPanel extends SceneObjectBase<LibraryVizPanelState> {
});
}
this.setState({ panel, _loadedPanel: libPanel, isLoaded: true });
this.setState({ panel, _loadedPanel: libPanel, isLoaded: true, name: libPanel.name });
}
private async loadLibraryPanelFromPanelModel() {
let vizPanel = this.state.panel!;
try {
const libPanel = await getLibraryPanel(this.state.uid, true);
this.setPanelFromLibPanel(libPanel);
} catch (err) {
vizPanel.setState({
_pluginLoadError: `Unable to load library panel: ${this.state.uid}`,

View File

@@ -18,6 +18,7 @@ import { dynamicDashNavActions } from '../utils/registerDynamicDashNavAction';
import { DashboardScene } from './DashboardScene';
import { GoToSnapshotOriginButton } from './GoToSnapshotOriginButton';
import { LibraryVizPanel } from './LibraryVizPanel';
interface Props {
dashboard: DashboardScene;
@@ -56,6 +57,9 @@ export function ToolbarActions({ dashboard }: Props) {
const buttonWithExtraMargin = useStyles2(getStyles);
const isEditingPanel = Boolean(editPanel);
const isViewingPanel = Boolean(viewPanelScene);
const isEditingLibraryPanel = Boolean(
editPanel?.state.vizManager.state.sourcePanel.resolve().parent instanceof LibraryVizPanel
);
const hasCopiedPanel = Boolean(copiedPanel);
toolbarActions.push({
@@ -233,7 +237,7 @@ export function ToolbarActions({ dashboard }: Props) {
toolbarActions.push({
group: 'back-button',
condition: isViewingPanel || isEditingPanel,
condition: (isViewingPanel || isEditingPanel) && !isEditingLibraryPanel,
render: () => (
<Button
onClick={() => {
@@ -347,7 +351,7 @@ export function ToolbarActions({ dashboard }: Props) {
toolbarActions.push({
group: 'main-buttons',
condition: isEditingPanel && !editview && !meta.isNew && !isViewingPanel,
condition: isEditingPanel && !isEditingLibraryPanel && !editview && !meta.isNew && !isViewingPanel,
render: () => (
<Button
onClick={editPanel?.onDiscard}
@@ -364,7 +368,41 @@ export function ToolbarActions({ dashboard }: Props) {
toolbarActions.push({
group: 'main-buttons',
condition: isEditing && (meta.canSave || canSaveAs),
condition: isEditingPanel && isEditingLibraryPanel && !editview && !isViewingPanel,
render: () => (
<Button
onClick={editPanel?.onDiscard}
tooltip="Discard library panel changes"
size="sm"
key="discardLibraryPanel"
fill="outline"
variant="destructive"
>
Discard library panel changes
</Button>
),
});
toolbarActions.push({
group: 'main-buttons',
condition: isEditingPanel && isEditingLibraryPanel && !editview && !isViewingPanel,
render: () => (
<Button
onClick={editPanel?.onSaveLibraryPanel}
tooltip="Save library panel"
size="sm"
key="saveLibraryPanel"
fill="outline"
variant="primary"
>
Save library panel
</Button>
),
});
toolbarActions.push({
group: 'main-buttons',
condition: isEditing && !isEditingLibraryPanel && (meta.canSave || canSaveAs),
render: () => {
// if we only can save
if (meta.isNew) {

View File

@@ -43,7 +43,6 @@ export function panelMenuBehavior(menu: VizPanelMenu, isRepeat = false) {
const items: PanelMenuItem[] = [];
const moreSubMenu: PanelMenuItem[] = [];
const panelId = getPanelIdForVizPanel(panel);
const dashboard = getDashboardSceneFor(panel);
const { isEmbedded } = dashboard.state.meta;
const exploreMenuItem = await getExploreMenuItem(panel);
@@ -72,7 +71,7 @@ export function panelMenuBehavior(menu: VizPanelMenu, isRepeat = false) {
iconClassName: 'eye',
shortcut: 'e',
onClick: () => DashboardInteractions.panelMenuItemClicked('edit'),
href: getEditPanelUrl(panelId),
href: getEditPanelUrl(getPanelIdForVizPanel(panel)),
});
}

View File

@@ -239,6 +239,9 @@ export function getDefaultRow(dashboard: DashboardScene): SceneGridRow {
});
}
export function isLibraryPanelChild(vizPanel: VizPanel) {
return vizPanel.parent instanceof LibraryVizPanel;
export function getLibraryPanel(vizPanel: VizPanel): LibraryVizPanel | undefined {
if (vizPanel.parent instanceof LibraryVizPanel) {
return vizPanel.parent;
}
return;
}

View File

@@ -11,11 +11,14 @@ import {
} from '@grafana/data';
import { PanelOptionsSupplier } from '@grafana/data/src/panel/PanelPlugin';
import {
isNestedPanelOptions,
NestedValueAccess,
PanelOptionsEditorBuilder,
isNestedPanelOptions,
} from '@grafana/data/src/utils/OptionsUIBuilders';
import { VizPanel } from '@grafana/scenes';
import { Input } from '@grafana/ui';
import { LibraryVizPanelInfo } from 'app/features/dashboard-scene/panel-edit/LibraryVizPanelInfo';
import { LibraryVizPanel } from 'app/features/dashboard-scene/scene/LibraryVizPanel';
import { getDataLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv';
import { OptionsPaneCategoryDescriptor } from './OptionsPaneCategoryDescriptor';
@@ -148,6 +151,43 @@ export function getVisualizationOptions(props: OptionPaneRenderProps): OptionsPa
return Object.values(categoryIndex);
}
export function getLibraryVizPanelOptionsCategory(libraryPanel: LibraryVizPanel): OptionsPaneCategoryDescriptor {
const descriptor = new OptionsPaneCategoryDescriptor({
title: 'Library panel options',
id: 'Library panel options',
isOpenDefault: true,
});
descriptor
.addItem(
new OptionsPaneItemDescriptor({
title: 'Name',
value: libraryPanel,
popularRank: 1,
render: function renderName() {
return (
<Input
id="LibraryPanelFrameName"
data-testid="library panel name input"
defaultValue={libraryPanel.state.name}
onBlur={(e) => libraryPanel.setState({ name: e.currentTarget.value })}
/>
);
},
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Information',
render: function renderLibraryPanelInformation() {
return <LibraryVizPanelInfo libraryPanel={libraryPanel} />;
},
})
);
return descriptor;
}
export interface OptionPaneRenderProps2 {
panel: VizPanel;
eventBus: EventBus;

View File

@@ -2,6 +2,8 @@ import { lastValueFrom } from 'rxjs';
import { defaultDashboard } from '@grafana/schema';
import { DashboardModel } from 'app/features/dashboard/state';
import { LibraryVizPanel } from 'app/features/dashboard-scene/scene/LibraryVizPanel';
import { vizPanelToPanel } from 'app/features/dashboard-scene/serialization/transformSceneToSaveModel';
import { getBackendSrv } from '../../../core/services/backend_srv';
import { DashboardSearchItem } from '../../search/types';
@@ -133,3 +135,28 @@ export async function getConnectedDashboards(uid: string): Promise<DashboardSear
return searchHits;
}
export function libraryVizPanelToSaveModel(libraryPanel: LibraryVizPanel) {
const { panel, uid, name, _loadedPanel } = libraryPanel.state;
const saveModel = {
uid,
folderUID: _loadedPanel?.folderUid,
name,
version: _loadedPanel?.version || 0,
model: vizPanelToPanel(panel!),
kind: LibraryElementKind.Panel,
};
return saveModel;
}
export async function updateLibraryVizPanel(libraryPanel: LibraryVizPanel): Promise<LibraryElementDTO> {
const { uid, folderUID, name, model, version, kind } = libraryVizPanelToSaveModel(libraryPanel);
const { result } = await getBackendSrv().patch(`/api/library-elements/${uid}`, {
folderUID,
name,
model,
version,
kind,
});
return result;
}