mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -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": [
|
"public/app/features/search/hooks/useSearchKeyboardSelection.ts:5381": [
|
||||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
[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": [
|
"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.", "0"],
|
||||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
[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) {
|
export function moveDashboards(dashboardUids: string[], toFolder: FolderInfo) {
|
||||||
const tasks = [];
|
const tasks = [];
|
||||||
|
|
||||||
@ -284,6 +304,13 @@ export function createFolder(payload: any) {
|
|||||||
return getBackendSrv().post('/api/folders', payload);
|
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 const SLICE_FOLDER_RESULTS_TO = 1000;
|
||||||
|
|
||||||
export function searchFolders(
|
export function searchFolders(
|
||||||
@ -311,7 +338,7 @@ export function deleteDashboard(uid: string, showSuccessAlert: boolean) {
|
|||||||
return getBackendSrv().delete<DeleteDashboardResponse>(`/api/dashboards/uid/${uid}`, { showSuccessAlert });
|
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 tasks.reduce((acc, task) => {
|
||||||
return Promise.resolve(acc).then(task);
|
return Promise.resolve(acc).then(task);
|
||||||
}, []);
|
}, []);
|
||||||
|
@ -51,7 +51,7 @@ describe('ManageActions', () => {
|
|||||||
|
|
||||||
// open Move modal
|
// open Move modal
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Move', hidden: true }));
|
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 () => {
|
it('should show delete modal when user click the delete button', async () => {
|
||||||
|
@ -1,23 +1,45 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import configureMockStore from 'redux-mock-store';
|
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';
|
import { MoveToFolderModal } from './MoveToFolderModal';
|
||||||
|
|
||||||
jest.mock('app/core/components/Select/FolderPicker', () => {
|
function makeSelections(dashboardUIDs: string[] = [], folderUIDs: string[] = []) {
|
||||||
return {
|
const dashboards = new Set(dashboardUIDs);
|
||||||
FolderPicker: () => null,
|
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', () => {
|
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 () => {
|
it('should render correct title, body, dismiss-, cancel- and move-text', async () => {
|
||||||
const items = new Map();
|
const items = makeSelections(['dash-uid-1', 'dash-uid-2']);
|
||||||
const dashboardsUIDs = new Set();
|
|
||||||
dashboardsUIDs.add('uid1');
|
|
||||||
dashboardsUIDs.add('uid2');
|
|
||||||
items.set('dashboard', dashboardsUIDs);
|
|
||||||
const mockStore = configureMockStore();
|
const mockStore = configureMockStore();
|
||||||
const store = mockStore({ dashboard: { panels: [] } });
|
const store = mockStore({ dashboard: { panels: [] } });
|
||||||
const onMoveItems = jest.fn();
|
const onMoveItems = jest.fn();
|
||||||
@ -28,9 +50,110 @@ describe('MoveToFolderModal', () => {
|
|||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Wait for folder picker to finish rendering
|
||||||
|
await screen.findByText('Choose');
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { name: 'Choose Dashboard Folder' })).toBeInTheDocument();
|
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: 'Cancel' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'Move' })).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 { css } from '@emotion/css';
|
||||||
import React, { useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
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 { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
||||||
|
import config from 'app/core/config';
|
||||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
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 { FolderInfo } from 'app/types';
|
||||||
|
|
||||||
|
import { GENERAL_FOLDER_UID } from '../../constants';
|
||||||
import { OnMoveOrDeleleSelectedItems } from '../../types';
|
import { OnMoveOrDeleleSelectedItems } from '../../types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -20,14 +22,65 @@ export const MoveToFolderModal = ({ results, onMoveItems, onDismiss }: Props) =>
|
|||||||
const [folder, setFolder] = useState<FolderInfo | null>(null);
|
const [folder, setFolder] = useState<FolderInfo | null>(null);
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const notifyApp = useAppNotification();
|
const notifyApp = useAppNotification();
|
||||||
const selectedDashboards = Array.from(results.get('dashboard') ?? []);
|
|
||||||
const [moving, setMoving] = useState(false);
|
const [moving, setMoving] = useState(false);
|
||||||
|
|
||||||
const moveTo = () => {
|
const nestedFoldersEnabled = config.featureToggles.nestedFolders;
|
||||||
if (folder && selectedDashboards.length) {
|
|
||||||
|
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';
|
const folderTitle = folder.title ?? 'General';
|
||||||
setMoving(true);
|
setMoving(true);
|
||||||
moveDashboards(selectedDashboards, folder).then((result: any) => {
|
moveDashboards(selectedDashboards, folder).then((result) => {
|
||||||
if (result.successCount > 0) {
|
if (result.successCount > 0) {
|
||||||
const ending = result.successCount === 1 ? '' : 's';
|
const ending = result.successCount === 1 ? '' : 's';
|
||||||
const header = `Dashboard${ending} Moved`;
|
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 (
|
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}>
|
<div className={styles.content}>
|
||||||
<p>
|
{nestedFoldersEnabled && selectedFolders.length > 0 && (
|
||||||
Move the {selectedDashboards.length} selected dashboard{selectedDashboards.length === 1 ? '' : 's'} to the
|
<Alert severity="warning" title=" Moving this item may change its permissions" />
|
||||||
following folder:
|
)}
|
||||||
</p>
|
|
||||||
<FolderPicker allowEmpty={true} enableCreateNew={false} onChange={(f) => setFolder(f)} />
|
<p>Move {thingsMoving} to:</p>
|
||||||
|
|
||||||
|
<FolderPicker allowEmpty={true} enableCreateNew={false} onChange={handleFolderChange} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HorizontalGroup justify="center">
|
<HorizontalGroup justify="flex-end">
|
||||||
<Button icon={moving ? 'fa fa-spinner' : undefined} disabled={!folder} variant="primary" onClick={moveTo}>
|
<Button icon={moving ? 'fa fa-spinner' : undefined} variant="primary" onClick={moveTo}>
|
||||||
Move
|
Move
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" onClick={onDismiss}>
|
<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) => {
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
return {
|
return {
|
||||||
modal: css`
|
modal: css`
|
||||||
|
Loading…
Reference in New Issue
Block a user