mirror of
https://github.com/grafana/grafana.git
synced 2025-02-12 16:45:43 -06:00
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:
parent
c2813869e4
commit
9f4ab1b6dd
@ -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"],
|
||||
|
@ -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);
|
||||
}, []);
|
||||
|
@ -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 () => {
|
||||
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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`
|
||||
|
Loading…
Reference in New Issue
Block a user