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 { BaseQueryFn, createApi } from '@reduxjs/toolkit/query/react';
|
||||||
import { lastValueFrom } from 'rxjs';
|
import { lastValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { isTruthy } from '@grafana/data';
|
||||||
import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime';
|
import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime';
|
||||||
import { FolderDTO } from 'app/types';
|
import { FolderDTO } from 'app/types';
|
||||||
|
|
||||||
|
import { DashboardTreeSelection } from '../types';
|
||||||
|
|
||||||
interface RequestOptions extends BackendSrvRequest {
|
interface RequestOptions extends BackendSrvRequest {
|
||||||
manageError?: (err: unknown) => { error: unknown };
|
manageError?: (err: unknown) => { error: unknown };
|
||||||
showErrorAlert?: boolean;
|
showErrorAlert?: boolean;
|
||||||
@ -35,8 +38,60 @@ export const browseDashboardsAPI = createApi({
|
|||||||
getFolder: builder.query<FolderDTO, string>({
|
getFolder: builder.query<FolderDTO, string>({
|
||||||
query: (folderUID) => ({ url: `/folders/${folderUID}` }),
|
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';
|
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 appEvents from 'app/core/app_events';
|
||||||
import { ShowModalReactEvent } from 'app/types/events';
|
import { ShowModalReactEvent } from 'app/types/events';
|
||||||
|
|
||||||
import { useSelectedItemsState } from '../../state';
|
import { useActionSelectionState } from '../../state';
|
||||||
|
|
||||||
import { DeleteModal } from './DeleteModal';
|
import { DeleteModal } from './DeleteModal';
|
||||||
import { MoveModal } from './MoveModal';
|
import { MoveModal } from './MoveModal';
|
||||||
@ -15,7 +15,7 @@ export interface Props {}
|
|||||||
|
|
||||||
export function BrowseActions() {
|
export function BrowseActions() {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const selectedItems = useSelectedItemsState();
|
const selectedItems = useActionSelectionState();
|
||||||
|
|
||||||
const onMove = () => {
|
const onMove = () => {
|
||||||
appEvents.publish(
|
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 userEvent from '@testing-library/user-event';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { TestProvider } from 'test/helpers/TestProvider';
|
||||||
|
|
||||||
import { DeleteModal, Props } from './DeleteModal';
|
import { DeleteModal, Props } from './DeleteModal';
|
||||||
|
|
||||||
|
function render(...[ui, options]: Parameters<typeof rtlRender>) {
|
||||||
|
rtlRender(<TestProvider>{ui}</TestProvider>, options);
|
||||||
|
}
|
||||||
|
|
||||||
describe('browse-dashboards DeleteModal', () => {
|
describe('browse-dashboards DeleteModal', () => {
|
||||||
const mockOnDismiss = jest.fn();
|
const mockOnDismiss = jest.fn();
|
||||||
const mockOnConfirm = jest.fn();
|
const mockOnConfirm = jest.fn();
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2, isTruthy } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { ConfirmModal, useStyles2 } from '@grafana/ui';
|
import { Alert, ConfirmModal, Spinner, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { useGetAffectedItemsQuery } from '../../api/browseDashboardsAPI';
|
||||||
import { DashboardTreeSelection } from '../../types';
|
import { DashboardTreeSelection } from '../../types';
|
||||||
|
|
||||||
import { buildBreakdownString } from './utils';
|
import { buildBreakdownString } from './utils';
|
||||||
@ -17,14 +18,7 @@ export interface Props {
|
|||||||
|
|
||||||
export const DeleteModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Props) => {
|
export const DeleteModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Props) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
const { data, isFetching, isLoading, error } = useGetAffectedItemsQuery(selectedItems);
|
||||||
// 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 onDelete = () => {
|
const onDelete = () => {
|
||||||
onConfirm();
|
onConfirm();
|
||||||
@ -36,9 +30,13 @@ export const DeleteModal = ({ onConfirm, onDismiss, selectedItems, ...props }: P
|
|||||||
body={
|
body={
|
||||||
<div className={styles.modalBody}>
|
<div className={styles.modalBody}>
|
||||||
This action will delete the following content:
|
This action will delete the following content:
|
||||||
<p className={styles.breakdown}>
|
<div className={styles.breakdown}>
|
||||||
{buildBreakdownString(folderCount, dashboardCount, libraryPanelCount, alertRuleCount)}
|
<>
|
||||||
</p>
|
{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>
|
</div>
|
||||||
}
|
}
|
||||||
confirmationText="Delete"
|
confirmationText="Delete"
|
||||||
@ -55,6 +53,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
breakdown: css({
|
breakdown: css({
|
||||||
...theme.typography.bodySmall,
|
...theme.typography.bodySmall,
|
||||||
color: theme.colors.text.secondary,
|
color: theme.colors.text.secondary,
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
}),
|
}),
|
||||||
modalBody: css({
|
modalBody: css({
|
||||||
...theme.typography.body,
|
...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 userEvent from '@testing-library/user-event';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { TestProvider } from 'test/helpers/TestProvider';
|
||||||
import { selectOptionInTest } from 'test/helpers/selectOptionInTest';
|
import { selectOptionInTest } from 'test/helpers/selectOptionInTest';
|
||||||
|
|
||||||
import * as api from 'app/features/manage-dashboards/state/actions';
|
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';
|
import { MoveModal, Props } from './MoveModal';
|
||||||
|
|
||||||
|
function render(...[ui, options]: Parameters<typeof rtlRender>) {
|
||||||
|
rtlRender(<TestProvider>{ui}</TestProvider>, options);
|
||||||
|
}
|
||||||
|
|
||||||
describe('browse-dashboards MoveModal', () => {
|
describe('browse-dashboards MoveModal', () => {
|
||||||
const mockOnDismiss = jest.fn();
|
const mockOnDismiss = jest.fn();
|
||||||
const mockOnConfirm = jest.fn();
|
const mockOnConfirm = jest.fn();
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2, isTruthy } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { Alert, Button, Field, Modal, useStyles2 } from '@grafana/ui';
|
import { Alert, Button, Field, Modal, Spinner, useStyles2 } from '@grafana/ui';
|
||||||
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
||||||
|
|
||||||
|
import { useGetAffectedItemsQuery } from '../../api/browseDashboardsAPI';
|
||||||
import { DashboardTreeSelection } from '../../types';
|
import { DashboardTreeSelection } from '../../types';
|
||||||
|
|
||||||
import { buildBreakdownString } from './utils';
|
import { buildBreakdownString } from './utils';
|
||||||
@ -19,14 +20,8 @@ export interface Props {
|
|||||||
export const MoveModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Props) => {
|
export const MoveModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Props) => {
|
||||||
const [moveTarget, setMoveTarget] = useState<string>();
|
const [moveTarget, setMoveTarget] = useState<string>();
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
const selectedFolders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]);
|
||||||
// TODO abstract all this counting logic out
|
const { data, isFetching, isLoading, error } = useGetAffectedItemsQuery(selectedItems);
|
||||||
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 onMove = () => {
|
const onMove = () => {
|
||||||
if (moveTarget !== undefined) {
|
if (moveTarget !== undefined) {
|
||||||
@ -37,11 +32,15 @@ export const MoveModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Pro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title="Move" onDismiss={onDismiss} {...props}>
|
<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:
|
This action will move the following content:
|
||||||
<p className={styles.breakdown}>
|
<div className={styles.breakdown}>
|
||||||
{buildBreakdownString(folderCount, dashboardCount, libraryPanelCount, alertRuleCount)}
|
<>
|
||||||
</p>
|
{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">
|
<Field label="Folder name">
|
||||||
<FolderPicker allowEmpty onChange={({ uid }) => setMoveTarget(uid)} />
|
<FolderPicker allowEmpty onChange={({ uid }) => setMoveTarget(uid)} />
|
||||||
</Field>
|
</Field>
|
||||||
@ -61,5 +60,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
breakdown: css({
|
breakdown: css({
|
||||||
...theme.typography.bodySmall,
|
...theme.typography.bodySmall,
|
||||||
color: theme.colors.text.secondary,
|
color: theme.colors.text.secondary,
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
@ -5,7 +5,7 @@ import { useDispatch } from 'app/types';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
useFlatTreeState,
|
useFlatTreeState,
|
||||||
useSelectedItemsState,
|
useCheckboxSelectionState,
|
||||||
fetchChildren,
|
fetchChildren,
|
||||||
setFolderOpenState,
|
setFolderOpenState,
|
||||||
setItemSelectionState,
|
setItemSelectionState,
|
||||||
@ -22,7 +22,7 @@ interface BrowseViewProps {
|
|||||||
export function BrowseView({ folderUID, width, height }: BrowseViewProps) {
|
export function BrowseView({ folderUID, width, height }: BrowseViewProps) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const flatTree = useFlatTreeState(folderUID);
|
const flatTree = useFlatTreeState(folderUID);
|
||||||
const selectedItems = useSelectedItemsState();
|
const selectedItems = useCheckboxSelectionState();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchChildren(folderUID));
|
dispatch(fetchChildren(folderUID));
|
||||||
|
@ -3,7 +3,7 @@ import { createSelector } from 'reselect';
|
|||||||
import { DashboardViewItem } from 'app/features/search/types';
|
import { DashboardViewItem } from 'app/features/search/types';
|
||||||
import { useSelector, StoreState } from 'app/types';
|
import { useSelector, StoreState } from 'app/types';
|
||||||
|
|
||||||
import { DashboardsTreeItem } from '../types';
|
import { DashboardsTreeItem, DashboardTreeSelection } from '../types';
|
||||||
|
|
||||||
const flatTreeSelector = createSelector(
|
const flatTreeSelector = createSelector(
|
||||||
(wholeState: StoreState) => wholeState.browseDashboards.rootItems,
|
(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) {
|
export function useFlatTreeState(folderUID: string | undefined) {
|
||||||
return useSelector((state) => flatTreeSelector(state, folderUID));
|
return useSelector((state) => flatTreeSelector(state, folderUID));
|
||||||
}
|
}
|
||||||
@ -32,10 +40,14 @@ export function useHasSelection() {
|
|||||||
return useSelector((state) => hasSelectionSelector(state));
|
return useSelector((state) => hasSelectionSelector(state));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSelectedItemsState() {
|
export function useCheckboxSelectionState() {
|
||||||
return useSelector((wholeState: StoreState) => wholeState.browseDashboards.selectedItems);
|
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
|
* 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));
|
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