mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
LibraryPanels: Prevents deletion of connected library panels (#32277)
* LibraryPanels: Prevents deletion of connected library panels * Refactor: adds the delete library panel modal * Chore: updates after PR comments
This commit is contained in:
parent
26823ee438
commit
376ed8a381
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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())
|
||||
})
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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<Props> = ({ 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 (
|
||||
<Modal className={styles.modal} title="Delete library panel" icon="trash-alt" onDismiss={onDismiss} isOpen={true}>
|
||||
{!done ? <LoadingIndicator /> : null}
|
||||
{done ? (
|
||||
<div>
|
||||
{connected ? <HasConnectedDashboards dashboardTitles={dashboardTitles} /> : null}
|
||||
{!connected ? <Confirm /> : null}
|
||||
|
||||
<HorizontalGroup>
|
||||
<Button variant="destructive" onClick={onConfirm} disabled={connected}>
|
||||
Delete
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onDismiss}>
|
||||
Cancel
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const LoadingIndicator: FC = () => <span>Loading library panel...</span>;
|
||||
|
||||
const Confirm: FC = () => {
|
||||
const styles = useStyles(getModalStyles);
|
||||
|
||||
return <div className={styles.modalText}>Do you want to delete this panel?</div>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<p className={styles.textInfo}>
|
||||
{'This library panel can not be deleted because it is connected to '}
|
||||
<strong>{message}</strong>
|
||||
{' Remove the library panel from the dashboards listed below and retry.'}
|
||||
</p>
|
||||
<table className={styles.myTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Dashboard name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dashboardTitles.map((title, i) => (
|
||||
<tr key={`dash-title-${i}`}>
|
||||
<td>{title}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -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 }));
|
||||
};
|
||||
}
|
@ -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<DeleteLibraryPanelModalState>()
|
||||
.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<DeleteLibraryPanelModalState>()
|
||||
.givenReducer(deleteLibraryPanelModalReducer, initialDeleteLibraryPanelModalState)
|
||||
.whenActionIsDispatched(searchCompleted({ dashboards }))
|
||||
.thenStateShouldEqual({
|
||||
loadingState: LoadingState.Done,
|
||||
dashboardTitles: ['A', 'B'],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
};
|
@ -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<LibraryPanelCardProps & { children?: JSX
|
||||
)}
|
||||
</Card>
|
||||
{showDeletionModal && (
|
||||
<ConfirmModal
|
||||
isOpen={showDeletionModal}
|
||||
icon="trash-alt"
|
||||
title="Delete library panel"
|
||||
body="Do you want to delete this panel?"
|
||||
confirmText="Delete"
|
||||
<DeleteLibraryPanelModal
|
||||
libraryPanel={libraryPanel}
|
||||
onConfirm={onDeletePanel}
|
||||
onDismiss={() => setShowDeletionModal(false)}
|
||||
/>
|
||||
|
@ -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<AnyAction>) => void;
|
||||
type SearchArgs = Pick<LibraryPanelsViewState, 'searchString' | 'perPage' | 'page' | 'currentPanelId'>;
|
||||
|
||||
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 }) =>
|
||||
|
@ -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<Props> = ({
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
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};
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
53
public/app/features/library-panels/styles.ts
Normal file
53
public/app/features/library-panels/styles.ts
Normal file
@ -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};
|
||||
`,
|
||||
};
|
||||
}
|
@ -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<LibraryPanelDTO, 'uid' | 'name' | 'met
|
||||
export interface PanelModelWithLibraryPanel extends PanelModel {
|
||||
libraryPanel: PanelModelLibraryPanel;
|
||||
}
|
||||
|
||||
export type DispatchResult = (dispatch: Dispatch<AnyAction>) => void;
|
||||
|
Loading…
Reference in New Issue
Block a user