diff --git a/pkg/services/librarypanels/api.go b/pkg/services/librarypanels/api.go index 8e07d9e318a..3c7aa6d2307 100644 --- a/pkg/services/librarypanels/api.go +++ b/pkg/services/librarypanels/api.go @@ -128,5 +128,8 @@ func toLibraryPanelError(err error, message string) response.Response { if errors.Is(err, models.ErrFolderAccessDenied) { return response.Error(403, models.ErrFolderAccessDenied.Error(), err) } + if errors.Is(err, errLibraryPanelHasConnectedDashboards) { + return response.Error(403, errLibraryPanelHasConnectedDashboards.Error(), err) + } return response.Error(500, message, err) } diff --git a/pkg/services/librarypanels/database.go b/pkg/services/librarypanels/database.go index 4814328a8c7..7eca1df5fc5 100644 --- a/pkg/services/librarypanels/database.go +++ b/pkg/services/librarypanels/database.go @@ -173,8 +173,14 @@ func (lps *LibraryPanelService) deleteLibraryPanel(c *models.ReqContext, uid str if err := lps.requirePermissionsOnFolder(c.SignedInUser, panel.FolderID); err != nil { return err } - if _, err := session.Exec("DELETE FROM library_panel_dashboard WHERE librarypanel_id=?", panel.ID); err != nil { + var dashIDs []struct { + DashboardID int64 `xorm:"dashboard_id"` + } + sql := "SELECT dashboard_id FROM library_panel_dashboard WHERE librarypanel_id=?" + if err := session.SQL(sql, panel.ID).Find(&dashIDs); err != nil { return err + } else if len(dashIDs) > 0 { + return errLibraryPanelHasConnectedDashboards } result, err := session.Exec("DELETE FROM library_panel WHERE id=?", panel.ID) diff --git a/pkg/services/librarypanels/librarypanels_delete_test.go b/pkg/services/librarypanels/librarypanels_delete_test.go index d32dcf6ebeb..6e08cb7b48e 100644 --- a/pkg/services/librarypanels/librarypanels_delete_test.go +++ b/pkg/services/librarypanels/librarypanels_delete_test.go @@ -30,4 +30,15 @@ func TestDeleteLibraryPanel(t *testing.T) { resp := sc.service.deleteHandler(sc.reqContext) require.Equal(t, 404, resp.Status()) }) + + scenarioWithLibraryPanel(t, "When an admin tries to delete a library panel that is connected, it should fail", + func(t *testing.T, sc scenarioContext) { + sc.reqContext.ReplaceAllParams(map[string]string{":uid": sc.initialResult.Result.UID, ":dashboardId": "1"}) + resp := sc.service.connectHandler(sc.reqContext) + require.Equal(t, 200, resp.Status()) + + sc.reqContext.ReplaceAllParams(map[string]string{":uid": sc.initialResult.Result.UID}) + resp = sc.service.deleteHandler(sc.reqContext) + require.Equal(t, 403, resp.Status()) + }) } diff --git a/pkg/services/librarypanels/models.go b/pkg/services/librarypanels/models.go index cbd97b78c88..87b9ef00bf4 100644 --- a/pkg/services/librarypanels/models.go +++ b/pkg/services/librarypanels/models.go @@ -111,6 +111,8 @@ var ( ErrFolderHasConnectedLibraryPanels = errors.New("folder contains library panels that are linked to dashboards") // errLibraryPanelVersionMismatch is an error for when a library panel has been changed by someone else. errLibraryPanelVersionMismatch = errors.New("the library panel has been changed by someone else") + // errLibraryPanelHasConnectedDashboards is an error for when an user deletes a library panel that is connected to library panels. + errLibraryPanelHasConnectedDashboards = errors.New("the library panel is linked to dashboards") ) // Commands diff --git a/public/app/features/library-panels/components/DeleteLibraryPanelModal/DeleteLibraryPanelModal.tsx b/public/app/features/library-panels/components/DeleteLibraryPanelModal/DeleteLibraryPanelModal.tsx new file mode 100644 index 00000000000..50b6caf9445 --- /dev/null +++ b/public/app/features/library-panels/components/DeleteLibraryPanelModal/DeleteLibraryPanelModal.tsx @@ -0,0 +1,91 @@ +import React, { FC, useEffect, useMemo, useReducer } from 'react'; +import { Button, HorizontalGroup, Modal, useStyles } from '@grafana/ui'; +import { LoadingState } from '@grafana/data'; + +import { LibraryPanelDTO } from '../../types'; +import { asyncDispatcher } from '../LibraryPanelsView/actions'; +import { deleteLibraryPanelModalReducer, initialDeleteLibraryPanelModalState } from './reducer'; +import { getConnectedDashboards } from './actions'; +import { getModalStyles } from '../../styles'; + +interface Props { + libraryPanel: LibraryPanelDTO; + onConfirm: () => void; + onDismiss: () => void; +} + +export const DeleteLibraryPanelModal: FC = ({ libraryPanel, onDismiss, onConfirm }) => { + const styles = useStyles(getModalStyles); + const [{ dashboardTitles, loadingState }, dispatch] = useReducer( + deleteLibraryPanelModalReducer, + initialDeleteLibraryPanelModalState + ); + const asyncDispatch = useMemo(() => asyncDispatcher(dispatch), [dispatch]); + useEffect(() => { + asyncDispatch(getConnectedDashboards(libraryPanel)); + }, []); + const connected = Boolean(dashboardTitles.length); + const done = loadingState === LoadingState.Done; + + return ( + + {!done ? : null} + {done ? ( +
+ {connected ? : null} + {!connected ? : null} + + + + + +
+ ) : null} +
+ ); +}; + +const LoadingIndicator: FC = () => Loading library panel...; + +const Confirm: FC = () => { + const styles = useStyles(getModalStyles); + + return
Do you want to delete this panel?
; +}; + +const HasConnectedDashboards: FC<{ dashboardTitles: string[] }> = ({ dashboardTitles }) => { + const styles = useStyles(getModalStyles); + const suffix = dashboardTitles.length === 1 ? 'dashboard.' : 'dashboards.'; + const message = `${dashboardTitles.length} ${suffix}`; + if (dashboardTitles.length === 0) { + return null; + } + + return ( +
+

+ {'This library panel can not be deleted because it is connected to '} + {message} + {' Remove the library panel from the dashboards listed below and retry.'} +

+ + + + + + + + {dashboardTitles.map((title, i) => ( + + + + ))} + +
Dashboard name
{title}
+
+ ); +}; diff --git a/public/app/features/library-panels/components/DeleteLibraryPanelModal/actions.ts b/public/app/features/library-panels/components/DeleteLibraryPanelModal/actions.ts new file mode 100644 index 00000000000..c8955fdff7e --- /dev/null +++ b/public/app/features/library-panels/components/DeleteLibraryPanelModal/actions.ts @@ -0,0 +1,17 @@ +import { DispatchResult, LibraryPanelDTO } from '../../types'; +import { getLibraryPanelConnectedDashboards } from '../../state/api'; +import { getBackendSrv } from '../../../../core/services/backend_srv'; +import { searchCompleted } from './reducer'; + +export function getConnectedDashboards(libraryPanel: LibraryPanelDTO): DispatchResult { + return async function (dispatch) { + const connectedDashboards = await getLibraryPanelConnectedDashboards(libraryPanel.uid); + if (!connectedDashboards.length) { + dispatch(searchCompleted({ dashboards: [] })); + return; + } + + const dashboards = await getBackendSrv().search({ dashboardIds: connectedDashboards }); + dispatch(searchCompleted({ dashboards })); + }; +} diff --git a/public/app/features/library-panels/components/DeleteLibraryPanelModal/reducer.test.ts b/public/app/features/library-panels/components/DeleteLibraryPanelModal/reducer.test.ts new file mode 100644 index 00000000000..78d9265edce --- /dev/null +++ b/public/app/features/library-panels/components/DeleteLibraryPanelModal/reducer.test.ts @@ -0,0 +1,35 @@ +import { reducerTester } from '../../../../../test/core/redux/reducerTester'; +import { + deleteLibraryPanelModalReducer, + DeleteLibraryPanelModalState, + initialDeleteLibraryPanelModalState, + searchCompleted, +} from './reducer'; +import { LoadingState } from '@grafana/data'; + +describe('deleteLibraryPanelModalReducer', () => { + describe('when created', () => { + it('then initial state should be correct', () => { + reducerTester() + .givenReducer(deleteLibraryPanelModalReducer, initialDeleteLibraryPanelModalState) + .whenActionIsDispatched({ type: 'noop' }) + .thenStateShouldEqual({ + loadingState: LoadingState.Loading, + dashboardTitles: [], + }); + }); + }); + + describe('when searchCompleted is dispatched', () => { + it('then state should be correct', () => { + const dashboards: any[] = [{ title: 'A' }, { title: 'B' }]; + reducerTester() + .givenReducer(deleteLibraryPanelModalReducer, initialDeleteLibraryPanelModalState) + .whenActionIsDispatched(searchCompleted({ dashboards })) + .thenStateShouldEqual({ + loadingState: LoadingState.Done, + dashboardTitles: ['A', 'B'], + }); + }); + }); +}); diff --git a/public/app/features/library-panels/components/DeleteLibraryPanelModal/reducer.ts b/public/app/features/library-panels/components/DeleteLibraryPanelModal/reducer.ts new file mode 100644 index 00000000000..091b4f499de --- /dev/null +++ b/public/app/features/library-panels/components/DeleteLibraryPanelModal/reducer.ts @@ -0,0 +1,33 @@ +import { DashboardSearchHit } from 'app/features/search/types'; +import { LoadingState } from '@grafana/data'; +import { AnyAction } from 'redux'; +import { createAction } from '@reduxjs/toolkit'; + +export interface DeleteLibraryPanelModalState { + loadingState: LoadingState; + dashboardTitles: string[]; +} + +export const initialDeleteLibraryPanelModalState: DeleteLibraryPanelModalState = { + loadingState: LoadingState.Loading, + dashboardTitles: [], +}; + +export const searchCompleted = createAction<{ dashboards: DashboardSearchHit[] }>( + 'libraryPanels/delete/searchCompleted' +); + +export const deleteLibraryPanelModalReducer = ( + state: DeleteLibraryPanelModalState = initialDeleteLibraryPanelModalState, + action: AnyAction +): DeleteLibraryPanelModalState => { + if (searchCompleted.match(action)) { + return { + ...state, + dashboardTitles: action.payload.dashboards.map((d) => d.title), + loadingState: LoadingState.Done, + }; + } + + return state; +}; diff --git a/public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx b/public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx index f93940a2db9..ff493a10cd5 100644 --- a/public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx +++ b/public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx @@ -1,8 +1,9 @@ import React, { useState } from 'react'; -import { Card, ConfirmModal, Icon, IconButton, Tooltip, useStyles } from '@grafana/ui'; +import { Card, Icon, IconButton, Tooltip, useStyles } from '@grafana/ui'; import { css } from 'emotion'; import { GrafanaTheme } from '@grafana/data'; import { LibraryPanelDTO } from '../../types'; +import { DeleteLibraryPanelModal } from '../DeleteLibraryPanelModal/DeleteLibraryPanelModal'; export interface LibraryPanelCardProps { libraryPanel: LibraryPanelDTO; @@ -69,12 +70,8 @@ export const LibraryPanelCard: React.FC {showDeletionModal && ( - setShowDeletionModal(false)} /> diff --git a/public/app/features/library-panels/components/LibraryPanelsView/actions.ts b/public/app/features/library-panels/components/LibraryPanelsView/actions.ts index 65ea1d0ebd3..a80fdf5d7bc 100644 --- a/public/app/features/library-panels/components/LibraryPanelsView/actions.ts +++ b/public/app/features/library-panels/components/LibraryPanelsView/actions.ts @@ -5,8 +5,8 @@ import { catchError, finalize, mapTo, mergeMap, share, takeUntil } from 'rxjs/op import { deleteLibraryPanel as apiDeleteLibraryPanel, getLibraryPanels } from '../../state/api'; import { initialLibraryPanelsViewState, initSearch, LibraryPanelsViewState, searchCompleted } from './reducer'; +import { DispatchResult } from '../../types'; -type DispatchResult = (dispatch: Dispatch) => void; type SearchArgs = Pick; export function searchForLibraryPanels(args: SearchArgs): DispatchResult { @@ -17,6 +17,7 @@ export function searchForLibraryPanels(args: SearchArgs): DispatchResult { name: args.searchString, perPage: args.perPage, page: args.page, + excludeUid: args.currentPanelId, }) ).pipe( mergeMap(({ perPage, libraryPanels, page, totalCount }) => diff --git a/public/app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal.tsx b/public/app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal.tsx index e09293d08aa..3a6d3a5f133 100644 --- a/public/app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal.tsx +++ b/public/app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal.tsx @@ -1,12 +1,11 @@ import React, { useCallback, useState } from 'react'; -import { Button, HorizontalGroup, Icon, Input, Modal, stylesFactory, useStyles } from '@grafana/ui'; -import { GrafanaTheme } from '@grafana/data'; -import { css } from 'emotion'; +import { Button, HorizontalGroup, Icon, Input, Modal, useStyles } from '@grafana/ui'; import { useAsync, useDebounce } from 'react-use'; import { getBackendSrv } from 'app/core/services/backend_srv'; import { usePanelSave } from '../../utils/usePanelSave'; import { getLibraryPanelConnectedDashboards } from '../../state/api'; import { PanelModelWithLibraryPanel } from '../../types'; +import { getModalStyles } from '../../styles'; interface Props { panel: PanelModelWithLibraryPanel; @@ -121,45 +120,3 @@ export const SaveLibraryPanelModal: React.FC = ({ ); }; - -const getModalStyles = stylesFactory((theme: GrafanaTheme) => { - return { - myTable: css` - max-height: 204px; - overflow-y: auto; - margin-top: 11px; - margin-bottom: 28px; - border-radius: ${theme.border.radius.sm}; - border: 1px solid ${theme.colors.bg3}; - background: ${theme.colors.bg1}; - color: ${theme.colors.textSemiWeak}; - font-size: ${theme.typography.size.md}; - width: 100%; - - thead { - color: #538ade; - font-size: ${theme.typography.size.sm}; - } - - th, - td { - padding: 6px 13px; - height: ${theme.spacing.xl}; - } - - tbody > tr:nth-child(odd) { - background: ${theme.colors.bg2}; - } - `, - noteTextbox: css` - margin-bottom: ${theme.spacing.xl}; - `, - textInfo: css` - color: ${theme.colors.textSemiWeak}; - font-size: ${theme.typography.size.sm}; - `, - dashboardSearch: css` - margin-top: ${theme.spacing.md}; - `, - }; -}); diff --git a/public/app/features/library-panels/styles.ts b/public/app/features/library-panels/styles.ts new file mode 100644 index 00000000000..ffcbffce1a5 --- /dev/null +++ b/public/app/features/library-panels/styles.ts @@ -0,0 +1,53 @@ +import { css } from 'emotion'; +import { GrafanaTheme } from '@grafana/data'; + +export function getModalStyles(theme: GrafanaTheme) { + return { + myTable: css` + max-height: 204px; + overflow-y: auto; + margin-top: 11px; + margin-bottom: 28px; + border-radius: ${theme.border.radius.sm}; + border: 1px solid ${theme.colors.bg3}; + background: ${theme.colors.bg1}; + color: ${theme.colors.textSemiWeak}; + font-size: ${theme.typography.size.md}; + width: 100%; + + thead { + color: #538ade; + font-size: ${theme.typography.size.sm}; + } + + th, + td { + padding: 6px 13px; + height: ${theme.spacing.xl}; + } + + tbody > tr:nth-child(odd) { + background: ${theme.colors.bg2}; + } + `, + noteTextbox: css` + margin-bottom: ${theme.spacing.xl}; + `, + textInfo: css` + color: ${theme.colors.textSemiWeak}; + font-size: ${theme.typography.size.sm}; + `, + dashboardSearch: css` + margin-top: ${theme.spacing.md}; + `, + modal: css` + width: 500px; + `, + modalText: css` + font-size: ${theme.typography.heading.h4}; + color: ${theme.colors.link}; + margin-bottom: calc(${theme.spacing.d} * 2); + padding-top: ${theme.spacing.d}; + `, + }; +} diff --git a/public/app/features/library-panels/types.ts b/public/app/features/library-panels/types.ts index 235c5dadb0e..36f509e66b4 100644 --- a/public/app/features/library-panels/types.ts +++ b/public/app/features/library-panels/types.ts @@ -1,4 +1,6 @@ import { PanelModel } from '../dashboard/state'; +import { Dispatch } from 'react'; +import { AnyAction } from '@reduxjs/toolkit'; export interface LibraryPanelSearchResult { totalCount: number; @@ -38,3 +40,5 @@ export type PanelModelLibraryPanel = Pick) => void;