mirror of https://github.com/grafana/grafana
NestedFolders: Refactor BrowseView state into redux (#66898)
* refactor all state into redux * add tests for reducers * added commentspull/66987/head
parent
74d3d3cf4a
commit
3518f8ec53
@ -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, |
||||
}; |
Loading…
Reference in new issue