From 470ac231bf3ad06ab124263ceb807af499e64da7 Mon Sep 17 00:00:00 2001 From: Clarity-89 Date: Tue, 13 Jan 2026 16:25:42 +0200 Subject: [PATCH] BrowseDashboards: Split utils --- .../browse-dashboards/api/recentlyViewed.ts | 36 +++++++ .../browse-dashboards/api/services.ts | 2 +- .../components/BrowseView.tsx | 12 +-- .../components/CheckboxCell.tsx | 4 +- .../components/DashboardsTree.tsx | 7 +- .../browse-dashboards/components/NameCell.tsx | 5 +- .../components/RecentlyViewedDashboards.tsx | 4 +- .../components/SearchView.tsx | 3 +- .../browse-dashboards/components/utils.ts | 98 ------------------- .../features/browse-dashboards/permissions.ts | 17 ++++ .../features/browse-dashboards/state/hooks.ts | 4 +- .../browse-dashboards/state/reducers.ts | 2 +- .../browse-dashboards/utils/dashboards.ts | 47 +++++++++ 13 files changed, 121 insertions(+), 120 deletions(-) create mode 100644 public/app/features/browse-dashboards/api/recentlyViewed.ts delete mode 100644 public/app/features/browse-dashboards/components/utils.ts create mode 100644 public/app/features/browse-dashboards/utils/dashboards.ts diff --git a/public/app/features/browse-dashboards/api/recentlyViewed.ts b/public/app/features/browse-dashboards/api/recentlyViewed.ts new file mode 100644 index 00000000000..9da884bb221 --- /dev/null +++ b/public/app/features/browse-dashboards/api/recentlyViewed.ts @@ -0,0 +1,36 @@ +import impressionSrv from 'app/core/services/impression_srv'; +import { getGrafanaSearcher } from 'app/features/search/service/searcher'; +import { DashboardQueryResult } from 'app/features/search/service/types'; + +/** + * Returns dashboard search results ordered the same way the user opened them. + */ +export async function getRecentlyViewedDashboards(maxItems = 5): Promise { + try { + const recentlyOpened = (await impressionSrv.getDashboardOpened()).slice(0, maxItems); + if (!recentlyOpened.length) { + return []; + } + + const searchResults = await getGrafanaSearcher().search({ + kind: ['dashboard'], + limit: recentlyOpened.length, + uid: recentlyOpened, + }); + + const dashboards = searchResults.view.toArray(); + // Keep dashboards in the same order the user opened them. + // When a UID is missing from the search response + // push it to the end instead of letting indexOf return -1 + const order = (uid: string) => { + const idx = recentlyOpened.indexOf(uid); + return idx === -1 ? recentlyOpened.length : idx; + }; + + dashboards.sort((a, b) => order(a.uid) - order(b.uid)); + return dashboards; + } catch (error) { + console.error('Failed to load recently viewed dashboards', error); + return []; + } +} diff --git a/public/app/features/browse-dashboards/api/services.ts b/public/app/features/browse-dashboards/api/services.ts index 98be615583b..41a32655520 100644 --- a/public/app/features/browse-dashboards/api/services.ts +++ b/public/app/features/browse-dashboards/api/services.ts @@ -7,7 +7,7 @@ import { queryResultToViewItem } from 'app/features/search/service/utils'; import { DashboardViewItem } from 'app/features/search/types'; import { AccessControlAction } from 'app/types/accessControl'; -import { getFolderURL, isSharedWithMe } from '../components/utils'; +import { getFolderURL, isSharedWithMe } from '../utils/dashboards'; export const PAGE_SIZE = 50; diff --git a/public/app/features/browse-dashboards/components/BrowseView.tsx b/public/app/features/browse-dashboards/components/BrowseView.tsx index 0b42b79f9ae..91c8454b09a 100644 --- a/public/app/features/browse-dashboards/components/BrowseView.tsx +++ b/public/app/features/browse-dashboards/components/BrowseView.tsx @@ -13,20 +13,20 @@ import { DashboardViewItem } from 'app/features/search/types'; import { useDispatch, useSelector } from 'app/types/store'; import { PAGE_SIZE } from '../api/services'; +import { canSelectItems } from '../permissions'; import { fetchNextChildrenPage } from '../state/actions'; import { - useFlatTreeState, + rootItemsSelector, + useBrowseLoadingStatus, useCheckboxSelectionState, useChildrenByParentUIDState, - useBrowseLoadingStatus, + useFlatTreeState, useLoadNextChildrenPage, - rootItemsSelector, } from '../state/hooks'; -import { setFolderOpenState, setItemSelectionState, setAllSelection } from '../state/slice'; -import { BrowseDashboardsState, DashboardTreeSelection, SelectionState, BrowseDashboardsPermissions } from '../types'; +import { setAllSelection, setFolderOpenState, setItemSelectionState } from '../state/slice'; +import { BrowseDashboardsPermissions, BrowseDashboardsState, DashboardTreeSelection, SelectionState } from '../types'; import { DashboardsTree } from './DashboardsTree'; -import { canSelectItems } from './utils'; interface BrowseViewProps { height: number; diff --git a/public/app/features/browse-dashboards/components/CheckboxCell.tsx b/public/app/features/browse-dashboards/components/CheckboxCell.tsx index c86fd681c95..fcc348e75f8 100644 --- a/public/app/features/browse-dashboards/components/CheckboxCell.tsx +++ b/public/app/features/browse-dashboards/components/CheckboxCell.tsx @@ -10,9 +10,9 @@ import { useSelectionRepoValidation } from 'app/features/provisioning/hooks/useS import { getReadOnlyTooltipText } from 'app/features/provisioning/utils/repository'; import { useSelector } from 'app/types/store'; +import { canEditItemType } from '../permissions'; import { DashboardsTreeCellProps, SelectionState } from '../types'; - -import { isSharedWithMe, canEditItemType } from './utils'; +import { isSharedWithMe } from '../utils/dashboards'; export default function CheckboxCell({ row: { original: row }, diff --git a/public/app/features/browse-dashboards/components/DashboardsTree.tsx b/public/app/features/browse-dashboards/components/DashboardsTree.tsx index a7999919db9..9fc61084433 100644 --- a/public/app/features/browse-dashboards/components/DashboardsTree.tsx +++ b/public/app/features/browse-dashboards/components/DashboardsTree.tsx @@ -1,6 +1,6 @@ import { css, cx } from '@emotion/css'; -import { useCallback, useEffect, useId, useMemo, useRef } from 'react'; import * as React from 'react'; +import { useCallback, useEffect, useId, useMemo, useRef } from 'react'; import { TableInstance, useTable } from 'react-table'; import { VariableSizeList as List } from 'react-window'; import InfiniteLoader from 'react-window-infinite-loader'; @@ -11,20 +11,21 @@ import { Trans, t } from '@grafana/i18n'; import { useStyles2 } from '@grafana/ui'; import { DashboardViewItem } from 'app/features/search/types'; +import { canSelectItems } from '../permissions'; import { + BrowseDashboardsPermissions, DashboardsTreeCellProps, DashboardsTreeColumn, DashboardsTreeItem, SelectionState, - BrowseDashboardsPermissions, } from '../types'; +import { makeRowID } from '../utils/dashboards'; import CheckboxCell from './CheckboxCell'; import CheckboxHeaderCell from './CheckboxHeaderCell'; import { NameCell } from './NameCell'; import { TagsCell } from './TagsCell'; import { useCustomFlexLayout } from './customFlexTableLayout'; -import { makeRowID, canSelectItems } from './utils'; interface DashboardsTreeProps { items: DashboardsTreeItem[]; diff --git a/public/app/features/browse-dashboards/components/NameCell.tsx b/public/app/features/browse-dashboards/components/NameCell.tsx index 0049fb8f829..04e38f9ba90 100644 --- a/public/app/features/browse-dashboards/components/NameCell.tsx +++ b/public/app/features/browse-dashboards/components/NameCell.tsx @@ -4,7 +4,7 @@ import Skeleton from 'react-loading-skeleton'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; -import { Icon, IconButton, Link, Spinner, useStyles2, Text } from '@grafana/ui'; +import { Icon, IconButton, Link, Spinner, Text, useStyles2 } from '@grafana/ui'; import { getSvgSize } from '@grafana/ui/internal'; import { getIconForItem } from 'app/features/search/service/utils'; @@ -12,8 +12,7 @@ import { Indent } from '../../../core/components/Indent/Indent'; import { FolderRepo } from '../../../core/components/NestedFolderPicker/FolderRepo'; import { useChildrenByParentUIDState } from '../state/hooks'; import { DashboardsTreeCellProps } from '../types'; - -import { makeRowID } from './utils'; +import { makeRowID } from '../utils/dashboards'; const CHEVRON_SIZE = 'md'; const ICON_SIZE = 'sm'; diff --git a/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx b/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx index f0c2d4d3a13..231779f04e6 100644 --- a/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx +++ b/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx @@ -6,12 +6,12 @@ import { GrafanaTheme2, store } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; import { evaluateBooleanFlag } from '@grafana/runtime/internal'; -import { Button, CollapsableSection, Spinner, Stack, Text, useStyles2, Grid } from '@grafana/ui'; +import { Button, CollapsableSection, Grid, Spinner, Stack, Text, useStyles2 } from '@grafana/ui'; import { contextSrv } from 'app/core/services/context_srv'; import { useDashboardLocationInfo } from 'app/features/search/hooks/useDashboardLocationInfo'; import { DashListItem } from 'app/plugins/panel/dashlist/DashListItem'; -import { getRecentlyViewedDashboards } from './utils'; +import { getRecentlyViewedDashboards } from '../api/recentlyViewed'; const MAX_RECENT = 5; diff --git a/public/app/features/browse-dashboards/components/SearchView.tsx b/public/app/features/browse-dashboards/components/SearchView.tsx index 24148cefcf7..bfe38408e55 100644 --- a/public/app/features/browse-dashboards/components/SearchView.tsx +++ b/public/app/features/browse-dashboards/components/SearchView.tsx @@ -9,12 +9,11 @@ import { SearchStateManager } from 'app/features/search/state/SearchStateManager import { DashboardViewItemKind, SearchState } from 'app/features/search/types'; import { useDispatch, useSelector } from 'app/types/store'; +import { canEditItemType, canSelectItems } from '../permissions'; import { useHasSelection } from '../state/hooks'; import { setAllSelection, setItemSelectionState } from '../state/slice'; import { BrowseDashboardsPermissions } from '../types'; -import { canEditItemType, canSelectItems } from './utils'; - interface SearchViewProps { height: number; width: number; diff --git a/public/app/features/browse-dashboards/components/utils.ts b/public/app/features/browse-dashboards/components/utils.ts deleted file mode 100644 index e1a3d5b5a02..00000000000 --- a/public/app/features/browse-dashboards/components/utils.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { config } from '@grafana/runtime'; -import { contextSrv } from 'app/core/services/context_srv'; -import impressionSrv from 'app/core/services/impression_srv'; -import { ResourceRef } from 'app/features/provisioning/components/BulkActions/useBulkActionJob'; -import { getGrafanaSearcher } from 'app/features/search/service/searcher'; -import { DashboardQueryResult } from 'app/features/search/service/types'; - -import { DashboardTreeSelection, DashboardViewItemWithUIItems, BrowseDashboardsPermissions } from '../types'; - -export function makeRowID(baseId: string, item: DashboardViewItemWithUIItems) { - return baseId + item.uid; -} - -export function isSharedWithMe(uid: string) { - return uid === config.sharedWithMeFolderUID; -} - -// Construct folder URL and append orgId to it -export function getFolderURL(uid: string) { - const { orgId } = contextSrv.user; - const subUrlPrefix = config.appSubUrl ?? ''; - const url = `${subUrlPrefix}/dashboards/f/${uid}/`; - - if (orgId) { - return `${url}?orgId=${orgId}`; - } - return url; -} - -// Collect selected dashboard and folder from the DashboardTreeSelection -// This is used to prepare the items for bulk delete operation. -export function collectSelectedItems(selectedItems: Omit) { - const resources: ResourceRef[] = []; - - // folders - for (const [uid, selected] of Object.entries(selectedItems.folder)) { - if (selected) { - resources.push({ name: uid, group: 'folder.grafana.app', kind: 'Folder' }); - } - } - - // dashboards - for (const [uid, selected] of Object.entries(selectedItems.dashboard)) { - if (selected) { - resources.push({ name: uid, group: 'dashboard.grafana.app', kind: 'Dashboard' }); - } - } - - return resources; -} - -export function canEditItemType(itemKind: string, permissions: BrowseDashboardsPermissions) { - const { canEditFolders, canDeleteFolders, canEditDashboards, canDeleteDashboards } = permissions; - return itemKind === 'folder' - ? Boolean(canEditFolders || canDeleteFolders) - : Boolean(canEditDashboards || canDeleteDashboards); -} - -export function canSelectItems(permissions: BrowseDashboardsPermissions) { - const { canEditFolders, canDeleteFolders, canEditDashboards, canDeleteDashboards } = permissions; - // Users can select items only if they have both edit and delete permissions for at least one item type - const canSelectFolders = canEditFolders || canDeleteFolders; - const canSelectDashboards = canEditDashboards || canDeleteDashboards; - return Boolean(canSelectFolders || canSelectDashboards); -} - -/** - * Returns dashboard search results ordered the same way the user opened them. - */ -export async function getRecentlyViewedDashboards(maxItems = 5): Promise { - try { - const recentlyOpened = (await impressionSrv.getDashboardOpened()).slice(0, maxItems); - if (!recentlyOpened.length) { - return []; - } - - const searchResults = await getGrafanaSearcher().search({ - kind: ['dashboard'], - limit: recentlyOpened.length, - uid: recentlyOpened, - }); - - const dashboards = searchResults.view.toArray(); - // Keep dashboards in the same order the user opened them. - // When a UID is missing from the search response - // push it to the end instead of letting indexOf return -1 - const order = (uid: string) => { - const idx = recentlyOpened.indexOf(uid); - return idx === -1 ? recentlyOpened.length : idx; - }; - - dashboards.sort((a, b) => order(a.uid) - order(b.uid)); - return dashboards; - } catch (error) { - console.error('Failed to load recently viewed dashboards', error); - return []; - } -} diff --git a/public/app/features/browse-dashboards/permissions.ts b/public/app/features/browse-dashboards/permissions.ts index 7580eb09c2f..2e0824cd47f 100644 --- a/public/app/features/browse-dashboards/permissions.ts +++ b/public/app/features/browse-dashboards/permissions.ts @@ -2,6 +2,8 @@ import { contextSrv } from 'app/core/services/context_srv'; import { AccessControlAction } from 'app/types/accessControl'; import { FolderDTO } from 'app/types/folders'; +import { BrowseDashboardsPermissions } from './types'; + function checkFolderPermission(action: AccessControlAction, folderDTO?: FolderDTO) { return folderDTO ? contextSrv.hasPermissionInMetadata(action, folderDTO) : contextSrv.hasPermission(action); } @@ -31,3 +33,18 @@ export function getFolderPermissions(folderDTO?: FolderDTO) { canDeleteDashboards, }; } + +export function canEditItemType(itemKind: string, permissions: BrowseDashboardsPermissions) { + const { canEditFolders, canDeleteFolders, canEditDashboards, canDeleteDashboards } = permissions; + return itemKind === 'folder' + ? Boolean(canEditFolders || canDeleteFolders) + : Boolean(canEditDashboards || canDeleteDashboards); +} + +export function canSelectItems(permissions: BrowseDashboardsPermissions) { + const { canEditFolders, canDeleteFolders, canEditDashboards, canDeleteDashboards } = permissions; + // Users can select items only if they have both edit and delete permissions for at least one item type + const canSelectFolders = canEditFolders || canDeleteFolders; + const canSelectDashboards = canEditDashboards || canDeleteDashboards; + return Boolean(canSelectFolders || canSelectDashboards); +} diff --git a/public/app/features/browse-dashboards/state/hooks.ts b/public/app/features/browse-dashboards/state/hooks.ts index 2c0bee62a98..20c941e8270 100644 --- a/public/app/features/browse-dashboards/state/hooks.ts +++ b/public/app/features/browse-dashboards/state/hooks.ts @@ -2,10 +2,9 @@ import { useCallback, useRef } from 'react'; import { createSelector } from 'reselect'; import { DashboardViewItem } from 'app/features/search/types'; -import { useSelector, StoreState, useDispatch } from 'app/types/store'; +import { StoreState, useDispatch, useSelector } from 'app/types/store'; import { PAGE_SIZE } from '../api/services'; -import { isSharedWithMe } from '../components/utils'; import { BrowseDashboardsState, DashboardsTreeItem, @@ -13,6 +12,7 @@ import { DashboardViewItemWithUIItems, UIDashboardViewItem, } from '../types'; +import { isSharedWithMe } from '../utils/dashboards'; import { fetchNextChildrenPage } from './actions'; import { getPaginationPlaceholders } from './utils'; diff --git a/public/app/features/browse-dashboards/state/reducers.ts b/public/app/features/browse-dashboards/state/reducers.ts index 316b2d9f5dc..83588db8320 100644 --- a/public/app/features/browse-dashboards/state/reducers.ts +++ b/public/app/features/browse-dashboards/state/reducers.ts @@ -3,8 +3,8 @@ import { PayloadAction } from '@reduxjs/toolkit'; import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types'; import { GENERAL_FOLDER_UID } from '../../search/constants'; -import { isSharedWithMe } from '../components/utils'; import { BrowseDashboardsState } from '../types'; +import { isSharedWithMe } from '../utils/dashboards'; import { fetchNextChildrenPage, refetchChildren } from './actions'; import { findItem } from './utils'; diff --git a/public/app/features/browse-dashboards/utils/dashboards.ts b/public/app/features/browse-dashboards/utils/dashboards.ts new file mode 100644 index 00000000000..65edddbe9f4 --- /dev/null +++ b/public/app/features/browse-dashboards/utils/dashboards.ts @@ -0,0 +1,47 @@ +import { config } from '@grafana/runtime'; +import { contextSrv } from 'app/core/services/context_srv'; +import { ResourceRef } from 'app/features/provisioning/components/BulkActions/useBulkActionJob'; + +import { DashboardTreeSelection, DashboardViewItemWithUIItems } from '../types'; + +export function makeRowID(baseId: string, item: DashboardViewItemWithUIItems) { + return baseId + item.uid; +} + +export function isSharedWithMe(uid: string) { + return uid === config.sharedWithMeFolderUID; +} + +// Construct folder URL and append orgId to it +export function getFolderURL(uid: string) { + const { orgId } = contextSrv.user; + const subUrlPrefix = config.appSubUrl ?? ''; + const url = `${subUrlPrefix}/dashboards/f/${uid}/`; + + if (orgId) { + return `${url}?orgId=${orgId}`; + } + return url; +} + +// Collect selected dashboard and folder from the DashboardTreeSelection +// This is used to prepare the items for bulk delete operation. +export function collectSelectedItems(selectedItems: Omit) { + const resources: ResourceRef[] = []; + + // folders + for (const [uid, selected] of Object.entries(selectedItems.folder)) { + if (selected) { + resources.push({ name: uid, group: 'folder.grafana.app', kind: 'Folder' }); + } + } + + // dashboards + for (const [uid, selected] of Object.entries(selectedItems.dashboard)) { + if (selected) { + resources.push({ name: uid, group: 'dashboard.grafana.app', kind: 'Dashboard' }); + } + } + + return resources; +}