BulkDeleteProvisionedResource: Use resource names to perform bulk deletion job (#109281)
* BulkMoveProvisionedResource and BulkDeleteProvisionedResource: use resource name for BE job
This commit is contained in:
-151
@@ -1,151 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
import { t } from '@grafana/i18n';
|
||||
import { Alert } from '@grafana/ui';
|
||||
|
||||
export type MoveResultFailed = {
|
||||
status?: 'failed';
|
||||
title?: string;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export function BulkActionFailureBanner({ result, onDismiss }: { result: MoveResultFailed[]; onDismiss: () => void }) {
|
||||
return (
|
||||
<Alert
|
||||
severity="error"
|
||||
title={t('browse-dashboards.bulk-action-resources-form.failed-alert', '{{count}} items failed', {
|
||||
count: result.length,
|
||||
})}
|
||||
onRemove={onDismiss}
|
||||
>
|
||||
<ul>
|
||||
{result.map((item) => (
|
||||
<li key={item.title}>
|
||||
<strong>{item.title}</strong>
|
||||
{item.errorMessage && `: ${item.errorMessage}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { Alert, Button, Stack } from '@grafana/ui';
|
||||
|
||||
import { BulkActionFailureBanner, MoveResultFailed } from './BulkActionFailureBanner';
|
||||
import { BulkActionProgress, ProgressState } from './BulkActionProgress';
|
||||
import { MoveResultSuccessState } from './utils';
|
||||
|
||||
interface Props {
|
||||
action: 'move' | 'delete';
|
||||
progress: ProgressState | null;
|
||||
successState: MoveResultSuccessState;
|
||||
failureResults: MoveResultFailed[] | undefined;
|
||||
handleSuccess: () => void;
|
||||
setFailureResults: (results: MoveResultFailed[] | undefined) => void;
|
||||
}
|
||||
|
||||
export function BulkActionPostSubmitStep({
|
||||
action,
|
||||
progress,
|
||||
successState,
|
||||
failureResults,
|
||||
handleSuccess,
|
||||
setFailureResults,
|
||||
}: Props) {
|
||||
if (progress) {
|
||||
return <BulkActionProgress progress={progress} action={action} />;
|
||||
}
|
||||
|
||||
if (successState.allSuccess) {
|
||||
return (
|
||||
<>
|
||||
<Alert severity="success" title={t('browse-dashboards.bulk-action-resources-form.progress-title', 'Success')}>
|
||||
{action === 'move'
|
||||
? t('browse-dashboards.bulk-action-resources-form.all-moved', 'All resources have been moved successfully')
|
||||
: t(
|
||||
'browse-dashboards.bulk-action-resources-form.all-deleted',
|
||||
'All resources have been deleted successfully'
|
||||
)}
|
||||
</Alert>
|
||||
<Stack gap={2}>
|
||||
<Button onClick={() => handleSuccess()}>
|
||||
<Trans i18nKey="browse-dashboards.bulk-action-resources-form.button-done">Done</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (failureResults) {
|
||||
return <BulkActionFailureBanner result={failureResults} onDismiss={() => setFailureResults(undefined)} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { BulkActionProgress, ProgressState } from './BulkActionProgress';
|
||||
|
||||
const setup = (progressOverrides: Partial<ProgressState> = {}, action: 'delete' | 'move' = 'delete') => {
|
||||
const defaultProgress: ProgressState = {
|
||||
current: 5,
|
||||
total: 10,
|
||||
item: 'Test Dashboard',
|
||||
...progressOverrides,
|
||||
};
|
||||
|
||||
const props = {
|
||||
progress: defaultProgress,
|
||||
};
|
||||
|
||||
return {
|
||||
...render(<BulkActionProgress {...props} action={action} />),
|
||||
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();
|
||||
});
|
||||
|
||||
it('should render moving action text when action is move', () => {
|
||||
setup({ current: 2, total: 4, item: 'Moving Dashboard' }, 'move');
|
||||
|
||||
expect(screen.getByText(/Moving:/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Moving Dashboard/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { Box, Text, Stack, Spinner } from '@grafana/ui';
|
||||
import ProgressBar from 'app/features/provisioning/Shared/ProgressBar';
|
||||
|
||||
export type ProgressState = {
|
||||
current: number;
|
||||
total: number;
|
||||
item: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
progress: ProgressState;
|
||||
action: 'move' | 'delete';
|
||||
}
|
||||
|
||||
export function BulkActionProgress({ progress, action }: Props) {
|
||||
const progressPercentage = Math.round((progress.current / progress.total) * 100);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center">
|
||||
<Text>
|
||||
<Trans i18nKey="browse-dashboards.bulk-move-resources-form.progress">
|
||||
Progress: {{ current: progress.current }} of {{ total: progress.total }}
|
||||
</Trans>
|
||||
</Text>
|
||||
<Spinner size="sm" />
|
||||
</Stack>
|
||||
<ProgressBar progress={progressPercentage} topBottomSpacing={1} />
|
||||
<Text variant="bodySmall" color="secondary">
|
||||
{action === 'move' ? (
|
||||
<Trans i18nKey="browse-dashboards.bulk-move-resources-form.moving">Moving</Trans>
|
||||
) : (
|
||||
<Trans i18nKey="browse-dashboards.bulk-move-resources-form.deleting">Deleting</Trans>
|
||||
)}
|
||||
: {progress.item}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+167
-131
@@ -1,45 +1,10 @@
|
||||
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 { Job, RepositoryView } from 'app/api/clients/provisioning/v0alpha1';
|
||||
|
||||
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);
|
||||
}),
|
||||
}));
|
||||
import { ResponseType } from './useBulkActionJob';
|
||||
|
||||
jest.mock('../BrowseActions/DescendantCount', () => ({
|
||||
DescendantCount: jest.fn(({ selectedItems }) => (
|
||||
@@ -54,82 +19,98 @@ jest.mock('app/features/provisioning/hooks/useGetResourceRepositoryView', () =>
|
||||
useGetResourceRepositoryView: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('BulkDeleteProvisionedResource', () => {
|
||||
jest.mock('./useBulkActionJob', () => ({
|
||||
useBulkActionJob: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('app/features/provisioning/Job/JobStatus', () => ({
|
||||
JobStatus: jest.fn(({ watch, jobType }) => (
|
||||
<div data-testid="job-status">
|
||||
Job Status - {jobType} - {watch?.status?.state || 'pending'}
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getAppEvents: jest.fn(() => ({
|
||||
publish: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockUseGetResourceRepositoryView = jest.mocked(
|
||||
require('app/features/provisioning/hooks/useGetResourceRepositoryView').useGetResourceRepositoryView
|
||||
);
|
||||
const mockUseBulkActionJob = jest.mocked(require('./useBulkActionJob').useBulkActionJob);
|
||||
const mockGetAppEvents = jest.mocked(require('@grafana/runtime').getAppEvents);
|
||||
|
||||
function setup(
|
||||
repository: RepositoryView | null,
|
||||
mockJobResult: ResponseType = {
|
||||
success: true,
|
||||
job: { metadata: { name: 'test-job' }, status: { state: 'success' } },
|
||||
},
|
||||
isLoading = false
|
||||
) {
|
||||
const selectedItems = {
|
||||
folder: { 'folder-1': true },
|
||||
dashboard: { 'dashboard-1': true },
|
||||
};
|
||||
|
||||
const defaultRepository: RepositoryView = {
|
||||
name: 'test-folder', // This must match the folderUid passed to the component
|
||||
name: 'test-folder',
|
||||
type: 'github',
|
||||
title: 'Test Repository',
|
||||
target: 'folder',
|
||||
workflows: ['branch', 'write'],
|
||||
};
|
||||
|
||||
const selectedItems = {
|
||||
folder: { 'folder-1': true },
|
||||
dashboard: { 'dashboard-1': true },
|
||||
};
|
||||
const onDismiss = jest.fn();
|
||||
const mockCreateBulkJob = jest.fn().mockResolvedValue(mockJobResult);
|
||||
|
||||
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',
|
||||
mockUseGetResourceRepositoryView.mockReturnValue({
|
||||
repository: repository ?? defaultRepository,
|
||||
folder: repository
|
||||
? {
|
||||
metadata: {
|
||||
annotations: {
|
||||
'grafana.app/file-path': '/test/folder',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
isInstanceManaged: false,
|
||||
});
|
||||
}
|
||||
: null,
|
||||
isInstanceManaged: false,
|
||||
});
|
||||
|
||||
mockUseBulkActionJob.mockReturnValue({
|
||||
createBulkJob: mockCreateBulkJob,
|
||||
isLoading,
|
||||
});
|
||||
|
||||
const renderResult = render(
|
||||
<BulkDeleteProvisionedResource folderUid="test-folder" selectedItems={selectedItems} onDismiss={onDismiss} />
|
||||
);
|
||||
|
||||
return {
|
||||
onDismiss,
|
||||
mockCreateBulkJob,
|
||||
selectedItems,
|
||||
defaultRepository,
|
||||
...renderResult,
|
||||
};
|
||||
}
|
||||
|
||||
describe('BulkDeleteProvisionedResource', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
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();
|
||||
setup(null);
|
||||
|
||||
expect(await screen.findByText(/This will delete selected folders and their descendants/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Delete/i })).toBeInTheDocument();
|
||||
@@ -137,7 +118,7 @@ describe('BulkDeleteProvisionedResource', () => {
|
||||
});
|
||||
|
||||
it('calls onDismiss when Cancel is clicked', async () => {
|
||||
const { onDismiss, user } = setup();
|
||||
const { onDismiss, user } = setup(null);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
|
||||
@@ -145,57 +126,112 @@ describe('BulkDeleteProvisionedResource', () => {
|
||||
});
|
||||
|
||||
it('handles successful deletion', async () => {
|
||||
const { user } = setup();
|
||||
const { user, mockCreateBulkJob, defaultRepository } = setup(null);
|
||||
|
||||
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 });
|
||||
expect(mockCreateBulkJob).toHaveBeenCalledWith(
|
||||
defaultRepository,
|
||||
expect.objectContaining({
|
||||
action: 'delete',
|
||||
delete: expect.objectContaining({
|
||||
resources: expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'folder-1', kind: 'Folder' }),
|
||||
expect.objectContaining({ name: 'dashboard-1', kind: 'Dashboard' }),
|
||||
]),
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
// Should show JobStatus component
|
||||
expect(await screen.findByTestId('job-status')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Job Status - delete - success/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles deletion errors', async () => {
|
||||
const mockPublish = jest.fn();
|
||||
const { user } = setup(null, { success: false, error: 'Network error' });
|
||||
|
||||
mockGetAppEvents.mockReturnValue({
|
||||
publish: mockPublish,
|
||||
});
|
||||
|
||||
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 remain on form (not show JobStatus) when there's an error
|
||||
expect(screen.queryByTestId('job-status')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Delete/i })).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
|
||||
expect(mockPublish).toHaveBeenCalledWith({
|
||||
type: 'alert-error',
|
||||
payload: ['Error deleting resources', 'Network error'],
|
||||
});
|
||||
});
|
||||
|
||||
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' },
|
||||
});
|
||||
})
|
||||
);
|
||||
const workingJob: Job = { metadata: { name: 'test-job' }, status: { state: 'working' } };
|
||||
const { user } = setup(null, { success: true, job: workingJob });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Delete/i }));
|
||||
|
||||
expect(screen.getByText(/Deleting.../)).toBeInTheDocument();
|
||||
// After click, should show JobStatus
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('job-status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Job Status - delete - working/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns null when repository is not available', () => {
|
||||
setup(null);
|
||||
it('Should not show buttons when job is in working state', async () => {
|
||||
const workingJob: Job = { metadata: { name: 'test-job' }, status: { state: 'working' } };
|
||||
const { user } = setup(null, { success: true, job: workingJob });
|
||||
|
||||
expect(screen.getByLabelText('Repository not found')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /Delete/i }));
|
||||
|
||||
// Should show JobStatus after submission
|
||||
expect(await screen.findByTestId('job-status')).toBeInTheDocument();
|
||||
|
||||
// Done button should not be disabled
|
||||
const deleteButton = screen.queryByRole('button', { name: /Delete/i });
|
||||
expect(deleteButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls createBulkJob with branch workflow parameters when branch is selected', async () => {
|
||||
const { user, mockCreateBulkJob, defaultRepository } = setup(null);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Delete/i }));
|
||||
|
||||
expect(mockCreateBulkJob).toHaveBeenCalledWith(
|
||||
defaultRepository,
|
||||
expect.objectContaining({
|
||||
action: 'delete',
|
||||
delete: expect.objectContaining({
|
||||
ref: expect.stringContaining('bulk-delete/'),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('calls createBulkJob with write workflow parameters when write is selected', async () => {
|
||||
const { user, mockCreateBulkJob, defaultRepository } = setup(null);
|
||||
|
||||
// Switch to write workflow
|
||||
const writeRadio = screen.getByRole('radio', { name: /Save/i });
|
||||
await user.click(writeRadio);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Delete/i }));
|
||||
|
||||
expect(mockCreateBulkJob).toHaveBeenCalledWith(
|
||||
defaultRepository,
|
||||
expect.objectContaining({
|
||||
action: 'delete',
|
||||
delete: expect.objectContaining({
|
||||
resources: expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'folder-1', kind: 'Folder' }),
|
||||
expect.objectContaining({ name: 'dashboard-1', kind: 'Dashboard' }),
|
||||
]),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+42
-131
@@ -1,155 +1,76 @@
|
||||
import { useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { getAppEvents } from '@grafana/runtime';
|
||||
import { Box, Button, Stack } from '@grafana/ui';
|
||||
import {
|
||||
DeleteRepositoryFilesWithPathApiArg,
|
||||
DeleteRepositoryFilesWithPathApiResponse,
|
||||
RepositoryView,
|
||||
useDeleteRepositoryFilesWithPathMutation,
|
||||
} from 'app/api/clients/provisioning/v0alpha1';
|
||||
import { extractErrorMessage } from 'app/api/utils';
|
||||
import { AnnoKeySourcePath } from 'app/features/apiserver/types';
|
||||
import { Job, RepositoryView } from 'app/api/clients/provisioning/v0alpha1';
|
||||
import { ResourceEditFormSharedFields } from 'app/features/dashboard-scene/components/Provisioned/ResourceEditFormSharedFields';
|
||||
import { getDefaultWorkflow, getWorkflowOptions } from 'app/features/dashboard-scene/saving/provisioned/defaults';
|
||||
import { generateTimestamp } from 'app/features/dashboard-scene/saving/provisioned/utils/timestamp';
|
||||
import { JobStatus } from 'app/features/provisioning/Job/JobStatus';
|
||||
import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView';
|
||||
import { useSelector } from 'app/types/store';
|
||||
|
||||
import { useChildrenByParentUIDState, rootItemsSelector } from '../../state/hooks';
|
||||
import { findItem } from '../../state/utils';
|
||||
import { DescendantCount } from '../BrowseActions/DescendantCount';
|
||||
import { collectSelectedItems, fetchProvisionedDashboardPath } from '../utils';
|
||||
import { collectSelectedItems } from '../utils';
|
||||
|
||||
import { MoveResultFailed } from './BulkActionFailureBanner';
|
||||
import { BulkActionPostSubmitStep } from './BulkActionPostSubmitStep';
|
||||
import { ProgressState } from './BulkActionProgress';
|
||||
import { RepoInvalidStateBanner } from './RepoInvalidStateBanner';
|
||||
import { useBulkActionRequest } from './useBulkActionRequest';
|
||||
import { DeleteJobSpec, useBulkActionJob } from './useBulkActionJob';
|
||||
import { useFolderNameFromSelection } from './useFolderNameFromSelection';
|
||||
import {
|
||||
BulkActionFormData,
|
||||
BulkActionProvisionResourceProps,
|
||||
BulkSuccessResponse,
|
||||
MoveResultSuccessState,
|
||||
} from './utils';
|
||||
import { BulkActionFormData, BulkActionProvisionResourceProps } from './utils';
|
||||
|
||||
interface FormProps extends BulkActionProvisionResourceProps {
|
||||
initialValues: BulkActionFormData;
|
||||
repository: RepositoryView;
|
||||
workflowOptions: Array<{ label: string; value: string }>;
|
||||
folderPath?: string;
|
||||
}
|
||||
|
||||
function FormContent({ initialValues, selectedItems, repository, workflowOptions, folderPath, onDismiss }: FormProps) {
|
||||
function FormContent({ initialValues, selectedItems, repository, workflowOptions, onDismiss }: FormProps) {
|
||||
// States
|
||||
const [progress, setProgress] = useState<ProgressState | null>(null);
|
||||
const [failureResults, setFailureResults] = useState<MoveResultFailed[] | undefined>();
|
||||
const [successState, setSuccessState] = useState<MoveResultSuccessState>({
|
||||
allSuccess: false,
|
||||
repoUrl: '',
|
||||
});
|
||||
const [job, setJob] = useState<Job>();
|
||||
const [hasSubmitted, setHasSubmitted] = useState(false);
|
||||
|
||||
// Hooks
|
||||
const [deleteRepoFile, request] = useDeleteRepositoryFilesWithPathMutation();
|
||||
const { createBulkJob, isLoading: isCreatingJob } = useBulkActionJob();
|
||||
const methods = useForm<BulkActionFormData>({ defaultValues: initialValues });
|
||||
const childrenByParentUID = useChildrenByParentUIDState();
|
||||
const rootItems = useSelector(rootItemsSelector);
|
||||
const { handleSubmit, watch } = methods;
|
||||
const workflow = watch('workflow');
|
||||
const { handleSuccess } = useBulkActionRequest({ workflow, repository, successState, onDismiss });
|
||||
|
||||
const getResourcePath = async (uid: string, isFolder: boolean): Promise<string | undefined> => {
|
||||
const item = findItem(rootItems?.items || [], childrenByParentUID, uid);
|
||||
if (!item) {
|
||||
return undefined;
|
||||
}
|
||||
return isFolder ? `${folderPath}/${item.title}/` : fetchProvisionedDashboardPath(uid);
|
||||
};
|
||||
|
||||
const handleSubmitForm = async (data: BulkActionFormData) => {
|
||||
setFailureResults(undefined);
|
||||
setHasSubmitted(true);
|
||||
|
||||
const targets = collectSelectedItems(selectedItems, childrenByParentUID, rootItems?.items || []);
|
||||
const resources = collectSelectedItems(selectedItems);
|
||||
|
||||
if (targets.length > 0) {
|
||||
setProgress({
|
||||
current: 0,
|
||||
total: targets.length,
|
||||
item: targets[0].displayName || 'Unknown',
|
||||
// Create the delete job spec
|
||||
const jobSpec: DeleteJobSpec = {
|
||||
action: 'delete',
|
||||
delete: {
|
||||
ref: data.workflow === 'write' ? undefined : data.ref,
|
||||
resources,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await createBulkJob(repository, jobSpec);
|
||||
|
||||
if (result.success && result.job) {
|
||||
setJob(result.job); // Store the job for tracking
|
||||
} else if (!result.success && result.error) {
|
||||
// Handle error case - show error alert
|
||||
getAppEvents().publish({
|
||||
type: AppEvents.alertError.name,
|
||||
payload: [
|
||||
t('browse-dashboards.bulk-delete-resources-form.error-deleting-resources', 'Error deleting resources'),
|
||||
result.error,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const successes: BulkSuccessResponse<
|
||||
DeleteRepositoryFilesWithPathApiArg,
|
||||
DeleteRepositoryFilesWithPathApiResponse
|
||||
> = [];
|
||||
const failures: MoveResultFailed[] = [];
|
||||
|
||||
// Iterate through each selected item and delete it
|
||||
// We want sequential processing to avoid overwhelming the API
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
const { uid, isFolder, displayName } = targets[i];
|
||||
setProgress({
|
||||
current: i,
|
||||
total: targets.length,
|
||||
item: displayName,
|
||||
});
|
||||
|
||||
try {
|
||||
// get path in repository
|
||||
const path = await getResourcePath(uid, isFolder);
|
||||
if (!path) {
|
||||
failures.push({
|
||||
status: 'failed',
|
||||
title: `${isFolder ? 'Folder' : 'Dashboard'}: ${displayName}`,
|
||||
errorMessage: t('browse-dashboards.bulk-delete-resources-form.error-path-not-found', 'Path not found'),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// build params
|
||||
const deleteParams: DeleteRepositoryFilesWithPathApiArg = {
|
||||
name: repository.name,
|
||||
path,
|
||||
ref: workflow === 'write' ? undefined : data.ref,
|
||||
message: data.comment || `Delete resource ${path}`,
|
||||
};
|
||||
|
||||
// perform delete operation
|
||||
const response = await deleteRepoFile(deleteParams).unwrap();
|
||||
successes.push({ index: i, item: deleteParams, data: response });
|
||||
} catch (error: unknown) {
|
||||
failures.push({
|
||||
status: 'failed',
|
||||
title: `${isFolder ? 'Folder' : 'Dashboard'}: ${displayName}`,
|
||||
errorMessage: extractErrorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
setProgress({
|
||||
current: i + 1,
|
||||
total: targets.length,
|
||||
item: targets[i + 1]?.displayName,
|
||||
});
|
||||
}
|
||||
|
||||
setProgress(null);
|
||||
|
||||
if (successes.length > 0 && failures.length === 0) {
|
||||
// handleSuccess(successes);
|
||||
setSuccessState({
|
||||
allSuccess: true,
|
||||
repoUrl: successes[0].data.urls?.newPullRequestURL,
|
||||
});
|
||||
} else if (failures.length > 0) {
|
||||
setFailureResults(failures);
|
||||
setHasSubmitted(false); // Reset submit state so user can try again
|
||||
}
|
||||
};
|
||||
|
||||
const disableBtn =
|
||||
isCreatingJob || job?.status?.state === 'working' || job?.status?.state === 'pending' || hasSubmitted;
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={handleSubmit(handleSubmitForm)}>
|
||||
@@ -161,15 +82,8 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions
|
||||
<DescendantCount selectedItems={{ ...selectedItems, panel: {}, $all: false }} />
|
||||
</Box>
|
||||
|
||||
{hasSubmitted ? (
|
||||
<BulkActionPostSubmitStep
|
||||
action="delete"
|
||||
progress={progress}
|
||||
successState={successState}
|
||||
failureResults={failureResults}
|
||||
handleSuccess={handleSuccess}
|
||||
setFailureResults={setFailureResults}
|
||||
/>
|
||||
{hasSubmitted && job ? (
|
||||
<JobStatus watch={job} jobType="delete" />
|
||||
) : (
|
||||
<>
|
||||
<ResourceEditFormSharedFields
|
||||
@@ -180,14 +94,13 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions
|
||||
repository={repository}
|
||||
hidePath
|
||||
/>
|
||||
|
||||
<Stack gap={2}>
|
||||
<Button type="submit" disabled={request.isLoading || !!failureResults} variant="destructive">
|
||||
{request.isLoading
|
||||
<Button type="submit" disabled={disableBtn} variant="destructive">
|
||||
{job?.status?.state === 'working' || job?.status?.state === 'pending'
|
||||
? t('browse-dashboards.bulk-delete-resources-form.button-deleting', 'Deleting...')
|
||||
: t('browse-dashboards.bulk-delete-resources-form.button-delete', 'Delete')}
|
||||
</Button>
|
||||
<Button variant="secondary" fill="outline" onClick={onDismiss} disabled={request.isLoading}>
|
||||
<Button variant="secondary" fill="outline" onClick={onDismiss} disabled={isCreatingJob}>
|
||||
<Trans i18nKey="browse-dashboards.bulk-delete-resources-form.button-cancel">Cancel</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
@@ -205,10 +118,9 @@ export function BulkDeleteProvisionedResource({
|
||||
onDismiss,
|
||||
}: BulkActionProvisionResourceProps) {
|
||||
const folderName = useFolderNameFromSelection({ folderUid, selectedItems });
|
||||
const { repository, folder, isReadOnlyRepo } = useGetResourceRepositoryView({ folderName });
|
||||
const { repository, isReadOnlyRepo } = useGetResourceRepositoryView({ folderName });
|
||||
|
||||
const workflowOptions = getWorkflowOptions(repository);
|
||||
const folderPath = folder?.metadata?.annotations?.[AnnoKeySourcePath] || '';
|
||||
const timestamp = generateTimestamp();
|
||||
|
||||
const initialValues = {
|
||||
@@ -228,7 +140,6 @@ export function BulkDeleteProvisionedResource({
|
||||
initialValues={initialValues}
|
||||
repository={repository}
|
||||
workflowOptions={workflowOptions}
|
||||
folderPath={folderPath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+54
-186
@@ -2,45 +2,26 @@ import { skipToken } from '@reduxjs/toolkit/query';
|
||||
import { useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { FolderPicker } from '@grafana/runtime';
|
||||
import { FolderPicker, getAppEvents } from '@grafana/runtime';
|
||||
import { Box, Button, Field, Stack } from '@grafana/ui';
|
||||
import { useGetFolderQuery } from 'app/api/clients/folder/v1beta1';
|
||||
import {
|
||||
CreateRepositoryFilesWithPathApiArg,
|
||||
CreateRepositoryFilesWithPathApiResponse,
|
||||
RepositoryView,
|
||||
useCreateRepositoryFilesWithPathMutation,
|
||||
ResourceWrapper,
|
||||
} from 'app/api/clients/provisioning/v0alpha1';
|
||||
import { extractErrorMessage } from 'app/api/utils';
|
||||
import { ScopedResourceClient } from 'app/features/apiserver/client';
|
||||
import { RepositoryView, Job } from 'app/api/clients/provisioning/v0alpha1';
|
||||
import { AnnoKeySourcePath } from 'app/features/apiserver/types';
|
||||
import { ResourceEditFormSharedFields } from 'app/features/dashboard-scene/components/Provisioned/ResourceEditFormSharedFields';
|
||||
import { getDefaultWorkflow, getWorkflowOptions } from 'app/features/dashboard-scene/saving/provisioned/defaults';
|
||||
import { generateTimestamp } from 'app/features/dashboard-scene/saving/provisioned/utils/timestamp';
|
||||
import { JobStatus } from 'app/features/provisioning/Job/JobStatus';
|
||||
import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView';
|
||||
import { useSelector } from 'app/types/store';
|
||||
|
||||
import { useChildrenByParentUIDState, rootItemsSelector } from '../../state/hooks';
|
||||
import { findItem } from '../../state/utils';
|
||||
import { DescendantCount } from '../BrowseActions/DescendantCount';
|
||||
import { collectSelectedItems, fetchProvisionedDashboardPath } from '../utils';
|
||||
import { collectSelectedItems } from '../utils';
|
||||
|
||||
import { MoveResultFailed } from './BulkActionFailureBanner';
|
||||
import { BulkActionPostSubmitStep } from './BulkActionPostSubmitStep';
|
||||
import { ProgressState } from './BulkActionProgress';
|
||||
import { RepoInvalidStateBanner } from './RepoInvalidStateBanner';
|
||||
import { useBulkActionRequest } from './useBulkActionRequest';
|
||||
import { MoveJobSpec, useBulkActionJob } from './useBulkActionJob';
|
||||
import { useFolderNameFromSelection } from './useFolderNameFromSelection';
|
||||
import {
|
||||
BulkActionFormData,
|
||||
BulkActionProvisionResourceProps,
|
||||
BulkSuccessResponse,
|
||||
getTargetFolderPathInRepo,
|
||||
getResourceTargetPath,
|
||||
MoveResultSuccessState,
|
||||
} from './utils';
|
||||
import { BulkActionFormData, BulkActionProvisionResourceProps, getTargetFolderPathInRepo } from './utils';
|
||||
|
||||
interface FormProps extends BulkActionProvisionResourceProps {
|
||||
initialValues: BulkActionFormData;
|
||||
@@ -51,178 +32,64 @@ interface FormProps extends BulkActionProvisionResourceProps {
|
||||
|
||||
function FormContent({ initialValues, selectedItems, repository, workflowOptions, folderPath, onDismiss }: FormProps) {
|
||||
// States
|
||||
const [job, setJob] = useState<Job>();
|
||||
const [targetFolderUID, setTargetFolderUID] = useState<string | undefined>(undefined);
|
||||
const [progress, setProgress] = useState<ProgressState | null>(null);
|
||||
const [failureResults, setFailureResults] = useState<MoveResultFailed[] | undefined>();
|
||||
const [successState, setSuccessState] = useState<MoveResultSuccessState>({
|
||||
allSuccess: false,
|
||||
repoUrl: '',
|
||||
});
|
||||
const [hasSubmitted, setHasSubmitted] = useState(false);
|
||||
|
||||
// Hooks
|
||||
const [moveFile, moveRequest] = useCreateRepositoryFilesWithPathMutation();
|
||||
const { createBulkJob, isLoading: isCreatingJob } = useBulkActionJob();
|
||||
const methods = useForm<BulkActionFormData>({ defaultValues: initialValues });
|
||||
const childrenByParentUID = useChildrenByParentUIDState();
|
||||
const rootItems = useSelector(rootItemsSelector);
|
||||
const { handleSubmit, watch } = methods;
|
||||
const workflow = watch('workflow');
|
||||
const { handleSuccess } = useBulkActionRequest({ workflow, repository, successState, onDismiss });
|
||||
|
||||
// Get target folder data
|
||||
const { data: targetFolder } = useGetFolderQuery(targetFolderUID ? { name: targetFolderUID } : skipToken);
|
||||
|
||||
const getResourceCurrentPath = async (uid: string, isFolder: boolean): Promise<string | undefined> => {
|
||||
const item = findItem(rootItems?.items || [], childrenByParentUID, uid);
|
||||
if (!item) {
|
||||
return undefined;
|
||||
}
|
||||
return isFolder ? `${folderPath}/${item.title}/` : fetchProvisionedDashboardPath(uid);
|
||||
};
|
||||
|
||||
const getDashboardBody = async (currentPath: string) => {
|
||||
const repositoryClient = new ScopedResourceClient({
|
||||
group: 'provisioning.grafana.app',
|
||||
version: 'v0alpha1',
|
||||
resource: 'repositories',
|
||||
});
|
||||
const fileResponse = await repositoryClient.subresource<ResourceWrapper>(repository.name, `files/${currentPath}`);
|
||||
return fileResponse.resource?.file;
|
||||
};
|
||||
|
||||
const setupMoveOperation = () => {
|
||||
const targetFolderPathInRepo = getTargetFolderPathInRepo({ targetFolder });
|
||||
const targets = collectSelectedItems(selectedItems, childrenByParentUID, rootItems?.items || []);
|
||||
const resources = collectSelectedItems(selectedItems);
|
||||
|
||||
if (targets.length > 0) {
|
||||
setProgress({
|
||||
current: 0,
|
||||
total: targets.length,
|
||||
item: targets[0].displayName || 'Unknown',
|
||||
});
|
||||
}
|
||||
|
||||
return { targetFolderPathInRepo, targets };
|
||||
};
|
||||
|
||||
const createFileBody = async (isFolder: boolean, displayName: string, currentPath: string) => {
|
||||
if (isFolder) {
|
||||
return {
|
||||
title: displayName,
|
||||
type: 'folder',
|
||||
};
|
||||
}
|
||||
|
||||
const fileBody = await getDashboardBody(currentPath);
|
||||
if (!fileBody) {
|
||||
throw new Error(
|
||||
t('browse-dashboards.bulk-move-resources-form.error-file-content-not-found', 'File content not found')
|
||||
);
|
||||
}
|
||||
|
||||
return fileBody;
|
||||
return { targetFolderPathInRepo, resources };
|
||||
};
|
||||
|
||||
const handleSubmitForm = async (data: BulkActionFormData) => {
|
||||
setFailureResults(undefined);
|
||||
setHasSubmitted(true);
|
||||
|
||||
// 1. Validate
|
||||
if (!targetFolder) {
|
||||
setFailureResults([
|
||||
{
|
||||
status: 'failed',
|
||||
title: t('browse-dashboards.bulk-move-resources-form.error-title', 'Target Folder Error'),
|
||||
},
|
||||
]);
|
||||
return;
|
||||
// 1. Setup
|
||||
const { targetFolderPathInRepo, resources } = setupMoveOperation();
|
||||
|
||||
if (!targetFolderPathInRepo) {
|
||||
throw new Error(
|
||||
t(
|
||||
'browse-dashboards.bulk-move-resources-form.error-no-target-folder-path',
|
||||
'Target folder path in repository is invalid, please select another folder.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Setup
|
||||
const { targetFolderPathInRepo, targets } = setupMoveOperation();
|
||||
// Create the move job spec
|
||||
const jobSpec: MoveJobSpec = {
|
||||
action: 'move',
|
||||
move: {
|
||||
ref: data.workflow === 'write' ? undefined : data.ref,
|
||||
targetPath: `${targetFolderPathInRepo}/`,
|
||||
resources,
|
||||
},
|
||||
};
|
||||
|
||||
// 3. Process items
|
||||
const successes: BulkSuccessResponse<
|
||||
CreateRepositoryFilesWithPathApiArg,
|
||||
CreateRepositoryFilesWithPathApiResponse
|
||||
> = [];
|
||||
const failures: MoveResultFailed[] = [];
|
||||
const result = await createBulkJob(repository, jobSpec);
|
||||
|
||||
// Iterate through each selected item and move it
|
||||
// We want sequential processing to avoid overwhelming the API
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
const { uid, isFolder, displayName } = targets[i];
|
||||
setProgress({
|
||||
current: i,
|
||||
total: targets.length,
|
||||
item: displayName,
|
||||
if (result.success && result.job) {
|
||||
setJob(result.job); // Store the job for tracking
|
||||
} else if (!result.success && result.error) {
|
||||
getAppEvents().publish({
|
||||
type: AppEvents.alertError.name,
|
||||
payload: [
|
||||
t('browse-dashboards.bulk-move-resources-form.error-moving-resources', 'Error moving resources'),
|
||||
result.error,
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. Get source path in repository
|
||||
const currentPath = await getResourceCurrentPath(uid, isFolder);
|
||||
if (!currentPath) {
|
||||
failures.push({
|
||||
status: 'failed',
|
||||
title: `${isFolder ? 'Folder' : 'Dashboard'}: ${displayName}`,
|
||||
errorMessage: t('browse-dashboards.bulk-move-resources-form.error-path-not-found', 'Path not found'),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!targetFolderPathInRepo) {
|
||||
failures.push({
|
||||
status: 'failed',
|
||||
title: `${isFolder ? 'Folder' : 'Dashboard'}: ${displayName}`,
|
||||
errorMessage: t(
|
||||
'browse-dashboards.bulk-move-resources-form.error-target-folder-path-missing',
|
||||
'Target folder path is missing'
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const newPath = getResourceTargetPath(currentPath, targetFolderPathInRepo);
|
||||
const fileBody = await createFileBody(isFolder, displayName, currentPath);
|
||||
|
||||
// Build move parameters
|
||||
const moveParams: CreateRepositoryFilesWithPathApiArg = {
|
||||
name: repository.name,
|
||||
path: newPath, // NEW target path
|
||||
ref: workflow === 'write' ? undefined : data.ref,
|
||||
message: data.comment || `Move resource ${displayName}`,
|
||||
originalPath: currentPath, // CURRENT path (source)
|
||||
body: fileBody, // File content
|
||||
};
|
||||
|
||||
// Call endpoint to move resource
|
||||
const response = await moveFile(moveParams).unwrap();
|
||||
successes.push({ index: i, item: moveParams, data: response });
|
||||
} catch (error: unknown) {
|
||||
failures.push({
|
||||
status: 'failed',
|
||||
title: `${isFolder ? 'Folder' : 'Dashboard'}: ${displayName}`,
|
||||
errorMessage: extractErrorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
setProgress({
|
||||
current: i + 1,
|
||||
total: targets.length,
|
||||
item: targets[i + 1]?.displayName,
|
||||
});
|
||||
}
|
||||
|
||||
setProgress(null);
|
||||
|
||||
if (successes.length > 0 && failures.length === 0) {
|
||||
// handleSuccess(successes);
|
||||
setSuccessState({
|
||||
allSuccess: true,
|
||||
repoUrl: successes[0].data.urls?.newPullRequestURL,
|
||||
});
|
||||
} else if (failures.length > 0) {
|
||||
setFailureResults(failures);
|
||||
setHasSubmitted(false); // Reset submit state so user can try again
|
||||
}
|
||||
};
|
||||
|
||||
@@ -237,15 +104,8 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions
|
||||
<DescendantCount selectedItems={{ ...selectedItems, panel: {}, $all: false }} />
|
||||
</Box>
|
||||
|
||||
{hasSubmitted ? (
|
||||
<BulkActionPostSubmitStep
|
||||
action="move"
|
||||
progress={progress}
|
||||
successState={successState}
|
||||
failureResults={failureResults}
|
||||
handleSuccess={handleSuccess}
|
||||
setFailureResults={setFailureResults}
|
||||
/>
|
||||
{hasSubmitted && job ? (
|
||||
<JobStatus watch={job} jobType="delete" />
|
||||
) : (
|
||||
<>
|
||||
{/* Target folder selection */}
|
||||
@@ -262,12 +122,20 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions
|
||||
/>
|
||||
|
||||
<Stack gap={2}>
|
||||
<Button type="submit" disabled={moveRequest.isLoading || !!failureResults}>
|
||||
{moveRequest.isLoading
|
||||
<Button
|
||||
tooltip={
|
||||
!targetFolder
|
||||
? t('browse-dashboards.bulk-move-resources-form.button-tooltip', 'Please select a target folder')
|
||||
: undefined
|
||||
}
|
||||
type="submit"
|
||||
disabled={!!job || isCreatingJob || hasSubmitted || !targetFolder}
|
||||
>
|
||||
{isCreatingJob
|
||||
? t('browse-dashboards.bulk-move-resources-form.button-moving', 'Moving...')
|
||||
: t('browse-dashboards.bulk-move-resources-form.button-move', 'Move')}
|
||||
</Button>
|
||||
<Button variant="secondary" fill="outline" onClick={onDismiss} disabled={moveRequest.isLoading}>
|
||||
<Button variant="secondary" fill="outline" onClick={onDismiss} disabled={isCreatingJob}>
|
||||
<Trans i18nKey="browse-dashboards.bulk-move-resources-form.button-cancel">Cancel</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useCreateRepositoryJobsMutation, RepositoryView, Job } from 'app/api/clients/provisioning/v0alpha1';
|
||||
import { extractErrorMessage } from 'app/api/utils';
|
||||
|
||||
export interface ResourceRef {
|
||||
name: string;
|
||||
group: 'dashboard.grafana.app' | 'folder.grafana.app';
|
||||
kind: 'Dashboard' | 'Folder';
|
||||
}
|
||||
|
||||
export interface DeleteJobSpec {
|
||||
action: 'delete';
|
||||
delete: {
|
||||
ref?: string;
|
||||
resources: ResourceRef[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface MoveJobSpec {
|
||||
action: 'move';
|
||||
move: {
|
||||
ref?: string;
|
||||
targetPath: string; // Must end with '/' slash
|
||||
resources: ResourceRef[];
|
||||
};
|
||||
}
|
||||
|
||||
export type BulkJobSpec = DeleteJobSpec | MoveJobSpec;
|
||||
|
||||
interface UseBulkActionJobResult {
|
||||
createBulkJob: (
|
||||
repository: RepositoryView,
|
||||
jobSpec: BulkJobSpec
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
jobId?: string;
|
||||
job?: Job; // Return the full job object
|
||||
error?: string;
|
||||
}>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export type ResponseType = { success: boolean; jobId?: string; job?: Job; error?: string };
|
||||
|
||||
// This hook is used to create bulk action (delete, move) jobs for provisioning resources
|
||||
export function useBulkActionJob(): UseBulkActionJobResult {
|
||||
const [createJob, { isLoading }] = useCreateRepositoryJobsMutation();
|
||||
|
||||
const createBulkJob = async (repository: RepositoryView, jobSpec: BulkJobSpec): Promise<ResponseType> => {
|
||||
try {
|
||||
const response = await createJob({
|
||||
name: repository.name,
|
||||
jobSpec,
|
||||
}).unwrap();
|
||||
|
||||
const jobId = response.metadata?.name;
|
||||
return {
|
||||
success: true,
|
||||
jobId,
|
||||
job: response, // Return the full job object
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: extractErrorMessage(error) };
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
createBulkJob,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useNavigate } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { RepositoryView } from 'app/api/clients/provisioning/v0alpha1';
|
||||
import { buildResourceBranchRedirectUrl } from 'app/features/dashboard-scene/settings/utils';
|
||||
|
||||
import { MoveResultSuccessState } from './utils';
|
||||
|
||||
interface Props {
|
||||
workflow?: 'branch' | 'write';
|
||||
repository: RepositoryView;
|
||||
successState: MoveResultSuccessState;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
export function useBulkActionRequest({ workflow, repository, successState, onDismiss }: Props) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSuccess = () => {
|
||||
if (workflow === 'branch') {
|
||||
onDismiss?.();
|
||||
if (successState.repoUrl) {
|
||||
const url = buildResourceBranchRedirectUrl({
|
||||
paramName: 'repo_url',
|
||||
paramValue: successState.repoUrl,
|
||||
repoType: repository.type,
|
||||
});
|
||||
|
||||
navigate(url);
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
} else {
|
||||
onDismiss?.();
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
return {
|
||||
handleSuccess,
|
||||
};
|
||||
}
|
||||
@@ -16,17 +16,6 @@ export interface BulkActionProvisionResourceProps {
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export type BulkSuccessResponse<T, K> = Array<{
|
||||
index: number;
|
||||
item: T;
|
||||
data: K;
|
||||
}>;
|
||||
|
||||
export type MoveResultSuccessState = {
|
||||
allSuccess: boolean;
|
||||
repoUrl?: string;
|
||||
};
|
||||
|
||||
export function getTargetFolderPathInRepo({ targetFolder }: { targetFolder?: Folder }): string | undefined {
|
||||
if (!targetFolder) {
|
||||
return undefined;
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { AnnoKeySourcePath } from 'app/features/apiserver/types';
|
||||
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
|
||||
import { DashboardViewItem } from 'app/features/search/types';
|
||||
|
||||
import { useChildrenByParentUIDState } from '../state/hooks';
|
||||
import { findItem } from '../state/utils';
|
||||
import { DashboardTreeSelection, DashboardViewItemWithUIItems, BrowseDashboardsPermissions } from '../types';
|
||||
|
||||
import { ResourceRef } from './BulkActions/useBulkActionJob';
|
||||
|
||||
export function makeRowID(baseId: string, item: DashboardViewItemWithUIItems) {
|
||||
return baseId + item.uid;
|
||||
}
|
||||
@@ -63,47 +60,26 @@ export function formatFolderName(folderName?: string): string {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Fetch provisioned dashboard path in repository
|
||||
export async function fetchProvisionedDashboardPath(uid: string): Promise<string | undefined> {
|
||||
try {
|
||||
const dto = await getDashboardAPI().getDashboardDTO(uid);
|
||||
const sourcePath =
|
||||
'meta' in dto
|
||||
? dto.meta.k8s?.annotations?.[AnnoKeySourcePath] || dto.meta.provisionedExternalId
|
||||
: dto.metadata?.annotations?.[AnnoKeySourcePath];
|
||||
return `${sourcePath}`;
|
||||
} catch (error) {
|
||||
console.error('Error fetching provisioned dashboard path:', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect selected dashboard and folder from the DashboardTreeSelection
|
||||
// This is used to prepare the items for bulk delete operation.
|
||||
export function collectSelectedItems(
|
||||
selectedItems: Omit<DashboardTreeSelection, 'panel' | '$all'>,
|
||||
childrenByParentUID: ReturnType<typeof useChildrenByParentUIDState>,
|
||||
rootItems: DashboardViewItem[] = []
|
||||
) {
|
||||
const targets: Array<{ uid: string; isFolder: boolean; displayName: string }> = [];
|
||||
export function collectSelectedItems(selectedItems: Omit<DashboardTreeSelection, 'panel' | '$all'>) {
|
||||
const resources: ResourceRef[] = [];
|
||||
|
||||
// folders
|
||||
for (const [uid, selected] of Object.entries(selectedItems.folder)) {
|
||||
if (selected) {
|
||||
const item = findItem(rootItems, childrenByParentUID, uid);
|
||||
targets.push({ uid, isFolder: true, displayName: item?.title || uid });
|
||||
resources.push({ name: uid, group: 'folder.grafana.app', kind: 'Folder' });
|
||||
}
|
||||
}
|
||||
|
||||
// dashboards
|
||||
for (const [uid, selected] of Object.entries(selectedItems.dashboard)) {
|
||||
if (selected) {
|
||||
const item = findItem(rootItems, childrenByParentUID, uid);
|
||||
targets.push({ uid, isFolder: false, displayName: item?.title || uid });
|
||||
resources.push({ name: uid, group: 'dashboard.grafana.app', kind: 'Dashboard' });
|
||||
}
|
||||
}
|
||||
|
||||
return targets;
|
||||
return resources;
|
||||
}
|
||||
|
||||
export function canEditItemType(itemKind: string, permissions: BrowseDashboardsPermissions) {
|
||||
|
||||
@@ -4,18 +4,19 @@ import { Trans, t } from '@grafana/i18n';
|
||||
import { Spinner, Stack, Text } from '@grafana/ui';
|
||||
import { useGetRepositoryJobsWithPathQuery } from 'app/api/clients/provisioning/v0alpha1';
|
||||
|
||||
import { useStepStatus } from '../Wizard/StepStatusContext';
|
||||
import { StepStatusInfo } from '../Wizard/types';
|
||||
|
||||
import { JobContent } from './JobContent';
|
||||
|
||||
export interface FinishedJobProps {
|
||||
jobUid: string;
|
||||
repositoryName: string;
|
||||
jobType: 'sync' | 'delete' | 'move';
|
||||
onStatusChange?: (statusInfo: StepStatusInfo) => void;
|
||||
}
|
||||
|
||||
export function FinishedJobStatus({ jobUid, repositoryName }: FinishedJobProps) {
|
||||
export function FinishedJobStatus({ jobUid, repositoryName, jobType, onStatusChange }: FinishedJobProps) {
|
||||
const hasRetried = useRef(false);
|
||||
const { setStepStatusInfo } = useStepStatus();
|
||||
const finishedQuery = useGetRepositoryJobsWithPathQuery({
|
||||
name: repositoryName,
|
||||
uid: jobUid,
|
||||
@@ -39,7 +40,7 @@ export function FinishedJobStatus({ jobUid, repositoryName }: FinishedJobProps)
|
||||
const { state, message, errors } = job.status;
|
||||
|
||||
if (state === 'error') {
|
||||
setStepStatusInfo({
|
||||
onStatusChange?.({
|
||||
status: 'error',
|
||||
error: {
|
||||
title: t('provisioning.job-status.status.title-error-running-job', 'Error running job'),
|
||||
@@ -47,14 +48,14 @@ export function FinishedJobStatus({ jobUid, repositoryName }: FinishedJobProps)
|
||||
},
|
||||
});
|
||||
} else if (state === 'success') {
|
||||
setStepStatusInfo({
|
||||
onStatusChange?.({
|
||||
status: 'success',
|
||||
success: {
|
||||
title: t('provisioning.job-status.status.title-success-running-job', 'Job completed successfully'),
|
||||
},
|
||||
});
|
||||
} else if (state === 'warning') {
|
||||
setStepStatusInfo({
|
||||
onStatusChange?.({
|
||||
status: 'warning',
|
||||
warning: {
|
||||
title: t('provisioning.job-status.status.title-warning-running-job', 'Job completed with warnings'),
|
||||
@@ -69,10 +70,10 @@ export function FinishedJobStatus({ jobUid, repositoryName }: FinishedJobProps)
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [finishedQuery, job, setStepStatusInfo]);
|
||||
}, [finishedQuery, job, onStatusChange]);
|
||||
|
||||
if (retryFailed) {
|
||||
setStepStatusInfo({
|
||||
onStatusChange?.({
|
||||
status: 'error',
|
||||
error: {
|
||||
title: t('provisioning.job-status.no-job-found', 'No job found'),
|
||||
@@ -96,5 +97,5 @@ export function FinishedJobStatus({ jobUid, repositoryName }: FinishedJobProps)
|
||||
);
|
||||
}
|
||||
|
||||
return <JobContent job={job} isFinishedJob={true} />;
|
||||
return <JobContent job={job} isFinishedJob={true} onStatusChange={onStatusChange} jobType={jobType} />;
|
||||
}
|
||||
|
||||
@@ -6,17 +6,18 @@ import { Job } from 'app/api/clients/provisioning/v0alpha1';
|
||||
|
||||
import { RepositoryLink } from '../Repository/RepositoryLink';
|
||||
import ProgressBar from '../Shared/ProgressBar';
|
||||
import { useStepStatus } from '../Wizard/StepStatusContext';
|
||||
import { StepStatusInfo } from '../Wizard/types';
|
||||
|
||||
import { JobSummary } from './JobSummary';
|
||||
|
||||
export interface JobContentProps {
|
||||
jobType: 'sync' | 'delete' | 'move';
|
||||
job?: Job;
|
||||
isFinishedJob?: boolean;
|
||||
onStatusChange?: (statusInfo: StepStatusInfo) => void;
|
||||
}
|
||||
|
||||
export function JobContent({ job, isFinishedJob = false }: JobContentProps) {
|
||||
const { setStepStatusInfo } = useStepStatus();
|
||||
export function JobContent({ jobType, job, isFinishedJob = false, onStatusChange }: JobContentProps) {
|
||||
const errorSetRef = useRef(false);
|
||||
|
||||
if (!job?.status) {
|
||||
@@ -34,11 +35,11 @@ export function JobContent({ job, isFinishedJob = false }: JobContentProps) {
|
||||
|
||||
switch (state) {
|
||||
case 'success':
|
||||
setStepStatusInfo({ status: 'success' });
|
||||
onStatusChange?.({ status: 'success' });
|
||||
break;
|
||||
case 'warning':
|
||||
if (!errorSetRef.current) {
|
||||
setStepStatusInfo({
|
||||
onStatusChange?.({
|
||||
status: 'warning',
|
||||
warning: {
|
||||
title: t('provisioning.job-status.status.title-warning-running-job', 'Job completed with warnings'),
|
||||
@@ -50,7 +51,7 @@ export function JobContent({ job, isFinishedJob = false }: JobContentProps) {
|
||||
break;
|
||||
case 'error':
|
||||
if (!errorSetRef.current) {
|
||||
setStepStatusInfo({
|
||||
onStatusChange?.({
|
||||
status: 'error',
|
||||
error: {
|
||||
title: t('provisioning.job-status.status.title-error-running-job', 'Error running job'),
|
||||
@@ -62,12 +63,12 @@ export function JobContent({ job, isFinishedJob = false }: JobContentProps) {
|
||||
break;
|
||||
case 'working':
|
||||
case 'pending':
|
||||
setStepStatusInfo({ status: 'running' });
|
||||
onStatusChange?.({ status: 'running' });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, [state, message, errors, setStepStatusInfo]);
|
||||
}, [state, message, errors, onStatusChange]);
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={2}>
|
||||
@@ -75,7 +76,7 @@ export function JobContent({ job, isFinishedJob = false }: JobContentProps) {
|
||||
{['working', 'pending'].includes(state ?? '') && (
|
||||
<Stack direction="row" alignItems="center" justifyContent="center" gap={2}>
|
||||
<Spinner size={24} />
|
||||
<Text element="h4" color="secondary">
|
||||
<Text element="h5" color="secondary">
|
||||
{message ?? state ?? t('provisioning.job-status.starting', 'Starting...')}
|
||||
</Text>
|
||||
</Stack>
|
||||
@@ -94,7 +95,7 @@ export function JobContent({ job, isFinishedJob = false }: JobContentProps) {
|
||||
</Stack>
|
||||
)}
|
||||
{state === 'success' ? (
|
||||
<RepositoryLink name={repoName} />
|
||||
<RepositoryLink name={repoName} jobType={jobType} />
|
||||
) : (
|
||||
<ControlledCollapse label={t('provisioning.job-status.label-view-details', 'View details')} isOpen={false}>
|
||||
<pre>{JSON.stringify(job, null, 2)}</pre>
|
||||
|
||||
@@ -2,17 +2,18 @@ import { Trans, t } from '@grafana/i18n';
|
||||
import { Spinner, Stack, Text } from '@grafana/ui';
|
||||
import { Job, useListJobQuery } from 'app/api/clients/provisioning/v0alpha1';
|
||||
|
||||
import { useStepStatus } from '../Wizard/StepStatusContext';
|
||||
import { StepStatusInfo } from '../Wizard/types';
|
||||
|
||||
import { FinishedJobStatus } from './FinishedJobStatus';
|
||||
import { JobContent } from './JobContent';
|
||||
|
||||
export interface JobStatusProps {
|
||||
watch: Job;
|
||||
onStatusChange?: (statusInfo: StepStatusInfo) => void;
|
||||
jobType: 'sync' | 'delete' | 'move';
|
||||
}
|
||||
|
||||
export function JobStatus({ watch }: JobStatusProps) {
|
||||
const { setStepStatusInfo } = useStepStatus();
|
||||
export function JobStatus({ jobType, watch, onStatusChange }: JobStatusProps) {
|
||||
const activeQuery = useListJobQuery({
|
||||
fieldSelector: `metadata.name=${watch.metadata?.name}`,
|
||||
watch: true,
|
||||
@@ -36,7 +37,7 @@ export function JobStatus({ watch }: JobStatusProps) {
|
||||
}
|
||||
|
||||
if (activeQuery.isError) {
|
||||
setStepStatusInfo({
|
||||
onStatusChange?.({
|
||||
status: 'error',
|
||||
error: {
|
||||
title: t('provisioning.job-status.title.error-fetching-active-job', 'Error fetching active job'),
|
||||
@@ -46,11 +47,18 @@ export function JobStatus({ watch }: JobStatusProps) {
|
||||
}
|
||||
|
||||
if (activeJob) {
|
||||
return <JobContent job={activeJob} isFinishedJob={false} />;
|
||||
return <JobContent job={activeJob} isFinishedJob={false} onStatusChange={onStatusChange} jobType={jobType} />;
|
||||
}
|
||||
|
||||
if (shouldCheckFinishedJobs) {
|
||||
return <FinishedJobStatus jobUid={watch.metadata?.uid!} repositoryName={repoLabel} />;
|
||||
return (
|
||||
<FinishedJobStatus
|
||||
jobUid={watch.metadata?.uid!}
|
||||
repositoryName={repoLabel}
|
||||
onStatusChange={onStatusChange}
|
||||
jobType={jobType}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { skipToken } from '@reduxjs/toolkit/query';
|
||||
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { Stack, Text, TextLink } from '@grafana/ui';
|
||||
import { LinkButton, Stack, Text, TextLink } from '@grafana/ui';
|
||||
import { useGetRepositoryQuery } from 'app/api/clients/provisioning/v0alpha1';
|
||||
|
||||
import { getRepoHref } from '../utils/git';
|
||||
|
||||
type RepositoryLinkProps = {
|
||||
name?: string;
|
||||
jobType: 'sync' | 'delete' | 'move';
|
||||
};
|
||||
|
||||
export function RepositoryLink({ name }: RepositoryLinkProps) {
|
||||
export function RepositoryLink({ name, jobType }: RepositoryLinkProps) {
|
||||
const repoQuery = useGetRepositoryQuery(name ? { name } : skipToken);
|
||||
const repo = repoQuery.data;
|
||||
|
||||
@@ -20,21 +21,36 @@ export function RepositoryLink({ name }: RepositoryLinkProps) {
|
||||
|
||||
const repoHref = getRepoHref(repo.spec?.github);
|
||||
|
||||
if (jobType === 'sync') {
|
||||
return (
|
||||
<>
|
||||
<Text>
|
||||
<Trans i18nKey="provisioning.repository-link.grafana-repository-synced">
|
||||
Your resources are now in your external storage and provisioned into your instance. From now on, your
|
||||
instance and the external storage will be synchronized.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{repoHref && (
|
||||
<Stack direction="row" gap={2}>
|
||||
<TextLink href={repoHref} external>
|
||||
<Trans i18nKey="provisioning.repository-link.sync-job.view-repository">View repository</Trans>
|
||||
</TextLink>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={1}>
|
||||
<Text>
|
||||
<Trans i18nKey="provisioning.repository-link.grafana-repository-synced">
|
||||
Your resources are now in your external storage and provisioned into your instance. From now on, your instance
|
||||
and the external storage will be synchronized.
|
||||
</Trans>
|
||||
</Text>
|
||||
<>
|
||||
{repoHref && (
|
||||
<Stack direction="row" gap={2}>
|
||||
<TextLink href={repoHref} external>
|
||||
<Trans i18nKey="provisioning.repository-link.view-repository">View repository</Trans>
|
||||
</TextLink>
|
||||
<LinkButton href={repoHref} icon="external-link-alt" variant="secondary" target="_blank">
|
||||
<Trans i18nKey="provisioning.repository-link.delete-or-move-job.view-repository">View repository</Trans>
|
||||
</LinkButton>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function SynchronizeStep({ isLegacyStorage }: SynchronizeStepProps) {
|
||||
};
|
||||
|
||||
if (job) {
|
||||
return <JobStatus watch={job} />;
|
||||
return <JobStatus watch={job} onStatusChange={setStepStatusInfo} jobType="sync" />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -3519,26 +3519,18 @@
|
||||
"browse-view": {
|
||||
"this-folder-is-empty": "This folder is empty"
|
||||
},
|
||||
"bulk-action-resources-form": {
|
||||
"all-deleted": "All resources have been deleted successfully",
|
||||
"all-moved": "All resources have been moved successfully",
|
||||
"button-done": "Done",
|
||||
"failed-alert_one": "{{count}} items failed",
|
||||
"failed-alert_other": "{{count}} items failed",
|
||||
"progress-title": "Success"
|
||||
},
|
||||
"bulk-delete-resources-form": {
|
||||
"button-cancel": "Cancel",
|
||||
"button-delete": "Delete",
|
||||
"button-deleting": "Deleting...",
|
||||
"delete-warning": "This will delete selected folders and their descendants. In total, this will affect:",
|
||||
"error-path-not-found": "Path not found"
|
||||
"error-deleting-resources": "Error deleting resources"
|
||||
},
|
||||
"bulk-move-resources-form": {
|
||||
"button-cancel": "Cancel",
|
||||
"button-move": "Move",
|
||||
"button-moving": "Moving...",
|
||||
"deleting": "Deleting",
|
||||
"button-tooltip": "Please select a target folder",
|
||||
"error": {
|
||||
"read-only-message": "If you have direct access to the target, please make modifications directly in the target repository.",
|
||||
"read-only-saving-message": "Repository is read-only and provisioned in git. {{readOnlyMessage}}",
|
||||
@@ -3546,13 +3538,9 @@
|
||||
"repository-not-found-message": "The repository for the selected folder could not be found. Please ensure that the folder is provisioned correctly.",
|
||||
"repository-not-found-title": "Repository not found"
|
||||
},
|
||||
"error-file-content-not-found": "File content not found",
|
||||
"error-path-not-found": "Path not found",
|
||||
"error-target-folder-path-missing": "Target folder path is missing",
|
||||
"error-title": "Target Folder Error",
|
||||
"error-moving-resources": "Error moving resources",
|
||||
"error-no-target-folder-path": "Target folder path in repository is invalid, please select another folder.",
|
||||
"move-warning": "This will move selected folders and their descendants. In total, this will affect:",
|
||||
"moving": "Moving",
|
||||
"progress": "Progress: {{current}} of {{total}}",
|
||||
"target-folder": "Target Folder"
|
||||
},
|
||||
"counts": {
|
||||
@@ -11442,8 +11430,13 @@
|
||||
"title-repository-is-unhealthy": "Repository is unhealthy"
|
||||
},
|
||||
"repository-link": {
|
||||
"delete-or-move-job": {
|
||||
"view-repository": "View repository"
|
||||
},
|
||||
"grafana-repository-synced": "Your resources are now in your external storage and provisioned into your instance. From now on, your instance and the external storage will be synchronized.",
|
||||
"view-repository": "View repository"
|
||||
"sync-job": {
|
||||
"view-repository": "View repository"
|
||||
}
|
||||
},
|
||||
"repository-overview": {
|
||||
"checked": "Checked:",
|
||||
|
||||
Reference in New Issue
Block a user