mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
parent
9b81d738bf
commit
02086e843f
@ -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) => {
|
||||
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<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;
|
||||
}
|
||||
|
28
public/app/features/browse-dashboards/state/utils.ts
Normal file
28
public/app/features/browse-dashboards/state/utils.ts
Normal file
@ -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…
Reference in New Issue
Block a user