diff --git a/public/app/features/search/constants.ts b/public/app/features/search/constants.ts index b39269716db..98f67f71295 100644 --- a/public/app/features/search/constants.ts +++ b/public/app/features/search/constants.ts @@ -5,5 +5,6 @@ export const SEARCH_ITEM_MARGIN = 8; export const DEFAULT_SORT = { label: 'A\u2013Z', value: 'alpha-asc' }; export const SECTION_STORAGE_KEY = 'search.sections'; export const GENERAL_FOLDER_ID = 0; +export const GENERAL_FOLDER_UID = 'GeneralFolderUID'; export const GENERAL_FOLDER_TITLE = 'General'; export const PREVIEWS_LOCAL_STORAGE_KEY = 'grafana.dashboard.previews'; diff --git a/public/app/features/search/page/components/ConfirmDeleteModal.test.tsx b/public/app/features/search/page/components/ConfirmDeleteModal.test.tsx new file mode 100644 index 00000000000..c9e34903120 --- /dev/null +++ b/public/app/features/search/page/components/ConfirmDeleteModal.test.tsx @@ -0,0 +1,30 @@ +import { render, screen, within } from '@testing-library/react'; +import React from 'react'; + +import { ConfirmDeleteModal } from './ConfirmDeleteModal'; + +describe('ConfirmModal', () => { + it('should render correct title, body, dismiss-, cancel- and delete-text', () => { + const items = new Map(); + const dashboardsUIDs = new Set(); + dashboardsUIDs.add('uid1'); + dashboardsUIDs.add('uid2'); + items.set('dashboard', dashboardsUIDs); + const isDeleteModalOpen = true; + const onDeleteItems = jest.fn(); + render( + {}} + /> + ); + + expect(screen.getByRole('heading', { name: 'Delete' })).toBeInTheDocument(); + expect(screen.getByText('Do you want to delete the 2 selected dashboards?')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + const button = screen.getByRole('button', { name: 'Confirm Modal Danger Button' }); + expect(within(button).getByText('Delete')).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/search/page/components/ConfirmDeleteModal.tsx b/public/app/features/search/page/components/ConfirmDeleteModal.tsx new file mode 100644 index 00000000000..8058f641fbc --- /dev/null +++ b/public/app/features/search/page/components/ConfirmDeleteModal.tsx @@ -0,0 +1,71 @@ +import { css } from '@emotion/css'; +import React, { FC } from 'react'; + +import { GrafanaTheme } from '@grafana/data'; +import { ConfirmModal, stylesFactory, useTheme } from '@grafana/ui'; +import { deleteFoldersAndDashboards } from 'app/features/manage-dashboards/state/actions'; + +import { OnDeleteSelectedItems } from '../../types'; + +interface Props { + onDeleteItems: OnDeleteSelectedItems; + results: Map>; + isOpen: boolean; + onDismiss: () => void; +} + +export const ConfirmDeleteModal: FC = ({ results, onDeleteItems, isOpen, onDismiss }) => { + const theme = useTheme(); + const styles = getStyles(theme); + + const dashboards = Array.from(results.get('dashboard') ?? []); + const folders = Array.from(results.get('folders') ?? []); + + const folderCount = folders.length; + const dashCount = dashboards.length; + + let text = 'Do you want to delete the '; + let subtitle; + const dashEnding = dashCount === 1 ? '' : 's'; + const folderEnding = folderCount === 1 ? '' : 's'; + + if (folderCount > 0 && dashCount > 0) { + text += `selected folder${folderEnding} and dashboard${dashEnding}?\n`; + subtitle = `All dashboards and alerts of the selected folder${folderEnding} will also be deleted`; + } else if (folderCount > 0) { + text += `selected folder${folderEnding} and all ${folderCount === 1 ? 'its' : 'their'} dashboards and alerts?`; + } else { + text += `${dashCount} selected dashboard${dashEnding}?`; + } + + const deleteItems = () => { + deleteFoldersAndDashboards(folders, dashboards).then(() => { + onDismiss(); + onDeleteItems(folders, dashboards); + }); + }; + + return isOpen ? ( + + {text} {subtitle &&
{subtitle}
} + + } + confirmText="Delete" + onConfirm={deleteItems} + onDismiss={onDismiss} + /> + ) : null; +}; + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + return { + subtitle: css` + font-size: ${theme.typography.size.base}; + padding-top: ${theme.spacing.md}; + `, + }; +}); diff --git a/public/app/features/search/page/components/ManageActions.tsx b/public/app/features/search/page/components/ManageActions.tsx index 50bff96f534..8c4bac310b9 100644 --- a/public/app/features/search/page/components/ManageActions.tsx +++ b/public/app/features/search/page/components/ManageActions.tsx @@ -1,31 +1,64 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Button, Checkbox, HorizontalGroup, useStyles2 } from '@grafana/ui'; +import { contextSrv } from 'app/core/services/context_srv'; +import { FolderDTO, FolderInfo } from 'app/types'; + +import { GENERAL_FOLDER_UID } from '../../constants'; import { getStyles } from './ActionRow'; +import { ConfirmDeleteModal } from './ConfirmDeleteModal'; +import { MoveToFolderModal } from './MoveToFolderModal'; type Props = { items: Map>; + folder?: FolderDTO; // when we are loading in folder page }; -export function ManageActions({ items }: Props) { +export function ManageActions({ items, folder }: Props) { const styles = useStyles2(getStyles); - const canMove = true; - const canDelete = true; + const canSave = folder?.canSave; + const hasEditPermissionInFolders = folder ? canSave : contextSrv.hasEditPermissionInFolders; + + const canMove = hasEditPermissionInFolders; + + // TODO: check user permissions for delete, should not be able to delete if includes general folder and user don't have permissions + // There is not GENERAL_FOLDER_UID configured yet, we need to make sure to add it to the data. + const selectedFolders = Array.from(items.get('folders') ?? []); + console.log({ selectedFolders }); + const includesGeneralFolder = selectedFolders.find((result) => result === GENERAL_FOLDER_UID); + + const canDelete = hasEditPermissionInFolders && !includesGeneralFolder; + const [isMoveModalOpen, setIsMoveModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const onMove = () => { - alert('TODO, move....'); + setIsMoveModalOpen(true); }; const onDelete = () => { - alert('TODO, delete....'); + setIsDeleteModalOpen(true); }; const onToggleAll = () => { alert('TODO, toggle all....'); }; + //Todo: update item lists that were moved + const onMoveItems = (selectedDashboards: string[], folder: FolderInfo | null) => { + console.log({ selectedDashboards }); + console.log({ folder }); + console.log('items were moved in the backend'); + }; + + //Todo: update item lists that were deleted + const onDeleteItems = (folders: string[], dashboards: string[]) => { + console.log({ folders }); + console.log({ dashboards }); + console.log('items were moved in the backend'); + }; + return (
@@ -48,6 +81,19 @@ export function ManageActions({ items }: Props) { })}
+ + setIsDeleteModalOpen(false)} + /> + setIsMoveModalOpen(false)} + />
); } diff --git a/public/app/features/search/page/components/MoveToFolderModal.test.tsx b/public/app/features/search/page/components/MoveToFolderModal.test.tsx new file mode 100644 index 00000000000..d4d25677fa7 --- /dev/null +++ b/public/app/features/search/page/components/MoveToFolderModal.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { Provider } from 'react-redux'; +import configureMockStore from 'redux-mock-store'; + +import { MoveToFolderModal } from './MoveToFolderModal'; + +jest.mock('app/core/components/Select/FolderPicker', () => { + return { + FolderPicker: () => null, + }; +}); + +describe('MoveToFolderModal', () => { + it('should render correct title, body, dismiss-, cancel- and move-text', async () => { + const items = new Map(); + const dashboardsUIDs = new Set(); + dashboardsUIDs.add('uid1'); + dashboardsUIDs.add('uid2'); + items.set('dashboard', dashboardsUIDs); + const isMoveModalOpen = true; + const mockStore = configureMockStore(); + const store = mockStore({ dashboard: { panels: [] } }); + const onMoveItems = jest.fn(); + + render( + + {}} /> + + ); + + expect(screen.getByRole('heading', { name: 'Choose Dashboard Folder' })).toBeInTheDocument(); + expect(screen.getByText('Move the 2 selected dashboards to the following folder:')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Move' })).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/search/page/components/MoveToFolderModal.tsx b/public/app/features/search/page/components/MoveToFolderModal.tsx new file mode 100644 index 00000000000..26e672673e0 --- /dev/null +++ b/public/app/features/search/page/components/MoveToFolderModal.tsx @@ -0,0 +1,90 @@ +import { css } from '@emotion/css'; +import React, { FC, useState } from 'react'; + +import { GrafanaTheme } from '@grafana/data'; +import { Button, HorizontalGroup, Modal, stylesFactory, useTheme } from '@grafana/ui'; +import { FolderPicker } from 'app/core/components/Select/FolderPicker'; +import { useAppNotification } from 'app/core/copy/appNotification'; +import { moveDashboards } from 'app/features/manage-dashboards/state/actions'; +import { FolderInfo } from 'app/types'; + +import { OnMoveSelectedItems } from '../../types'; + +interface Props { + onMoveItems: OnMoveSelectedItems; + results: Map>; + isOpen: boolean; + onDismiss: () => void; +} + +export const MoveToFolderModal: FC = ({ results, onMoveItems, isOpen, onDismiss }) => { + const [folder, setFolder] = useState(null); + const theme = useTheme(); + const styles = getStyles(theme); + const notifyApp = useAppNotification(); + const selectedDashboards = Array.from(results.get('dashboard') ?? []); + + const moveTo = () => { + if (folder && selectedDashboards.length) { + const folderTitle = folder.title ?? 'General'; + + moveDashboards(selectedDashboards, folder).then((result: any) => { + if (result.successCount > 0) { + const ending = result.successCount === 1 ? '' : 's'; + const header = `Dashboard${ending} Moved`; + const msg = `${result.successCount} dashboard${ending} moved to ${folderTitle}`; + notifyApp.success(header, msg); + } + + if (result.totalCount === result.alreadyInFolderCount) { + notifyApp.error('Error', `Dashboard already belongs to folder ${folderTitle}`); + } else { + //update the list + onMoveItems(selectedDashboards, folder); + } + + onDismiss(); + }); + } + }; + + return isOpen ? ( + + <> +
+

+ Move the {selectedDashboards.length} selected dashboard{selectedDashboards.length === 1 ? '' : 's'} to the + following folder: +

+ setFolder(f as FolderInfo)} /> +
+ + + + + + +
+ ) : null; +}; + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + return { + modal: css` + width: 500px; + `, + content: css` + margin-bottom: ${theme.spacing.lg}; + `, + }; +}); diff --git a/public/app/features/search/page/components/SearchResultsTable.tsx b/public/app/features/search/page/components/SearchResultsTable.tsx index 22cc6b758f5..4b0733778d4 100644 --- a/public/app/features/search/page/components/SearchResultsTable.tsx +++ b/public/app/features/search/page/components/SearchResultsTable.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react/jsx-no-undef */ import { css } from '@emotion/css'; import React, { useMemo } from 'react'; import { useTable, Column, TableOptions, Cell, useAbsoluteLayout } from 'react-table'; diff --git a/public/app/features/search/types.ts b/public/app/features/search/types.ts index 1ee78da5174..28619ba28a8 100644 --- a/public/app/features/search/types.ts +++ b/public/app/features/search/types.ts @@ -108,3 +108,7 @@ export interface SearchQueryParams { layout?: SearchLayout | null; folder?: string | null; } + +// new Search Types +export type OnDeleteSelectedItems = (folders: string[], dashboards: string[]) => void; +export type OnMoveSelectedItems = (selectedDashboards: string[], folder: FolderInfo | null) => void;