NestedFolders: Refactor BrowseView state into redux (#66898)

* refactor all state into redux

* add tests for reducers

* added comments
pull/66987/head
Josh Hunt 2 years ago committed by GitHub
parent 74d3d3cf4a
commit 3518f8ec53
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      public/app/core/reducers/root.ts
  2. 2
      public/app/features/browse-dashboards/api/browseDashboardsAPI.ts
  3. 9
      public/app/features/browse-dashboards/components/BrowseView.test.tsx
  4. 172
      public/app/features/browse-dashboards/components/BrowseView.tsx
  5. 10
      public/app/features/browse-dashboards/components/DashboardsTree.test.tsx
  6. 10
      public/app/features/browse-dashboards/state/actions.ts
  7. 72
      public/app/features/browse-dashboards/state/hooks.ts
  8. 3
      public/app/features/browse-dashboards/state/index.ts
  9. 190
      public/app/features/browse-dashboards/state/reducers.test.ts
  10. 107
      public/app/features/browse-dashboards/state/reducers.ts
  11. 37
      public/app/features/browse-dashboards/state/slice.ts
  12. 15
      public/app/features/browse-dashboards/types.ts

@ -6,6 +6,7 @@ import alertingReducers from 'app/features/alerting/state/reducers';
import apiKeysReducers from 'app/features/api-keys/state/reducers';
import authConfigReducers from 'app/features/auth-config/state/reducers';
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 panelEditorReducers from 'app/features/dashboard/components/PanelEditor/state/reducers';
import dashboardReducers from 'app/features/dashboard/state/reducers';
@ -41,6 +42,7 @@ const rootReducers = {
...userReducers,
...invitesReducers,
...organizationReducers,
...browseDashboardsReducers,
...ldapReducers,
...importDashboardReducers,
...panelEditorReducers,

@ -29,7 +29,7 @@ function createBackendSrvBaseQuery({ baseURL }: { baseURL: string }): BaseQueryF
}
export const browseDashboardsAPI = createApi({
reducerPath: 'browse-dashboards',
reducerPath: 'browseDashboardsAPI',
baseQuery: createBackendSrvBaseQuery({ baseURL: '/api' }),
endpoints: (builder) => ({
getFolder: builder.query<FolderDTO, string>({

@ -1,10 +1,9 @@
import { getByLabelText, render as rtlRender, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Router } from 'react-router-dom';
import { TestProvider } from 'test/helpers/TestProvider';
import { selectors } from '@grafana/e2e-selectors';
import { locationService } from '@grafana/runtime';
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();
function render(...args: Parameters<typeof rtlRender>) {
const [ui, options] = args;
rtlRender(<Router history={locationService.getHistory()}>{ui}</Router>, options);
function render(...[ui, options]: Parameters<typeof rtlRender>) {
rtlRender(<TestProvider>{ui}</TestProvider>, options);
}
jest.mock('app/features/search/service/folders', () => {

@ -1,10 +1,15 @@
import produce from 'immer';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect } from 'react';
import { getFolderChildren } from 'app/features/search/service/folders';
import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
import { DashboardViewItem } 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';
@ -15,102 +20,30 @@ interface BrowseViewProps {
}
export function BrowseView({ folderUID, width, height }: BrowseViewProps) {
const [openFolders, setOpenFolders] = useState<Record<string, boolean>>({ [folderUID ?? '$$root']: true });
const [selectedItems, setSelectedItems] = useState<
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]
);
const dispatch = useDispatch();
const flatTree = useFlatTreeState(folderUID);
const selectedItems = useSelectedItemsState();
useEffect(() => {
loadChildrenForUID(folderUID);
// No need to depend on loadChildrenForUID - we only want this to run
// 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]
);
dispatch(fetchChildren(folderUID));
}, [dispatch, folderUID]);
const handleFolderClick = useCallback(
(uid: string, newState: boolean) => {
if (newState) {
loadChildrenForUID(uid);
}
(clickedFolderUID: string, isOpen: boolean) => {
dispatch(setFolderOpenState({ folderUID: clickedFolderUID, isOpen }));
setOpenFolders((old) => ({ ...old, [uid]: newState }));
if (isOpen) {
dispatch(fetchChildren(clickedFolderUID));
}
},
[loadChildrenForUID]
[dispatch]
);
const handleItemSelectionChange = useCallback(
(item: DashboardViewItem, newState: boolean) => {
// Recursively set selection state for this item and all descendants
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;
}
}
})
);
(item: DashboardViewItem, isSelected: boolean) => {
dispatch(setItemSelectionState({ item, isSelected }));
},
[childrenByUID]
[dispatch]
);
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 userEvent from '@testing-library/user-event';
import React from 'react';
import { Router } from 'react-router-dom';
import { locationService } from '@grafana/runtime';
import { TestProvider } from 'test/helpers/TestProvider';
import { wellFormedDashboard, wellFormedEmptyFolder, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture';
import { DashboardsTree } from './DashboardsTree';
function render(...args: Parameters<typeof rtlRender>) {
const [ui, options] = args;
rtlRender(<Router history={locationService.getHistory()}>{ui}</Router>, options);
function render(...[ui, options]: Parameters<typeof rtlRender>) {
rtlRender(<TestProvider>{ui}</TestProvider>, options);
}
describe('browse-dashboards DashboardsTree', () => {

@ -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);
}
);

@ -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));
}

@ -0,0 +1,3 @@
export * from './slice';
export * from './actions';
export * from './hooks';

@ -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: {},
});
});
});
});

@ -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;
}

@ -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 {
kind: 'ui-empty-folder';
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;
level: number;
isOpen: boolean;

Loading…
Cancel
Save