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
pull/69908/head
Ashley Harrison 2 years ago committed by GitHub
parent 95b1f3c875
commit eacb5bee7e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      public/app/core/components/Page/PageHeader.tsx
  2. 252
      public/app/features/browse-dashboards/api/browseDashboardsAPI.ts
  3. 89
      public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx
  4. 27
      public/app/features/browse-dashboards/components/BrowseView.tsx
  5. 41
      public/app/features/browse-dashboards/components/CreateNewButton.tsx
  6. 28
      public/app/features/browse-dashboards/components/FolderActionsButton.tsx
  7. 59
      public/app/features/browse-dashboards/state/actions.ts
  8. 25
      public/app/features/browse-dashboards/state/hooks.ts
  9. 14
      public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx
  10. 9
      public/app/types/dashboard.ts

@ -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',

@ -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<DashboardTreeSelection, 'panel' | '$all'>;
}
interface MoveItemsArgs extends DeleteItemsArgs {
destinationUID: string;
}
function createBackendSrvBaseQuery({ baseURL }: { baseURL: string }): BaseQueryFn<RequestOptions> {
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<FolderDTO, string>({
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<FolderDTO, { title: string; parentUid?: string }>({
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<FolderDTO, FolderDTO>({
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<void, { folder: FolderDTO; destinationUID: string }>({
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<void, FolderDTO>({
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<DescendantCount, DashboardTreeSelection>({
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<void, { folderUID: string; destinationUID: string }>({
query: ({ folderUID, destinationUID }) => ({
url: `/folders/${folderUID}/move`,
// move *multiple* items (folders and dashboards). used in the move modal.
moveItems: builder.mutation<void, MoveItemsArgs>({
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<void, DeleteItemsArgs>({
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<SaveDashboardResponseDTO, SaveDashboardCommand>({
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';

@ -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<string | undefined>) => {
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<string | undefined>();
// 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<string | undefined>();
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<actionType, string> = {
delete: 'grafana_manage_dashboards_item_deleted',
};
function trackAction(action: actionType, selectedDashboards: string[], selectedFolders: string[]) {
function trackAction(action: actionType, selectedItems: Omit<DashboardTreeSelection, 'panel' | '$all'>) {
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,

@ -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;
}

@ -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<typeof connector>;
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

@ -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 = () => {

@ -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<string | undefined>();
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<RefetchChildrenResult> => {
@ -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<DeleteDashboardResponse>(`/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,
});
}
);

@ -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
*

@ -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();

@ -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;

Loading…
Cancel
Save