From 9f4ab1b6ddaddd5a44964cae636cd16621eca486 Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Fri, 31 Mar 2023 12:31:06 +0100 Subject: [PATCH] NestedFolders: Support moving folders into other folders (#65519) * wip for move folders * hello * Polish up move dashboard results messages * tests * fix other test * tweak messages when things can't be moved * tweak messages when things can't be moved * fix tests * remove comment * restore failOnConsole * . * Fix move modal not opening due to dodgy rebase --- .betterer.results | 3 - .../manage-dashboards/state/actions.ts | 29 +++- .../page/components/ManageActions.test.tsx | 2 +- .../components/MoveToFolderModal.test.tsx | 145 ++++++++++++++++-- .../page/components/MoveToFolderModal.tsx | 139 +++++++++++++++-- 5 files changed, 287 insertions(+), 31 deletions(-) diff --git a/.betterer.results b/.betterer.results index b4be81394ee..d91d8349bbe 100644 --- a/.betterer.results +++ b/.betterer.results @@ -3292,9 +3292,6 @@ exports[`better eslint`] = { "public/app/features/search/hooks/useSearchKeyboardSelection.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/features/search/page/components/MoveToFolderModal.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/features/search/page/components/columns.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], diff --git a/public/app/features/manage-dashboards/state/actions.ts b/public/app/features/manage-dashboards/state/actions.ts index 1579cd6306b..582f407fb4b 100644 --- a/public/app/features/manage-dashboards/state/actions.ts +++ b/public/app/features/manage-dashboards/state/actions.ts @@ -182,6 +182,26 @@ const getDataSourceOptions = (input: { pluginId: string; pluginName: string }, i } }; +export async function moveFolders(folderUIDs: string[], toFolder: FolderInfo) { + const result = { + totalCount: folderUIDs.length, + successCount: 0, + }; + + for (const folderUID of folderUIDs) { + try { + const newFolderDTO = await moveFolder(folderUID, toFolder); + if (newFolderDTO !== null) { + result.successCount += 1; + } + } catch (err) { + console.error('Failed to move a folder', err); + } + } + + return result; +} + export function moveDashboards(dashboardUids: string[], toFolder: FolderInfo) { const tasks = []; @@ -284,6 +304,13 @@ export function createFolder(payload: any) { return getBackendSrv().post('/api/folders', payload); } +export function moveFolder(uid: string, toFolder: FolderInfo) { + const payload = { + parentUid: toFolder.uid, + }; + return getBackendSrv().post(`/api/folders/${uid}/move`, payload, { showErrorAlert: false }); +} + export const SLICE_FOLDER_RESULTS_TO = 1000; export function searchFolders( @@ -311,7 +338,7 @@ export function deleteDashboard(uid: string, showSuccessAlert: boolean) { return getBackendSrv().delete(`/api/dashboards/uid/${uid}`, { showSuccessAlert }); } -function executeInOrder(tasks: any[]) { +function executeInOrder(tasks: any[]): Promise { return tasks.reduce((acc, task) => { return Promise.resolve(acc).then(task); }, []); diff --git a/public/app/features/search/page/components/ManageActions.test.tsx b/public/app/features/search/page/components/ManageActions.test.tsx index 41a94bb4933..04b25893b00 100644 --- a/public/app/features/search/page/components/ManageActions.test.tsx +++ b/public/app/features/search/page/components/ManageActions.test.tsx @@ -51,7 +51,7 @@ describe('ManageActions', () => { // open Move modal await userEvent.click(screen.getByRole('button', { name: 'Move', hidden: true })); - expect(screen.getByText(/Move the 2 selected dashboards to the following folder:/i)).toBeInTheDocument(); + expect(screen.getByText(/Move 2 dashboards to:/i)).toBeInTheDocument(); }); it('should show delete modal when user click the delete button', async () => { diff --git a/public/app/features/search/page/components/MoveToFolderModal.test.tsx b/public/app/features/search/page/components/MoveToFolderModal.test.tsx index 230777e79f5..3da2968e8d2 100644 --- a/public/app/features/search/page/components/MoveToFolderModal.test.tsx +++ b/public/app/features/search/page/components/MoveToFolderModal.test.tsx @@ -1,23 +1,45 @@ import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import { Provider } from 'react-redux'; import configureMockStore from 'redux-mock-store'; +import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; + +import { selectors } from '@grafana/e2e-selectors'; +import config from 'app/core/config'; +import * as api from 'app/features/manage-dashboards/state/actions'; + +import { DashboardSearchHit, DashboardSearchItemType } from '../../types'; import { MoveToFolderModal } from './MoveToFolderModal'; -jest.mock('app/core/components/Select/FolderPicker', () => { - return { - FolderPicker: () => null, - }; -}); +function makeSelections(dashboardUIDs: string[] = [], folderUIDs: string[] = []) { + const dashboards = new Set(dashboardUIDs); + const folders = new Set(folderUIDs); + + return new Map([ + ['dashboard', dashboards], + ['folder', folders], + ]); +} + +function makeDashboardSearchHit(title: string, uid: string, type = DashboardSearchItemType.DashDB): DashboardSearchHit { + return { title, uid, tags: [], type, url: `/d/${uid}` }; +} describe('MoveToFolderModal', () => { + jest + .spyOn(api, 'searchFolders') + .mockResolvedValue([ + makeDashboardSearchHit('General', '', DashboardSearchItemType.DashFolder), + makeDashboardSearchHit('Folder 1', 'folder-uid-1', DashboardSearchItemType.DashFolder), + makeDashboardSearchHit('Folder 2', 'folder-uid-1', DashboardSearchItemType.DashFolder), + makeDashboardSearchHit('Folder 3', 'folder-uid-3', DashboardSearchItemType.DashFolder), + ]); + 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 items = makeSelections(['dash-uid-1', 'dash-uid-2']); + const mockStore = configureMockStore(); const store = mockStore({ dashboard: { panels: [] } }); const onMoveItems = jest.fn(); @@ -28,9 +50,110 @@ describe('MoveToFolderModal', () => { ); + // Wait for folder picker to finish rendering + await screen.findByText('Choose'); + expect(screen.getByRole('heading', { name: 'Choose Dashboard Folder' })).toBeInTheDocument(); - expect(screen.getByText('Move the 2 selected dashboards to the following folder:')).toBeInTheDocument(); + expect(screen.getByText('Move 2 dashboards to:')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Move' })).toBeInTheDocument(); }); + + it('should move dashboards, but not folders', async () => { + const moveDashboardsMock = jest.spyOn(api, 'moveDashboards').mockResolvedValue({ + successCount: 2, + totalCount: 2, + alreadyInFolderCount: 0, + }); + + const moveFoldersMock = jest.spyOn(api, 'moveFolders').mockResolvedValue({ + successCount: 1, + totalCount: 1, + }); + + const items = makeSelections(['dash-uid-1', 'dash-uid-2'], ['folder-uid-1']); + + const mockStore = configureMockStore(); + const store = mockStore({ dashboard: { panels: [] } }); + const onMoveItems = jest.fn(); + + render( + + {}} /> + + ); + + // Wait for folder picker to finish rendering + await screen.findByText('Choose'); + + const folderPicker = screen.getByLabelText(selectors.components.FolderPicker.input); + await selectOptionInTest(folderPicker, 'Folder 3'); + + const moveButton = screen.getByText('Move'); + await userEvent.click(moveButton); + + expect(moveDashboardsMock).toHaveBeenCalledWith(['dash-uid-1', 'dash-uid-2'], { + title: 'Folder 3', + uid: 'folder-uid-3', + }); + + expect(moveFoldersMock).not.toHaveBeenCalled(); + }); + + describe('with nestedFolders feature flag', () => { + let originalNestedFoldersValue = config.featureToggles.nestedFolders; + + beforeAll(() => { + originalNestedFoldersValue = config.featureToggles.nestedFolders; + config.featureToggles.nestedFolders = true; + }); + + afterAll(() => { + config.featureToggles.nestedFolders = originalNestedFoldersValue; + }); + + it('should move folders and dashboards', async () => { + const moveDashboardsMock = jest.spyOn(api, 'moveDashboards').mockResolvedValue({ + successCount: 2, + totalCount: 2, + alreadyInFolderCount: 0, + }); + + const moveFoldersMock = jest.spyOn(api, 'moveFolders').mockResolvedValue({ + successCount: 1, + totalCount: 1, + }); + + const items = makeSelections(['dash-uid-1', 'dash-uid-2'], ['folder-uid-1']); + + const mockStore = configureMockStore(); + const store = mockStore({ dashboard: { panels: [] } }); + const onMoveItems = jest.fn(); + + render( + + {}} /> + + ); + + // Wait for folder picker to finish rendering + await screen.findByText('Choose'); + + const folderPicker = screen.getByLabelText(selectors.components.FolderPicker.input); + await selectOptionInTest(folderPicker, 'Folder 3'); + + const moveButton = screen.getByRole('button', { name: 'Move' }); + await userEvent.click(moveButton); + + expect(moveDashboardsMock).toHaveBeenCalledWith(['dash-uid-1', 'dash-uid-2'], { + title: 'Folder 3', + uid: 'folder-uid-3', + }); + + expect(moveFoldersMock).toHaveBeenCalledWith(['folder-uid-1'], { + title: 'Folder 3', + uid: 'folder-uid-3', + }); + }); + }); }); diff --git a/public/app/features/search/page/components/MoveToFolderModal.tsx b/public/app/features/search/page/components/MoveToFolderModal.tsx index 24a3abef1e4..40e441785f7 100644 --- a/public/app/features/search/page/components/MoveToFolderModal.tsx +++ b/public/app/features/search/page/components/MoveToFolderModal.tsx @@ -1,13 +1,15 @@ import { css } from '@emotion/css'; -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { Button, HorizontalGroup, Modal, useStyles2 } from '@grafana/ui'; +import { Alert, Button, HorizontalGroup, Modal, useStyles2 } from '@grafana/ui'; import { FolderPicker } from 'app/core/components/Select/FolderPicker'; +import config from 'app/core/config'; import { useAppNotification } from 'app/core/copy/appNotification'; -import { moveDashboards } from 'app/features/manage-dashboards/state/actions'; +import { moveDashboards, moveFolders } from 'app/features/manage-dashboards/state/actions'; import { FolderInfo } from 'app/types'; +import { GENERAL_FOLDER_UID } from '../../constants'; import { OnMoveOrDeleleSelectedItems } from '../../types'; interface Props { @@ -20,14 +22,65 @@ export const MoveToFolderModal = ({ results, onMoveItems, onDismiss }: Props) => const [folder, setFolder] = useState(null); const styles = useStyles2(getStyles); const notifyApp = useAppNotification(); - const selectedDashboards = Array.from(results.get('dashboard') ?? []); const [moving, setMoving] = useState(false); - const moveTo = () => { - if (folder && selectedDashboards.length) { + const nestedFoldersEnabled = config.featureToggles.nestedFolders; + + const selectedDashboards = Array.from(results.get('dashboard') ?? []); + const selectedFolders = nestedFoldersEnabled + ? Array.from(results.get('folder') ?? []).filter((v) => v !== GENERAL_FOLDER_UID) + : []; + + const handleFolderChange = useCallback( + (newFolder: FolderInfo) => { + setFolder(newFolder); + }, + [setFolder] + ); + + const moveTo = async () => { + if (!folder) { + return; + } + + if (nestedFoldersEnabled) { + setMoving(true); + let totalCount = 0; + let successCount = 0; + + if (selectedDashboards.length) { + const moveDashboardsResult = await moveDashboards(selectedDashboards, folder); + + totalCount += moveDashboardsResult.totalCount; + successCount += moveDashboardsResult.successCount; + } + + if (selectedFolders.length) { + const moveFoldersResult = await moveFolders(selectedFolders, folder); + + totalCount += moveFoldersResult.totalCount; + successCount += moveFoldersResult.successCount; + } + + const destTitle = folder.title ?? 'General'; + notifyNestedMoveResult(notifyApp, destTitle, { + selectedDashboardsCount: selectedDashboards.length, + selectedFoldersCount: selectedFolders.length, + totalCount, + successCount, + }); + + onMoveItems(); + setMoving(false); + onDismiss(); + + return; + } + + if (selectedDashboards.length) { const folderTitle = folder.title ?? 'General'; setMoving(true); - moveDashboards(selectedDashboards, folder).then((result: any) => { + moveDashboards(selectedDashboards, folder).then((result) => { if (result.successCount > 0) { const ending = result.successCount === 1 ? '' : 's'; const header = `Dashboard${ending} Moved`; @@ -48,19 +101,35 @@ export const MoveToFolderModal = ({ results, onMoveItems, onDismiss }: Props) => } }; + const thingsMoving = [ + ['folder', 'folders', selectedFolders.length] as const, + ['dashboard', 'dashboards', selectedDashboards.length] as const, + ] + .filter(([single, plural, count]) => count > 0) + .map(([single, plural, count]) => `${count.toLocaleString()} ${count === 1 ? single : plural}`) + .join(' and '); + return ( - + <>
-

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

- setFolder(f)} /> + {nestedFoldersEnabled && selectedFolders.length > 0 && ( + + )} + +

Move {thingsMoving} to:

+ +
- -