BrowseActions: check selected items are provisioned or not (#107919)

* BrowseActions: check selected items are provisioned or not

---------

Co-authored-by: Clarity-89 <homes89@ukr.net>
Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>
This commit is contained in:
Yunwen Zheng
2025-07-16 11:22:45 -04:00
committed by GitHub
co-authored by Clarity-89 Alex Khomenko
parent 45d176a672
commit 2202c99d70
7 changed files with 261 additions and 34 deletions
@@ -97,7 +97,7 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
const hasAdminRights = contextSrv.hasRole('Admin') || contextSrv.isGrafanaAdmin;
const isProvisionedFolder = folder?.managedBy === ManagerKind.Repo;
const showEditTitle = canEditFolders && folderUID && !isProvisionedFolder;
const canSelect = (canEditFolders || canEditDashboards) && !isProvisionedFolder;
const canSelect = canEditFolders || canEditDashboards;
const onEditTitle = async (newValue: string) => {
if (folderDTO) {
const result = await saveFolder({
@@ -171,7 +171,7 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
</div>
{hasSelection ? (
<BrowseActions />
<BrowseActions folderDTO={folderDTO} />
) : (
<div className={styles.filters}>
<BrowseFilters />
@@ -1,11 +1,13 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { Trans, t } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
import { Button, Stack, Tooltip } from '@grafana/ui';
import { Button, Drawer, Stack, Tooltip } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { ManagerKind } from 'app/features/apiserver/types';
import { useSearchStateManager } from 'app/features/search/state/SearchStateManager';
import { ShowModalReactEvent } from 'app/types/events';
import { FolderDTO } from 'app/types/folders';
import { useDispatch } from 'app/types/store';
import { useDeleteItemsMutation, useMoveItemsMutation } from '../../api/browseDashboardsAPI';
@@ -15,15 +17,27 @@ import { DashboardTreeSelection } from '../../types';
import { DeleteModal } from './DeleteModal';
import { MoveModal } from './MoveModal';
import { SelectedMixResourcesMsgModal } from './SelectedMixResourcesMsgModal';
import { useSelectionProvisioningStatus } from './useSelectionProvisioningStatus';
export interface Props {}
export interface Props {
folderDTO?: FolderDTO;
}
export function BrowseActions({ folderDTO }: Props) {
const [showBulkDeleteProvisionedResource, setShowBulkDeleteProvisionedResource] = useState(false);
export function BrowseActions() {
const dispatch = useDispatch();
const selectedItems = useActionSelectionState();
const [deleteItems] = useDeleteItemsMutation();
const [moveItems] = useMoveItemsMutation();
const [, stateManager] = useSearchStateManager();
const provisioningEnabled = config.featureToggles.provisioning;
const { hasProvisioned, hasNonProvisioned } = useSelectionProvisioningStatus(
selectedItems,
folderDTO?.managedBy === ManagerKind.Repo
);
// Folders can only be moved if nested folders is enabled
const moveIsInvalid = useMemo(
@@ -67,15 +81,29 @@ export function BrowseActions() {
};
const showDeleteModal = () => {
appEvents.publish(
new ShowModalReactEvent({
component: DeleteModal,
props: {
selectedItems,
onConfirm: onDelete,
},
})
);
if (hasProvisioned && hasNonProvisioned && provisioningEnabled) {
// Mixed selection
appEvents.publish(
new ShowModalReactEvent({
component: SelectedMixResourcesMsgModal,
props: {},
})
);
} else if (hasProvisioned && provisioningEnabled) {
// Only provisioned items
setShowBulkDeleteProvisionedResource(true);
} else {
// Only non-provisioned items
appEvents.publish(
new ShowModalReactEvent({
component: DeleteModal,
props: {
selectedItems,
onConfirm: onDelete,
},
})
);
}
};
const moveButton = (
@@ -85,19 +113,33 @@ export function BrowseActions() {
);
return (
<Stack gap={1} data-testid="manage-actions">
{moveIsInvalid ? (
<Tooltip content={t('browse-dashboards.action.cannot-move-folders', 'Folders cannot be moved')}>
{moveButton}
</Tooltip>
) : (
moveButton
)}
<>
<Stack gap={1} data-testid="manage-actions">
{moveIsInvalid ? (
<Tooltip content={t('browse-dashboards.action.cannot-move-folders', 'Folders cannot be moved')}>
{moveButton}
</Tooltip>
) : (
moveButton
)}
<Button onClick={showDeleteModal} variant="destructive">
<Trans i18nKey="browse-dashboards.action.delete-button">Delete</Trans>
</Button>
</Stack>
<Button onClick={showDeleteModal} variant="destructive">
<Trans i18nKey="browse-dashboards.action.delete-button">Delete</Trans>
</Button>
</Stack>
{showBulkDeleteProvisionedResource && (
<Drawer
title={t('browse-dashboards.action.bulk-delete-provisioned-resources', 'Bulk Delete Provisioned Resources')}
onClose={() => setShowBulkDeleteProvisionedResource(false)}
size="md"
>
{/* TODO: Implement bulk delete for provisioned resources, PR will merge soon https://github.com/grafana/grafana/pull/107800 */}
<Trans i18nKey="browse-dashboards.action.bulk-delete-provisioned-resources-not-implemented">
Bulk delete for provisioned resources is not implemented yet.
</Trans>
</Drawer>
)}
</>
);
}
@@ -0,0 +1,30 @@
import { css } from '@emotion/css';
import { Trans, t } from '@grafana/i18n';
import { Modal, useStyles2 } from '@grafana/ui';
export interface Props {}
export const SelectedMixResourcesMsgModal = ({ onDismiss }: { onDismiss: () => void }) => {
const styles = useStyles2(getStyles);
return (
<Modal
title={t('browse-dashboards.action.selected-mix-resources-modal-title', 'Mixed resource types selected')}
isOpen={true}
onDismiss={onDismiss}
className={styles.modal}
>
<Trans i18nKey="browse-dashboards.action.selected-mix-resources-modal-text">
You have selected both provisioned and non-provisioned resources. These cannot be processed together. Please
select only provisioned resources or only non-provisioned resources and try again.
</Trans>
</Modal>
);
};
const getStyles = () => ({
modal: css({
label: 'RowOptionsModal',
width: '500px',
}),
});
@@ -0,0 +1,154 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { config } from '@grafana/runtime';
import { ScopedResourceClient } from 'app/features/apiserver/client';
import { AnnoKeyManagerKind, ManagerKind } from 'app/features/apiserver/types';
import { isProvisionedDashboard as isProvisionedDashboardFromMeta } from 'app/features/browse-dashboards/api/isProvisioned';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { useIsProvisionedInstance } from 'app/features/provisioning/hooks/useIsProvisionedInstance';
import { useSearchStateManager } from 'app/features/search/state/SearchStateManager';
import { useSelector } from 'app/types/store';
import { findItem } from '../../state/utils';
import { DashboardTreeSelection } from '../../types';
export function useSelectionProvisioningStatus(
selectedItems: Omit<DashboardTreeSelection, 'panel' | '$all'>,
isParentProvisioned: boolean
) {
const browseState = useSelector((state) => state.browseDashboards);
const isProvisionedInstance = useIsProvisionedInstance();
const [, stateManager] = useSearchStateManager();
const isSearching = stateManager.hasSearchFilters();
const provisioningEnabled = config.featureToggles.provisioning;
const [status, setStatus] = useState({ hasProvisioned: false, hasNonProvisioned: false });
const [folderCache, setFolderCache] = useState<Record<string, boolean>>({});
const [dashboardCache, setDashboardCache] = useState<Record<string, boolean>>({});
// Create folder resource client for k8s API
const folderClient = useMemo(
() =>
new ScopedResourceClient({
group: 'folder.grafana.app',
version: 'v1beta1',
resource: 'folders',
}),
[]
);
const findItemInState = useCallback(
(uid: string) => {
const item = findItem(browseState.rootItems?.items || [], browseState.childrenByParentUID, uid);
return item ? { parentUID: item.parentUID, managedBy: item.managedBy } : undefined;
},
[browseState]
);
const getFolderMeta = useCallback(
async (uid: string) => {
if (folderCache[uid] !== undefined) {
return folderCache[uid];
}
try {
const folder = await folderClient.get(uid);
const managedBy = folder.metadata?.annotations?.[AnnoKeyManagerKind];
const result = managedBy === ManagerKind.Repo;
setFolderCache((prev) => ({ ...prev, [uid]: result }));
return result;
} catch {
return false;
}
},
[folderCache, folderClient]
);
const getDashboardMeta = useCallback(
async (uid: string) => {
if (dashboardCache[uid] !== undefined) {
return dashboardCache[uid];
}
try {
const dto = await getDashboardAPI().getDashboardDTO(uid);
const result = isProvisionedDashboardFromMeta(dto);
setDashboardCache((prev) => ({ ...prev, [uid]: result }));
return result;
} catch {
return false;
}
},
[dashboardCache]
);
const checkItemProvisioning = useCallback(
async (uid: string, isFolder: boolean): Promise<boolean> => {
if (isSearching) {
// If searching, we need provisioning status with fetching metadata
return isFolder ? await getFolderMeta(uid) : await getDashboardMeta(uid);
}
const item = findItemInState(uid);
if (isFolder) {
return item?.managedBy === ManagerKind.Repo;
}
// Check parent folder first
const parent = item?.parentUID ? findItemInState(item.parentUID) : undefined;
if (parent?.managedBy === ManagerKind.Repo) {
return true;
}
return item?.managedBy === ManagerKind.Repo;
},
[isSearching, getFolderMeta, getDashboardMeta, findItemInState]
);
useEffect(() => {
const checkProvisioningStatus = async () => {
// If the instance is provisioned or the parent folder is provisioned, we can skip checking individual items
if (isProvisionedInstance || isParentProvisioned) {
setStatus({ hasProvisioned: true, hasNonProvisioned: false });
return;
}
if (!provisioningEnabled) {
setStatus({ hasProvisioned: false, hasNonProvisioned: true });
return;
}
const folders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]);
const dashboards = Object.keys(selectedItems.dashboard).filter((uid) => selectedItems.dashboard[uid]);
let hasProvisioned = false;
let hasNonProvisioned = false;
const allItems = [
...folders.map((uid) => ({ uid, isFolder: true })),
...dashboards.map((uid) => ({ uid, isFolder: false })),
];
for (const { uid, isFolder } of allItems) {
const isProvisioned = await checkItemProvisioning(uid, isFolder);
isProvisioned ? (hasProvisioned = true) : (hasNonProvisioned = true);
if (hasProvisioned && hasNonProvisioned) {
// If we have both provisioned and non-provisioned items, we can stop checking
break;
}
}
setStatus({ hasProvisioned, hasNonProvisioned });
};
checkProvisioningStatus();
}, [
selectedItems,
isProvisionedInstance,
isParentProvisioned,
isSearching,
findItemInState,
checkItemProvisioning,
provisioningEnabled,
]);
return status;
}
@@ -5,7 +5,6 @@ import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { Checkbox, useStyles2 } from '@grafana/ui';
import { ManagerKind } from '../../apiserver/types';
import { DashboardsTreeCellProps, SelectionState } from '../types';
import { isSharedWithMe } from './utils';
@@ -42,7 +41,6 @@ export default function CheckboxCell({
value={state === SelectionState.Selected}
indeterminate={state === SelectionState.Mixed}
onChange={(ev) => onItemSelectionChange?.(item, ev.currentTarget.checked)}
disabled={item.managedBy === ManagerKind.Repo}
/>
);
}
@@ -2,7 +2,6 @@ import { PayloadAction } from '@reduxjs/toolkit';
import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
import { ManagerKind } from '../../apiserver/types';
import { isSharedWithMe } from '../components/utils';
import { BrowseDashboardsState } from '../types';
@@ -92,7 +91,7 @@ export function setItemSelectionState(
const { item, isSelected } = action.payload;
// UI shouldn't allow it, but also prevent sharedwithme from being selected
if (isSharedWithMe(item.uid) || item.managedBy === ManagerKind.Repo) {
if (isSharedWithMe(item.uid)) {
return;
}
@@ -173,8 +172,8 @@ export function setAllSelection(
}
for (const child of collection.items) {
// Don't traverse into the sharedwithme/provisioned folders
if (isSharedWithMe(child.uid) || child.managedBy === ManagerKind.Repo) {
// Don't traverse into the sharedwithme folder
if (isSharedWithMe(child.uid)) {
continue;
}
+5 -1
View File
@@ -3461,6 +3461,8 @@
},
"browse-dashboards": {
"action": {
"bulk-delete-provisioned-resources": "Bulk Delete Provisioned Resources",
"bulk-delete-provisioned-resources-not-implemented": "Bulk delete for provisioned resources is not implemented yet.",
"cancel-button": "Cancel",
"cannot-move-folders": "Folders cannot be moved",
"confirmation-text": "Delete",
@@ -3479,7 +3481,9 @@
"move-modal-text": "This action will move the following content:",
"move-modal-title": "Move",
"moving": "Moving...",
"new-folder-name-required-phrase": "Folder name is required."
"new-folder-name-required-phrase": "Folder name is required.",
"selected-mix-resources-modal-text": "You have selected both provisioned and non-provisioned resources. These cannot be processed together. Please select only provisioned resources or only non-provisioned resources and try again.",
"selected-mix-resources-modal-title": "Mixed resource types selected"
},
"actions": {
"button-to-recently-deleted": "Recently deleted"