mirror of https://github.com/grafana/grafana
Nested folders: Create basic Move/Delete modals (#67140)
* add modal scaffolding * add some unit tests * remove dummy api, add some TODO comments * small test refactor * another small test refactor * fix unit tests due to aria-label/data-testid changepull/67229/head
parent
bb66f14c1d
commit
e6e741546f
@ -1,41 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { Button, useStyles2 } from '@grafana/ui'; |
||||
|
||||
export interface Props {} |
||||
|
||||
export function BrowseActions() { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
const onMove = () => { |
||||
// TODO real implemenation, stub for now
|
||||
console.log('onMoveClicked'); |
||||
}; |
||||
|
||||
const onDelete = () => { |
||||
// TODO real implementation, stub for now
|
||||
console.log('onDeleteClicked'); |
||||
}; |
||||
|
||||
return ( |
||||
<div className={styles.row} data-testid="manage-actions"> |
||||
<Button onClick={onMove} variant="secondary"> |
||||
Move |
||||
</Button> |
||||
<Button onClick={onDelete} variant="destructive"> |
||||
Delete |
||||
</Button> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
row: css({ |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
gap: theme.spacing(1), |
||||
marginBottom: theme.spacing(2), |
||||
}), |
||||
}); |
@ -1,8 +1,13 @@ |
||||
import { render, screen } from '@testing-library/react'; |
||||
import { render as rtlRender, screen } from '@testing-library/react'; |
||||
import React from 'react'; |
||||
import { TestProvider } from 'test/helpers/TestProvider'; |
||||
|
||||
import { BrowseActions } from './BrowseActions'; |
||||
|
||||
function render(...[ui, options]: Parameters<typeof rtlRender>) { |
||||
rtlRender(<TestProvider>{ui}</TestProvider>, options); |
||||
} |
||||
|
||||
describe('browse-dashboards BrowseActions', () => { |
||||
it('displays Move and Delete buttons', () => { |
||||
render(<BrowseActions />); |
@ -0,0 +1,67 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { Button, useStyles2 } from '@grafana/ui'; |
||||
import appEvents from 'app/core/app_events'; |
||||
import { ShowModalReactEvent } from 'app/types/events'; |
||||
|
||||
import { useSelectedItemsState } from '../../state'; |
||||
|
||||
import { DeleteModal } from './DeleteModal'; |
||||
import { MoveModal } from './MoveModal'; |
||||
|
||||
export interface Props {} |
||||
|
||||
export function BrowseActions() { |
||||
const styles = useStyles2(getStyles); |
||||
const selectedItems = useSelectedItemsState(); |
||||
|
||||
const onMove = () => { |
||||
appEvents.publish( |
||||
new ShowModalReactEvent({ |
||||
component: MoveModal, |
||||
props: { |
||||
selectedItems, |
||||
onConfirm: (moveTarget: string) => { |
||||
console.log(`MoveModal onConfirm clicked with target ${moveTarget}!`); |
||||
}, |
||||
}, |
||||
}) |
||||
); |
||||
}; |
||||
|
||||
const onDelete = () => { |
||||
appEvents.publish( |
||||
new ShowModalReactEvent({ |
||||
component: DeleteModal, |
||||
props: { |
||||
selectedItems, |
||||
onConfirm: () => { |
||||
console.log('DeleteModal onConfirm clicked!'); |
||||
}, |
||||
}, |
||||
}) |
||||
); |
||||
}; |
||||
|
||||
return ( |
||||
<div className={styles.row} data-testid="manage-actions"> |
||||
<Button onClick={onMove} variant="secondary"> |
||||
Move |
||||
</Button> |
||||
<Button onClick={onDelete} variant="destructive"> |
||||
Delete |
||||
</Button> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
row: css({ |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
gap: theme.spacing(1), |
||||
marginBottom: theme.spacing(2), |
||||
}), |
||||
}); |
@ -0,0 +1,72 @@ |
||||
import { render, screen } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
import React from 'react'; |
||||
|
||||
import { DeleteModal, Props } from './DeleteModal'; |
||||
|
||||
describe('browse-dashboards DeleteModal', () => { |
||||
const mockOnDismiss = jest.fn(); |
||||
const mockOnConfirm = jest.fn(); |
||||
|
||||
const defaultProps: Props = { |
||||
isOpen: true, |
||||
onConfirm: mockOnConfirm, |
||||
onDismiss: mockOnDismiss, |
||||
selectedItems: { |
||||
folder: {}, |
||||
dashboard: {}, |
||||
panel: {}, |
||||
}, |
||||
}; |
||||
|
||||
it('renders a dialog with the correct title', async () => { |
||||
render(<DeleteModal {...defaultProps} />); |
||||
|
||||
expect(await screen.findByRole('dialog', { name: 'Delete Compute Resources' })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('displays a `Delete` button', async () => { |
||||
render(<DeleteModal {...defaultProps} />); |
||||
|
||||
expect(await screen.findByRole('button', { name: 'Delete' })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('displays a `Cancel` button', async () => { |
||||
render(<DeleteModal {...defaultProps} />); |
||||
|
||||
expect(await screen.findByRole('button', { name: 'Cancel' })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('only enables the `Delete` button if the confirmation text is typed', async () => { |
||||
render(<DeleteModal {...defaultProps} />); |
||||
|
||||
const confirmationInput = await screen.findByPlaceholderText('Type Delete to confirm'); |
||||
await userEvent.type(confirmationInput, 'Delete'); |
||||
|
||||
expect(await screen.findByRole('button', { name: 'Delete' })).toBeEnabled(); |
||||
}); |
||||
|
||||
it('calls onConfirm when clicking the `Delete` button', async () => { |
||||
render(<DeleteModal {...defaultProps} />); |
||||
|
||||
const confirmationInput = await screen.findByPlaceholderText('Type Delete to confirm'); |
||||
await userEvent.type(confirmationInput, 'Delete'); |
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Delete' })); |
||||
expect(mockOnConfirm).toHaveBeenCalled(); |
||||
}); |
||||
|
||||
it('calls onDismiss when clicking the `Cancel` button', async () => { |
||||
render(<DeleteModal {...defaultProps} />); |
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Cancel' })); |
||||
expect(mockOnDismiss).toHaveBeenCalled(); |
||||
}); |
||||
|
||||
it('calls onDismiss when clicking the X', async () => { |
||||
render(<DeleteModal {...defaultProps} />); |
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Close dialog' })); |
||||
expect(mockOnDismiss).toHaveBeenCalled(); |
||||
}); |
||||
}); |
@ -0,0 +1,62 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2, isTruthy } from '@grafana/data'; |
||||
import { ConfirmModal, useStyles2 } from '@grafana/ui'; |
||||
|
||||
import { DashboardTreeSelection } from '../../types'; |
||||
|
||||
import { buildBreakdownString } from './utils'; |
||||
|
||||
export interface Props { |
||||
isOpen: boolean; |
||||
onConfirm: () => void; |
||||
onDismiss: () => void; |
||||
selectedItems: DashboardTreeSelection; |
||||
} |
||||
|
||||
export const DeleteModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Props) => { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
// TODO abstract all this counting logic out
|
||||
const folderCount = Object.values(selectedItems.folder).filter(isTruthy).length; |
||||
const dashboardCount = Object.values(selectedItems.dashboard).filter(isTruthy).length; |
||||
// hardcoded values for now
|
||||
// TODO replace with dummy API
|
||||
const libraryPanelCount = 1; |
||||
const alertRuleCount = 1; |
||||
|
||||
const onDelete = () => { |
||||
onConfirm(); |
||||
onDismiss(); |
||||
}; |
||||
|
||||
return ( |
||||
<ConfirmModal |
||||
body={ |
||||
<div className={styles.modalBody}> |
||||
This action will delete the following content: |
||||
<p className={styles.breakdown}> |
||||
{buildBreakdownString(folderCount, dashboardCount, libraryPanelCount, alertRuleCount)} |
||||
</p> |
||||
</div> |
||||
} |
||||
confirmationText="Delete" |
||||
confirmText="Delete" |
||||
onDismiss={onDismiss} |
||||
onConfirm={onDelete} |
||||
title="Delete Compute Resources" |
||||
{...props} |
||||
/> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
breakdown: css({ |
||||
...theme.typography.bodySmall, |
||||
color: theme.colors.text.secondary, |
||||
}), |
||||
modalBody: css({ |
||||
...theme.typography.body, |
||||
}), |
||||
}); |
@ -0,0 +1,101 @@ |
||||
import { render, screen } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
import React from 'react'; |
||||
import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; |
||||
|
||||
import * as api from 'app/features/manage-dashboards/state/actions'; |
||||
import { DashboardSearchHit } from 'app/features/search/types'; |
||||
|
||||
import { MoveModal, Props } from './MoveModal'; |
||||
|
||||
describe('browse-dashboards MoveModal', () => { |
||||
const mockOnDismiss = jest.fn(); |
||||
const mockOnConfirm = jest.fn(); |
||||
const mockFolders = [ |
||||
{ title: 'General', uid: '' } as DashboardSearchHit, |
||||
{ title: 'Folder 1', uid: 'wfTJJL5Wz' } as DashboardSearchHit, |
||||
]; |
||||
let props: Props; |
||||
|
||||
beforeEach(() => { |
||||
props = { |
||||
isOpen: true, |
||||
onConfirm: mockOnConfirm, |
||||
onDismiss: mockOnDismiss, |
||||
selectedItems: { |
||||
folder: {}, |
||||
dashboard: {}, |
||||
panel: {}, |
||||
}, |
||||
}; |
||||
|
||||
// mock the searchFolders api call so the folder picker has some folders in it
|
||||
jest.spyOn(api, 'searchFolders').mockResolvedValue(mockFolders); |
||||
}); |
||||
|
||||
it('renders a dialog with the correct title', async () => { |
||||
render(<MoveModal {...props} />); |
||||
|
||||
expect(await screen.findByRole('dialog', { name: 'Move' })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('displays a `Move` button', async () => { |
||||
render(<MoveModal {...props} />); |
||||
|
||||
expect(await screen.findByRole('button', { name: 'Move' })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('displays a `Cancel` button', async () => { |
||||
render(<MoveModal {...props} />); |
||||
|
||||
expect(await screen.findByRole('button', { name: 'Cancel' })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('displays a folder picker', async () => { |
||||
render(<MoveModal {...props} />); |
||||
|
||||
expect(await screen.findByRole('combobox', { name: 'Select a folder' })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('displays a warning about permissions if a folder is selected', async () => { |
||||
props.selectedItems.folder = { |
||||
myFolderUid: true, |
||||
}; |
||||
render(<MoveModal {...props} />); |
||||
|
||||
expect(await screen.findByText('Moving this item may change its permissions.')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('only enables the `Move` button if a folder is selected', async () => { |
||||
render(<MoveModal {...props} />); |
||||
|
||||
expect(await screen.findByRole('button', { name: 'Move' })).toBeDisabled(); |
||||
const folderPicker = await screen.findByRole('combobox', { name: 'Select a folder' }); |
||||
|
||||
await selectOptionInTest(folderPicker, mockFolders[1].title); |
||||
expect(await screen.findByRole('button', { name: 'Move' })).toBeEnabled(); |
||||
}); |
||||
|
||||
it('calls onConfirm when clicking the `Move` button', async () => { |
||||
render(<MoveModal {...props} />); |
||||
const folderPicker = await screen.findByRole('combobox', { name: 'Select a folder' }); |
||||
|
||||
await selectOptionInTest(folderPicker, mockFolders[1].title); |
||||
await userEvent.click(await screen.findByRole('button', { name: 'Move' })); |
||||
expect(mockOnConfirm).toHaveBeenCalledWith(mockFolders[1].uid); |
||||
}); |
||||
|
||||
it('calls onDismiss when clicking the `Cancel` button', async () => { |
||||
render(<MoveModal {...props} />); |
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Cancel' })); |
||||
expect(mockOnDismiss).toHaveBeenCalled(); |
||||
}); |
||||
|
||||
it('calls onDismiss when clicking the X', async () => { |
||||
render(<MoveModal {...props} />); |
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Close dialog' })); |
||||
expect(mockOnDismiss).toHaveBeenCalled(); |
||||
}); |
||||
}); |
@ -0,0 +1,65 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React, { useState } from 'react'; |
||||
|
||||
import { GrafanaTheme2, isTruthy } from '@grafana/data'; |
||||
import { Alert, Button, Field, Modal, useStyles2 } from '@grafana/ui'; |
||||
import { FolderPicker } from 'app/core/components/Select/FolderPicker'; |
||||
|
||||
import { DashboardTreeSelection } from '../../types'; |
||||
|
||||
import { buildBreakdownString } from './utils'; |
||||
|
||||
export interface Props { |
||||
isOpen: boolean; |
||||
onConfirm: (targetFolderUid: string) => void; |
||||
onDismiss: () => void; |
||||
selectedItems: DashboardTreeSelection; |
||||
} |
||||
|
||||
export const MoveModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Props) => { |
||||
const [moveTarget, setMoveTarget] = useState<string>(); |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
// TODO abstract all this counting logic out
|
||||
const folderCount = Object.values(selectedItems.folder).filter(isTruthy).length; |
||||
const dashboardCount = Object.values(selectedItems.dashboard).filter(isTruthy).length; |
||||
// hardcoded values for now
|
||||
// TODO replace with dummy API
|
||||
const libraryPanelCount = 1; |
||||
const alertRuleCount = 1; |
||||
|
||||
const onMove = () => { |
||||
if (moveTarget !== undefined) { |
||||
onConfirm(moveTarget); |
||||
} |
||||
onDismiss(); |
||||
}; |
||||
|
||||
return ( |
||||
<Modal title="Move" onDismiss={onDismiss} {...props}> |
||||
{folderCount > 0 && <Alert severity="warning" title="Moving this item may change its permissions." />} |
||||
This action will move the following content: |
||||
<p className={styles.breakdown}> |
||||
{buildBreakdownString(folderCount, dashboardCount, libraryPanelCount, alertRuleCount)} |
||||
</p> |
||||
<Field label="Folder name"> |
||||
<FolderPicker allowEmpty onChange={({ uid }) => setMoveTarget(uid)} /> |
||||
</Field> |
||||
<Modal.ButtonRow> |
||||
<Button onClick={onDismiss} variant="secondary"> |
||||
Cancel |
||||
</Button> |
||||
<Button disabled={moveTarget === undefined} onClick={onMove} variant="primary"> |
||||
Move |
||||
</Button> |
||||
</Modal.ButtonRow> |
||||
</Modal> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
breakdown: css({ |
||||
...theme.typography.bodySmall, |
||||
color: theme.colors.text.secondary, |
||||
}), |
||||
}); |
@ -0,0 +1,23 @@ |
||||
import { buildBreakdownString } from './utils'; |
||||
|
||||
describe('browse-dashboards utils', () => { |
||||
describe('buildBreakdownString', () => { |
||||
it.each` |
||||
folderCount | dashboardCount | libraryPanelCount | alertRuleCount | expected |
||||
${0} | ${0} | ${0} | ${0} | ${'0 items'} |
||||
${1} | ${0} | ${0} | ${0} | ${'1 item: 1 folder'} |
||||
${2} | ${0} | ${0} | ${0} | ${'2 items: 2 folders'} |
||||
${0} | ${1} | ${0} | ${0} | ${'1 item: 1 dashboard'} |
||||
${0} | ${2} | ${0} | ${0} | ${'2 items: 2 dashboards'} |
||||
${1} | ${0} | ${1} | ${1} | ${'3 items: 1 folder, 1 library panel, 1 alert rule'} |
||||
${2} | ${0} | ${3} | ${4} | ${'9 items: 2 folders, 3 library panels, 4 alert rules'} |
||||
${1} | ${1} | ${1} | ${1} | ${'4 items: 1 folder, 1 dashboard, 1 library panel, 1 alert rule'} |
||||
${1} | ${2} | ${3} | ${4} | ${'10 items: 1 folder, 2 dashboards, 3 library panels, 4 alert rules'} |
||||
`(
|
||||
'returns the correct message for the various inputs', |
||||
({ folderCount, dashboardCount, libraryPanelCount, alertRuleCount, expected }) => { |
||||
expect(buildBreakdownString(folderCount, dashboardCount, libraryPanelCount, alertRuleCount)).toEqual(expected); |
||||
} |
||||
); |
||||
}); |
||||
}); |
@ -0,0 +1,26 @@ |
||||
export function buildBreakdownString( |
||||
folderCount: number, |
||||
dashboardCount: number, |
||||
libraryPanelCount: number, |
||||
alertRuleCount: number |
||||
) { |
||||
const total = folderCount + dashboardCount + libraryPanelCount + alertRuleCount; |
||||
const parts = []; |
||||
if (folderCount) { |
||||
parts.push(`${folderCount} ${folderCount === 1 ? 'folder' : 'folders'}`); |
||||
} |
||||
if (dashboardCount) { |
||||
parts.push(`${dashboardCount} ${dashboardCount === 1 ? 'dashboard' : 'dashboards'}`); |
||||
} |
||||
if (libraryPanelCount) { |
||||
parts.push(`${libraryPanelCount} ${libraryPanelCount === 1 ? 'library panel' : 'library panels'}`); |
||||
} |
||||
if (alertRuleCount) { |
||||
parts.push(`${alertRuleCount} ${alertRuleCount === 1 ? 'alert rule' : 'alert rules'}`); |
||||
} |
||||
let breakdownString = `${total} ${total === 1 ? 'item' : 'items'}`; |
||||
if (parts.length > 0) { |
||||
breakdownString += `: ${parts.join(', ')}`; |
||||
} |
||||
return breakdownString; |
||||
} |
Loading…
Reference in new issue