diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkActionFailureBanner.test.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkActionFailureBanner.test.tsx deleted file mode 100644 index 9d91c74fbae..00000000000 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkActionFailureBanner.test.tsx +++ /dev/null @@ -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>, 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(), - 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(); - }); -}); diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkActionFailureBanner.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkActionFailureBanner.tsx deleted file mode 100644 index 332e0695144..00000000000 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkActionFailureBanner.tsx +++ /dev/null @@ -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 ( - -
    - {result.map((item) => ( -
  • - {item.title} - {item.errorMessage && `: ${item.errorMessage}`} -
  • - ))} -
-
- ); -} diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkActionPostSubmitStep.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkActionPostSubmitStep.tsx deleted file mode 100644 index c3a03aac1c9..00000000000 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkActionPostSubmitStep.tsx +++ /dev/null @@ -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 ; - } - - if (successState.allSuccess) { - return ( - <> - - {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' - )} - - - - - - ); - } - - if (failureResults) { - return setFailureResults(undefined)} />; - } - - return null; -} diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.test.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.test.tsx deleted file mode 100644 index 47a6fabfb6a..00000000000 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { render, screen } from '@testing-library/react'; - -import { BulkActionProgress, ProgressState } from './BulkActionProgress'; - -const setup = (progressOverrides: Partial = {}, action: 'delete' | 'move' = 'delete') => { - const defaultProgress: ProgressState = { - current: 5, - total: 10, - item: 'Test Dashboard', - ...progressOverrides, - }; - - const props = { - progress: defaultProgress, - }; - - return { - ...render(), - 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(); - }); -}); diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.tsx deleted file mode 100644 index f022554d52a..00000000000 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.tsx +++ /dev/null @@ -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 ( - - - - - Progress: {{ current: progress.current }} of {{ total: progress.total }} - - - - - - - {action === 'move' ? ( - Moving - ) : ( - Deleting - )} - : {progress.item} - - - ); -} diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.test.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.test.tsx index b4fc6013446..34b61e59041 100644 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.test.tsx +++ b/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.test.tsx @@ -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 }) => ( +
+ Job Status - {jobType} - {watch?.status?.state || 'pending'} +
+ )), +})); + +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( + + ); + + 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( - - ); - - 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' }), + ]), + }), + }) + ); }); }); diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.tsx index 8bcb95e6a2d..786b31d6c8a 100644 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.tsx +++ b/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.tsx @@ -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(null); - const [failureResults, setFailureResults] = useState(); - const [successState, setSuccessState] = useState({ - allSuccess: false, - repoUrl: '', - }); + const [job, setJob] = useState(); const [hasSubmitted, setHasSubmitted] = useState(false); // Hooks - const [deleteRepoFile, request] = useDeleteRepositoryFilesWithPathMutation(); + const { createBulkJob, isLoading: isCreatingJob } = useBulkActionJob(); const methods = useForm({ 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 => { - 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 (
@@ -161,15 +82,8 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions - {hasSubmitted ? ( - + {hasSubmitted && job ? ( + ) : ( <> - - - @@ -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} /> ); } diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkMoveProvisionedResource.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkMoveProvisionedResource.tsx index 883ec38c578..22640003c20 100644 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkMoveProvisionedResource.tsx +++ b/public/app/features/browse-dashboards/components/BulkActions/BulkMoveProvisionedResource.tsx @@ -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(); const [targetFolderUID, setTargetFolderUID] = useState(undefined); - const [progress, setProgress] = useState(null); - const [failureResults, setFailureResults] = useState(); - const [successState, setSuccessState] = useState({ - allSuccess: false, - repoUrl: '', - }); const [hasSubmitted, setHasSubmitted] = useState(false); // Hooks - const [moveFile, moveRequest] = useCreateRepositoryFilesWithPathMutation(); + const { createBulkJob, isLoading: isCreatingJob } = useBulkActionJob(); const methods = useForm({ 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 => { - 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(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 - {hasSubmitted ? ( - + {hasSubmitted && job ? ( + ) : ( <> {/* Target folder selection */} @@ -262,12 +122,20 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions /> - - diff --git a/public/app/features/browse-dashboards/components/BulkActions/useBulkActionJob.ts b/public/app/features/browse-dashboards/components/BulkActions/useBulkActionJob.ts new file mode 100644 index 00000000000..fe47c0469e6 --- /dev/null +++ b/public/app/features/browse-dashboards/components/BulkActions/useBulkActionJob.ts @@ -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 => { + 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, + }; +} diff --git a/public/app/features/browse-dashboards/components/BulkActions/useBulkActionRequest.ts b/public/app/features/browse-dashboards/components/BulkActions/useBulkActionRequest.ts deleted file mode 100644 index 0e0ddfcaf2c..00000000000 --- a/public/app/features/browse-dashboards/components/BulkActions/useBulkActionRequest.ts +++ /dev/null @@ -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, - }; -} diff --git a/public/app/features/browse-dashboards/components/BulkActions/utils.ts b/public/app/features/browse-dashboards/components/BulkActions/utils.ts index 48fc4892c73..d039fecab23 100644 --- a/public/app/features/browse-dashboards/components/BulkActions/utils.ts +++ b/public/app/features/browse-dashboards/components/BulkActions/utils.ts @@ -16,17 +16,6 @@ export interface BulkActionProvisionResourceProps { onDismiss?: () => void; } -export type BulkSuccessResponse = 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; diff --git a/public/app/features/browse-dashboards/components/utils.ts b/public/app/features/browse-dashboards/components/utils.ts index 66a85a6d0ee..3443967fc62 100644 --- a/public/app/features/browse-dashboards/components/utils.ts +++ b/public/app/features/browse-dashboards/components/utils.ts @@ -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 { - 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, - childrenByParentUID: ReturnType, - rootItems: DashboardViewItem[] = [] -) { - const targets: Array<{ uid: string; isFolder: boolean; displayName: string }> = []; +export function collectSelectedItems(selectedItems: Omit) { + 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) { diff --git a/public/app/features/provisioning/Job/FinishedJobStatus.tsx b/public/app/features/provisioning/Job/FinishedJobStatus.tsx index 05ed8c1a13c..60b5ce73917 100644 --- a/public/app/features/provisioning/Job/FinishedJobStatus.tsx +++ b/public/app/features/provisioning/Job/FinishedJobStatus.tsx @@ -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 ; + return ; } diff --git a/public/app/features/provisioning/Job/JobContent.tsx b/public/app/features/provisioning/Job/JobContent.tsx index 867f7844fda..0e469937b88 100644 --- a/public/app/features/provisioning/Job/JobContent.tsx +++ b/public/app/features/provisioning/Job/JobContent.tsx @@ -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 ( @@ -75,7 +76,7 @@ export function JobContent({ job, isFinishedJob = false }: JobContentProps) { {['working', 'pending'].includes(state ?? '') && ( - + {message ?? state ?? t('provisioning.job-status.starting', 'Starting...')} @@ -94,7 +95,7 @@ export function JobContent({ job, isFinishedJob = false }: JobContentProps) { )} {state === 'success' ? ( - + ) : (
{JSON.stringify(job, null, 2)}
diff --git a/public/app/features/provisioning/Job/JobStatus.tsx b/public/app/features/provisioning/Job/JobStatus.tsx index ee05d56c051..3a517ea775c 100644 --- a/public/app/features/provisioning/Job/JobStatus.tsx +++ b/public/app/features/provisioning/Job/JobStatus.tsx @@ -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 ; + return ; } if (shouldCheckFinishedJobs) { - return ; + return ( + + ); } return ( diff --git a/public/app/features/provisioning/Repository/RepositoryLink.tsx b/public/app/features/provisioning/Repository/RepositoryLink.tsx index 85ad92810c8..f97a448efa4 100644 --- a/public/app/features/provisioning/Repository/RepositoryLink.tsx +++ b/public/app/features/provisioning/Repository/RepositoryLink.tsx @@ -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 ( + <> + + + 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. + + + + {repoHref && ( + + + View repository + + + )} + + ); + } + return ( - - - - 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. - - + <> {repoHref && ( - - View repository - + + View repository + )} - + ); } diff --git a/public/app/features/provisioning/Wizard/SynchronizeStep.tsx b/public/app/features/provisioning/Wizard/SynchronizeStep.tsx index 64ae9ee8732..d05d1a96c4e 100644 --- a/public/app/features/provisioning/Wizard/SynchronizeStep.tsx +++ b/public/app/features/provisioning/Wizard/SynchronizeStep.tsx @@ -39,7 +39,7 @@ export function SynchronizeStep({ isLegacyStorage }: SynchronizeStepProps) { }; if (job) { - return ; + return ; } return ( diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index fddcbfc2009..2c9adb3e3e4 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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:",