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
pull/32290/head
Hugo Häggmark 4 years ago committed by GitHub
parent 26823ee438
commit 376ed8a381
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      pkg/services/librarypanels/api.go
  2. 8
      pkg/services/librarypanels/database.go
  3. 11
      pkg/services/librarypanels/librarypanels_delete_test.go
  4. 2
      pkg/services/librarypanels/models.go
  5. 91
      public/app/features/library-panels/components/DeleteLibraryPanelModal/DeleteLibraryPanelModal.tsx
  6. 17
      public/app/features/library-panels/components/DeleteLibraryPanelModal/actions.ts
  7. 35
      public/app/features/library-panels/components/DeleteLibraryPanelModal/reducer.test.ts
  8. 33
      public/app/features/library-panels/components/DeleteLibraryPanelModal/reducer.ts
  9. 11
      public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx
  10. 3
      public/app/features/library-panels/components/LibraryPanelsView/actions.ts
  11. 47
      public/app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal.tsx
  12. 53
      public/app/features/library-panels/styles.ts
  13. 4
      public/app/features/library-panels/types.ts

@ -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};
`,
};
});

@ -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…
Cancel
Save