mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
NestedFolders: Refactor BrowseView state into redux (#66898)
* refactor all state into redux * add tests for reducers * added comments
This commit is contained in:
parent
74d3d3cf4a
commit
3518f8ec53
@ -6,6 +6,7 @@ import alertingReducers from 'app/features/alerting/state/reducers';
|
|||||||
import apiKeysReducers from 'app/features/api-keys/state/reducers';
|
import apiKeysReducers from 'app/features/api-keys/state/reducers';
|
||||||
import authConfigReducers from 'app/features/auth-config/state/reducers';
|
import authConfigReducers from 'app/features/auth-config/state/reducers';
|
||||||
import { browseDashboardsAPI } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
import { browseDashboardsAPI } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
||||||
|
import browseDashboardsReducers from 'app/features/browse-dashboards/state/slice';
|
||||||
import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi';
|
import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi';
|
||||||
import panelEditorReducers from 'app/features/dashboard/components/PanelEditor/state/reducers';
|
import panelEditorReducers from 'app/features/dashboard/components/PanelEditor/state/reducers';
|
||||||
import dashboardReducers from 'app/features/dashboard/state/reducers';
|
import dashboardReducers from 'app/features/dashboard/state/reducers';
|
||||||
@ -41,6 +42,7 @@ const rootReducers = {
|
|||||||
...userReducers,
|
...userReducers,
|
||||||
...invitesReducers,
|
...invitesReducers,
|
||||||
...organizationReducers,
|
...organizationReducers,
|
||||||
|
...browseDashboardsReducers,
|
||||||
...ldapReducers,
|
...ldapReducers,
|
||||||
...importDashboardReducers,
|
...importDashboardReducers,
|
||||||
...panelEditorReducers,
|
...panelEditorReducers,
|
||||||
|
@ -29,7 +29,7 @@ function createBackendSrvBaseQuery({ baseURL }: { baseURL: string }): BaseQueryF
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const browseDashboardsAPI = createApi({
|
export const browseDashboardsAPI = createApi({
|
||||||
reducerPath: 'browse-dashboards',
|
reducerPath: 'browseDashboardsAPI',
|
||||||
baseQuery: createBackendSrvBaseQuery({ baseURL: '/api' }),
|
baseQuery: createBackendSrvBaseQuery({ baseURL: '/api' }),
|
||||||
endpoints: (builder) => ({
|
endpoints: (builder) => ({
|
||||||
getFolder: builder.query<FolderDTO, string>({
|
getFolder: builder.query<FolderDTO, string>({
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
import { getByLabelText, render as rtlRender, screen } from '@testing-library/react';
|
import { getByLabelText, 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 { Router } from 'react-router-dom';
|
import { TestProvider } from 'test/helpers/TestProvider';
|
||||||
|
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { locationService } from '@grafana/runtime';
|
|
||||||
|
|
||||||
import { wellFormedTree } from '../fixtures/dashboardsTreeItem.fixture';
|
import { wellFormedTree } from '../fixtures/dashboardsTreeItem.fixture';
|
||||||
|
|
||||||
@ -12,10 +11,8 @@ import { BrowseView } from './BrowseView';
|
|||||||
|
|
||||||
const [mockTree, { folderA, folderA_folderA, folderA_folderB, folderA_folderB_dashbdB, dashbdD }] = wellFormedTree();
|
const [mockTree, { folderA, folderA_folderA, folderA_folderB, folderA_folderB_dashbdB, dashbdD }] = wellFormedTree();
|
||||||
|
|
||||||
function render(...args: Parameters<typeof rtlRender>) {
|
function render(...[ui, options]: Parameters<typeof rtlRender>) {
|
||||||
const [ui, options] = args;
|
rtlRender(<TestProvider>{ui}</TestProvider>, options);
|
||||||
|
|
||||||
rtlRender(<Router history={locationService.getHistory()}>{ui}</Router>, options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
jest.mock('app/features/search/service/folders', () => {
|
jest.mock('app/features/search/service/folders', () => {
|
||||||
|
@ -1,10 +1,15 @@
|
|||||||
import produce from 'immer';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { getFolderChildren } from 'app/features/search/service/folders';
|
import { DashboardViewItem } from 'app/features/search/types';
|
||||||
import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
|
import { useDispatch } from 'app/types';
|
||||||
|
|
||||||
import { DashboardsTreeItem } from '../types';
|
import {
|
||||||
|
useFlatTreeState,
|
||||||
|
useSelectedItemsState,
|
||||||
|
fetchChildren,
|
||||||
|
setFolderOpenState,
|
||||||
|
setItemSelectionState,
|
||||||
|
} from '../state';
|
||||||
|
|
||||||
import { DashboardsTree } from './DashboardsTree';
|
import { DashboardsTree } from './DashboardsTree';
|
||||||
|
|
||||||
@ -15,102 +20,30 @@ interface BrowseViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BrowseView({ folderUID, width, height }: BrowseViewProps) {
|
export function BrowseView({ folderUID, width, height }: BrowseViewProps) {
|
||||||
const [openFolders, setOpenFolders] = useState<Record<string, boolean>>({ [folderUID ?? '$$root']: true });
|
const dispatch = useDispatch();
|
||||||
|
const flatTree = useFlatTreeState(folderUID);
|
||||||
const [selectedItems, setSelectedItems] = useState<
|
const selectedItems = useSelectedItemsState();
|
||||||
Record<DashboardViewItemKind, Record<string, boolean | undefined>>
|
|
||||||
>({
|
|
||||||
folder: {},
|
|
||||||
dashboard: {},
|
|
||||||
panel: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rather than storing an actual tree structure (requiring traversing the tree to update children), instead
|
|
||||||
// we keep track of children for each UID and then later combine them in the format required to display them
|
|
||||||
const [childrenByUID, setChildrenByUID] = useState<Record<string, DashboardViewItem[] | undefined>>({});
|
|
||||||
|
|
||||||
const loadChildrenForUID = useCallback(
|
|
||||||
async (uid: string | undefined) => {
|
|
||||||
const folderKey = uid ?? '$$root';
|
|
||||||
|
|
||||||
const childItems = await getFolderChildren(uid, undefined, true);
|
|
||||||
setChildrenByUID((v) => ({ ...v, [folderKey]: childItems }));
|
|
||||||
|
|
||||||
// If the parent is already selected, mark these items as selected also
|
|
||||||
const parentIsSelected = selectedItems.folder[folderKey];
|
|
||||||
if (parentIsSelected) {
|
|
||||||
setSelectedItems((currentState) =>
|
|
||||||
produce(currentState, (draft) => {
|
|
||||||
for (const child of childItems) {
|
|
||||||
draft[child.kind][child.uid] = true;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[selectedItems]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadChildrenForUID(folderUID);
|
dispatch(fetchChildren(folderUID));
|
||||||
// No need to depend on loadChildrenForUID - we only want this to run
|
}, [dispatch, folderUID]);
|
||||||
// when folderUID changes (initial page view)
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [folderUID]);
|
|
||||||
|
|
||||||
const flatTree = useMemo(
|
|
||||||
() => createFlatTree(folderUID, childrenByUID, openFolders),
|
|
||||||
[folderUID, childrenByUID, openFolders]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFolderClick = useCallback(
|
const handleFolderClick = useCallback(
|
||||||
(uid: string, newState: boolean) => {
|
(clickedFolderUID: string, isOpen: boolean) => {
|
||||||
if (newState) {
|
dispatch(setFolderOpenState({ folderUID: clickedFolderUID, isOpen }));
|
||||||
loadChildrenForUID(uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpenFolders((old) => ({ ...old, [uid]: newState }));
|
if (isOpen) {
|
||||||
|
dispatch(fetchChildren(clickedFolderUID));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[loadChildrenForUID]
|
[dispatch]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleItemSelectionChange = useCallback(
|
const handleItemSelectionChange = useCallback(
|
||||||
(item: DashboardViewItem, newState: boolean) => {
|
(item: DashboardViewItem, isSelected: boolean) => {
|
||||||
// Recursively set selection state for this item and all descendants
|
dispatch(setItemSelectionState({ item, isSelected }));
|
||||||
setSelectedItems((old) =>
|
|
||||||
produce(old, (draft) => {
|
|
||||||
function markChildren(kind: DashboardViewItemKind, uid: string) {
|
|
||||||
draft[kind][uid] = newState;
|
|
||||||
if (kind !== 'folder') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let children = childrenByUID[uid] ?? [];
|
|
||||||
for (const child of children) {
|
|
||||||
markChildren(child.kind, child.uid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
markChildren(item.kind, item.uid);
|
|
||||||
|
|
||||||
// If we're unselecting an item, unselect all ancestors also
|
|
||||||
if (!newState) {
|
|
||||||
let nextParentUID = item.parentUID;
|
|
||||||
|
|
||||||
while (nextParentUID) {
|
|
||||||
const parent = findItem(childrenByUID, nextParentUID);
|
|
||||||
if (!parent) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
draft[parent.kind][parent.uid] = false;
|
|
||||||
nextParentUID = parent.parentUID;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[childrenByUID]
|
[dispatch]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -124,60 +57,3 @@ export function BrowseView({ folderUID, width, height }: BrowseViewProps) {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Creates a flat list of items, with nested children indicated by its increasing level
|
|
||||||
function createFlatTree(
|
|
||||||
rootFolderUID: string | undefined,
|
|
||||||
childrenByUID: Record<string, DashboardViewItem[] | undefined>,
|
|
||||||
openFolders: Record<string, boolean>,
|
|
||||||
level = 0
|
|
||||||
): DashboardsTreeItem[] {
|
|
||||||
function mapItem(item: DashboardViewItem, parentUID: string | undefined, level: number): DashboardsTreeItem[] {
|
|
||||||
const mappedChildren = createFlatTree(item.uid, childrenByUID, openFolders, level + 1);
|
|
||||||
|
|
||||||
const isOpen = Boolean(openFolders[item.uid]);
|
|
||||||
const emptyFolder = childrenByUID[item.uid]?.length === 0;
|
|
||||||
if (isOpen && emptyFolder) {
|
|
||||||
mappedChildren.push({
|
|
||||||
isOpen: false,
|
|
||||||
level: level + 1,
|
|
||||||
item: { kind: 'ui-empty-folder', uid: item.uid + '-empty-folder' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const thisItem = {
|
|
||||||
item,
|
|
||||||
parentUID,
|
|
||||||
level,
|
|
||||||
isOpen,
|
|
||||||
};
|
|
||||||
|
|
||||||
return [thisItem, ...mappedChildren];
|
|
||||||
}
|
|
||||||
|
|
||||||
const folderKey = rootFolderUID ?? '$$root';
|
|
||||||
const isOpen = Boolean(openFolders[folderKey]);
|
|
||||||
const items = (isOpen && childrenByUID[folderKey]) || [];
|
|
||||||
|
|
||||||
return items.flatMap((item) => mapItem(item, rootFolderUID, level));
|
|
||||||
}
|
|
||||||
|
|
||||||
function findItem(
|
|
||||||
childrenByUID: Record<string, DashboardViewItem[] | undefined>,
|
|
||||||
uid: string
|
|
||||||
): DashboardViewItem | undefined {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
@ -1,18 +1,14 @@
|
|||||||
import { render as rtlRender, 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 { Router } from 'react-router-dom';
|
import { TestProvider } from 'test/helpers/TestProvider';
|
||||||
|
|
||||||
import { locationService } from '@grafana/runtime';
|
|
||||||
|
|
||||||
import { wellFormedDashboard, wellFormedEmptyFolder, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture';
|
import { wellFormedDashboard, wellFormedEmptyFolder, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture';
|
||||||
|
|
||||||
import { DashboardsTree } from './DashboardsTree';
|
import { DashboardsTree } from './DashboardsTree';
|
||||||
|
|
||||||
function render(...args: Parameters<typeof rtlRender>) {
|
function render(...[ui, options]: Parameters<typeof rtlRender>) {
|
||||||
const [ui, options] = args;
|
rtlRender(<TestProvider>{ui}</TestProvider>, options);
|
||||||
|
|
||||||
rtlRender(<Router history={locationService.getHistory()}>{ui}</Router>, options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('browse-dashboards DashboardsTree', () => {
|
describe('browse-dashboards DashboardsTree', () => {
|
||||||
|
10
public/app/features/browse-dashboards/state/actions.ts
Normal file
10
public/app/features/browse-dashboards/state/actions.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
import { getFolderChildren } from 'app/features/search/service/folders';
|
||||||
|
|
||||||
|
export const fetchChildren = createAsyncThunk(
|
||||||
|
'browseDashboards/fetchChildren',
|
||||||
|
async (parentUID: string | undefined) => {
|
||||||
|
return await getFolderChildren(parentUID, undefined, true);
|
||||||
|
}
|
||||||
|
);
|
72
public/app/features/browse-dashboards/state/hooks.ts
Normal file
72
public/app/features/browse-dashboards/state/hooks.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { createSelector } from 'reselect';
|
||||||
|
|
||||||
|
import { DashboardViewItem } from 'app/features/search/types';
|
||||||
|
import { useSelector, StoreState } from 'app/types';
|
||||||
|
|
||||||
|
import { DashboardsTreeItem } from '../types';
|
||||||
|
|
||||||
|
const flatTreeSelector = createSelector(
|
||||||
|
(wholeState: StoreState) => wholeState.browseDashboards.rootItems,
|
||||||
|
(wholeState: StoreState) => wholeState.browseDashboards.childrenByParentUID,
|
||||||
|
(wholeState: StoreState) => wholeState.browseDashboards.openFolders,
|
||||||
|
(wholeState: StoreState, rootFolderUID: string | undefined) => rootFolderUID,
|
||||||
|
(rootItems, childrenByParentUID, openFolders, folderUID) => {
|
||||||
|
return createFlatTree(folderUID, rootItems, childrenByParentUID, openFolders);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export function useFlatTreeState(folderUID: string | undefined) {
|
||||||
|
return useSelector((state) => flatTreeSelector(state, folderUID));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSelectedItemsState() {
|
||||||
|
return useSelector((wholeState: StoreState) => wholeState.browseDashboards.selectedItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a list of items, with level indicating it's 'nested' in the tree structure
|
||||||
|
*
|
||||||
|
* @param folderUID The UID of the folder being viewed, or undefined if at root Browse Dashboards page
|
||||||
|
* @param rootItems Array of loaded items at the root level (without a parent). If viewing a folder, we expect this to be empty and unused
|
||||||
|
* @param childrenByUID Arrays of children keyed by their parent UID
|
||||||
|
* @param openFolders Object of UID to whether that item is expanded or not
|
||||||
|
* @param level level of item in the tree. Only to be specified when called recursively.
|
||||||
|
*/
|
||||||
|
function createFlatTree(
|
||||||
|
folderUID: string | undefined,
|
||||||
|
rootItems: DashboardViewItem[],
|
||||||
|
childrenByUID: Record<string, DashboardViewItem[] | undefined>,
|
||||||
|
openFolders: Record<string, boolean>,
|
||||||
|
level = 0
|
||||||
|
): DashboardsTreeItem[] {
|
||||||
|
function mapItem(item: DashboardViewItem, parentUID: string | undefined, level: number): DashboardsTreeItem[] {
|
||||||
|
const mappedChildren = createFlatTree(item.uid, rootItems, childrenByUID, openFolders, level + 1);
|
||||||
|
|
||||||
|
const isOpen = Boolean(openFolders[item.uid]);
|
||||||
|
const emptyFolder = childrenByUID[item.uid]?.length === 0;
|
||||||
|
if (isOpen && emptyFolder) {
|
||||||
|
mappedChildren.push({
|
||||||
|
isOpen: false,
|
||||||
|
level: level + 1,
|
||||||
|
item: { kind: 'ui-empty-folder', uid: item.uid + '-empty-folder' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const thisItem = {
|
||||||
|
item,
|
||||||
|
parentUID,
|
||||||
|
level,
|
||||||
|
isOpen,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [thisItem, ...mappedChildren];
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOpen = (folderUID && openFolders[folderUID]) || level === 0;
|
||||||
|
|
||||||
|
const items = folderUID
|
||||||
|
? (isOpen && childrenByUID[folderUID]) || [] // keep seperate lines
|
||||||
|
: rootItems;
|
||||||
|
|
||||||
|
return items.flatMap((item) => mapItem(item, folderUID, level));
|
||||||
|
}
|
3
public/app/features/browse-dashboards/state/index.ts
Normal file
3
public/app/features/browse-dashboards/state/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './slice';
|
||||||
|
export * from './actions';
|
||||||
|
export * from './hooks';
|
190
public/app/features/browse-dashboards/state/reducers.test.ts
Normal file
190
public/app/features/browse-dashboards/state/reducers.test.ts
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
import { wellFormedDashboard, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture';
|
||||||
|
import { BrowseDashboardsState } from '../types';
|
||||||
|
|
||||||
|
import { extraReducerFetchChildrenFulfilled, setFolderOpenState, setItemSelectionState } from './reducers';
|
||||||
|
|
||||||
|
function createInitialState(): BrowseDashboardsState {
|
||||||
|
return {
|
||||||
|
rootItems: [],
|
||||||
|
childrenByParentUID: {},
|
||||||
|
openFolders: {},
|
||||||
|
selectedItems: {
|
||||||
|
dashboard: {},
|
||||||
|
folder: {},
|
||||||
|
panel: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('browse-dashboards reducers', () => {
|
||||||
|
describe('extraReducerFetchChildrenFulfilled', () => {
|
||||||
|
it('updates state correctly for root items', () => {
|
||||||
|
const state = createInitialState();
|
||||||
|
const children = [
|
||||||
|
wellFormedFolder(1).item,
|
||||||
|
wellFormedFolder(2).item,
|
||||||
|
wellFormedFolder(3).item,
|
||||||
|
wellFormedDashboard(4).item,
|
||||||
|
];
|
||||||
|
|
||||||
|
const action = {
|
||||||
|
payload: children,
|
||||||
|
type: 'action-type',
|
||||||
|
meta: {
|
||||||
|
arg: undefined,
|
||||||
|
requestId: 'abc-123',
|
||||||
|
requestStatus: 'fulfilled' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
extraReducerFetchChildrenFulfilled(state, action);
|
||||||
|
|
||||||
|
expect(state.rootItems).toEqual(children);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates state correctly for items in folders', () => {
|
||||||
|
const state = createInitialState();
|
||||||
|
const parentFolder = wellFormedFolder(1).item;
|
||||||
|
const children = [wellFormedFolder(2).item, wellFormedDashboard(3).item];
|
||||||
|
|
||||||
|
const action = {
|
||||||
|
payload: children,
|
||||||
|
type: 'action-type',
|
||||||
|
meta: {
|
||||||
|
arg: parentFolder.uid,
|
||||||
|
requestId: 'abc-123',
|
||||||
|
requestStatus: 'fulfilled' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
extraReducerFetchChildrenFulfilled(state, action);
|
||||||
|
|
||||||
|
expect(state.childrenByParentUID).toEqual({ [parentFolder.uid]: children });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks children as selected if the parent is selected', () => {
|
||||||
|
const parentFolder = wellFormedFolder(1).item;
|
||||||
|
|
||||||
|
const state = createInitialState();
|
||||||
|
state.selectedItems.folder[parentFolder.uid] = true;
|
||||||
|
|
||||||
|
const childFolder = wellFormedFolder(2).item;
|
||||||
|
const childDashboard = wellFormedDashboard(3).item;
|
||||||
|
|
||||||
|
const action = {
|
||||||
|
payload: [childFolder, childDashboard],
|
||||||
|
type: 'action-type',
|
||||||
|
meta: {
|
||||||
|
arg: parentFolder.uid,
|
||||||
|
requestId: 'abc-123',
|
||||||
|
requestStatus: 'fulfilled' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
extraReducerFetchChildrenFulfilled(state, action);
|
||||||
|
|
||||||
|
expect(state.selectedItems).toEqual({
|
||||||
|
dashboard: {
|
||||||
|
[childDashboard.uid]: true,
|
||||||
|
},
|
||||||
|
folder: {
|
||||||
|
[parentFolder.uid]: true,
|
||||||
|
[childFolder.uid]: true,
|
||||||
|
},
|
||||||
|
panel: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setFolderOpenState', () => {
|
||||||
|
it('updates state correctly', () => {
|
||||||
|
const state = createInitialState();
|
||||||
|
const folderUID = 'abc-123';
|
||||||
|
setFolderOpenState(state, { type: 'setFolderOpenState', payload: { folderUID, isOpen: true } });
|
||||||
|
|
||||||
|
expect(state.openFolders).toEqual({ [folderUID]: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setItemSelectionState', () => {
|
||||||
|
it('marks items as selected', () => {
|
||||||
|
const state = createInitialState();
|
||||||
|
const dashboard = wellFormedDashboard().item;
|
||||||
|
|
||||||
|
setItemSelectionState(state, { type: 'setItemSelectionState', payload: { item: dashboard, isSelected: true } });
|
||||||
|
|
||||||
|
expect(state.selectedItems).toEqual({
|
||||||
|
dashboard: {
|
||||||
|
[dashboard.uid]: true,
|
||||||
|
},
|
||||||
|
folder: {},
|
||||||
|
panel: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks descendants as selected when the parent folder is selected', () => {
|
||||||
|
const state = createInitialState();
|
||||||
|
|
||||||
|
const parentFolder = wellFormedFolder(1).item;
|
||||||
|
const childDashboard = wellFormedDashboard(2, {}, { parentUID: parentFolder.uid }).item;
|
||||||
|
const childFolder = wellFormedFolder(3, {}, { parentUID: parentFolder.uid }).item;
|
||||||
|
const grandchildDashboard = wellFormedDashboard(4, {}, { parentUID: childFolder.uid }).item;
|
||||||
|
|
||||||
|
state.childrenByParentUID[parentFolder.uid] = [childDashboard, childFolder];
|
||||||
|
state.childrenByParentUID[childFolder.uid] = [grandchildDashboard];
|
||||||
|
|
||||||
|
setItemSelectionState(state, {
|
||||||
|
type: 'setItemSelectionState',
|
||||||
|
payload: { item: parentFolder, isSelected: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.selectedItems).toEqual({
|
||||||
|
dashboard: {
|
||||||
|
[childDashboard.uid]: true,
|
||||||
|
[grandchildDashboard.uid]: true,
|
||||||
|
},
|
||||||
|
folder: {
|
||||||
|
[parentFolder.uid]: true,
|
||||||
|
[childFolder.uid]: true,
|
||||||
|
},
|
||||||
|
panel: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unselects parents when items are unselected', () => {
|
||||||
|
const state = createInitialState();
|
||||||
|
|
||||||
|
const parentFolder = wellFormedFolder(1).item;
|
||||||
|
const childDashboard = wellFormedDashboard(2, {}, { parentUID: parentFolder.uid }).item;
|
||||||
|
const childFolder = wellFormedFolder(3, {}, { parentUID: parentFolder.uid }).item;
|
||||||
|
const grandchildDashboard = wellFormedDashboard(4, {}, { parentUID: childFolder.uid }).item;
|
||||||
|
|
||||||
|
state.rootItems = [parentFolder];
|
||||||
|
state.childrenByParentUID[parentFolder.uid] = [childDashboard, childFolder];
|
||||||
|
state.childrenByParentUID[childFolder.uid] = [grandchildDashboard];
|
||||||
|
|
||||||
|
state.selectedItems.dashboard[childDashboard.uid] = true;
|
||||||
|
state.selectedItems.dashboard[grandchildDashboard.uid] = true;
|
||||||
|
state.selectedItems.folder[parentFolder.uid] = true;
|
||||||
|
state.selectedItems.folder[childFolder.uid] = true;
|
||||||
|
|
||||||
|
// Unselect the deepest grandchild dashboard
|
||||||
|
setItemSelectionState(state, {
|
||||||
|
type: 'setItemSelectionState',
|
||||||
|
payload: { item: grandchildDashboard, isSelected: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.selectedItems).toEqual({
|
||||||
|
dashboard: {
|
||||||
|
[childDashboard.uid]: true,
|
||||||
|
[grandchildDashboard.uid]: false,
|
||||||
|
},
|
||||||
|
folder: {
|
||||||
|
[parentFolder.uid]: false,
|
||||||
|
[childFolder.uid]: false,
|
||||||
|
},
|
||||||
|
panel: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
107
public/app/features/browse-dashboards/state/reducers.ts
Normal file
107
public/app/features/browse-dashboards/state/reducers.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
|
||||||
|
|
||||||
|
import { BrowseDashboardsState } from '../types';
|
||||||
|
|
||||||
|
import { fetchChildren } from './actions';
|
||||||
|
|
||||||
|
type FetchChildrenAction = ReturnType<typeof fetchChildren.fulfilled>;
|
||||||
|
|
||||||
|
export function extraReducerFetchChildrenFulfilled(state: BrowseDashboardsState, action: FetchChildrenAction) {
|
||||||
|
const parentUID = action.meta.arg;
|
||||||
|
const children = action.payload;
|
||||||
|
|
||||||
|
if (!parentUID) {
|
||||||
|
state.rootItems = children;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.childrenByParentUID[parentUID] = children;
|
||||||
|
|
||||||
|
// If the parent of the items we've loaded are selected, we must select all these items also
|
||||||
|
const parentIsSelected = state.selectedItems.folder[parentUID];
|
||||||
|
if (parentIsSelected) {
|
||||||
|
for (const child of children) {
|
||||||
|
state.selectedItems[child.kind][child.uid] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFolderOpenState(
|
||||||
|
state: BrowseDashboardsState,
|
||||||
|
action: PayloadAction<{ folderUID: string; isOpen: boolean }>
|
||||||
|
) {
|
||||||
|
const { folderUID, isOpen } = action.payload;
|
||||||
|
state.openFolders[folderUID] = isOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setItemSelectionState(
|
||||||
|
state: BrowseDashboardsState,
|
||||||
|
action: PayloadAction<{ item: DashboardViewItem; isSelected: boolean }>
|
||||||
|
) {
|
||||||
|
const { item, isSelected } = action.payload;
|
||||||
|
|
||||||
|
function markChildren(kind: DashboardViewItemKind, uid: string) {
|
||||||
|
state.selectedItems[kind][uid] = isSelected;
|
||||||
|
|
||||||
|
if (kind !== 'folder') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let children = state.childrenByParentUID[uid] ?? [];
|
||||||
|
for (const child of children) {
|
||||||
|
markChildren(child.kind, child.uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markChildren(item.kind, item.uid);
|
||||||
|
|
||||||
|
// If we're unselecting an item, unselect all ancestors (parent, grandparent, etc) also
|
||||||
|
// so we can later show a UI-only 'mixed' checkbox
|
||||||
|
if (!isSelected) {
|
||||||
|
let nextParentUID = item.parentUID;
|
||||||
|
|
||||||
|
// this is like a recursive climb up the parents of the tree while we have a
|
||||||
|
// parentUID (we've hit a root dashboard/folder)
|
||||||
|
while (nextParentUID) {
|
||||||
|
const parent = findItem(state.rootItems, state.childrenByParentUID, nextParentUID);
|
||||||
|
|
||||||
|
// This case should not happen, but a find can theortically return undefined, and it
|
||||||
|
// helps limit infinite loops
|
||||||
|
if (!parent) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.selectedItems[parent.kind][parent.uid] = false;
|
||||||
|
nextParentUID = parent.parentUID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
37
public/app/features/browse-dashboards/state/slice.ts
Normal file
37
public/app/features/browse-dashboards/state/slice.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
import { BrowseDashboardsState } from '../types';
|
||||||
|
|
||||||
|
import { fetchChildren } from './actions';
|
||||||
|
import * as allReducers from './reducers';
|
||||||
|
|
||||||
|
const { extraReducerFetchChildrenFulfilled, ...baseReducers } = allReducers;
|
||||||
|
|
||||||
|
const initialState: BrowseDashboardsState = {
|
||||||
|
rootItems: [],
|
||||||
|
childrenByParentUID: {},
|
||||||
|
openFolders: {},
|
||||||
|
selectedItems: {
|
||||||
|
dashboard: {},
|
||||||
|
folder: {},
|
||||||
|
panel: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const browseDashboardsSlice = createSlice({
|
||||||
|
name: 'browseDashboards',
|
||||||
|
initialState,
|
||||||
|
reducers: baseReducers,
|
||||||
|
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder.addCase(fetchChildren.fulfilled, extraReducerFetchChildrenFulfilled);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const browseDashboardsReducer = browseDashboardsSlice.reducer;
|
||||||
|
|
||||||
|
export const { setFolderOpenState, setItemSelectionState } = browseDashboardsSlice.actions;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
browseDashboards: browseDashboardsReducer,
|
||||||
|
};
|
@ -1,13 +1,22 @@
|
|||||||
import { DashboardViewItem as OrigDashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
|
import { DashboardViewItem as DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
|
||||||
|
|
||||||
|
export interface BrowseDashboardsState {
|
||||||
|
rootItems: DashboardViewItem[];
|
||||||
|
childrenByParentUID: Record<string, DashboardViewItem[] | undefined>;
|
||||||
|
selectedItems: DashboardTreeSelection;
|
||||||
|
|
||||||
|
// Only folders can ever be open or closed, so no need to seperate this by kind
|
||||||
|
openFolders: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UIDashboardViewItem {
|
export interface UIDashboardViewItem {
|
||||||
kind: 'ui-empty-folder';
|
kind: 'ui-empty-folder';
|
||||||
uid: string;
|
uid: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DashboardViewItem = OrigDashboardViewItem | UIDashboardViewItem;
|
type DashboardViewItemWithUIItems = DashboardViewItem | UIDashboardViewItem;
|
||||||
|
|
||||||
export interface DashboardsTreeItem<T extends DashboardViewItem = DashboardViewItem> {
|
export interface DashboardsTreeItem<T extends DashboardViewItemWithUIItems = DashboardViewItemWithUIItems> {
|
||||||
item: T;
|
item: T;
|
||||||
level: number;
|
level: number;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
Loading…
Reference in New Issue
Block a user