BulkDeleteProvisionedResource: Add unit tests (#108484)

* BulkDeleteProvisionedResource: added unit tests and reorganize folder permission

---------

Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>
This commit is contained in:
Yunwen Zheng
2025-07-23 17:12:23 +00:00
committed by GitHub
co-authored by Tom Ratcliffe
parent cc869e7668
commit 709aeb4e1a
7 changed files with 422 additions and 8 deletions
@@ -14,7 +14,7 @@ import { useDeleteItemsMutation, useMoveItemsMutation } from '../../api/browseDa
import { useActionSelectionState } from '../../state/hooks';
import { setAllSelection } from '../../state/slice';
import { DashboardTreeSelection } from '../../types';
import { BulkDeleteProvisionedResource } from '../BulkDeleteProvisionedResource';
import { BulkDeleteProvisionedResource } from '../BulkActions/BulkDeleteProvisionedResource';
import { DeleteModal } from './DeleteModal';
import { MoveModal } from './MoveModal';
@@ -0,0 +1,151 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BulkActionFailureBanner, MoveResultFailed } from './BulkActionFailureBanner';
const setup = (resultOverrides?: Array<Partial<MoveResultFailed>>, onDismissOverride?: () => void) => {
const defaultFailedItems: MoveResultFailed[] = [
{
status: 'failed',
title: 'Dashboard 1',
errorMessage: 'Permission denied',
},
{
status: 'failed',
title: 'Dashboard 2',
errorMessage: 'Network error',
},
];
const result =
resultOverrides !== undefined
? resultOverrides.map((override) => ({ status: 'failed' as const, title: 'Default Title', ...override }))
: defaultFailedItems;
const onDismiss = onDismissOverride || jest.fn();
const props = {
result,
onDismiss,
};
return {
user: userEvent.setup(),
...render(<BulkActionFailureBanner {...props} />),
props,
};
};
describe('BulkActionFailureBanner', () => {
it('should display error alert with correct item count', () => {
const testData = [{ title: 'Single Item', errorMessage: 'Single error' }];
setup(testData);
// Test that an alert is rendered
const alert = screen.getByRole('alert');
expect(alert).toBeInTheDocument();
// Test structure: should have same number of list items as input data
const listItems = screen.getAllByRole('listitem');
expect(listItems).toHaveLength(testData.length);
});
it('should render correct number of failed items with proper structure', () => {
const testData = [
{ title: 'Failed Dashboard A', errorMessage: 'Access denied' },
{ title: 'Failed Dashboard B', errorMessage: 'Validation failed' },
];
setup(testData);
const alert = screen.getByRole('alert');
expect(alert).toBeInTheDocument();
// Test structure: number of list items matches input
const listItems = screen.getAllByRole('listitem');
expect(listItems).toHaveLength(testData.length);
// Test that each item has the expected structure (title + error message)
listItems.forEach((item, index) => {
const title = testData[index].title;
const errorMessage = testData[index].errorMessage;
if (title) {
expect(item).toHaveTextContent(title);
}
if (errorMessage) {
expect(item).toHaveTextContent(errorMessage);
}
});
});
it('should handle mixed scenarios', () => {
const testData = [
{ title: 'Item without error' },
{ title: 'Item with empty error', errorMessage: '' },
{ title: 'Another item with error', errorMessage: 'Another error' },
];
setup(testData);
const alert = screen.getByRole('alert');
expect(alert).toBeInTheDocument();
const listItems = screen.getAllByRole('listitem');
expect(listItems).toHaveLength(testData.length);
// Test that items with error messages contain both title and error
// Items without errors should only contain title
testData.forEach((data, index) => {
const listItem = listItems[index];
// All items should have their title
if (data.title) {
expect(listItem).toHaveTextContent(data.title);
}
// Only items with non-empty error messages should show the error
if (data.errorMessage && data.errorMessage.trim() !== '') {
expect(listItem).toHaveTextContent(data.errorMessage);
}
});
});
it('should maintain list structure', () => {
const testData = [
{ title: 'Item 1', errorMessage: 'Error 1' },
{ title: 'Item 2', errorMessage: 'Error 2' },
];
setup(testData);
// Test semantic structure
const list = screen.getByRole('list');
expect(list).toBeInTheDocument();
const listItems = screen.getAllByRole('listitem');
expect(listItems).toHaveLength(testData.length);
});
it('should handle items without error messages', () => {
const testData = [{ title: 'Just Title Item' }];
setup(testData);
const listItems = screen.getAllByRole('listitem');
expect(listItems).toHaveLength(1);
const item = listItems[0];
expect(item).toHaveTextContent(testData[0].title!);
// Should not contain colon separator when no error message
expect(item.textContent).not.toMatch(/:\s*.+$/);
});
it('should render dismissible alert', () => {
const onDismiss = jest.fn();
setup([], onDismiss);
const alert = screen.getByRole('alert');
expect(alert).toBeInTheDocument();
// Alert should have close button (dismissible)
const closeButton = screen.getByRole('button', { name: /close/i });
expect(closeButton).toBeInTheDocument();
});
});
@@ -0,0 +1,62 @@
import { render, screen } from '@testing-library/react';
import { BulkActionProgress, ProgressState } from './BulkActionProgress';
const setup = (progressOverrides: Partial<ProgressState> = {}) => {
const defaultProgress: ProgressState = {
current: 5,
total: 10,
item: 'Test Dashboard',
...progressOverrides,
};
const props = {
progress: defaultProgress,
};
return {
...render(<BulkActionProgress {...props} />),
props,
};
};
describe('BulkActionProgress', () => {
it('should render progress text with current and total values', () => {
setup({ current: 3, total: 8 });
expect(screen.getByText(/Progress: 3 of 8/)).toBeInTheDocument();
});
it('should render current item being deleted', () => {
setup({ item: 'My Test Dashboard' });
expect(screen.getByText(/Deleting:/)).toBeInTheDocument();
expect(screen.getByText(/My Test Dashboard/)).toBeInTheDocument();
});
it('should handle edge case with total of 1', () => {
setup({ current: 1, total: 1 });
expect(screen.getByText(/Progress: 1 of 1/)).toBeInTheDocument();
});
it('should handle edge case with zero current progress', () => {
setup({ current: 0, total: 5 });
expect(screen.getByText(/Progress: 0 of 5/)).toBeInTheDocument();
});
it('should render all required elements together', () => {
setup({ current: 7, total: 15, item: 'Complex Dashboard Name' });
// Progress text
expect(screen.getByText(/Progress: 7 of 15/)).toBeInTheDocument();
// Spinner icon
expect(screen.getByTestId('Spinner')).toBeInTheDocument();
// Current item text
expect(screen.getByText(/Deleting:/)).toBeInTheDocument();
expect(screen.getByText(/Complex Dashboard Name/)).toBeInTheDocument();
});
});
@@ -1,5 +1,5 @@
import { Trans } from '@grafana/i18n';
import { Box, Icon, Text, Stack } from '@grafana/ui';
import { Box, Text, Stack, Spinner } from '@grafana/ui';
import ProgressBar from 'app/features/provisioning/Shared/ProgressBar';
export interface ProgressState {
@@ -21,7 +21,7 @@ export function BulkActionProgress({ progress }: { progress: ProgressState }) {
values={{ current: progress.current, total: progress.total }}
/>
</Text>
<Icon name="spinner" className="fa-spin" size="sm" />
<Spinner size="sm" />
</Stack>
<ProgressBar progress={progressPercentage} topBottomSpacing={1} />
<Text variant="bodySmall" color="secondary">
@@ -0,0 +1,201 @@
import { screen, waitFor } from '@testing-library/react';
import { HttpResponse, http } from 'msw';
import { render } from 'test/test-utils';
import { setBackendSrv } from '@grafana/runtime';
import server, { setupMockServer } from '@grafana/test-utils/server';
import { RepositoryView } from 'app/api/clients/provisioning/v0alpha1';
import { backendSrv } from 'app/core/services/backend_srv';
import { BulkDeleteProvisionedResource } from './BulkDeleteProvisionedResource';
// Set up backendSrv as recommended in the PR comment
setBackendSrv(backendSrv);
setupMockServer();
jest.mock('../utils', () => ({
collectSelectedItems: jest.fn().mockReturnValue([
{ uid: 'folder-1', isFolder: true, displayName: 'Test Folder' },
{ uid: 'dashboard-1', isFolder: false, displayName: 'Test Dashboard' },
]),
fetchProvisionedDashboardPath: jest.fn().mockResolvedValue('/test/dashboard.json'),
}));
jest.mock('../../state/hooks', () => ({
useChildrenByParentUIDState: jest.fn().mockReturnValue({}),
rootItemsSelector: jest.fn().mockReturnValue({
items: [
{ uid: 'folder-1', title: 'Test Folder', kind: 'folder' },
{ uid: 'dashboard-1', title: 'Test Dashboard', kind: 'dashboard' },
],
}),
}));
jest.mock('../../state/utils', () => ({
findItem: jest.fn().mockImplementation((rootItems: unknown[], childrenByUID: unknown, uid: string) => {
const mockRootItems = [
{ uid: 'folder-1', title: 'Test Folder', kind: 'folder' },
{ uid: 'dashboard-1', title: 'Test Dashboard', kind: 'dashboard' },
];
return mockRootItems.find((item) => item.uid === uid);
}),
}));
jest.mock('../BrowseActions/DescendantCount', () => ({
DescendantCount: jest.fn(({ selectedItems }) => (
<div data-testid="descendant-count">
Mocked descendant count for {Object.keys(selectedItems.folder).length} folders and{' '}
{Object.keys(selectedItems.dashboard).length} dashboards
</div>
)),
}));
jest.mock('app/features/provisioning/hooks/useGetResourceRepositoryView', () => ({
useGetResourceRepositoryView: jest.fn(),
}));
describe('BulkDeleteProvisionedResource', () => {
const defaultRepository: RepositoryView = {
name: 'test-folder', // This must match the folderUid passed to the component
type: 'github',
title: 'Test Repository',
target: 'folder',
workflows: ['branch', 'write'],
};
const selectedItems = {
folder: { 'folder-1': true },
dashboard: { 'dashboard-1': true },
};
beforeEach(() => {
server.use(
http.delete('/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/:name/files/*', () => {
return HttpResponse.json({
urls: { repositoryURL: 'https://github.com/test/repo' },
});
})
);
jest.clearAllMocks();
const { useGetResourceRepositoryView } = jest.requireMock(
'app/features/provisioning/hooks/useGetResourceRepositoryView'
);
useGetResourceRepositoryView.mockReturnValue({
repository: defaultRepository,
folder: {
metadata: {
annotations: {
'grafana.app/file-path': '/test/folder',
},
},
},
isInstanceManaged: false,
});
});
afterEach(() => {
jest.restoreAllMocks();
});
function setup(repository: RepositoryView | null = defaultRepository) {
const onDismiss = jest.fn();
const { useGetResourceRepositoryView } = jest.requireMock(
'app/features/provisioning/hooks/useGetResourceRepositoryView'
);
useGetResourceRepositoryView.mockReturnValue({
repository,
folder: repository
? {
metadata: {
annotations: {
'grafana.app/file-path': '/test/folder',
},
},
}
: null,
isInstanceManaged: false,
});
const renderResult = render(
<BulkDeleteProvisionedResource folderUid="test-folder" selectedItems={selectedItems} onDismiss={onDismiss} />
);
return {
onDismiss,
...renderResult,
};
}
it('renders the delete warning and form', async () => {
setup();
expect(await screen.findByText(/This will delete selected folders and their descendants/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Delete/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
});
it('calls onDismiss when Cancel is clicked', async () => {
const { onDismiss, user } = setup();
await user.click(screen.getByRole('button', { name: /Cancel/i }));
expect(onDismiss).toHaveBeenCalled();
});
it('handles successful deletion', async () => {
const { user } = setup();
await user.click(screen.getByRole('button', { name: /Delete/i }));
expect(await screen.findByText(/All resources have been deleted successfully/)).toBeInTheDocument();
});
it('handles deletion errors', async () => {
const { user } = setup();
// Mock API to return error for this test
server.use(
http.delete('/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/:name/files/*', () => {
return HttpResponse.json({ message: 'Network error' }, { status: 500 });
})
);
await user.click(screen.getByRole('button', { name: /Delete/i }));
await waitFor(() => {
// Should show error alert with failed items
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByLabelText(/items failed/)).toBeInTheDocument();
// Should have error list items for both folder and dashboard
const errorItems = screen.getAllByRole('listitem');
expect(errorItems).toHaveLength(2); // One for folder, one for dashboard
});
});
it('shows loading state during deletion', async () => {
const { user } = setup();
// Mock slow API response
server.use(
http.delete('/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/:name/files/*', async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
return HttpResponse.json({
urls: { repositoryURL: 'https://github.com/test/repo' },
});
})
);
await user.click(screen.getByRole('button', { name: /Delete/i }));
expect(screen.getByText(/Deleting.../)).toBeInTheDocument();
});
it('returns null when repository is not available', () => {
const { container } = setup(null);
expect(container.firstChild).toBeNull();
});
});
@@ -19,14 +19,14 @@ import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/us
import { WorkflowOption } from 'app/features/provisioning/types';
import { useSelector } from 'app/types/store';
import { useChildrenByParentUIDState, rootItemsSelector } from '../state/hooks';
import { findItem } from '../state/utils';
import { DashboardTreeSelection } from '../types';
import { useChildrenByParentUIDState, rootItemsSelector } from '../../state/hooks';
import { findItem } from '../../state/utils';
import { DashboardTreeSelection } from '../../types';
import { DescendantCount } from '../BrowseActions/DescendantCount';
import { collectSelectedItems, fetchProvisionedDashboardPath } from '../utils';
import { DescendantCount } from './BrowseActions/DescendantCount';
import { BulkActionFailureBanner, MoveResultFailed } from './BulkActionFailureBanner';
import { BulkActionProgress, ProgressState } from './BulkActionProgress';
import { collectSelectedItems, fetchProvisionedDashboardPath } from './utils';
interface BulkDeleteFormData {
comment: string;