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:
co-authored by
Clarity-89
Alex Khomenko
parent
45d176a672
commit
2202c99d70
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+30
@@ -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',
|
||||
}),
|
||||
});
|
||||
+154
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user