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:
@@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+1
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user