mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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),
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 ?? []) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user