Browse Dashboards: Prevent cross repo resource selection (#109500)

* BrowseActions: when selecting item from a provisioned folder, disable all items from other provisioned folder. Display a  tooltip when checkbox is disabled.
This commit is contained in:
Yunwen Zheng
2025-08-12 13:45:08 +00:00
committed by GitHub
parent 904bf6b807
commit 57865b6a90
10 changed files with 222 additions and 25 deletions
@@ -12,6 +12,10 @@ import { useSelector } from 'app/types/store';
import { findItem } from '../../state/utils';
import { DashboardTreeSelection } from '../../types';
// TODO: This will soon be remove after bulk action is merged in
// This hook can be remove once searching endpoint returns provisioning status
// It is used to determine if the selected items are provisioned or not, which is currently missing from the search API
export function useSelectionProvisioningStatus(
selectedItems: Omit<DashboardTreeSelection, 'panel' | '$all'>,
isParentProvisioned: boolean
@@ -23,7 +27,6 @@ export function useSelectionProvisioningStatus(
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>>({});
@@ -38,6 +41,8 @@ export function useSelectionProvisioningStatus(
[]
);
// Simplified: removed complex root folder tracking logic
const findItemInState = useCallback(
(uid: string) => {
const item = findItem(browseState.rootItems?.items || [], browseState.childrenByParentUID, uid);
@@ -84,7 +89,6 @@ export function useSelectionProvisioningStatus(
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);
}
@@ -93,7 +97,7 @@ export function useSelectionProvisioningStatus(
return item?.managedBy === ManagerKind.Repo;
}
// Check parent folder first
// Check parent folder first for dashboards
const parent = item?.parentUID ? findItemInState(item.parentUID) : undefined;
if (parent?.managedBy === ManagerKind.Repo) {
return true;
@@ -106,7 +110,7 @@ export function useSelectionProvisioningStatus(
useEffect(() => {
const checkProvisioningStatus = async () => {
// If the instance is provisioned or the parent folder is provisioned, we can skip checking individual items
// Early returns for simple cases
if (isProvisionedInstance || isParentProvisioned) {
setStatus({ hasProvisioned: true, hasNonProvisioned: false });
return;
@@ -120,6 +124,12 @@ export function useSelectionProvisioningStatus(
const folders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]);
const dashboards = Object.keys(selectedItems.dashboard).filter((uid) => selectedItems.dashboard[uid]);
// If no items selected
if (folders.length === 0 && dashboards.length === 0) {
setStatus({ hasProvisioned: false, hasNonProvisioned: false });
return;
}
let hasProvisioned = false;
let hasNonProvisioned = false;
@@ -127,11 +137,18 @@ export function useSelectionProvisioningStatus(
...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 (isProvisioned) {
hasProvisioned = true;
} else {
hasNonProvisioned = true;
}
if (hasProvisioned && hasNonProvisioned) {
// If we have both provisioned and non-provisioned items, we can stop checking
// If we have both, we can stop checking
break;
}
}
@@ -140,15 +157,10 @@ export function useSelectionProvisioningStatus(
};
checkProvisioningStatus();
}, [
selectedItems,
isProvisionedInstance,
isParentProvisioned,
isSearching,
findItemInState,
checkItemProvisioning,
provisioningEnabled,
]);
}, [selectedItems, isProvisionedInstance, isParentProvisioned, checkItemProvisioning, provisioningEnabled]);
return status;
return {
hasProvisioned: status.hasProvisioned,
hasNonProvisioned: status.hasNonProvisioned,
};
}
@@ -0,0 +1,35 @@
import { useSelector } from 'app/types/store';
import { useChildrenByParentUIDState, rootItemsSelector } from '../../state/hooks';
import { findItem } from '../../state/utils';
import { DashboardTreeSelection } from '../../types';
import { getItemRepositoryUid } from '../utils';
// This hook is responsible for validating if all selected resources (dashboard folders and dashboards) are in the same repository
export function useSelectionRepoValidation(selectedItems: Omit<DashboardTreeSelection, 'panel' | '$all'>) {
const childrenByParentUID = useChildrenByParentUIDState();
const rootItems = useSelector(rootItemsSelector)?.items ?? [];
const getRepoUid = (uid: string) => {
const item = findItem(rootItems, childrenByParentUID, uid);
return item ? getItemRepositoryUid(item, rootItems, childrenByParentUID) : 'non_provisioned';
};
const selectedUIDs = [
...Object.keys(selectedItems.folder || {}).filter((id) => selectedItems.folder[id]),
...Object.keys(selectedItems.dashboard || {}).filter((id) => selectedItems.dashboard[id]),
];
const repoUIDs = selectedUIDs.map(getRepoUid).filter((repoId): repoId is string => !!repoId);
const selectedItemsRepoUID = repoUIDs.length > 0 ? repoUIDs[0] : undefined;
const isCrossRepo = new Set(repoUIDs).size > 1;
const isInLockedRepo = (uid: string) => !selectedItemsRepoUID || getRepoUid(uid) === selectedItemsRepoUID;
return {
selectedItemsRepoUID,
isInLockedRepo,
isCrossRepo, // true if items are from different repositories
};
}
@@ -1,4 +1,8 @@
import { t } from '@grafana/i18n';
import { DashboardViewItem } from 'app/features/search/types';
import { findItem } from '../../state/utils';
import { DashboardViewItemCollection } from '../../types';
export function buildBreakdownString(
folderCount: number,
@@ -26,3 +30,40 @@ export function buildBreakdownString(
}
return breakdownString;
}
// Utility: Get root folder for any item (reusing existing pattern from reducers.ts)
export function getItemRootFolder(
item: { uid: string; parentUID?: string; kind?: string },
browseState: {
rootItems?: { items: DashboardViewItem[] };
childrenByParentUID: Record<string, DashboardViewItemCollection>;
}
): string | undefined {
const rootItems = browseState.rootItems?.items || [];
// If it's already a root-level item, return its UID (only for folders)
if (!item.parentUID) {
return item.kind === 'folder' ? item.uid : undefined;
}
// For nested items, traverse up to find root folder (same pattern as reducers.ts)
let nextParentUID = item.parentUID;
while (nextParentUID) {
const parent = findItem(rootItems, browseState.childrenByParentUID, nextParentUID);
// Safety check to prevent infinite loops (same as reducers.ts)
if (!parent) {
break;
}
// Found the root folder (no parent)
if (!parent.parentUID) {
return parent.uid;
}
nextParentUID = parent.parentUID;
}
return undefined;
}
@@ -3,6 +3,8 @@ import { render } from 'test/test-utils';
import { Job, RepositoryView } from 'app/api/clients/provisioning/v0alpha1';
import { useSelectionRepoValidation } from '../BrowseActions/useSelectionRepoValidation';
import { BulkDeleteProvisionedResource } from './BulkDeleteProvisionedResource';
import { ResponseType } from './useBulkActionJob';
@@ -19,6 +21,14 @@ jest.mock('app/features/provisioning/hooks/useGetResourceRepositoryView', () =>
useGetResourceRepositoryView: jest.fn(),
}));
jest.mock('../BrowseActions/useSelectionRepoValidation', () => ({
useSelectionRepoValidation: jest.fn(),
}));
const mockUseSelectionRepoValidation = useSelectionRepoValidation as jest.MockedFunction<
typeof useSelectionRepoValidation
>;
jest.mock('./useBulkActionJob', () => ({
useBulkActionJob: jest.fn(),
}));
@@ -103,6 +113,12 @@ function setup(
describe('BulkDeleteProvisionedResource', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseSelectionRepoValidation.mockReturnValue({
selectedItemsRepoUID: 'test-folder',
isInLockedRepo: jest.fn().mockReturnValue(false),
isCrossRepo: false,
});
});
afterEach(() => {
@@ -11,13 +11,14 @@ import { getDefaultWorkflow, getWorkflowOptions } from 'app/features/dashboard-s
import { generateTimestamp } from 'app/features/dashboard-scene/saving/provisioned/utils/timestamp';
import { JobStatus } from 'app/features/provisioning/Job/JobStatus';
import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView';
import { GENERAL_FOLDER_UID } from 'app/features/search/constants';
import { DescendantCount } from '../BrowseActions/DescendantCount';
import { useSelectionRepoValidation } from '../BrowseActions/useSelectionRepoValidation';
import { collectSelectedItems } from '../utils';
import { RepoInvalidStateBanner } from './RepoInvalidStateBanner';
import { DeleteJobSpec, useBulkActionJob } from './useBulkActionJob';
import { useFolderNameFromSelection } from './useFolderNameFromSelection';
import { BulkActionFormData, BulkActionProvisionResourceProps } from './utils';
interface FormProps extends BulkActionProvisionResourceProps {
@@ -117,9 +118,14 @@ export function BulkDeleteProvisionedResource({
selectedItems,
onDismiss,
}: BulkActionProvisionResourceProps) {
const folderName = useFolderNameFromSelection({ folderUid, selectedItems });
const { repository, isReadOnlyRepo } = useGetResourceRepositoryView({ folderName });
// Check if we're on the root browser dashboards page
const isRootPage = !folderUid || folderUid === GENERAL_FOLDER_UID;
const { selectedItemsRepoUID } = useSelectionRepoValidation(selectedItems);
// For root provisioned folders, the folder UID is the repository name
const { repository, isReadOnlyRepo } = useGetResourceRepositoryView({
folderName: isRootPage ? selectedItemsRepoUID : folderUid,
});
const workflowOptions = getWorkflowOptions(repository);
const timestamp = generateTimestamp();
@@ -14,13 +14,14 @@ import { getDefaultWorkflow, getWorkflowOptions } from 'app/features/dashboard-s
import { generateTimestamp } from 'app/features/dashboard-scene/saving/provisioned/utils/timestamp';
import { JobStatus } from 'app/features/provisioning/Job/JobStatus';
import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView';
import { GENERAL_FOLDER_UID } from 'app/features/search/constants';
import { DescendantCount } from '../BrowseActions/DescendantCount';
import { useSelectionRepoValidation } from '../BrowseActions/useSelectionRepoValidation';
import { collectSelectedItems } from '../utils';
import { RepoInvalidStateBanner } from './RepoInvalidStateBanner';
import { MoveJobSpec, useBulkActionJob } from './useBulkActionJob';
import { useFolderNameFromSelection } from './useFolderNameFromSelection';
import { BulkActionFormData, BulkActionProvisionResourceProps, getTargetFolderPathInRepo } from './utils';
interface FormProps extends BulkActionProvisionResourceProps {
@@ -148,8 +149,12 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions
}
export function BulkMoveProvisionedResource({ folderUid, selectedItems, onDismiss }: BulkActionProvisionResourceProps) {
const folderName = useFolderNameFromSelection({ folderUid, selectedItems });
const { repository, folder, isReadOnlyRepo } = useGetResourceRepositoryView({ folderName });
// Check if we're on the root browser dashboards page
const isRootPage = !folderUid || folderUid === GENERAL_FOLDER_UID;
const { selectedItemsRepoUID } = useSelectionRepoValidation(selectedItems);
const { repository, folder, isReadOnlyRepo } = useGetResourceRepositoryView({
folderName: isRootPage ? selectedItemsRepoUID : folderUid,
});
const workflowOptions = getWorkflowOptions(repository);
const folderPath = folder?.metadata?.annotations?.[AnnoKeySourcePath] || '';
@@ -172,7 +177,7 @@ export function BulkMoveProvisionedResource({ folderUid, selectedItems, onDismis
initialValues={initialValues}
repository={repository}
workflowOptions={workflowOptions}
folderPath={folderPath}
folderPath={isRootPage ? '/' : folderPath}
/>
);
}
@@ -3,10 +3,13 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { Checkbox, useStyles2 } from '@grafana/ui';
import { Checkbox, Tooltip, useStyles2 } from '@grafana/ui';
import { ManagerKind } from 'app/features/apiserver/types';
import { useSelector } from 'app/types/store';
import { DashboardsTreeCellProps, SelectionState } from '../types';
import { useSelectionRepoValidation } from './BrowseActions/useSelectionRepoValidation';
import { isSharedWithMe, canEditItemType } from './utils';
export default function CheckboxCell({
@@ -17,6 +20,11 @@ export default function CheckboxCell({
}: DashboardsTreeCellProps) {
const item = row.item;
// Get current selection state for repository validation
const selectedItems = useSelector((state) => state.browseDashboards.selectedItems);
const { selectedItemsRepoUID, isInLockedRepo } = useSelectionRepoValidation(selectedItems);
// Early returns for cases where we should show a spacer instead of checkbox
if (!isSelected) {
return <CheckboxSpacer />;
}
@@ -33,11 +41,31 @@ export default function CheckboxCell({
return <CheckboxSpacer />;
}
// Disable checkbox for root provisioned folder itself
if (item.managedBy === ManagerKind.Repo && !item.parentUID) {
return <CheckboxSpacer />;
}
// Check if user can edit this specific item type
if (permissions && !canEditItemType(item.kind, permissions)) {
return <CheckboxSpacer />;
}
if (selectedItemsRepoUID && !isInLockedRepo(item.uid)) {
return (
<Tooltip
content={t(
'browse-dashboards.dashboards-tree.checkbox.disabled-not-in-same-repo',
'This item is not in the same repository as the selected items.'
)}
>
<span>
<Checkbox disabled value={false} />
</span>
</Tooltip>
);
}
const state = isSelected(item);
return (
@@ -1,7 +1,15 @@
import { config } from '@grafana/runtime';
import { contextSrv } from 'app/core/core';
import { ManagerKind } from 'app/features/apiserver/types';
import { DashboardViewItem } from 'app/features/search/types';
import { DashboardTreeSelection, DashboardViewItemWithUIItems, BrowseDashboardsPermissions } from '../types';
import { findItem } from '../state/utils';
import {
DashboardTreeSelection,
DashboardViewItemWithUIItems,
BrowseDashboardsPermissions,
BrowseDashboardsState,
} from '../types';
import { ResourceRef } from './BulkActions/useBulkActionJob';
@@ -96,3 +104,35 @@ export function canSelectItems(permissions: BrowseDashboardsPermissions) {
const canSelectDashboards = canEditDashboards || canDeleteDashboards;
return Boolean(canSelectFolders || canSelectDashboards);
}
/**
* Finds the repository name for an item by traversing up the tree to find the root provisioned folder (managed by ManagerKind.Repo)
* This should be an edge case where user have multiple provisioned folders and try to managing resources on root folder
*/
export function getItemRepositoryUid(
item: DashboardViewItem,
rootItems: DashboardViewItem[],
childrenByParentUID: BrowseDashboardsState['childrenByParentUID']
): string {
// For root provisioned folders, the UID is the repository name
if (item.managedBy === ManagerKind.Repo && !item.parentUID && item.kind === 'folder') {
return item.uid;
}
// Traverse up the tree to find the root provisioned folder
let currentItem = item;
while (currentItem.parentUID) {
const parent = findItem(rootItems, childrenByParentUID, currentItem.parentUID);
if (!parent) {
break;
}
if (parent.managedBy === ManagerKind.Repo && !parent.parentUID) {
return currentItem.parentUID;
}
currentItem = parent;
}
return 'non_provisioned';
}
@@ -1,5 +1,6 @@
import { PayloadAction } from '@reduxjs/toolkit';
import { ManagerKind } from 'app/features/apiserver/types';
import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
import { GENERAL_FOLDER_UID } from '../../search/constants';
@@ -96,6 +97,11 @@ export function setItemSelectionState(
return;
}
// Prevent selection of root provisioned folders
if (item.managedBy === ManagerKind.Repo && !item.parentUID) {
return;
}
// Selecting a folder selects all children, and unselecting a folder deselects all children
// so propagate the new selection state to all descendants
function markChildren(kind: DashboardViewItemKind, uid: string) {
@@ -178,6 +184,11 @@ export function setAllSelection(
continue;
}
// Skip all provisioned resources during "select all" on root level
if (child.managedBy === ManagerKind.Repo && !child.parentUID) {
continue;
}
state.selectedItems[child.kind][child.uid] = isSelected;
if (child.kind !== 'folder') {
+3
View File
@@ -3556,6 +3556,9 @@
"total_other": "{{count}} item"
},
"dashboards-tree": {
"checkbox": {
"disabled-not-in-same-repo": "This item is not in the same repository as the selected items."
},
"collapse-folder-button": "Collapse folder {{title}}",
"expand-folder-button": "Expand folder {{title}}",
"name-column": "Name",