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
This commit is contained in:
Josh Hunt 2023-03-31 12:31:06 +01:00 committed by GitHub
parent c2813869e4
commit 9f4ab1b6dd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 287 additions and 31 deletions

View File

@ -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"],

View File

@ -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<DeleteDashboardResponse>(`/api/dashboards/uid/${uid}`, { showSuccessAlert });
}
function executeInOrder(tasks: any[]) {
function executeInOrder(tasks: any[]): Promise<unknown> {
return tasks.reduce((acc, task) => {
return Promise.resolve(acc).then(task);
}, []);

View File

@ -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 () => {

View File

@ -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', () => {
</Provider>
);
// 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(
<Provider store={store}>
<MoveToFolderModal onMoveItems={onMoveItems} results={items} onDismiss={() => {}} />
</Provider>
);
// 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(
<Provider store={store}>
<MoveToFolderModal onMoveItems={onMoveItems} results={items} onDismiss={() => {}} />
</Provider>
);
// 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',
});
});
});
});

View File

@ -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<FolderInfo | null>(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 (
<Modal className={styles.modal} title="Choose Dashboard Folder" icon="folder-plus" isOpen onDismiss={onDismiss}>
<Modal
isOpen
className={styles.modal}
title={nestedFoldersEnabled ? 'Move' : 'Choose Dashboard Folder'}
icon="folder-plus"
onDismiss={onDismiss}
>
<>
<div className={styles.content}>
<p>
Move the {selectedDashboards.length} selected dashboard{selectedDashboards.length === 1 ? '' : 's'} to the
following folder:
</p>
<FolderPicker allowEmpty={true} enableCreateNew={false} onChange={(f) => setFolder(f)} />
{nestedFoldersEnabled && selectedFolders.length > 0 && (
<Alert severity="warning" title=" Moving this item may change its permissions" />
)}
<p>Move {thingsMoving} to:</p>
<FolderPicker allowEmpty={true} enableCreateNew={false} onChange={handleFolderChange} />
</div>
<HorizontalGroup justify="center">
<Button icon={moving ? 'fa fa-spinner' : undefined} disabled={!folder} variant="primary" onClick={moveTo}>
<HorizontalGroup justify="flex-end">
<Button icon={moving ? 'fa fa-spinner' : undefined} variant="primary" onClick={moveTo}>
Move
</Button>
<Button variant="secondary" onClick={onDismiss}>
@ -72,6 +141,46 @@ export const MoveToFolderModal = ({ results, onMoveItems, onDismiss }: Props) =>
);
};
interface NotifyCounts {
selectedDashboardsCount: number;
selectedFoldersCount: number;
totalCount: number;
successCount: number;
}
function notifyNestedMoveResult(
notifyApp: ReturnType<typeof useAppNotification>,
destinationName: string,
{ selectedDashboardsCount, selectedFoldersCount, totalCount, successCount }: NotifyCounts
) {
let objectMoving: string | undefined;
const plural = successCount === 1 ? '' : 's';
const failedCount = totalCount - successCount;
if (selectedDashboardsCount && selectedFoldersCount) {
objectMoving = `Item${plural}`;
} else if (selectedDashboardsCount) {
objectMoving = `Dashboard${plural}`;
} else if (selectedFoldersCount) {
objectMoving = `Folder${plural}`;
}
if (objectMoving) {
const objectLower = objectMoving?.toLocaleLowerCase();
if (totalCount === successCount) {
notifyApp.success(`${objectMoving} moved`, `Moved ${successCount} ${objectLower} to ${destinationName}`);
} else if (successCount === 0) {
notifyApp.error(`Failed to move ${objectLower}`, `Could not move ${totalCount} ${objectLower} due to an error`);
} else {
notifyApp.warning(
`Partially moved ${objectLower}`,
`Failed to move ${failedCount} ${objectLower} to ${destinationName}`
);
}
}
}
const getStyles = (theme: GrafanaTheme2) => {
return {
modal: css`