diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 3ab369f4510..eb3da391bf3 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -166,6 +166,7 @@ export class GrafanaBootConfig implements GrafanaConfig { tokenExpirationDayLimit: undefined; disableFrontendSandboxForPlugins: string[] = []; + sharedWithMeFolderUID: string | undefined; constructor(options: GrafanaBootConfig) { this.bootData = options.bootData; diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx index 8f660156523..286dec961a2 100644 --- a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx +++ b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx @@ -4,6 +4,7 @@ import React, { useCallback, useId, useMemo, useState } from 'react'; import { useAsync } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { Alert, Icon, Input, LoadingBar, useStyles2 } from '@grafana/ui'; import { t } from 'app/core/internationalization'; import { skipToken, useGetFolderQuery } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; @@ -164,6 +165,10 @@ export function NestedFolderPicker({ return createFlatTree(undefined, searchCollection, childrenCollections, {}, 0, EXCLUDED_KINDS, excludeUIDs); } + const allExcludedUIDs = config.sharedWithMeFolderUID + ? [...(excludeUIDs || []), config.sharedWithMeFolderUID] + : excludeUIDs; + let flatTree = createFlatTree( undefined, rootCollection, @@ -171,7 +176,7 @@ export function NestedFolderPicker({ folderOpenState, 0, EXCLUDED_KINDS, - excludeUIDs + allExcludedUIDs ); if (showRootFolder) { diff --git a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts index fc68ca87565..0324a580a28 100644 --- a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts +++ b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts @@ -57,6 +57,7 @@ export const browseDashboardsAPI = createApi({ providesTags: (_result, _error, folderUID) => [{ type: 'getFolder', id: folderUID }], query: (folderUID) => ({ url: `/folders/${folderUID}`, params: { accesscontrol: true } }), }), + // create a new folder newFolder: builder.mutation({ query: ({ title, parentUid }) => ({ @@ -81,6 +82,7 @@ export const browseDashboardsAPI = createApi({ }); }, }), + // save an existing folder (e.g. rename) saveFolder: builder.mutation({ // because the getFolder calls contain the parents, renaming a parent/grandparent/etc needs to invalidate all child folders diff --git a/public/app/features/browse-dashboards/api/services.ts b/public/app/features/browse-dashboards/api/services.ts index 468392e965f..f4318793358 100644 --- a/public/app/features/browse-dashboards/api/services.ts +++ b/public/app/features/browse-dashboards/api/services.ts @@ -6,6 +6,7 @@ import { DashboardViewItem } from 'app/features/search/types'; import { contextSrv } from '../../../core/core'; import { AccessControlAction } from '../../../types'; +import { isSharedWithMe } from '../components/utils'; export const PAGE_SIZE = 50; @@ -36,7 +37,7 @@ export async function listFolders( title: item.title, parentTitle, parentUID, - url: `/dashboards/f/${item.uid}/`, + url: isSharedWithMe(item.uid) ? undefined : `/dashboards/f/${item.uid}/`, })); } diff --git a/public/app/features/browse-dashboards/components/CheckboxCell.tsx b/public/app/features/browse-dashboards/components/CheckboxCell.tsx index 83f84c9e039..fb8ffebfe20 100644 --- a/public/app/features/browse-dashboards/components/CheckboxCell.tsx +++ b/public/app/features/browse-dashboards/components/CheckboxCell.tsx @@ -8,26 +8,31 @@ import { t } from 'app/core/internationalization'; import { DashboardsTreeCellProps, SelectionState } from '../types'; +import { isSharedWithMe } from './utils'; + export default function CheckboxCell({ row: { original: row }, isSelected, onItemSelectionChange, }: DashboardsTreeCellProps) { - const styles = useStyles2(getStyles); const item = row.item; if (!isSelected) { - return ; + return ; } if (item.kind === 'ui') { if (item.uiKind === 'pagination-placeholder') { return ; } else { - return ; + return ; } } + if (isSharedWithMe(item.uid)) { + return ; + } + const state = isSelected(item); return ( @@ -41,6 +46,11 @@ export default function CheckboxCell({ ); } +function CheckboxSpacer() { + const styles = useStyles2(getStyles); + return ; +} + const getStyles = (theme: GrafanaTheme2) => ({ // Should be the same size as the so Dashboard name is aligned to Folder name siblings checkboxSpacer: css({ diff --git a/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx b/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx index 8a58cd9c421..caa039829bd 100644 --- a/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx +++ b/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx @@ -5,8 +5,14 @@ import { TestProvider } from 'test/helpers/TestProvider'; import { assertIsDefined } from 'test/helpers/asserts'; import { selectors } from '@grafana/e2e-selectors'; +import { config } from '@grafana/runtime'; -import { wellFormedDashboard, wellFormedEmptyFolder, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture'; +import { + sharedWithMeFolder, + wellFormedDashboard, + wellFormedEmptyFolder, + wellFormedFolder, +} from '../fixtures/dashboardsTreeItem.fixture'; import { SelectionState } from '../types'; import { DashboardsTree } from './DashboardsTree'; @@ -27,6 +33,10 @@ describe('browse-dashboards DashboardsTree', () => { const allItemsAreLoaded = () => true; const requestLoadMore = () => Promise.resolve(); + beforeAll(() => { + config.sharedWithMeFolderUID = 'sharedwithme'; + }); + it('renders a dashboard item', () => { render( { requestLoadMore={requestLoadMore} /> ); + expect(screen.queryByText(folder.item.title)).toBeInTheDocument(); }); + it('renders a folder link', () => { + render( + + ); + + expect(screen.queryByText(folder.item.title)).toHaveAttribute('href', folder.item.url); + }); + + it("doesn't link to the sharedwithme pseudo-folder", () => { + const sharedWithMe = sharedWithMeFolder(2); + + render( + + ); + + expect(screen.queryByText(sharedWithMe.item.title)).not.toHaveAttribute('href'); + }); + + it("doesn't render a checkbox for the sharedwithme pseudo-folder", () => { + const sharedWithMe = sharedWithMeFolder(2); + + render( + + ); + + expect( + screen.queryByTestId(selectors.pages.BrowseDashboards.table.checkbox(sharedWithMe.item.uid)) + ).not.toBeInTheDocument(); + }); + it('calls onFolderClick when a folder button is clicked', async () => { const handler = jest.fn(); render( diff --git a/public/app/features/browse-dashboards/components/DashboardsTree.tsx b/public/app/features/browse-dashboards/components/DashboardsTree.tsx index be3b5bee1c7..94d16c5ba77 100644 --- a/public/app/features/browse-dashboards/components/DashboardsTree.tsx +++ b/public/app/features/browse-dashboards/components/DashboardsTree.tsx @@ -1,7 +1,7 @@ import { css, cx } from '@emotion/css'; import React, { useCallback, useEffect, useId, useMemo, useRef } from 'react'; import { TableInstance, useTable } from 'react-table'; -import { FixedSizeList as List } from 'react-window'; +import { VariableSizeList as List } from 'react-window'; import InfiniteLoader from 'react-window-infinite-loader'; import { GrafanaTheme2, isTruthy } from '@grafana/data'; @@ -35,6 +35,7 @@ interface DashboardsTreeProps { const HEADER_HEIGHT = 36; const ROW_HEIGHT = 36; +const DIVIDER_HEIGHT = 0; // Yes - make it appear as a border on the row rather than a row itself export function DashboardsTree({ items, @@ -51,6 +52,7 @@ export function DashboardsTree({ const treeID = useId(); const infiniteLoaderRef = useRef(null); + const listRef = useRef(null); const styles = useStyles2(getStyles); useEffect(() => { @@ -60,6 +62,10 @@ export function DashboardsTree({ if (infiniteLoaderRef.current) { infiniteLoaderRef.current.resetloadMoreItemsCache(true); } + + if (listRef.current) { + listRef.current.resetAfterIndex(0); + } }, [items]); const tableColumns = useMemo(() => { @@ -123,6 +129,18 @@ export function DashboardsTree({ [requestLoadMore, items] ); + const getRowHeight = useCallback( + (rowIndex: number) => { + const row = items[rowIndex]; + if (row.item.kind === 'ui' && row.item.uiKind === 'divider') { + return DIVIDER_HEIGHT; + } + + return ROW_HEIGHT; + }, + [items] + ); + return (
{headerGroups.map((headerGroup) => { @@ -154,12 +172,16 @@ export function DashboardsTree({ > {({ onItemsRendered, ref }) => ( { + ref(elem); + listRef.current = elem; + }} height={height - HEADER_HEIGHT} width={width} itemCount={items.length} itemData={virtualData} - itemSize={ROW_HEIGHT} + estimatedItemSize={ROW_HEIGHT} + itemSize={getRowHeight} onItemsRendered={onItemsRendered} > {VirtualListRow} @@ -191,13 +213,23 @@ function VirtualListRow({ index, style, data }: VirtualListRowProps) { const row = rows[index]; prepareRow(row); + const dashboardItem = row.original.item; + + if (dashboardItem.kind === 'ui' && dashboardItem.uiKind === 'divider') { + return ( +
+
+
+ ); + } + return (
{row.cells.map((cell) => { @@ -221,6 +253,12 @@ const getStyles = (theme: GrafanaTheme2) => { gap: theme.spacing(1), }), + divider: css({ + borderTop: `1px solid ${theme.colors.border.weak}`, + width: '100%', + margin: 0, + }), + headerRow: css({ backgroundColor: theme.colors.background.secondary, height: HEADER_HEIGHT, diff --git a/public/app/features/browse-dashboards/components/NameCell.tsx b/public/app/features/browse-dashboards/components/NameCell.tsx index 11bbd5af231..086d3a4d5cc 100644 --- a/public/app/features/browse-dashboards/components/NameCell.tsx +++ b/public/app/features/browse-dashboards/components/NameCell.tsx @@ -7,7 +7,7 @@ import { reportInteraction } from '@grafana/runtime'; import { Icon, IconButton, Link, Spinner, useStyles2, Text } from '@grafana/ui'; import { getSvgSize } from '@grafana/ui/src/components/Icon/utils'; import { t } from 'app/core/internationalization'; -import { getIconForKind } from 'app/features/search/service/utils'; +import { getIconForItem } from 'app/features/search/service/utils'; import { Indent } from '../../../core/components/Indent/Indent'; import { useChildrenByParentUIDState } from '../state'; @@ -27,7 +27,7 @@ export function NameCell({ row: { original: data }, onFolderClick, treeID }: Nam const { item, level, isOpen } = data; const childrenByParentUID = useChildrenByParentUIDState(); const isLoading = isOpen && !childrenByParentUID[item.uid]; - const iconName = getIconForKind(data.item.kind, isOpen); + const iconName = getIconForItem(data.item, isOpen); if (item.kind === 'ui') { return ( diff --git a/public/app/features/browse-dashboards/components/utils.ts b/public/app/features/browse-dashboards/components/utils.ts index 56613b82e14..cc4485b06b8 100644 --- a/public/app/features/browse-dashboards/components/utils.ts +++ b/public/app/features/browse-dashboards/components/utils.ts @@ -1,5 +1,11 @@ +import { config } from '@grafana/runtime'; + import { DashboardViewItemWithUIItems } from '../types'; export function makeRowID(baseId: string, item: DashboardViewItemWithUIItems) { return baseId + item.uid; } + +export function isSharedWithMe(uid: string) { + return uid === config.sharedWithMeFolderUID; +} diff --git a/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts b/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts index 4cb15098aa3..9de6e59c06a 100644 --- a/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts +++ b/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts @@ -65,6 +65,14 @@ export function wellFormedFolder( }; } +export function sharedWithMeFolder(seed = 1): DashboardsTreeItem { + const folder = wellFormedFolder(seed, undefined, { + uid: 'sharedwithme', + url: undefined, + }); + return folder; +} + export function wellFormedTree() { let seed = 1; diff --git a/public/app/features/browse-dashboards/state/hooks.ts b/public/app/features/browse-dashboards/state/hooks.ts index 9d040810919..d46f0b44708 100644 --- a/public/app/features/browse-dashboards/state/hooks.ts +++ b/public/app/features/browse-dashboards/state/hooks.ts @@ -5,6 +5,7 @@ import { DashboardViewItem } from 'app/features/search/types'; import { useSelector, StoreState, useDispatch } from 'app/types'; import { PAGE_SIZE } from '../api/services'; +import { isSharedWithMe } from '../components/utils'; import { BrowseDashboardsState, DashboardsTreeItem, @@ -130,7 +131,7 @@ export function useLoadNextChildrenPage( } /** - * Creates a list of items, with level indicating it's 'nested' in the tree structure + * Creates a list of items, with level indicating it's nesting 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 @@ -180,7 +181,22 @@ export function createFlatTree( isOpen, }; - return [thisItem, ...mappedChildren]; + const items = [thisItem, ...mappedChildren]; + + if (isSharedWithMe(thisItem.item.uid)) { + items.push({ + item: { + kind: 'ui', + uiKind: 'divider', + uid: 'shared-with-me-divider', + }, + parentUID, + level: level + 1, + isOpen: false, + }); + } + + return items; } const isOpen = (folderUID && openFolders[folderUID]) || level === 0; diff --git a/public/app/features/browse-dashboards/state/reducers.test.ts b/public/app/features/browse-dashboards/state/reducers.test.ts index e90b484c129..5df508e0852 100644 --- a/public/app/features/browse-dashboards/state/reducers.test.ts +++ b/public/app/features/browse-dashboards/state/reducers.test.ts @@ -1,4 +1,6 @@ -import { wellFormedDashboard, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture'; +import { config } from '@grafana/runtime'; + +import { sharedWithMeFolder, wellFormedDashboard, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture'; import { fullyLoadedViewItemCollection } from '../fixtures/state.fixtures'; import { BrowseDashboardsState } from '../types'; @@ -19,6 +21,10 @@ function createInitialState(): BrowseDashboardsState { } describe('browse-dashboards reducers', () => { + beforeAll(() => { + config.sharedWithMeFolderUID = 'sharedwithme'; + }); + describe('fetchNextChildrenPageFulfilled', () => { it('loads first page of root items', () => { const pageSize = 50; @@ -321,11 +327,34 @@ describe('browse-dashboards reducers', () => { expect(state.selectedItems.$all).toBeFalsy(); }); + + it('does not allow the sharedwithme folder to be selected', () => { + let seed = 1; + const folder = wellFormedFolder(seed++).item; + const dashboard = wellFormedDashboard(seed++).item; + const sharedWithMe = sharedWithMeFolder(seed++).item; + const sharedWithMeDashboard = wellFormedDashboard(seed++, {}, { parentUID: sharedWithMe.uid }).item; + + const state = createInitialState(); + state.rootItems = fullyLoadedViewItemCollection([sharedWithMe, folder, dashboard]); + state.childrenByParentUID[sharedWithMe.uid] = fullyLoadedViewItemCollection([sharedWithMeDashboard]); + + setItemSelectionState(state, { + type: 'setItemSelectionState', + payload: { item: sharedWithMe, isSelected: true }, + }); + + expect(state.selectedItems.folder[sharedWithMe.uid]).toBeFalsy(); + }); }); describe('setAllSelection', () => { let seed = 1; const topLevelDashboard = wellFormedDashboard(seed++).item; + + const sharedWithMe = sharedWithMeFolder(seed++).item; + const sharedWithMeDashboard = wellFormedDashboard(seed++, {}, { parentUID: sharedWithMe.uid }).item; + const topLevelFolder = wellFormedFolder(seed++).item; const childDashboard = wellFormedDashboard(seed++, {}, { parentUID: topLevelFolder.uid }).item; const childFolder = wellFormedFolder(seed++, {}, { parentUID: topLevelFolder.uid }).item; @@ -407,5 +436,35 @@ describe('browse-dashboards reducers', () => { panel: {}, }); }); + + it("doesn't select the sharedwithme folder when selecting all", () => { + const state = createInitialState(); + + state.rootItems = fullyLoadedViewItemCollection([topLevelFolder, sharedWithMe]); + state.childrenByParentUID[sharedWithMe.uid] = fullyLoadedViewItemCollection([sharedWithMeDashboard]); + state.childrenByParentUID[topLevelFolder.uid] = fullyLoadedViewItemCollection([childDashboard, childFolder]); + + setAllSelection(state, { type: 'setAllSelection', payload: { isSelected: true, folderUID: undefined } }); + + expect(state.selectedItems.folder[sharedWithMe.uid]).toBeFalsy(); + expect(state.selectedItems.dashboard[sharedWithMeDashboard.uid]).toBeFalsy(); + }); + + it("doesn't select anything when on the sharedwithme folder page", () => { + const state = createInitialState(); + + state.rootItems = fullyLoadedViewItemCollection([topLevelFolder, topLevelDashboard]); + state.childrenByParentUID[topLevelFolder.uid] = fullyLoadedViewItemCollection([childDashboard, childFolder]); + state.childrenByParentUID[childFolder.uid] = fullyLoadedViewItemCollection([grandchildDashboard]); + + setAllSelection(state, { type: 'setAllSelection', payload: { isSelected: true, folderUID: sharedWithMe.uid } }); + + expect(state.selectedItems).toEqual({ + $all: false, + dashboard: {}, + folder: {}, + panel: {}, + }); + }); }); }); diff --git a/public/app/features/browse-dashboards/state/reducers.ts b/public/app/features/browse-dashboards/state/reducers.ts index 0eafcb939be..e506748dcd6 100644 --- a/public/app/features/browse-dashboards/state/reducers.ts +++ b/public/app/features/browse-dashboards/state/reducers.ts @@ -2,6 +2,7 @@ import { PayloadAction } from '@reduxjs/toolkit'; import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types'; +import { isSharedWithMe } from '../components/utils'; import { BrowseDashboardsState } from '../types'; import { fetchNextChildrenPage, refetchChildren } from './actions'; @@ -86,6 +87,11 @@ export function setItemSelectionState( ) { const { item, isSelected } = action.payload; + // UI shouldn't allow it, but also prevent sharedwithme from being selected + if (isSharedWithMe(item.uid)) { + return; + } + // Selecting a folder selects all children, and unselecting a folder deselects all children // so propagate the new selection state to all descendants function markChildren(kind: DashboardViewItemKind, uid: string) { @@ -103,26 +109,24 @@ export function setItemSelectionState( markChildren(item.kind, item.uid); - // If all children of a folder are selected, then the folder is also selected. - // If *any* child of a folder is unselelected, then the folder is alo unselected. - // Reconcile all ancestors to make sure they're in the correct state. - let nextParentUID = item.parentUID; + // If we're unselecting a child, we also need to unselect all ancestors. + if (!isSelected) { + let nextParentUID = item.parentUID; - while (nextParentUID) { - const parent = findItem(state.rootItems?.items ?? [], state.childrenByParentUID, nextParentUID); + while (nextParentUID) { + const parent = findItem(state.rootItems?.items ?? [], state.childrenByParentUID, nextParentUID); - // This case should not happen, but a find can theortically return undefined, and it - // helps limit infinite loops - if (!parent) { - break; - } + // This case should not happen, but a find can theortically return undefined, and it + // helps limit infinite loops + if (!parent) { + break; + } - if (!isSelected) { // A folder cannot be selected if any of it's children are unselected state.selectedItems[parent.kind][parent.uid] = false; - } - nextParentUID = parent.parentUID; + nextParentUID = parent.parentUID; + } } // Check to see if we should mark the header checkbox selected if all root items are selected @@ -135,6 +139,12 @@ export function setAllSelection( ) { const { isSelected, folderUID: folderUIDArg } = action.payload; + // If we're in the folder view for sharedwith me (currently not supported) + // bail and don't select anything + if (folderUIDArg && isSharedWithMe(folderUIDArg)) { + return; + } + state.selectedItems.$all = isSelected; // Search works a bit differently so the state here does different things... @@ -146,6 +156,11 @@ export function setAllSelection( if (isSelected) { // Recursively select the children of the folder in view function selectChildrenOfFolder(folderUID: string | undefined) { + // Don't descend into the sharedwithme folder + if (folderUID && isSharedWithMe(folderUID)) { + return; + } + const collection = folderUID ? state.childrenByParentUID[folderUID] : state.rootItems; // Bail early if the collection isn't found (not loaded yet) @@ -154,6 +169,11 @@ export function setAllSelection( } for (const child of collection.items) { + // Don't traverse into the sharedwithme folder + if (isSharedWithMe(child.uid)) { + continue; + } + state.selectedItems[child.kind][child.uid] = isSelected; if (child.kind !== 'folder') { diff --git a/public/app/features/browse-dashboards/types.ts b/public/app/features/browse-dashboards/types.ts index 8b0c0dc1ed5..db797d0b0f6 100644 --- a/public/app/features/browse-dashboards/types.ts +++ b/public/app/features/browse-dashboards/types.ts @@ -29,7 +29,7 @@ export interface BrowseDashboardsState { export interface UIDashboardViewItem { kind: 'ui'; - uiKind: 'empty-folder' | 'pagination-placeholder'; + uiKind: 'empty-folder' | 'pagination-placeholder' | 'divider'; uid: string; } diff --git a/public/app/features/search/page/components/columns.tsx b/public/app/features/search/page/components/columns.tsx index 34625204aa1..912b02fe029 100644 --- a/public/app/features/search/page/components/columns.tsx +++ b/public/app/features/search/page/components/columns.tsx @@ -178,6 +178,7 @@ export const generateColumns = ( return info ? ( + {info.name} diff --git a/public/app/features/search/service/utils.ts b/public/app/features/search/service/utils.ts index b4cb61756b8..6aeeaefba92 100644 --- a/public/app/features/search/service/utils.ts +++ b/public/app/features/search/service/utils.ts @@ -1,4 +1,6 @@ import { DataFrameView, IconName } from '@grafana/data'; +import { isSharedWithMe } from 'app/features/browse-dashboards/components/utils'; +import { DashboardViewItemWithUIItems } from 'app/features/browse-dashboards/types'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { DashboardViewItem, DashboardViewItemKind } from '../types'; @@ -50,6 +52,33 @@ export function getIconForKind(kind: string, isOpen?: boolean): IconName { return 'question-circle'; } +export function getIconForItem(item: DashboardViewItemWithUIItems, isOpen?: boolean): IconName { + if (item && isSharedWithMe(item.uid)) { + return 'users-alt'; + } else { + return getIconForKind(item.kind, isOpen); + } +} + +// export function getIconForItem(itemOrKind: string | DashboardViewItemWithUIItems, isOpen?: boolean): IconName { +// const kind = typeof itemOrKind === 'string' ? itemOrKind : itemOrKind.kind; +// const item = typeof itemOrKind === 'string' ? undefined : itemOrKind; + +// if (kind === 'dashboard') { +// return 'apps'; +// } + +// if (item && isSharedWithMe(item.uid)) { +// return 'users-alt'; +// } + +// if (kind === 'folder') { +// return isOpen ? 'folder-open' : 'folder'; +// } + +// return 'question-circle'; +// } + function parseKindString(kind: string): DashboardViewItemKind { switch (kind) { case 'dashboard':