Provision resource operation: refresh parent folder when resource operation is performed (#109555)

* refresh parent folder when resource is created or deleted

* bulk delete and bulk move job: after job success, refetch folders

* refresh parent on both branch and default workflow

* move after success logic into getRepositoryJobsWithPath
This commit is contained in:
Yunwen Zheng
2025-08-20 12:27:41 -04:00
committed by GitHub
parent 170c84c3f8
commit 66dc5ea4fb
12 changed files with 84 additions and 14 deletions
@@ -1,5 +1,7 @@
import { t } from '@grafana/i18n';
import { isFetchError } from '@grafana/runtime';
import { clearFolders } from 'app/features/browse-dashboards/state/slice';
import { getState } from 'app/store/store';
import { notifyApp } from '../../../../core/actions';
import { createSuccessNotification, createErrorNotification } from '../../../../core/copy/appNotification';
@@ -209,6 +211,24 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({
dispatch(refetchChildren({ parentUID: undefined, pageSize: PAGE_SIZE }));
},
},
getRepositoryJobsWithPath: {
onQueryStarted: async (_, { queryFulfilled, dispatch }) => {
try {
const result = await queryFulfilled;
const job = result.data;
// Clear folder cache after successful move/delete jobs
// We use clearFolders here to clear cached data and closes folders (immediate visual feedback)
// Force a refetch of subfolders if user has opened them, so user see latest data
if (job.status?.state === 'success' && (job.spec?.action === 'delete' || job.spec?.action === 'move')) {
const state = getState().browseDashboards;
dispatch(clearFolders(Object.keys(state.childrenByParentUID)));
}
} catch (e) {
console.error('Error in getRepositoryJobsWithPath:', e);
}
},
},
},
});
@@ -31,7 +31,7 @@ interface FormProps extends BulkActionProvisionResourceProps {
folderPath?: string;
}
function FormContent({ initialValues, selectedItems, repository, workflowOptions, folderPath, onDismiss }: FormProps) {
function FormContent({ initialValues, selectedItems, repository, workflowOptions, onDismiss }: FormProps) {
// States
const [job, setJob] = useState<Job>();
const [targetFolderUID, setTargetFolderUID] = useState<string | undefined>(undefined);
@@ -119,7 +119,7 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions
</Box>
{hasSubmitted && job ? (
<JobStatus watch={job} jobType="delete" />
<JobStatus watch={job} jobType="move" />
) : (
<>
{/* Target folder selection */}
@@ -14,6 +14,7 @@ export type BulkActionFormData = {
export interface BulkActionProvisionResourceProps {
folderUid?: string;
selectedItems: Omit<DashboardTreeSelection, 'panel' | '$all'>;
onActionComplete?: () => void;
onDismiss?: () => void;
}
@@ -21,6 +21,14 @@ jest.mock('react-router-dom-v5-compat', () => ({
useNavigate: () => mockNavigate,
}));
jest.mock('react-redux', () => {
const actual = jest.requireActual('react-redux');
return {
...actual,
useDispatch: jest.fn,
};
});
jest.mock('app/api/clients/provisioning/v0alpha1', () => ({
useDeleteRepositoryFilesWithPathMutation: jest.fn(),
provisioningAPI: {
@@ -59,6 +59,14 @@ jest.mock('app/features/provisioning/hooks/usePullRequestParam', () => {
};
});
jest.mock('react-redux', () => {
const actual = jest.requireActual('react-redux');
return {
...actual,
useDispatch: jest.fn(),
};
});
jest.mock('react-router-dom-v5-compat', () => {
const actual = jest.requireActual('react-router-dom-v5-compat');
return {
@@ -88,6 +88,7 @@ function FormContent({ initialValues, repository, workflowOptions, folder, onDis
// Use the repository-type and resource-type aware provisioned request handler
useProvisionedRequestHandler<FolderDTO>({
folderUID: folder?.metadata.name,
request,
workflow,
repository,
@@ -114,6 +114,7 @@ export function SaveProvisionedDashboardForm({
};
useProvisionedRequestHandler<Dashboard>({
folderUID: defaultValues.folder?.uid,
request,
workflow,
resourceType: 'dashboard',
@@ -21,6 +21,13 @@ jest.mock('app/api/clients/provisioning/v0alpha1', () => ({
},
},
}));
jest.mock('react-redux', () => {
const actual = jest.requireActual('react-redux');
return {
...actual,
useDispatch: jest.fn(),
};
});
jest.mock('../saving/provisioned/hooks');
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
@@ -7,6 +7,7 @@ import { getAppEvents } from '@grafana/runtime';
import { Button, Drawer, Stack } from '@grafana/ui';
import { RepositoryView, useDeleteRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning/v0alpha1';
import { RepoInvalidStateBanner } from 'app/features/browse-dashboards/components/BulkActions/RepoInvalidStateBanner';
import { getFolderURL } from 'app/features/browse-dashboards/components/utils';
import { PROVISIONING_URL } from 'app/features/provisioning/constants';
import { ResourceEditFormSharedFields } from '../components/Provisioned/ResourceEditFormSharedFields';
@@ -78,8 +79,7 @@ export function DeleteProvisionedDashboardForm({
const onWriteSuccess = () => {
dashboard.setState({ isDirty: false });
panelEditor?.onDiscard();
// TODO reset search state instead
window.location.href = '/dashboards';
navigate(getFolderURL(defaultValues.folder.uid || ''));
};
const onBranchSuccess = (path: string, info: ProvisionedOperationInfo, urls?: Record<string, string>) => {
@@ -17,6 +17,18 @@ jest.mock('@grafana/i18n', () => ({
const mockGetAppEvents = jest.mocked(getAppEvents);
jest.mock('react-redux', () => {
const actual = jest.requireActual('react-redux');
return {
...actual,
useDispatch: jest.fn(),
};
});
jest.mock('app/features/browse-dashboards/api/services', () => ({
PAGE_SIZE: 50,
}));
describe('useProvisionedRequestHandler', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -9,7 +9,10 @@ import {
RepositoryView,
} from 'app/api/clients/provisioning/v0alpha1';
import { Resource } from 'app/features/apiserver/types';
import { PAGE_SIZE } from 'app/features/browse-dashboards/api/services';
import { refetchChildren } from 'app/features/browse-dashboards/state/actions';
import { RepoType } from 'app/features/provisioning/Wizard/types';
import { useDispatch } from 'app/types/store';
type ResourceType = 'dashboard' | 'folder'; // Add more as needed, e.g., 'alert', etc.
@@ -45,6 +48,16 @@ interface ResourceConfig {
supportedWorkflows: string[];
}
interface Props<T> {
request: ProvisionedRequest;
folderUID?: string | undefined; // this is used to refetch folder items
workflow?: string;
handlers: RequestHandlers<T>;
successMessage?: string;
repository?: RepositoryView;
resourceType?: ResourceType;
}
/**
* Generic hook for handling provisioned resource operations across any resource type and repository provider.
*
@@ -52,20 +65,15 @@ interface ResourceConfig {
* Components are responsible for their own state management through specific workflow handlers.
*/
export function useProvisionedRequestHandler<T>({
folderUID,
request,
workflow,
handlers,
successMessage,
repository,
resourceType,
}: {
request: ProvisionedRequest;
workflow?: string;
handlers: RequestHandlers<T>;
successMessage?: string;
repository?: RepositoryView;
resourceType?: ResourceType;
}) {
}: Props<T>) {
const dispatch = useDispatch();
useEffect(() => {
const repoType = repository?.type || 'git';
const info: ProvisionedOperationInfo = {
@@ -99,12 +107,16 @@ export function useProvisionedRequestHandler<T>({
// Write workflow
if (workflow === 'write' && handlers.onWriteSuccess) {
if (folderUID) {
// refetch folder items after success if folderUID is passed in
dispatch(refetchChildren({ parentUID: folderUID || repository?.name, pageSize: PAGE_SIZE }));
}
handlers.onWriteSuccess(info, resourceData);
}
handlers.onDismiss?.();
}
}, [request, workflow, handlers, successMessage, repository, resourceType]);
}, [request, workflow, handlers, successMessage, repository, resourceType, folderUID, dispatch]);
}
function getContextualSuccessMessage(info: ProvisionedOperationInfo): string {
@@ -9,8 +9,8 @@ import { JobContent } from './JobContent';
export interface JobStatusProps {
watch: Job;
onStatusChange?: (statusInfo: StepStatusInfo) => void;
jobType: 'sync' | 'delete' | 'move';
onStatusChange?: (statusInfo: StepStatusInfo) => void;
}
export function JobStatus({ jobType, watch, onStatusChange }: JobStatusProps) {