Nested folders: hook up move/delete logic properly (#67648)

* clear selection post move/delete

* move actions out of rtk-query

* move findItems, create selectors, refetch children when moving/deleting

* cleaner syntax

* remove unnecessary function, just put logic in the selector

* handle moving/deleting from the root

* slightly cleaner

* handle when rootItems are undefined

* handle 'general' in the fetchChildren reducer

* only refresh at the end

* don't need thunk api
pull/67416/merge
Ashley Harrison 2 years ago committed by GitHub
parent 9b81d738bf
commit 02086e843f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 17
      public/app/features/browse-dashboards/BrowseDashboardsPage.tsx
  2. 69
      public/app/features/browse-dashboards/api/browseDashboardsAPI.ts
  3. 81
      public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx
  4. 4
      public/app/features/browse-dashboards/components/DashboardsTree.tsx
  5. 45
      public/app/features/browse-dashboards/state/actions.ts
  6. 95
      public/app/features/browse-dashboards/state/hooks.ts
  7. 31
      public/app/features/browse-dashboards/state/reducers.ts
  8. 28
      public/app/features/browse-dashboards/state/utils.ts
  9. 2
      public/app/features/search/service/folders.ts

@ -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<BrowseDashboardsPageRo
// New Browse/Manage/Search Dashboards views for nested folders
const BrowseDashboardsPage = memo(({ match }: Props) => {
// 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 ? <BrowseActions onActionComplete={() => setRerender(rerender + 1)} /> : <BrowseFilters />}
{hasSelection ? <BrowseActions /> : <BrowseFilters />}
<div className={styles.subView}>
<AutoSizer>
{({ width, height }) =>
isSearching ? (
<SearchView key={rerender} canSelect={canEditInFolder} width={width} height={height} />
<SearchView canSelect={canEditInFolder} width={width} height={height} />
) : (
<BrowseView
key={rerender}
canSelect={canEditInFolder}
width={width}
height={height}
folderUID={folderUID}
/>
<BrowseView canSelect={canEditInFolder} width={width} height={height} folderUID={folderUID} />
)
}
</AutoSizer>

@ -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<DeleteDashboardResponse, string>({
query: (dashboardUID) => ({
url: `/dashboards/uid/${dashboardUID}`,
method: 'DELETE',
}),
}),
deleteFolder: builder.mutation<void, string>({
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<FolderDTO, string>({
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';

@ -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<string | undefined>) => {
dispatch(
setAllSelection({
isSelected: false,
})
);
for (const parentUID of parentsToRefresh) {
dispatch(fetchChildren(parentUID));
}
};
const onDelete = async () => {
const parentsToRefresh = new Set<string | undefined>();
// 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<string | undefined>();
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 = () => {

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

@ -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<DeleteDashboardResponse>(`/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 });
}
);

@ -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) => {
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<DashboardTreeSelection, 'panel' | '$all'> = {
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<string, DashboardViewItem[] | undefined>
): Omit<DashboardTreeSelection, 'panel' | '$all'> {
// 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;
}

@ -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<typeof fetchChildren.fulfilled>;
@ -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<string, DashboardViewItem[] | undefined>,
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;
}

@ -0,0 +1,28 @@
import { DashboardViewItem } from 'app/features/search/types';
export function findItem(
rootItems: DashboardViewItem[],
childrenByUID: Record<string, DashboardViewItem[] | undefined>,
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;
}

@ -28,7 +28,7 @@ export async function getFolderChildren(
const dashboardsResults = await searcher.search({
kind: ['dashboard'],
query: '*',
location: parentUid ?? 'general',
location: parentUid || 'general',
limit: 1000,
});

Loading…
Cancel
Save