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:
+28
-16
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+35
@@ -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;
|
||||
}
|
||||
|
||||
+16
@@ -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(() => {
|
||||
|
||||
+9
-3
@@ -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();
|
||||
|
||||
|
||||
+9
-4
@@ -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') {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user