From 0b2640e9ff150fb39587e2a167e1ed2efa3804d7 Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Mon, 11 Mar 2024 20:48:27 +0100 Subject: [PATCH] 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 --- .../panel-edit/LibraryVizPanelInfo.tsx | 58 ++++++++++ .../panel-edit/PanelEditor.test.ts | 61 ++++++++++ .../panel-edit/PanelEditor.tsx | 22 +++- .../panel-edit/PanelEditorRenderer.tsx | 15 ++- .../panel-edit/PanelOptions.test.tsx | 63 ++++++++++ .../panel-edit/PanelOptions.tsx | 27 ++++- .../panel-edit/SaveLibraryVizPanelModal.tsx | 107 +++++++++++++++++ .../panel-edit/VizPanelManager.test.tsx | 46 +++++++- .../panel-edit/VizPanelManager.tsx | 36 ++++-- .../scene/DashboardSceneUrlSync.ts | 10 +- .../dashboard-scene/scene/LibraryVizPanel.tsx | 109 +++++++++--------- .../scene/NavToolbarActions.tsx | 44 ++++++- .../scene/PanelMenuBehavior.tsx | 3 +- .../features/dashboard-scene/utils/utils.ts | 7 +- .../PanelEditor/getVisualizationOptions.tsx | 42 ++++++- .../app/features/library-panels/state/api.ts | 27 +++++ 16 files changed, 596 insertions(+), 81 deletions(-) create mode 100644 public/app/features/dashboard-scene/panel-edit/LibraryVizPanelInfo.tsx create mode 100644 public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx create mode 100644 public/app/features/dashboard-scene/panel-edit/SaveLibraryVizPanelModal.tsx diff --git a/public/app/features/dashboard-scene/panel-edit/LibraryVizPanelInfo.tsx b/public/app/features/dashboard-scene/panel-edit/LibraryVizPanelInfo.tsx new file mode 100644 index 00000000000..bd741ee48fd --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/LibraryVizPanelInfo.tsx @@ -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 ( +
+
+ {`Used on ${meta.connectedDashboards} `} + {meta.connectedDashboards === 1 ? 'dashboard' : 'dashboards'} +
+
+ {dateTimeFormat(meta.updated, { format: 'L', timeZone: tz })} by + {meta.updatedBy.avatarUrl && ( + {`Avatar + )} + {meta.updatedBy.name} +
+
+ ); +}; + +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), + }), + }; +}; diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts index fe93e589fb2..ff8c44c2156 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts @@ -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 }); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index ad1494a38e0..7d2a1ceee2e 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -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 { @@ -105,7 +107,10 @@ export class PanelEditor extends SceneObjectBase { 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 { 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 { diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx index 9850f787517..38270d798f8 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx @@ -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) { @@ -57,7 +58,9 @@ export function PanelEditorRenderer({ model }: SceneComponentProps) function VizAndDataPane({ model }: SceneComponentProps) { 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) {
+ {showLibraryPanelSaveModal && libraryPanel && ( + + )} {dataPane && ( <>
diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx new file mode 100644 index 00000000000..3b03ce0e093 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx @@ -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 = ( + + ); + + 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' + ); + }); +}); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx index 988a7db4c9d..e9088b34535 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx @@ -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(({ 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(({ 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(({ 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 ?? []) { diff --git a/public/app/features/dashboard-scene/panel-edit/SaveLibraryVizPanelModal.tsx b/public/app/features/dashboard-scene/panel-edit/SaveLibraryVizPanelModal.tsx new file mode 100644 index 00000000000..d1178a76aa5 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/SaveLibraryVizPanelModal.tsx @@ -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([]); + 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 ( + +
+

+ {'This update will affect '} + + {libraryPanel.state._loadedPanel?.meta?.connectedDashboards}{' '} + {libraryPanel.state._loadedPanel?.meta?.connectedDashboards === 1 ? 'dashboard' : 'dashboards'}. + + The following dashboards using the panel will be affected: +

+ } + placeholder="Search affected dashboards" + value={searchString} + onChange={(e) => setSearchString(e.currentTarget.value)} + /> + {dashState.loading ? ( +

Loading connected dashboards...

+ ) : ( + + + + + + + + {filteredDashboards.map((dashName, i) => ( + + + + ))} + +
Dashboard name
{dashName}
+ )} + + + {isUnsavedPrompt && ( + + )} + + +
+
+ ); +}; diff --git a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx b/public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx index a33670c822a..3e13b91c3bc 100644 --- a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx @@ -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(); diff --git a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx b/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx index f2433db7f95..5f8c78e5b6a 100644 --- a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx +++ b/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx @@ -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 { }), }); } + + 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); + } + }); + } + } } /** diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts index fb02736233e..d46bf382f61 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts @@ -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) { diff --git a/public/app/features/dashboard-scene/scene/LibraryVizPanel.tsx b/public/app/features/dashboard-scene/scene/LibraryVizPanel.tsx index b7692f08a90..6efd02644ef 100644 --- a/public/app/features/dashboard-scene/scene/LibraryVizPanel.tsx +++ b/public/app/features/dashboard-scene/scene/LibraryVizPanel.tsx @@ -51,64 +51,67 @@ export class LibraryVizPanel extends SceneObjectBase { } }; + public setPanelFromLibPanel(libPanel: LibraryPanel) { + if (this.state._loadedPanel?.version === libPanel.version) { + return; + } + + const libPanelModel = new PanelModel(libPanel.model); + + const vizPanelState: VizPanelState = { + title: libPanelModel.title, + key: this.state.panelKey, + options: libPanelModel.options ?? {}, + fieldConfig: libPanelModel.fieldConfig, + pluginId: libPanelModel.type, + pluginVersion: libPanelModel.pluginVersion, + displayMode: libPanelModel.transparent ? 'transparent' : undefined, + description: libPanelModel.description, + $data: createPanelDataProvider(libPanelModel), + menu: new VizPanelMenu({ $behaviors: [panelMenuBehavior] }), + titleItems: [ + new VizPanelLinks({ + rawLinks: libPanelModel.links, + menu: new VizPanelLinksMenu({ $behaviors: [panelLinksBehavior] }), + }), + new PanelNotices(), + ], + }; + + const panel = new VizPanel(vizPanelState); + const gridItem = this.parent; + + if (libPanelModel.repeat && gridItem instanceof SceneGridItem && gridItem.parent instanceof SceneGridLayout) { + this._parent = undefined; + const repeater = new PanelRepeaterGridItem({ + key: gridItem.state.key, + x: gridItem.state.x, + y: gridItem.state.y, + width: libPanelModel.repeatDirection === 'h' ? 24 : gridItem.state.width, + height: gridItem.state.height, + itemHeight: gridItem.state.height, + source: this, + variableName: libPanelModel.repeat, + repeatedPanels: [], + repeatDirection: libPanelModel.repeatDirection === 'h' ? 'h' : 'v', + maxPerRow: libPanelModel.maxPerRow, + }); + gridItem.parent.setState({ + children: gridItem.parent.state.children.map((child) => + child.state.key === gridItem.state.key ? repeater : child + ), + }); + } + + 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); - - if (this.state._loadedPanel?.version === libPanel.version) { - return; - } - - const libPanelModel = new PanelModel(libPanel.model); - - const vizPanelState: VizPanelState = { - title: this.state.title, - key: this.state.panelKey, - options: libPanelModel.options ?? {}, - fieldConfig: libPanelModel.fieldConfig, - pluginId: libPanelModel.type, - pluginVersion: libPanelModel.pluginVersion, - displayMode: libPanelModel.transparent ? 'transparent' : undefined, - description: libPanelModel.description, - $data: createPanelDataProvider(libPanelModel), - menu: new VizPanelMenu({ $behaviors: [panelMenuBehavior] }), - titleItems: [ - new VizPanelLinks({ - rawLinks: libPanelModel.links, - menu: new VizPanelLinksMenu({ $behaviors: [panelLinksBehavior] }), - }), - new PanelNotices(), - ], - }; - - const panel = new VizPanel(vizPanelState); - const gridItem = this.parent; - - if (libPanelModel.repeat && gridItem instanceof SceneGridItem && gridItem.parent instanceof SceneGridLayout) { - this._parent = undefined; - const repeater = new PanelRepeaterGridItem({ - key: gridItem.state.key, - x: gridItem.state.x, - y: gridItem.state.y, - width: libPanelModel.repeatDirection === 'h' ? 24 : gridItem.state.width, - height: gridItem.state.height, - itemHeight: gridItem.state.height, - source: this, - variableName: libPanelModel.repeat, - repeatedPanels: [], - repeatDirection: libPanelModel.repeatDirection === 'h' ? 'h' : 'v', - maxPerRow: libPanelModel.maxPerRow, - }); - gridItem.parent.setState({ - children: gridItem.parent.state.children.map((child) => - child.state.key === gridItem.state.key ? repeater : child - ), - }); - } - - this.setState({ panel, _loadedPanel: libPanel, isLoaded: true }); + this.setPanelFromLibPanel(libPanel); } catch (err) { vizPanel.setState({ _pluginLoadError: `Unable to load library panel: ${this.state.uid}`, diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index 31a30316fde..9bfd56f1555 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -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: () => ( + ), + }); + + toolbarActions.push({ + group: 'main-buttons', + condition: isEditingPanel && isEditingLibraryPanel && !editview && !isViewingPanel, + render: () => ( + + ), + }); + + toolbarActions.push({ + group: 'main-buttons', + condition: isEditing && !isEditingLibraryPanel && (meta.canSave || canSaveAs), render: () => { // if we only can save if (meta.isNew) { diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx index de31e624675..756ccb9258d 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx @@ -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)), }); } diff --git a/public/app/features/dashboard-scene/utils/utils.ts b/public/app/features/dashboard-scene/utils/utils.ts index 00d50ee41d7..780c4b0445c 100644 --- a/public/app/features/dashboard-scene/utils/utils.ts +++ b/public/app/features/dashboard-scene/utils/utils.ts @@ -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; } diff --git a/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx b/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx index 5493b472259..ebcd9658a9f 100644 --- a/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx +++ b/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx @@ -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 ( + libraryPanel.setState({ name: e.currentTarget.value })} + /> + ); + }, + }) + ) + .addItem( + new OptionsPaneItemDescriptor({ + title: 'Information', + render: function renderLibraryPanelInformation() { + return ; + }, + }) + ); + + return descriptor; +} + export interface OptionPaneRenderProps2 { panel: VizPanel; eventBus: EventBus; diff --git a/public/app/features/library-panels/state/api.ts b/public/app/features/library-panels/state/api.ts index 1b3b77a027e..cf224b52bc7 100644 --- a/public/app/features/library-panels/state/api.ts +++ b/public/app/features/library-panels/state/api.ts @@ -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 { + 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; +}