From eacb5bee7e1601675daa07deb9d4b4de4520f3ee Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Thu, 22 Jun 2023 09:44:19 +0100 Subject: [PATCH] Nested folders: Consolidate mutations in RTK query (#70249) * this is an ok intermediate point * delete some unused actions + fix tag invalidation on folder save * remove prefetching for now (it creates a permanent subscription?!) * leave paginated fetch out of rtk query for now * ensure we're invalidating the cache correctly * fix dashboard saving * simplify * recursively invalidate children on rename * tidy up * don't need to invalidate tags on delete * don't need to invalidate on new either * make new refreshParents action * pageheader spacing * invalidate getFolder on move * bit of rearrangement --- .../app/core/components/Page/PageHeader.tsx | 2 +- .../api/browseDashboardsAPI.ts | 252 ++++++++++++++++-- .../BrowseActions/BrowseActions.tsx | 89 ++----- .../components/BrowseView.tsx | 27 +- .../components/CreateNewButton.tsx | 41 ++- .../components/FolderActionsButton.tsx | 28 +- .../browse-dashboards/state/actions.ts | 59 ++-- .../features/browse-dashboards/state/hooks.ts | 25 +- .../SaveDashboard/useDashboardSave.tsx | 14 +- public/app/types/dashboard.ts | 9 + 10 files changed, 355 insertions(+), 191 deletions(-) diff --git a/public/app/core/components/Page/PageHeader.tsx b/public/app/core/components/Page/PageHeader.tsx index 00953f7c478..d6f8aba8505 100644 --- a/public/app/core/components/Page/PageHeader.tsx +++ b/public/app/core/components/Page/PageHeader.tsx @@ -75,7 +75,7 @@ const getStyles = (theme: GrafanaTheme2) => { gap: theme.spacing(1, 4), justifyContent: 'space-between', maxWidth: '100%', - minWidth: '300px', + minWidth: '200px', }), pageHeader: css({ label: 'page-header', diff --git a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts index 63b9a98888c..7b97b71a5a4 100644 --- a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts +++ b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts @@ -1,17 +1,33 @@ import { BaseQueryFn, createApi } from '@reduxjs/toolkit/query/react'; import { lastValueFrom } from 'rxjs'; -import { isTruthy } from '@grafana/data'; -import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime'; -import { DescendantCount, DescendantCountDTO, FolderDTO } from 'app/types'; +import { isTruthy, locationUtil } from '@grafana/data'; +import { BackendSrvRequest, getBackendSrv, locationService } from '@grafana/runtime'; +import { notifyApp } from 'app/core/actions'; +import { createSuccessNotification } from 'app/core/copy/appNotification'; +import { contextSrv } from 'app/core/core'; +import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types'; +import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; +import { DashboardDTO, DescendantCount, DescendantCountDTO, FolderDTO, SaveDashboardResponseDTO } from 'app/types'; +import { refetchChildren, refreshParents } from '../state'; import { DashboardTreeSelection } from '../types'; +import { PAGE_SIZE, ROOT_PAGE_SIZE } from './services'; + interface RequestOptions extends BackendSrvRequest { manageError?: (err: unknown) => { error: unknown }; showErrorAlert?: boolean; } +interface DeleteItemsArgs { + selectedItems: Omit; +} + +interface MoveItemsArgs extends DeleteItemsArgs { + destinationUID: string; +} + function createBackendSrvBaseQuery({ baseURL }: { baseURL: string }): BaseQueryFn { async function backendSrvBaseQuery(requestOptions: RequestOptions) { try { @@ -36,22 +52,109 @@ export const browseDashboardsAPI = createApi({ reducerPath: 'browseDashboardsAPI', baseQuery: createBackendSrvBaseQuery({ baseURL: '/api' }), endpoints: (builder) => ({ + // get folder info (e.g. title, parents) but *not* children getFolder: builder.query({ + providesTags: (_result, _error, folderUID) => [{ type: 'getFolder', id: folderUID }], query: (folderUID) => ({ url: `/folders/${folderUID}`, params: { accesscontrol: true } }), - providesTags: (_result, _error, arg) => [{ type: 'getFolder', id: arg }], }), + // create a new folder + newFolder: builder.mutation({ + query: ({ title, parentUid }) => ({ + method: 'POST', + url: '/folders', + data: { + title, + parentUid, + }, + }), + onQueryStarted: ({ parentUid }, { queryFulfilled, dispatch }) => { + queryFulfilled.then(async ({ data: folder }) => { + await contextSrv.fetchUserPermissions(); + dispatch(notifyApp(createSuccessNotification('Folder created'))); + dispatch( + refetchChildren({ + parentUID: parentUid, + pageSize: parentUid ? PAGE_SIZE : ROOT_PAGE_SIZE, + }) + ); + locationService.push(locationUtil.stripBaseFromUrl(folder.url)); + }); + }, + }), + // save an existing folder (e.g. rename) saveFolder: builder.mutation({ - invalidatesTags: (_result, _error, args) => [{ type: 'getFolder', id: args.uid }], - query: (folder) => ({ + // because the getFolder calls contain the parents, renaming a parent/grandparent/etc needs to invalidate all child folders + // we could do something smart and recursively invalidate these child folders but it doesn't seem worth it + // instead let's just invalidate all the getFolder calls + invalidatesTags: ['getFolder'], + query: ({ uid, title, version }) => ({ method: 'PUT', - showErrorAlert: false, - url: `/folders/${folder.uid}`, + url: `/folders/${uid}`, data: { - title: folder.title, - version: folder.version, + title, + version, }, }), + onQueryStarted: ({ parentUid }, { queryFulfilled, dispatch }) => { + queryFulfilled.then(() => { + dispatch( + refetchChildren({ + parentUID: parentUid, + pageSize: parentUid ? PAGE_SIZE : ROOT_PAGE_SIZE, + }) + ); + }); + }, + }), + // move an *individual* folder. used in the folder actions menu. + moveFolder: builder.mutation({ + invalidatesTags: ['getFolder'], + query: ({ folder, destinationUID }) => ({ + url: `/folders/${folder.uid}/move`, + method: 'POST', + data: { parentUID: destinationUID }, + }), + onQueryStarted: ({ folder, destinationUID }, { queryFulfilled, dispatch }) => { + const { parentUid } = folder; + queryFulfilled.then(() => { + dispatch( + refetchChildren({ + parentUID: parentUid, + pageSize: parentUid ? PAGE_SIZE : ROOT_PAGE_SIZE, + }) + ); + dispatch( + refetchChildren({ + parentUID: destinationUID, + pageSize: destinationUID ? PAGE_SIZE : ROOT_PAGE_SIZE, + }) + ); + }); + }, }), + // delete an *individual* folder. used in the folder actions menu. + deleteFolder: builder.mutation({ + query: ({ uid }) => ({ + url: `/folders/${uid}`, + method: 'DELETE', + params: { + // TODO: Once backend returns alert rule counts, set this back to true + // when this is merged https://github.com/grafana/grafana/pull/67259 + forceDeleteRules: false, + }, + }), + onQueryStarted: ({ parentUid }, { queryFulfilled, dispatch }) => { + queryFulfilled.then(() => { + dispatch( + refetchChildren({ + parentUID: parentUid, + pageSize: parentUid ? PAGE_SIZE : ROOT_PAGE_SIZE, + }) + ); + }); + }, + }), + // gets the descendant counts for a folder. used in the move/delete modals. getAffectedItems: builder.query({ queryFn: async (selectedItems) => { const folderUIDs = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]); @@ -81,17 +184,132 @@ export const browseDashboardsAPI = createApi({ return { data: totalCounts }; }, }), - moveFolder: builder.mutation({ - query: ({ folderUID, destinationUID }) => ({ - url: `/folders/${folderUID}/move`, + // move *multiple* items (folders and dashboards). used in the move modal. + moveItems: builder.mutation({ + invalidatesTags: ['getFolder'], + queryFn: async ({ selectedItems, destinationUID }, _api, _extraOptions, baseQuery) => { + const selectedDashboards = Object.keys(selectedItems.dashboard).filter((uid) => selectedItems.dashboard[uid]); + const selectedFolders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]); + + // Move all the folders sequentially + // TODO error handling here + for (const folderUID of selectedFolders) { + await baseQuery({ + url: `/folders/${folderUID}/move`, + method: 'POST', + data: { parentUID: destinationUID }, + }); + } + + // Move all the dashboards sequentially + // TODO error handling here + for (const dashboardUID of selectedDashboards) { + const fullDash: DashboardDTO = await getBackendSrv().get(`/api/dashboards/uid/${dashboardUID}`); + + const options = { + dashboard: fullDash.dashboard, + folderUid: destinationUID, + overwrite: false, + message: '', + }; + + await baseQuery({ + url: `/dashboards/db`, + method: 'POST', + data: options, + }); + } + return { data: undefined }; + }, + onQueryStarted: ({ destinationUID, selectedItems }, { queryFulfilled, dispatch }) => { + const selectedDashboards = Object.keys(selectedItems.dashboard).filter((uid) => selectedItems.dashboard[uid]); + const selectedFolders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]); + queryFulfilled.then(() => { + dispatch( + refetchChildren({ + parentUID: destinationUID, + pageSize: destinationUID ? PAGE_SIZE : ROOT_PAGE_SIZE, + }) + ); + dispatch(refreshParents([...selectedFolders, ...selectedDashboards])); + }); + }, + }), + // delete *multiple* items (folders and dashboards). used in the delete modal. + deleteItems: builder.mutation({ + queryFn: async ({ selectedItems }, _api, _extraOptions, baseQuery) => { + const selectedDashboards = Object.keys(selectedItems.dashboard).filter((uid) => selectedItems.dashboard[uid]); + const selectedFolders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]); + // Delete all the folders sequentially + // TODO error handling here + for (const folderUID of selectedFolders) { + await baseQuery({ + url: `/folders/${folderUID}`, + method: 'DELETE', + params: { + // TODO: Once backend returns alert rule counts, set this back to true + // when this is merged https://github.com/grafana/grafana/pull/67259 + forceDeleteRules: false, + }, + }); + } + + // Delete all the dashboards sequentially + // TODO error handling here + for (const dashboardUID of selectedDashboards) { + await baseQuery({ + url: `/dashboards/uid/${dashboardUID}`, + method: 'DELETE', + }); + } + return { data: undefined }; + }, + onQueryStarted: ({ selectedItems }, { queryFulfilled, dispatch }) => { + const selectedDashboards = Object.keys(selectedItems.dashboard).filter((uid) => selectedItems.dashboard[uid]); + const selectedFolders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]); + queryFulfilled.then(() => { + dispatch(refreshParents([...selectedFolders, ...selectedDashboards])); + }); + }, + }), + // save an existing dashboard + saveDashboard: builder.mutation({ + query: ({ dashboard, folderUid, message, overwrite }) => ({ + url: `/dashboards/db`, method: 'POST', - data: { parentUID: destinationUID }, + data: { + dashboard, + folderUid, + message: message ?? '', + overwrite: Boolean(overwrite), + }, }), - invalidatesTags: (_result, _error, arg) => [{ type: 'getFolder', id: arg.folderUID }], + onQueryStarted: ({ folderUid }, { queryFulfilled, dispatch }) => { + dashboardWatcher.ignoreNextSave(); + queryFulfilled.then(async () => { + await contextSrv.fetchUserPermissions(); + dispatch( + refetchChildren({ + parentUID: folderUid, + pageSize: folderUid ? PAGE_SIZE : ROOT_PAGE_SIZE, + }) + ); + }); + }, }), }), }); -export const { endpoints, useGetAffectedItemsQuery, useGetFolderQuery, useMoveFolderMutation, useSaveFolderMutation } = - browseDashboardsAPI; +export const { + endpoints, + useDeleteFolderMutation, + useDeleteItemsMutation, + useGetAffectedItemsQuery, + useGetFolderQuery, + useMoveFolderMutation, + useMoveItemsMutation, + useNewFolderMutation, + useSaveDashboardMutation, + useSaveFolderMutation, +} = 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 1a1f11965f5..315b2feeff6 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx @@ -7,22 +7,12 @@ import { Button, useStyles2 } from '@grafana/ui'; import appEvents from 'app/core/app_events'; import { Trans } from 'app/core/internationalization'; import { useSearchStateManager } from 'app/features/search/state/SearchStateManager'; -import { useDispatch, useSelector } from 'app/types'; +import { useDispatch } from 'app/types'; import { ShowModalReactEvent } from 'app/types/events'; -import { useMoveFolderMutation } from '../../api/browseDashboardsAPI'; -import { PAGE_SIZE, ROOT_PAGE_SIZE } from '../../api/services'; -import { - childrenByParentUIDSelector, - deleteDashboard, - deleteFolder, - moveDashboard, - refetchChildren, - rootItemsSelector, - setAllSelection, - useActionSelectionState, -} from '../../state'; -import { findItem } from '../../state/utils'; +import { useDeleteItemsMutation, useMoveItemsMutation } from '../../api/browseDashboardsAPI'; +import { setAllSelection, useActionSelectionState } from '../../state'; +import { DashboardTreeSelection } from '../../types'; import { DeleteModal } from './DeleteModal'; import { MoveModal } from './MoveModal'; @@ -31,77 +21,33 @@ export interface Props {} export function BrowseActions() { const styles = useStyles2(getStyles); - const selectedItems = useActionSelectionState(); 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 [moveFolder] = useMoveFolderMutation(); - const childrenByParentUID = useSelector(childrenByParentUIDSelector); + const selectedItems = useActionSelectionState(); + const [deleteItems] = useDeleteItemsMutation(); + const [moveItems] = useMoveItemsMutation(); const [, stateManager] = useSearchStateManager(); + const isSearching = stateManager.hasSearchFilters(); - const onActionComplete = (parentsToRefresh: Set) => { + const onActionComplete = () => { dispatch(setAllSelection({ isSelected: false, folderUID: undefined })); if (isSearching) { // Redo search query stateManager.doSearchWithDebounce(); - } else { - // Refetch parents - for (const parentUID of parentsToRefresh) { - dispatch(refetchChildren({ parentUID, pageSize: parentUID ? PAGE_SIZE : ROOT_PAGE_SIZE })); - } } }; const onDelete = async () => { - const parentsToRefresh = new Set(); - - // Delete all the folders sequentially - // TODO error handling here - for (const folderUID of selectedFolders) { - await dispatch(deleteFolder(folderUID)); - // find the parent folder uid and add it to parentsToRefresh - const folder = findItem(rootItems?.items ?? [], childrenByParentUID, folderUID); - parentsToRefresh.add(folder?.parentUID); - } - - // Delete all the dashboards sequentially - // TODO error handling here - for (const dashboardUID of selectedDashboards) { - await dispatch(deleteDashboard(dashboardUID)); - // find the parent folder uid and add it to parentsToRefresh - const dashboard = findItem(rootItems?.items ?? [], childrenByParentUID, dashboardUID); - parentsToRefresh.add(dashboard?.parentUID); - } - trackAction('delete', selectedDashboards, selectedFolders); - onActionComplete(parentsToRefresh); + await deleteItems({ selectedItems }); + trackAction('delete', selectedItems); + onActionComplete(); }; 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 }); - // find the parent folder uid and add it to parentsToRefresh - const folder = findItem(rootItems?.items ?? [], childrenByParentUID, folderUID); - parentsToRefresh.add(folder?.parentUID); - } - - // Move all the dashboards sequentially - // TODO error handling here - for (const dashboardUID of selectedDashboards) { - await dispatch(moveDashboard({ dashboardUID, destinationUID })); - // find the parent folder uid and add it to parentsToRefresh - const dashboard = findItem(rootItems?.items ?? [], childrenByParentUID, dashboardUID); - parentsToRefresh.add(dashboard?.parentUID); - } - trackAction('move', selectedDashboards, selectedFolders); - onActionComplete(parentsToRefresh); + await moveItems({ selectedItems, destinationUID }); + trackAction('move', selectedItems); + onActionComplete(); }; const showMoveModal = () => { @@ -156,7 +102,10 @@ const actionMap: Record = { delete: 'grafana_manage_dashboards_item_deleted', }; -function trackAction(action: actionType, selectedDashboards: string[], selectedFolders: string[]) { +function trackAction(action: actionType, selectedItems: Omit) { + const selectedDashboards = Object.keys(selectedItems.dashboard).filter((uid) => selectedItems.dashboard[uid]); + const selectedFolders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]); + reportInteraction(actionMap[action], { item_counts: { folder: selectedFolders.length, diff --git a/public/app/features/browse-dashboards/components/BrowseView.tsx b/public/app/features/browse-dashboards/components/BrowseView.tsx index 4ebeaedd37c..691d272c086 100644 --- a/public/app/features/browse-dashboards/components/BrowseView.tsx +++ b/public/app/features/browse-dashboards/components/BrowseView.tsx @@ -1,19 +1,20 @@ -import React, { useCallback, useRef } from 'react'; +import React, { useCallback } from 'react'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; import { DashboardViewItem } from 'app/features/search/types'; import { useDispatch } from 'app/types'; -import { PAGE_SIZE, ROOT_PAGE_SIZE } from '../api/services'; +import { PAGE_SIZE } from '../api/services'; import { useFlatTreeState, useCheckboxSelectionState, - fetchNextChildrenPage, setFolderOpenState, setItemSelectionState, useChildrenByParentUIDState, setAllSelection, useBrowseLoadingStatus, + useLoadNextChildrenPage, + fetchNextChildrenPage, } from '../state'; import { BrowseDashboardsState, DashboardTreeSelection, SelectionState } from '../types'; @@ -163,23 +164,3 @@ function hasSelectedDescendants( return hasSelectedDescendants(v, childrenByParentUID, selectedItems); }); } - -function useLoadNextChildrenPage(folderUID: string | undefined) { - const dispatch = useDispatch(); - const requestInFlightRef = useRef(false); - - const handleLoadMore = useCallback(() => { - if (requestInFlightRef.current) { - return Promise.resolve(); - } - - requestInFlightRef.current = true; - - const promise = dispatch(fetchNextChildrenPage({ parentUID: folderUID, pageSize: ROOT_PAGE_SIZE })); - promise.finally(() => (requestInFlightRef.current = false)); - - return promise; - }, [dispatch, folderUID]); - - return handleLoadMore; -} diff --git a/public/app/features/browse-dashboards/components/CreateNewButton.tsx b/public/app/features/browse-dashboards/components/CreateNewButton.tsx index cfc8f8d3822..851621c7218 100644 --- a/public/app/features/browse-dashboards/components/CreateNewButton.tsx +++ b/public/app/features/browse-dashboards/components/CreateNewButton.tsx @@ -1,10 +1,8 @@ import React, { useState } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; import { useLocation } from 'react-router-dom'; import { reportInteraction } from '@grafana/runtime'; import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui'; -import { createNewFolder } from 'app/features/folders/state/actions'; import { getNewDashboardPhrase, getNewFolderPhrase, @@ -13,35 +11,36 @@ import { } from 'app/features/search/tempI18nPhrases'; import { FolderDTO } from 'app/types'; -import { NewFolderForm } from './NewFolderForm'; - -const mapDispatchToProps = { - createNewFolder, -}; +import { useNewFolderMutation } from '../api/browseDashboardsAPI'; -const connector = connect(null, mapDispatchToProps); +import { NewFolderForm } from './NewFolderForm'; -interface OwnProps { +interface Props { parentFolder?: FolderDTO; canCreateFolder: boolean; canCreateDashboard: boolean; } -type Props = OwnProps & ConnectedProps; - -function CreateNewButton({ parentFolder, canCreateDashboard, canCreateFolder, createNewFolder }: Props) { +export default function CreateNewButton({ parentFolder, canCreateDashboard, canCreateFolder }: Props) { const [isOpen, setIsOpen] = useState(false); const location = useLocation(); + const [newFolder] = useNewFolderMutation(); const [showNewFolderDrawer, setShowNewFolderDrawer] = useState(false); - const onCreateFolder = (folderName: string) => { - createNewFolder(folderName, parentFolder?.uid); - const depth = parentFolder?.parents ? parentFolder.parents.length + 1 : 0; - reportInteraction('grafana_manage_dashboards_folder_created', { - is_subfolder: Boolean(parentFolder?.uid), - folder_depth: depth, - }); - setShowNewFolderDrawer(false); + const onCreateFolder = async (folderName: string) => { + try { + await newFolder({ + title: folderName, + parentUid: parentFolder?.uid, + }); + const depth = parentFolder?.parents ? parentFolder.parents.length + 1 : 0; + reportInteraction('grafana_manage_dashboards_folder_created', { + is_subfolder: Boolean(parentFolder?.uid), + folder_depth: depth, + }); + } finally { + setShowNewFolderDrawer(false); + } }; const newMenu = ( @@ -97,8 +96,6 @@ function CreateNewButton({ parentFolder, canCreateDashboard, canCreateFolder, cr ); } -export default connector(CreateNewButton); - /** * * @param url without any parameters diff --git a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx index c6188b4023c..8e0ccbfce95 100644 --- a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx +++ b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx @@ -4,12 +4,10 @@ import { locationService, reportInteraction } from '@grafana/runtime'; import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui'; import { Permissions } from 'app/core/components/AccessControl'; import { appEvents, contextSrv } from 'app/core/core'; -import { AccessControlAction, FolderDTO, useDispatch } from 'app/types'; +import { AccessControlAction, FolderDTO } from 'app/types'; import { ShowModalReactEvent } from 'app/types/events'; -import { useMoveFolderMutation } from '../api/browseDashboardsAPI'; -import { PAGE_SIZE, ROOT_PAGE_SIZE } from '../api/services'; -import { deleteFolder, refetchChildren } from '../state'; +import { useDeleteFolderMutation, useMoveFolderMutation } from '../api/browseDashboardsAPI'; import { DeleteModal } from './BrowseActions/DeleteModal'; import { MoveModal } from './BrowseActions/MoveModal'; @@ -19,17 +17,17 @@ interface Props { } export function FolderActionsButton({ folder }: Props) { - const dispatch = useDispatch(); const [isOpen, setIsOpen] = useState(false); const [showPermissionsDrawer, setShowPermissionsDrawer] = useState(false); const [moveFolder] = useMoveFolderMutation(); + const [deleteFolder] = useDeleteFolderMutation(); const canViewPermissions = contextSrv.hasPermission(AccessControlAction.FoldersPermissionsRead); const canSetPermissions = contextSrv.hasPermission(AccessControlAction.FoldersPermissionsWrite); const canMoveFolder = contextSrv.hasPermission(AccessControlAction.FoldersWrite); const canDeleteFolder = contextSrv.hasPermission(AccessControlAction.FoldersDelete); const onMove = async (destinationUID: string) => { - await moveFolder({ folderUID: folder.uid, destinationUID }); + await moveFolder({ folder, destinationUID }); reportInteraction('grafana_manage_dashboards_item_moved', { item_counts: { folder: 1, @@ -37,17 +35,10 @@ export function FolderActionsButton({ folder }: Props) { }, source: 'folder_actions', }); - dispatch(refetchChildren({ parentUID: destinationUID, pageSize: destinationUID ? PAGE_SIZE : ROOT_PAGE_SIZE })); - - if (folder.parentUid) { - dispatch( - refetchChildren({ parentUID: folder.parentUid, pageSize: folder.parentUid ? PAGE_SIZE : ROOT_PAGE_SIZE }) - ); - } }; const onDelete = async () => { - await dispatch(deleteFolder(folder.uid)); + await deleteFolder(folder); reportInteraction('grafana_manage_dashboards_item_deleted', { item_counts: { folder: 1, @@ -55,12 +46,9 @@ export function FolderActionsButton({ folder }: Props) { }, source: 'folder_actions', }); - if (folder.parentUid) { - dispatch( - refetchChildren({ parentUID: folder.parentUid, pageSize: folder.parentUid ? PAGE_SIZE : ROOT_PAGE_SIZE }) - ); - } - locationService.push('/dashboards'); + const { parents } = folder; + const parentUrl = parents && parents.length ? parents[parents.length - 1].url : '/dashboards'; + locationService.push(parentUrl); }; const showMoveModal = () => { diff --git a/public/app/features/browse-dashboards/state/actions.ts b/public/app/features/browse-dashboards/state/actions.ts index 5db7c2d0614..2c29587e690 100644 --- a/public/app/features/browse-dashboards/state/actions.ts +++ b/public/app/features/browse-dashboards/state/actions.ts @@ -1,10 +1,10 @@ -import { getBackendSrv } from '@grafana/runtime'; -import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; import { GENERAL_FOLDER_UID } from 'app/features/search/constants'; import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types'; -import { createAsyncThunk, DashboardDTO } from 'app/types'; +import { createAsyncThunk } from 'app/types'; -import { listDashboards, listFolders } from '../api/services'; +import { listDashboards, listFolders, PAGE_SIZE, ROOT_PAGE_SIZE } from '../api/services'; + +import { findItem } from './utils'; interface FetchNextChildrenPageArgs { parentUID: string | undefined; @@ -30,6 +30,25 @@ interface RefetchChildrenResult { lastPageOfKind: boolean; } +export const refreshParents = createAsyncThunk( + 'browseDashboards/refreshParents', + async (uids: string[], { getState, dispatch }) => { + const { browseDashboards } = getState(); + const { rootItems, childrenByParentUID } = browseDashboards; + const parentsToRefresh = new Set(); + + for (const uid of uids) { + // find the parent folder uid + const item = findItem(rootItems?.items ?? [], childrenByParentUID, uid); + parentsToRefresh.add(item?.parentUID); + } + + for (const parentUID of parentsToRefresh) { + dispatch(refetchChildren({ parentUID, pageSize: parentUID ? PAGE_SIZE : ROOT_PAGE_SIZE })); + } + } +); + export const refetchChildren = createAsyncThunk( 'browseDashboards/refetchChildren', async ({ parentUID, pageSize }: RefetchChildrenArgs): Promise => { @@ -125,39 +144,9 @@ export const fetchNextChildrenPage = createAsyncThunk( return { children, - lastPageOfKind: lastPageOfKind, + lastPageOfKind, page, kind: fetchKind, }; } ); - -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, { - // TODO: Revisit this field when this permissions issue is resolved - // https://github.com/grafana/grafana-enterprise/issues/5144 - params: { forceDeleteRules: false }, - }); -}); - -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, - }); - } -); diff --git a/public/app/features/browse-dashboards/state/hooks.ts b/public/app/features/browse-dashboards/state/hooks.ts index 64436ba8c75..86bc7e33aa6 100644 --- a/public/app/features/browse-dashboards/state/hooks.ts +++ b/public/app/features/browse-dashboards/state/hooks.ts @@ -1,11 +1,14 @@ +import { useCallback, useRef } from 'react'; import { createSelector } from 'reselect'; import { DashboardViewItem } from 'app/features/search/types'; -import { useSelector, StoreState } from 'app/types'; +import { useSelector, StoreState, useDispatch } from 'app/types'; import { ROOT_PAGE_SIZE } from '../api/services'; import { BrowseDashboardsState, DashboardsTreeItem, DashboardTreeSelection } from '../types'; +import { fetchNextChildrenPage } from './actions'; + export const rootItemsSelector = (wholeState: StoreState) => wholeState.browseDashboards.rootItems; export const childrenByParentUIDSelector = (wholeState: StoreState) => wholeState.browseDashboards.childrenByParentUID; export const openFoldersSelector = (wholeState: StoreState) => wholeState.browseDashboards.openFolders; @@ -94,6 +97,26 @@ export function useActionSelectionState() { return useSelector((state) => selectedItemsForActionsSelector(state)); } +export function useLoadNextChildrenPage(folderUID: string | undefined) { + const dispatch = useDispatch(); + const requestInFlightRef = useRef(false); + + const handleLoadMore = useCallback(() => { + if (requestInFlightRef.current) { + return Promise.resolve(); + } + + requestInFlightRef.current = true; + + const promise = dispatch(fetchNextChildrenPage({ parentUID: folderUID, pageSize: ROOT_PAGE_SIZE })); + promise.finally(() => (requestInFlightRef.current = false)); + + return promise; + }, [dispatch, folderUID]); + + return handleLoadMore; +} + /** * Creates a list of items, with level indicating it's 'nested' in the tree structure * diff --git a/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx b/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx index 41f7c977199..697a57db29b 100644 --- a/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx @@ -1,11 +1,12 @@ import { useAsyncFn } from 'react-use'; import { locationUtil } from '@grafana/data'; -import { locationService, reportInteraction } from '@grafana/runtime'; +import { config, locationService, reportInteraction } from '@grafana/runtime'; import appEvents from 'app/core/app_events'; import { useAppNotification } from 'app/core/copy/appNotification'; import { contextSrv } from 'app/core/core'; import { updateDashboardName } from 'app/core/reducers/navBarTree'; +import { useSaveDashboardMutation } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; import { DashboardModel } from 'app/features/dashboard/state'; import { saveDashboard as saveDashboardApiCall } from 'app/features/manage-dashboards/state/actions'; import { useDispatch } from 'app/types'; @@ -28,10 +29,19 @@ const saveDashboard = async (saveModel: any, options: SaveDashboardOptions, dash export const useDashboardSave = (dashboard: DashboardModel, isCopy = false) => { const dispatch = useDispatch(); const notifyApp = useAppNotification(); + const [saveDashboardRtkQuery] = useSaveDashboardMutation(); const [state, onDashboardSave] = useAsyncFn( async (clone: DashboardModel, options: SaveDashboardOptions, dashboard: DashboardModel) => { try { - const result = await saveDashboard(clone, options, dashboard); + const queryResult = config.featureToggles.nestedFolders + ? await saveDashboardRtkQuery({ + dashboard: clone, + folderUid: options.folderUid ?? dashboard.meta.folderUid ?? clone.meta.folderUid, + message: options.message, + overwrite: options.overwrite, + }) + : await saveDashboard(clone, options, dashboard); + const result = config.featureToggles.nestedFolders ? queryResult.data : queryResult; dashboard.version = result.version; dashboard.clearUnsavedChanges(); diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index f984571ece9..cd12dfbb7dd 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -10,6 +10,15 @@ export interface DashboardDTO { meta: DashboardMeta; } +export interface SaveDashboardResponseDTO { + id: number; + slug: string; + status: string; + uid: string; + url: string; + version: number; +} + export interface DashboardMeta { slug?: string; uid?: string;