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 { t } from '@grafana/i18n';
import { isFetchError } from '@grafana/runtime'; 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 { notifyApp } from '../../../../core/actions';
import { createSuccessNotification, createErrorNotification } from '../../../../core/copy/appNotification'; import { createSuccessNotification, createErrorNotification } from '../../../../core/copy/appNotification';
@@ -209,6 +211,24 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({
dispatch(refetchChildren({ parentUID: undefined, pageSize: PAGE_SIZE })); 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; folderPath?: string;
} }
function FormContent({ initialValues, selectedItems, repository, workflowOptions, folderPath, onDismiss }: FormProps) { function FormContent({ initialValues, selectedItems, repository, workflowOptions, onDismiss }: FormProps) {
// States // States
const [job, setJob] = useState<Job>(); const [job, setJob] = useState<Job>();
const [targetFolderUID, setTargetFolderUID] = useState<string | undefined>(undefined); const [targetFolderUID, setTargetFolderUID] = useState<string | undefined>(undefined);
@@ -119,7 +119,7 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions
</Box> </Box>
{hasSubmitted && job ? ( {hasSubmitted && job ? (
<JobStatus watch={job} jobType="delete" /> <JobStatus watch={job} jobType="move" />
) : ( ) : (
<> <>
{/* Target folder selection */} {/* Target folder selection */}
@@ -14,6 +14,7 @@ export type BulkActionFormData = {
export interface BulkActionProvisionResourceProps { export interface BulkActionProvisionResourceProps {
folderUid?: string; folderUid?: string;
selectedItems: Omit<DashboardTreeSelection, 'panel' | '$all'>; selectedItems: Omit<DashboardTreeSelection, 'panel' | '$all'>;
onActionComplete?: () => void;
onDismiss?: () => void; onDismiss?: () => void;
} }
@@ -21,6 +21,14 @@ jest.mock('react-router-dom-v5-compat', () => ({
useNavigate: () => mockNavigate, useNavigate: () => mockNavigate,
})); }));
jest.mock('react-redux', () => {
const actual = jest.requireActual('react-redux');
return {
...actual,
useDispatch: jest.fn,
};
});
jest.mock('app/api/clients/provisioning/v0alpha1', () => ({ jest.mock('app/api/clients/provisioning/v0alpha1', () => ({
useDeleteRepositoryFilesWithPathMutation: jest.fn(), useDeleteRepositoryFilesWithPathMutation: jest.fn(),
provisioningAPI: { 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', () => { jest.mock('react-router-dom-v5-compat', () => {
const actual = jest.requireActual('react-router-dom-v5-compat'); const actual = jest.requireActual('react-router-dom-v5-compat');
return { return {
@@ -88,6 +88,7 @@ function FormContent({ initialValues, repository, workflowOptions, folder, onDis
// Use the repository-type and resource-type aware provisioned request handler // Use the repository-type and resource-type aware provisioned request handler
useProvisionedRequestHandler<FolderDTO>({ useProvisionedRequestHandler<FolderDTO>({
folderUID: folder?.metadata.name,
request, request,
workflow, workflow,
repository, repository,
@@ -114,6 +114,7 @@ export function SaveProvisionedDashboardForm({
}; };
useProvisionedRequestHandler<Dashboard>({ useProvisionedRequestHandler<Dashboard>({
folderUID: defaultValues.folder?.uid,
request, request,
workflow, workflow,
resourceType: 'dashboard', 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('../saving/provisioned/hooks');
jest.mock('@grafana/runtime', () => ({ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
@@ -7,6 +7,7 @@ import { getAppEvents } from '@grafana/runtime';
import { Button, Drawer, Stack } from '@grafana/ui'; import { Button, Drawer, Stack } from '@grafana/ui';
import { RepositoryView, useDeleteRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning/v0alpha1'; import { RepositoryView, useDeleteRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning/v0alpha1';
import { RepoInvalidStateBanner } from 'app/features/browse-dashboards/components/BulkActions/RepoInvalidStateBanner'; 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 { PROVISIONING_URL } from 'app/features/provisioning/constants';
import { ResourceEditFormSharedFields } from '../components/Provisioned/ResourceEditFormSharedFields'; import { ResourceEditFormSharedFields } from '../components/Provisioned/ResourceEditFormSharedFields';
@@ -78,8 +79,7 @@ export function DeleteProvisionedDashboardForm({
const onWriteSuccess = () => { const onWriteSuccess = () => {
dashboard.setState({ isDirty: false }); dashboard.setState({ isDirty: false });
panelEditor?.onDiscard(); panelEditor?.onDiscard();
// TODO reset search state instead navigate(getFolderURL(defaultValues.folder.uid || ''));
window.location.href = '/dashboards';
}; };
const onBranchSuccess = (path: string, info: ProvisionedOperationInfo, urls?: Record<string, string>) => { const onBranchSuccess = (path: string, info: ProvisionedOperationInfo, urls?: Record<string, string>) => {
@@ -17,6 +17,18 @@ jest.mock('@grafana/i18n', () => ({
const mockGetAppEvents = jest.mocked(getAppEvents); 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', () => { describe('useProvisionedRequestHandler', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
@@ -9,7 +9,10 @@ import {
RepositoryView, RepositoryView,
} from 'app/api/clients/provisioning/v0alpha1'; } from 'app/api/clients/provisioning/v0alpha1';
import { Resource } from 'app/features/apiserver/types'; 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 { 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. type ResourceType = 'dashboard' | 'folder'; // Add more as needed, e.g., 'alert', etc.
@@ -45,6 +48,16 @@ interface ResourceConfig {
supportedWorkflows: string[]; 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. * 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. * Components are responsible for their own state management through specific workflow handlers.
*/ */
export function useProvisionedRequestHandler<T>({ export function useProvisionedRequestHandler<T>({
folderUID,
request, request,
workflow, workflow,
handlers, handlers,
successMessage, successMessage,
repository, repository,
resourceType, resourceType,
}: { }: Props<T>) {
request: ProvisionedRequest; const dispatch = useDispatch();
workflow?: string;
handlers: RequestHandlers<T>;
successMessage?: string;
repository?: RepositoryView;
resourceType?: ResourceType;
}) {
useEffect(() => { useEffect(() => {
const repoType = repository?.type || 'git'; const repoType = repository?.type || 'git';
const info: ProvisionedOperationInfo = { const info: ProvisionedOperationInfo = {
@@ -99,12 +107,16 @@ export function useProvisionedRequestHandler<T>({
// Write workflow // Write workflow
if (workflow === 'write' && handlers.onWriteSuccess) { 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.onWriteSuccess(info, resourceData);
} }
handlers.onDismiss?.(); handlers.onDismiss?.();
} }
}, [request, workflow, handlers, successMessage, repository, resourceType]); }, [request, workflow, handlers, successMessage, repository, resourceType, folderUID, dispatch]);
} }
function getContextualSuccessMessage(info: ProvisionedOperationInfo): string { function getContextualSuccessMessage(info: ProvisionedOperationInfo): string {
@@ -9,8 +9,8 @@ import { JobContent } from './JobContent';
export interface JobStatusProps { export interface JobStatusProps {
watch: Job; watch: Job;
onStatusChange?: (statusInfo: StepStatusInfo) => void;
jobType: 'sync' | 'delete' | 'move'; jobType: 'sync' | 'delete' | 'move';
onStatusChange?: (statusInfo: StepStatusInfo) => void;
} }
export function JobStatus({ jobType, watch, onStatusChange }: JobStatusProps) { export function JobStatus({ jobType, watch, onStatusChange }: JobStatusProps) {