mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Nested folders: deduplicate selection of children (#67229)
* scaffold out dedupe logic, use mock api to get descendant info * rename methods * use for..of * some renaming for clarity
This commit is contained in:
parent
c54d2133a7
commit
258f11f08d
@ -1,9 +1,12 @@
|
||||
import { BaseQueryFn, createApi } from '@reduxjs/toolkit/query/react';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { isTruthy } from '@grafana/data';
|
||||
import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime';
|
||||
import { FolderDTO } from 'app/types';
|
||||
|
||||
import { DashboardTreeSelection } from '../types';
|
||||
|
||||
interface RequestOptions extends BackendSrvRequest {
|
||||
manageError?: (err: unknown) => { error: unknown };
|
||||
showErrorAlert?: boolean;
|
||||
@ -35,8 +38,60 @@ export const browseDashboardsAPI = createApi({
|
||||
getFolder: builder.query<FolderDTO, string>({
|
||||
query: (folderUID) => ({ url: `/folders/${folderUID}` }),
|
||||
}),
|
||||
getAffectedItems: builder.query<
|
||||
// TODO move to folder types file once structure is finalised
|
||||
{
|
||||
folder: number;
|
||||
dashboard: number;
|
||||
libraryPanel: number;
|
||||
alertRule: number;
|
||||
},
|
||||
DashboardTreeSelection
|
||||
>({
|
||||
queryFn: async (selectedItems) => {
|
||||
const folderUIDs = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]);
|
||||
// Mock descendant count
|
||||
// TODO convert to real implementation
|
||||
const mockDescendantCount = {
|
||||
folder: 1,
|
||||
dashboard: 1,
|
||||
libraryPanel: 1,
|
||||
alertRule: 1,
|
||||
};
|
||||
const promises = folderUIDs.map((id) => {
|
||||
return new Promise<typeof mockDescendantCount>((resolve, reject) => {
|
||||
// Artificial delay to simulate network request
|
||||
setTimeout(() => {
|
||||
resolve(mockDescendantCount);
|
||||
// reject(new Error('Uh oh!'));
|
||||
}, 1000);
|
||||
});
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const aggregatedResults = results.reduce(
|
||||
(acc, val) => ({
|
||||
folder: acc.folder + val.folder,
|
||||
dashboard: acc.dashboard + val.dashboard,
|
||||
libraryPanel: acc.libraryPanel + val.libraryPanel,
|
||||
alertRule: acc.alertRule + val.alertRule,
|
||||
}),
|
||||
{
|
||||
folder: 0,
|
||||
dashboard: 0,
|
||||
libraryPanel: 0,
|
||||
alertRule: 0,
|
||||
}
|
||||
);
|
||||
|
||||
// Add in the top level selected items
|
||||
aggregatedResults.folder += Object.values(selectedItems.folder).filter(isTruthy).length;
|
||||
aggregatedResults.dashboard += Object.values(selectedItems.dashboard).filter(isTruthy).length;
|
||||
return { data: aggregatedResults };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetFolderQuery } = browseDashboardsAPI;
|
||||
export const { useGetFolderQuery, useGetAffectedItemsQuery } = browseDashboardsAPI;
|
||||
export { skipToken } from '@reduxjs/toolkit/query/react';
|
||||
|
@ -6,7 +6,7 @@ import { Button, useStyles2 } from '@grafana/ui';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { ShowModalReactEvent } from 'app/types/events';
|
||||
|
||||
import { useSelectedItemsState } from '../../state';
|
||||
import { useActionSelectionState } from '../../state';
|
||||
|
||||
import { DeleteModal } from './DeleteModal';
|
||||
import { MoveModal } from './MoveModal';
|
||||
@ -15,7 +15,7 @@ export interface Props {}
|
||||
|
||||
export function BrowseActions() {
|
||||
const styles = useStyles2(getStyles);
|
||||
const selectedItems = useSelectedItemsState();
|
||||
const selectedItems = useActionSelectionState();
|
||||
|
||||
const onMove = () => {
|
||||
appEvents.publish(
|
||||
|
@ -1,9 +1,14 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render as rtlRender, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { TestProvider } from 'test/helpers/TestProvider';
|
||||
|
||||
import { DeleteModal, Props } from './DeleteModal';
|
||||
|
||||
function render(...[ui, options]: Parameters<typeof rtlRender>) {
|
||||
rtlRender(<TestProvider>{ui}</TestProvider>, options);
|
||||
}
|
||||
|
||||
describe('browse-dashboards DeleteModal', () => {
|
||||
const mockOnDismiss = jest.fn();
|
||||
const mockOnConfirm = jest.fn();
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2, isTruthy } from '@grafana/data';
|
||||
import { ConfirmModal, useStyles2 } from '@grafana/ui';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Alert, ConfirmModal, Spinner, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { useGetAffectedItemsQuery } from '../../api/browseDashboardsAPI';
|
||||
import { DashboardTreeSelection } from '../../types';
|
||||
|
||||
import { buildBreakdownString } from './utils';
|
||||
@ -17,14 +18,7 @@ export interface Props {
|
||||
|
||||
export const DeleteModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
// TODO abstract all this counting logic out
|
||||
const folderCount = Object.values(selectedItems.folder).filter(isTruthy).length;
|
||||
const dashboardCount = Object.values(selectedItems.dashboard).filter(isTruthy).length;
|
||||
// hardcoded values for now
|
||||
// TODO replace with dummy API
|
||||
const libraryPanelCount = 1;
|
||||
const alertRuleCount = 1;
|
||||
const { data, isFetching, isLoading, error } = useGetAffectedItemsQuery(selectedItems);
|
||||
|
||||
const onDelete = () => {
|
||||
onConfirm();
|
||||
@ -36,9 +30,13 @@ export const DeleteModal = ({ onConfirm, onDismiss, selectedItems, ...props }: P
|
||||
body={
|
||||
<div className={styles.modalBody}>
|
||||
This action will delete the following content:
|
||||
<p className={styles.breakdown}>
|
||||
{buildBreakdownString(folderCount, dashboardCount, libraryPanelCount, alertRuleCount)}
|
||||
</p>
|
||||
<div className={styles.breakdown}>
|
||||
<>
|
||||
{data && buildBreakdownString(data.folder, data.dashboard, data.libraryPanel, data.alertRule)}
|
||||
{(isFetching || isLoading) && <Spinner size={12} />}
|
||||
{error && <Alert severity="error" title="Unable to retrieve descendant information" />}
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
confirmationText="Delete"
|
||||
@ -55,6 +53,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
breakdown: css({
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.text.secondary,
|
||||
marginBottom: theme.spacing(2),
|
||||
}),
|
||||
modalBody: css({
|
||||
...theme.typography.body,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render as rtlRender, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { TestProvider } from 'test/helpers/TestProvider';
|
||||
import { selectOptionInTest } from 'test/helpers/selectOptionInTest';
|
||||
|
||||
import * as api from 'app/features/manage-dashboards/state/actions';
|
||||
@ -8,6 +9,10 @@ import { DashboardSearchHit } from 'app/features/search/types';
|
||||
|
||||
import { MoveModal, Props } from './MoveModal';
|
||||
|
||||
function render(...[ui, options]: Parameters<typeof rtlRender>) {
|
||||
rtlRender(<TestProvider>{ui}</TestProvider>, options);
|
||||
}
|
||||
|
||||
describe('browse-dashboards MoveModal', () => {
|
||||
const mockOnDismiss = jest.fn();
|
||||
const mockOnConfirm = jest.fn();
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2, isTruthy } from '@grafana/data';
|
||||
import { Alert, Button, Field, Modal, useStyles2 } from '@grafana/ui';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Alert, Button, Field, Modal, Spinner, useStyles2 } from '@grafana/ui';
|
||||
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
||||
|
||||
import { useGetAffectedItemsQuery } from '../../api/browseDashboardsAPI';
|
||||
import { DashboardTreeSelection } from '../../types';
|
||||
|
||||
import { buildBreakdownString } from './utils';
|
||||
@ -19,14 +20,8 @@ export interface Props {
|
||||
export const MoveModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Props) => {
|
||||
const [moveTarget, setMoveTarget] = useState<string>();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
// TODO abstract all this counting logic out
|
||||
const folderCount = Object.values(selectedItems.folder).filter(isTruthy).length;
|
||||
const dashboardCount = Object.values(selectedItems.dashboard).filter(isTruthy).length;
|
||||
// hardcoded values for now
|
||||
// TODO replace with dummy API
|
||||
const libraryPanelCount = 1;
|
||||
const alertRuleCount = 1;
|
||||
const selectedFolders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]);
|
||||
const { data, isFetching, isLoading, error } = useGetAffectedItemsQuery(selectedItems);
|
||||
|
||||
const onMove = () => {
|
||||
if (moveTarget !== undefined) {
|
||||
@ -37,11 +32,15 @@ export const MoveModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Pro
|
||||
|
||||
return (
|
||||
<Modal title="Move" onDismiss={onDismiss} {...props}>
|
||||
{folderCount > 0 && <Alert severity="warning" title="Moving this item may change its permissions." />}
|
||||
{selectedFolders.length > 0 && <Alert severity="warning" title="Moving this item may change its permissions." />}
|
||||
This action will move the following content:
|
||||
<p className={styles.breakdown}>
|
||||
{buildBreakdownString(folderCount, dashboardCount, libraryPanelCount, alertRuleCount)}
|
||||
</p>
|
||||
<div className={styles.breakdown}>
|
||||
<>
|
||||
{data && buildBreakdownString(data.folder, data.dashboard, data.libraryPanel, data.alertRule)}
|
||||
{(isFetching || isLoading) && <Spinner size={12} />}
|
||||
{error && <Alert severity="error" title="Unable to retrieve descendant information" />}
|
||||
</>
|
||||
</div>
|
||||
<Field label="Folder name">
|
||||
<FolderPicker allowEmpty onChange={({ uid }) => setMoveTarget(uid)} />
|
||||
</Field>
|
||||
@ -61,5 +60,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
breakdown: css({
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.text.secondary,
|
||||
marginBottom: theme.spacing(2),
|
||||
}),
|
||||
});
|
||||
|
@ -5,7 +5,7 @@ import { useDispatch } from 'app/types';
|
||||
|
||||
import {
|
||||
useFlatTreeState,
|
||||
useSelectedItemsState,
|
||||
useCheckboxSelectionState,
|
||||
fetchChildren,
|
||||
setFolderOpenState,
|
||||
setItemSelectionState,
|
||||
@ -22,7 +22,7 @@ interface BrowseViewProps {
|
||||
export function BrowseView({ folderUID, width, height }: BrowseViewProps) {
|
||||
const dispatch = useDispatch();
|
||||
const flatTree = useFlatTreeState(folderUID);
|
||||
const selectedItems = useSelectedItemsState();
|
||||
const selectedItems = useCheckboxSelectionState();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchChildren(folderUID));
|
||||
|
@ -3,7 +3,7 @@ import { createSelector } from 'reselect';
|
||||
import { DashboardViewItem } from 'app/features/search/types';
|
||||
import { useSelector, StoreState } from 'app/types';
|
||||
|
||||
import { DashboardsTreeItem } from '../types';
|
||||
import { DashboardsTreeItem, DashboardTreeSelection } from '../types';
|
||||
|
||||
const flatTreeSelector = createSelector(
|
||||
(wholeState: StoreState) => wholeState.browseDashboards.rootItems,
|
||||
@ -24,6 +24,14 @@ const hasSelectionSelector = createSelector(
|
||||
}
|
||||
);
|
||||
|
||||
const selectedItemsForActionsSelector = createSelector(
|
||||
(wholeState: StoreState) => wholeState.browseDashboards.selectedItems,
|
||||
(wholeState: StoreState) => wholeState.browseDashboards.childrenByParentUID,
|
||||
(selectedItems, childrenByParentUID) => {
|
||||
return getSelectedItemsForActions(selectedItems, childrenByParentUID);
|
||||
}
|
||||
);
|
||||
|
||||
export function useFlatTreeState(folderUID: string | undefined) {
|
||||
return useSelector((state) => flatTreeSelector(state, folderUID));
|
||||
}
|
||||
@ -32,10 +40,14 @@ export function useHasSelection() {
|
||||
return useSelector((state) => hasSelectionSelector(state));
|
||||
}
|
||||
|
||||
export function useSelectedItemsState() {
|
||||
export function useCheckboxSelectionState() {
|
||||
return useSelector((wholeState: StoreState) => wholeState.browseDashboards.selectedItems);
|
||||
}
|
||||
|
||||
export function useActionSelectionState() {
|
||||
return useSelector((state) => selectedItemsForActionsSelector(state));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a list of items, with level indicating it's 'nested' in the tree structure
|
||||
*
|
||||
@ -83,3 +95,43 @@ 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'> {
|
||||
// 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'> = {
|
||||
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;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user