mirror of https://github.com/grafana/grafana
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 commentspull/32290/head
parent
26823ee438
commit
376ed8a381
@ -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; |
||||
}; |
@ -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}; |
||||
`,
|
||||
}; |
||||
} |
Loading…
Reference in new issue