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 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', () => {
|
||||
|
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 {
|
||||
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…
Reference in New Issue
Block a user