diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index 71b150f6cd2..68ac180d36c 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.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, diff --git a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts index c87d45ef6fd..3fe6e34364a 100644 --- a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts +++ b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts @@ -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({ diff --git a/public/app/features/browse-dashboards/components/BrowseView.test.tsx b/public/app/features/browse-dashboards/components/BrowseView.test.tsx index cb84d927bd7..dcba29461f8 100644 --- a/public/app/features/browse-dashboards/components/BrowseView.test.tsx +++ b/public/app/features/browse-dashboards/components/BrowseView.test.tsx @@ -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) { - const [ui, options] = args; - - rtlRender({ui}, options); +function render(...[ui, options]: Parameters) { + rtlRender({ui}, options); } jest.mock('app/features/search/service/folders', () => { diff --git a/public/app/features/browse-dashboards/components/BrowseView.tsx b/public/app/features/browse-dashboards/components/BrowseView.tsx index d10d1bb3f15..233dd22998a 100644 --- a/public/app/features/browse-dashboards/components/BrowseView.tsx +++ b/public/app/features/browse-dashboards/components/BrowseView.tsx @@ -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>({ [folderUID ?? '$$root']: true }); - - const [selectedItems, setSelectedItems] = useState< - Record> - >({ - 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>({}); - - 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, - openFolders: Record, - 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, - 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; -} diff --git a/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx b/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx index e773a2dc669..1ac147c290a 100644 --- a/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx +++ b/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx @@ -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) { - const [ui, options] = args; - - rtlRender({ui}, options); +function render(...[ui, options]: Parameters) { + rtlRender({ui}, options); } describe('browse-dashboards DashboardsTree', () => { diff --git a/public/app/features/browse-dashboards/state/actions.ts b/public/app/features/browse-dashboards/state/actions.ts new file mode 100644 index 00000000000..98fa760a937 --- /dev/null +++ b/public/app/features/browse-dashboards/state/actions.ts @@ -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); + } +); diff --git a/public/app/features/browse-dashboards/state/hooks.ts b/public/app/features/browse-dashboards/state/hooks.ts new file mode 100644 index 00000000000..d5fb06ea59c --- /dev/null +++ b/public/app/features/browse-dashboards/state/hooks.ts @@ -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, + openFolders: Record, + 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)); +} diff --git a/public/app/features/browse-dashboards/state/index.ts b/public/app/features/browse-dashboards/state/index.ts new file mode 100644 index 00000000000..c5779d55154 --- /dev/null +++ b/public/app/features/browse-dashboards/state/index.ts @@ -0,0 +1,3 @@ +export * from './slice'; +export * from './actions'; +export * from './hooks'; diff --git a/public/app/features/browse-dashboards/state/reducers.test.ts b/public/app/features/browse-dashboards/state/reducers.test.ts new file mode 100644 index 00000000000..9c9cbde83c3 --- /dev/null +++ b/public/app/features/browse-dashboards/state/reducers.test.ts @@ -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: {}, + }); + }); + }); +}); diff --git a/public/app/features/browse-dashboards/state/reducers.ts b/public/app/features/browse-dashboards/state/reducers.ts new file mode 100644 index 00000000000..85b75ca675d --- /dev/null +++ b/public/app/features/browse-dashboards/state/reducers.ts @@ -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; + +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, + 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; +} diff --git a/public/app/features/browse-dashboards/state/slice.ts b/public/app/features/browse-dashboards/state/slice.ts new file mode 100644 index 00000000000..564f1fc4db7 --- /dev/null +++ b/public/app/features/browse-dashboards/state/slice.ts @@ -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, +}; diff --git a/public/app/features/browse-dashboards/types.ts b/public/app/features/browse-dashboards/types.ts index d9fe84c17b4..499927f979f 100644 --- a/public/app/features/browse-dashboards/types.ts +++ b/public/app/features/browse-dashboards/types.ts @@ -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; + selectedItems: DashboardTreeSelection; + + // Only folders can ever be open or closed, so no need to seperate this by kind + openFolders: Record; +} export interface UIDashboardViewItem { kind: 'ui-empty-folder'; uid: string; } -type DashboardViewItem = OrigDashboardViewItem | UIDashboardViewItem; +type DashboardViewItemWithUIItems = DashboardViewItem | UIDashboardViewItem; -export interface DashboardsTreeItem { +export interface DashboardsTreeItem { item: T; level: number; isOpen: boolean;