diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index bda3a89aa66..ac10735a8a7 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import React, { memo, useEffect, useMemo, useState } from 'react'; +import React, { memo, useEffect, useMemo } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { GrafanaTheme2 } from '@grafana/data'; @@ -30,9 +30,6 @@ export interface Props extends GrafanaRouteComponentProps { - // this is a complete hack to force a full rerender. - // TODO remove once we move everything to RTK query - const [rerender, setRerender] = useState(0); const { uid: folderUID } = match.params; const styles = useStyles2(getStyles); @@ -77,21 +74,15 @@ const BrowseDashboardsPage = memo(({ match }: Props) => { onChange={(e) => stateManager.onQueryChange(e)} /> - {hasSelection ? setRerender(rerender + 1)} /> : } + {hasSelection ? : }
{({ width, height }) => isSearching ? ( - + ) : ( - + ) } diff --git a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts index b5c879c0abb..29e02e8f2c6 100644 --- a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts +++ b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts @@ -3,8 +3,7 @@ import { lastValueFrom } from 'rxjs'; import { isTruthy } from '@grafana/data'; import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime'; -import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; -import { DashboardDTO, FolderDTO } from 'app/types'; +import { FolderDTO } from 'app/types'; import { DashboardTreeSelection } from '../types'; @@ -36,62 +35,6 @@ export const browseDashboardsAPI = createApi({ reducerPath: 'browseDashboardsAPI', baseQuery: createBackendSrvBaseQuery({ baseURL: '/api' }), endpoints: (builder) => ({ - deleteDashboard: builder.mutation({ - query: (dashboardUID) => ({ - url: `/dashboards/uid/${dashboardUID}`, - method: 'DELETE', - }), - }), - deleteFolder: builder.mutation({ - query: (folderUID) => ({ - url: `/folders/${folderUID}`, - method: 'DELETE', - params: { - forceDeleteRules: true, - }, - }), - }), - // TODO we can define this return type properly - moveDashboard: builder.mutation< - unknown, - { - dashboardUID: string; - destinationUID: string; - } - >({ - queryFn: async ({ dashboardUID, destinationUID }, _api, _extraOptions, baseQuery) => { - const fullDash: DashboardDTO = await getBackendSrv().get(`/api/dashboards/uid/${dashboardUID}`); - - const options = { - dashboard: fullDash.dashboard, - folderUid: destinationUID, - overwrite: false, - }; - - return baseQuery({ - url: '/dashboards/db', - method: 'POST', - data: { - message: '', - ...options, - }, - }); - }, - }), - // TODO this doesn't return void, find where the correct type is - moveFolder: builder.mutation< - void, - { - folderUID: string; - destinationUID: string; - } - >({ - query: ({ folderUID, destinationUID }) => ({ - url: `/folders/${folderUID}/move`, - method: 'POST', - data: { parentUid: destinationUID }, - }), - }), getFolder: builder.query({ query: (folderUID) => ({ url: `/folders/${folderUID}` }), }), @@ -150,13 +93,5 @@ export const browseDashboardsAPI = createApi({ }), }); -export const { - useDeleteDashboardMutation, - useDeleteFolderMutation, - useGetAffectedItemsQuery, - useGetFolderQuery, - useLazyGetFolderQuery, - useMoveDashboardMutation, - useMoveFolderMutation, -} = browseDashboardsAPI; +export const { useGetAffectedItemsQuery, useGetFolderQuery } = browseDashboardsAPI; export { skipToken } from '@reduxjs/toolkit/query/react'; diff --git a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx index 4b40aad2740..68479753977 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx @@ -4,69 +4,92 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Button, useStyles2 } from '@grafana/ui'; import appEvents from 'app/core/app_events'; +import { useDispatch, useSelector } from 'app/types'; import { ShowModalReactEvent } from 'app/types/events'; import { - useDeleteDashboardMutation, - useDeleteFolderMutation, - useMoveDashboardMutation, - useMoveFolderMutation, -} from '../../api/browseDashboardsAPI'; -import { useActionSelectionState } from '../../state'; + childrenByParentUIDSelector, + deleteDashboard, + deleteFolder, + fetchChildren, + moveDashboard, + moveFolder, + rootItemsSelector, + setAllSelection, + useActionSelectionState, +} from '../../state'; +import { findItem } from '../../state/utils'; import { DeleteModal } from './DeleteModal'; import { MoveModal } from './MoveModal'; -export interface Props { - // this is a complete hack to force a full rerender. - // TODO remove once we move everything to RTK query - onActionComplete?: () => void; -} +export interface Props {} -export function BrowseActions({ onActionComplete }: Props) { +export function BrowseActions() { const styles = useStyles2(getStyles); const selectedItems = useActionSelectionState(); - const [deleteDashboard] = useDeleteDashboardMutation(); - const [deleteFolder] = useDeleteFolderMutation(); - const [moveFolder] = useMoveFolderMutation(); - const [moveDashboard] = useMoveDashboardMutation(); + const dispatch = useDispatch(); const selectedDashboards = Object.keys(selectedItems.dashboard).filter((uid) => selectedItems.dashboard[uid]); const selectedFolders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]); + const rootItems = useSelector(rootItemsSelector); + const childrenByParentUID = useSelector(childrenByParentUIDSelector); + + const onActionComplete = (parentsToRefresh: Set) => { + dispatch( + setAllSelection({ + isSelected: false, + }) + ); + for (const parentUID of parentsToRefresh) { + dispatch(fetchChildren(parentUID)); + } + }; const onDelete = async () => { + const parentsToRefresh = new Set(); + // Delete all the folders sequentially // TODO error handling here for (const folderUID of selectedFolders) { - await deleteFolder(folderUID).unwrap(); + await dispatch(deleteFolder(folderUID)); + // find the parent folder uid and add it to parentsToRefresh + const folder = findItem(rootItems ?? [], childrenByParentUID, folderUID); + parentsToRefresh.add(folder?.parentUID); } - // Delete all the dashboards sequenetially + // Delete all the dashboards sequentially // TODO error handling here for (const dashboardUID of selectedDashboards) { - await deleteDashboard(dashboardUID).unwrap(); + await dispatch(deleteDashboard(dashboardUID)); + // find the parent folder uid and add it to parentsToRefresh + const dashboard = findItem(rootItems ?? [], childrenByParentUID, dashboardUID); + parentsToRefresh.add(dashboard?.parentUID); } - onActionComplete?.(); + onActionComplete(parentsToRefresh); }; const onMove = async (destinationUID: string) => { + const parentsToRefresh = new Set(); + parentsToRefresh.add(destinationUID); + // Move all the folders sequentially // TODO error handling here for (const folderUID of selectedFolders) { - await moveFolder({ - folderUID, - destinationUID, - }).unwrap(); + await dispatch(moveFolder({ folderUID, destinationUID })); + // find the parent folder uid and add it to parentsToRefresh + const folder = findItem(rootItems ?? [], childrenByParentUID, folderUID); + parentsToRefresh.add(folder?.parentUID); } // Move all the dashboards sequentially // TODO error handling here for (const dashboardUID of selectedDashboards) { - await moveDashboard({ - dashboardUID, - destinationUID, - }).unwrap(); + await dispatch(moveDashboard({ dashboardUID, destinationUID })); + // find the parent folder uid and add it to parentsToRefresh + const dashboard = findItem(rootItems ?? [], childrenByParentUID, dashboardUID); + parentsToRefresh.add(dashboard?.parentUID); } - onActionComplete?.(); + onActionComplete(parentsToRefresh); }; const showMoveModal = () => { diff --git a/public/app/features/browse-dashboards/components/DashboardsTree.tsx b/public/app/features/browse-dashboards/components/DashboardsTree.tsx index 2b471e892d3..979dfb648b4 100644 --- a/public/app/features/browse-dashboards/components/DashboardsTree.tsx +++ b/public/app/features/browse-dashboards/components/DashboardsTree.tsx @@ -92,7 +92,9 @@ export function DashboardsTree({ onAllSelectionChange, onItemSelectionChange, }), - [table, isSelected, onAllSelectionChange, onItemSelectionChange] + // we need this to rerender if items changes + // eslint-disable-next-line react-hooks/exhaustive-deps + [table, isSelected, onAllSelectionChange, onItemSelectionChange, items] ); return ( diff --git a/public/app/features/browse-dashboards/state/actions.ts b/public/app/features/browse-dashboards/state/actions.ts index 98fa760a937..ed0ad9541a2 100644 --- a/public/app/features/browse-dashboards/state/actions.ts +++ b/public/app/features/browse-dashboards/state/actions.ts @@ -1,10 +1,49 @@ -import { createAsyncThunk } from '@reduxjs/toolkit'; - +import { getBackendSrv } from '@grafana/runtime'; +import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; +import { GENERAL_FOLDER_UID } from 'app/features/search/constants'; import { getFolderChildren } from 'app/features/search/service/folders'; +import { createAsyncThunk, DashboardDTO } from 'app/types'; export const fetchChildren = createAsyncThunk( 'browseDashboards/fetchChildren', async (parentUID: string | undefined) => { - return await getFolderChildren(parentUID, undefined, true); + // Need to handle the case where the parentUID is the root + const uid = parentUID === GENERAL_FOLDER_UID ? undefined : parentUID; + return await getFolderChildren(uid, undefined, true); + } +); + +export const deleteDashboard = createAsyncThunk('browseDashboards/deleteDashboard', async (dashboardUID: string) => { + return getBackendSrv().delete(`/api/dashboards/uid/${dashboardUID}`); +}); + +export const deleteFolder = createAsyncThunk('browseDashboards/deleteFolder', async (folderUID: string) => { + return getBackendSrv().delete(`/api/folders/${folderUID}`, undefined, { + params: { forceDeleteRules: true }, + }); +}); + +export const moveDashboard = createAsyncThunk( + 'browseDashboards/moveDashboard', + async ({ dashboardUID, destinationUID }: { dashboardUID: string; destinationUID: string }) => { + const fullDash: DashboardDTO = await getBackendSrv().get(`/api/dashboards/uid/${dashboardUID}`); + + const options = { + dashboard: fullDash.dashboard, + folderUid: destinationUID, + overwrite: false, + }; + + return getBackendSrv().post('/api/dashboards/db', { + message: '', + ...options, + }); + } +); + +export const moveFolder = createAsyncThunk( + 'browseDashboards/moveFolder', + async ({ folderUID, destinationUID }: { folderUID: string; destinationUID: string }) => { + return getBackendSrv().post(`/api/folders/${folderUID}/move`, { parentUID: destinationUID }); } ); diff --git a/public/app/features/browse-dashboards/state/hooks.ts b/public/app/features/browse-dashboards/state/hooks.ts index 1523ea2ec91..66e74ccdaab 100644 --- a/public/app/features/browse-dashboards/state/hooks.ts +++ b/public/app/features/browse-dashboards/state/hooks.ts @@ -5,30 +5,61 @@ import { useSelector, StoreState } from 'app/types'; import { DashboardsTreeItem, DashboardTreeSelection } from '../types'; +export const rootItemsSelector = (wholeState: StoreState) => wholeState.browseDashboards.rootItems; +export const childrenByParentUIDSelector = (wholeState: StoreState) => wholeState.browseDashboards.childrenByParentUID; +export const openFoldersSelector = (wholeState: StoreState) => wholeState.browseDashboards.openFolders; +export const selectedItemsSelector = (wholeState: StoreState) => wholeState.browseDashboards.selectedItems; + const flatTreeSelector = createSelector( - (wholeState: StoreState) => wholeState.browseDashboards.rootItems, - (wholeState: StoreState) => wholeState.browseDashboards.childrenByParentUID, - (wholeState: StoreState) => wholeState.browseDashboards.openFolders, + rootItemsSelector, + childrenByParentUIDSelector, + openFoldersSelector, (wholeState: StoreState, rootFolderUID: string | undefined) => rootFolderUID, (rootItems, childrenByParentUID, openFolders, folderUID) => { return createFlatTree(folderUID, rootItems ?? [], childrenByParentUID, openFolders); } ); -const hasSelectionSelector = createSelector( - (wholeState: StoreState) => wholeState.browseDashboards.selectedItems, - (selectedItems) => { - return Object.values(selectedItems).some((selectedItem) => - Object.values(selectedItem).some((isSelected) => isSelected) - ); - } -); +const hasSelectionSelector = createSelector(selectedItemsSelector, (selectedItems) => { + return Object.values(selectedItems).some((selectedItem) => + Object.values(selectedItem).some((isSelected) => isSelected) + ); +}); +// Returns a DashboardTreeSelection but unselects any selected folder's children. +// This is useful when making backend requests to move or delete items. +// In this case, we only need to move/delete the parent folder and it will cascade to the children. const selectedItemsForActionsSelector = createSelector( - (wholeState: StoreState) => wholeState.browseDashboards.selectedItems, - (wholeState: StoreState) => wholeState.browseDashboards.childrenByParentUID, + selectedItemsSelector, + childrenByParentUIDSelector, (selectedItems, childrenByParentUID) => { - return getSelectedItemsForActions(selectedItems, childrenByParentUID); + // Take a copy of the selected items to work with + // We don't care about panels here, only dashboards and folders can be moved or deleted + const result: Omit = { + dashboard: { ...selectedItems.dashboard }, + folder: { ...selectedItems.folder }, + }; + + // Loop over selected folders in the input + for (const folderUID of Object.keys(selectedItems.folder)) { + const isSelected = selectedItems.folder[folderUID]; + if (isSelected) { + // Unselect any children in the output + const children = childrenByParentUID[folderUID]; + if (children) { + for (const child of children) { + if (child.kind === 'dashboard') { + result.dashboard[child.uid] = false; + } + if (child.kind === 'folder') { + result.folder[child.uid] = false; + } + } + } + } + } + + return result; } ); @@ -51,7 +82,7 @@ export function useHasSelection() { } export function useCheckboxSelectionState() { - return useSelector((wholeState: StoreState) => wholeState.browseDashboards.selectedItems); + return useSelector(selectedItemsSelector); } export function useChildrenByParentUIDState() { @@ -109,43 +140,3 @@ function createFlatTree( return items.flatMap((item) => mapItem(item, folderUID, level)); } - -/** - * Returns a DashboardTreeSelection but unselects any selected folder's children. - * This is useful when making backend requests to move or delete items. - * In this case, we only need to move/delete the parent folder and it will cascade to the children. - * @param selectedItemsState Overall selection state - * @param childrenByParentUID Arrays of children keyed by their parent UID - */ -function getSelectedItemsForActions( - selectedItemsState: DashboardTreeSelection, - childrenByParentUID: Record -): Omit { - // Take a copy of the selected items to work with - // We don't care about panels here, only dashboards and folders can be moved or deleted - const result = { - dashboard: { ...selectedItemsState.dashboard }, - folder: { ...selectedItemsState.folder }, - }; - - // Loop over selected folders in the input - for (const folderUID of Object.keys(selectedItemsState.folder)) { - const isSelected = selectedItemsState.folder[folderUID]; - if (isSelected) { - // Unselect any children in the output - const children = childrenByParentUID[folderUID]; - if (children) { - for (const child of children) { - if (child.kind === 'dashboard') { - result.dashboard[child.uid] = false; - } - if (child.kind === 'folder') { - result.folder[child.uid] = false; - } - } - } - } - } - - return result; -} diff --git a/public/app/features/browse-dashboards/state/reducers.ts b/public/app/features/browse-dashboards/state/reducers.ts index 1fcd31ff6df..3b8f23c9950 100644 --- a/public/app/features/browse-dashboards/state/reducers.ts +++ b/public/app/features/browse-dashboards/state/reducers.ts @@ -1,10 +1,12 @@ import { PayloadAction } from '@reduxjs/toolkit'; +import { GENERAL_FOLDER_UID } from 'app/features/search/constants'; import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types'; import { BrowseDashboardsState } from '../types'; import { fetchChildren } from './actions'; +import { findItem } from './utils'; type FetchChildrenAction = ReturnType; @@ -12,7 +14,7 @@ export function extraReducerFetchChildrenFulfilled(state: BrowseDashboardsState, const parentUID = action.meta.arg; const children = action.payload; - if (!parentUID) { + if (!parentUID || parentUID === GENERAL_FOLDER_UID) { state.rootItems = children; return; } @@ -133,30 +135,3 @@ export function setAllSelection(state: BrowseDashboardsState, action: PayloadAct } } } - -function findItem( - rootItems: DashboardViewItem[], - childrenByUID: Record, - uid: string -): DashboardViewItem | undefined { - for (const item of rootItems) { - if (item.uid === uid) { - return item; - } - } - - for (const parentUID in childrenByUID) { - const children = childrenByUID[parentUID]; - if (!children) { - continue; - } - - for (const child of children) { - if (child.uid === uid) { - return child; - } - } - } - - return undefined; -} diff --git a/public/app/features/browse-dashboards/state/utils.ts b/public/app/features/browse-dashboards/state/utils.ts new file mode 100644 index 00000000000..04b2bfb5cd4 --- /dev/null +++ b/public/app/features/browse-dashboards/state/utils.ts @@ -0,0 +1,28 @@ +import { DashboardViewItem } from 'app/features/search/types'; + +export function findItem( + rootItems: DashboardViewItem[], + childrenByUID: Record, + uid: string +): DashboardViewItem | undefined { + for (const item of rootItems) { + if (item.uid === uid) { + return item; + } + } + + for (const parentUID in childrenByUID) { + const children = childrenByUID[parentUID]; + if (!children) { + continue; + } + + for (const child of children) { + if (child.uid === uid) { + return child; + } + } + } + + return undefined; +} diff --git a/public/app/features/search/service/folders.ts b/public/app/features/search/service/folders.ts index 5da1e8d0ccc..7a096bce78a 100644 --- a/public/app/features/search/service/folders.ts +++ b/public/app/features/search/service/folders.ts @@ -28,7 +28,7 @@ export async function getFolderChildren( const dashboardsResults = await searcher.search({ kind: ['dashboard'], query: '*', - location: parentUid ?? 'general', + location: parentUid || 'general', limit: 1000, });