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