diff --git a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx index 7bc33f860ba..8e79a21fbbd 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx +++ b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx @@ -124,7 +124,7 @@ export class CustomScrollbar extends Component { autoHideTimeout={autoHideTimeout} hideTracksWhenNotNeeded={hideTracksWhenNotNeeded} // These autoHeightMin & autoHeightMax options affect firefox and chrome differently. - // Before these where set to inhert but that caused problems with cut of legends in firefox + // Before these where set to inherit but that caused problems with cut of legends in firefox autoHeightMax={autoHeightMax} autoHeightMin={autoHeightMin} renderTrackHorizontal={this.renderTrackHorizontal} diff --git a/public/app/core/selectors/location.ts b/public/app/core/selectors/location.ts index 21c08fc13c8..41f2f0f950e 100644 --- a/public/app/core/selectors/location.ts +++ b/public/app/core/selectors/location.ts @@ -4,3 +4,4 @@ export const getRouteParamsId = (state: LocationState) => state.routeParams.id; export const getRouteParamsPage = (state: LocationState) => state.routeParams.page; export const getRouteParams = (state: LocationState) => state.routeParams; export const getLocationQuery = (state: LocationState) => state.query; +export const getUrl = (state: LocationState) => state.url; diff --git a/public/app/features/search/components/DashboardListPage.tsx b/public/app/features/search/components/DashboardListPage.tsx index b5613221162..5aa01ec6012 100644 --- a/public/app/features/search/components/DashboardListPage.tsx +++ b/public/app/features/search/components/DashboardListPage.tsx @@ -5,7 +5,7 @@ import { NavModel, locationUtil } from '@grafana/data'; import { getLocationSrv } from '@grafana/runtime'; import { StoreState } from 'app/types'; import { getNavModel } from 'app/core/selectors/navModel'; -import { getRouteParams } from 'app/core/selectors/location'; +import { getRouteParams, getUrl } from 'app/core/selectors/location'; import Page from 'app/core/components/Page/Page'; import { loadFolderPage } from '../loaders'; import { ManageDashboards } from './ManageDashboards'; @@ -13,18 +13,19 @@ import { ManageDashboards } from './ManageDashboards'; interface Props { navModel: NavModel; uid?: string; + url: string; } -export const DashboardListPage: FC = memo(({ navModel, uid }) => { +export const DashboardListPage: FC = memo(({ navModel, uid, url }) => { const { loading, value } = useAsync(() => { - if (!uid) { + if (!uid || !url.startsWith('/dashboards')) { return Promise.resolve({ pageNavModel: navModel }); } - return loadFolderPage(uid, 'manage-folder-dashboards').then(({ folder, model }) => { - const url = locationUtil.stripBaseFromUrl(folder.url); + return loadFolderPage(uid!, 'manage-folder-dashboards').then(({ folder, model }) => { + const path = locationUtil.stripBaseFromUrl(folder.url); - if (url !== location.pathname) { - getLocationSrv().update({ path: url }); + if (path !== location.pathname) { + getLocationSrv().update({ path }); } return { id: folder.id, pageNavModel: { ...navModel, ...model } }; @@ -40,9 +41,12 @@ export const DashboardListPage: FC = memo(({ navModel, uid }) => { ); }); -const mapStateToProps: MapStateToProps = state => ({ - navModel: getNavModel(state.navIndex, 'manage-dashboards'), - uid: getRouteParams(state.location).uid as string | undefined, -}); +const mapStateToProps: MapStateToProps = state => { + return { + navModel: getNavModel(state.navIndex, 'manage-dashboards'), + uid: getRouteParams(state.location).uid as string | undefined, + url: getUrl(state.location), + }; +}; export default connect(mapStateToProps)(DashboardListPage); diff --git a/public/app/features/search/components/DashboardSearch.tsx b/public/app/features/search/components/DashboardSearch.tsx index 219997c3dec..1f4e20abbf7 100644 --- a/public/app/features/search/components/DashboardSearch.tsx +++ b/public/app/features/search/components/DashboardSearch.tsx @@ -1,4 +1,4 @@ -import React, { FC } from 'react'; +import React, { FC, memo } from 'react'; import { css } from 'emotion'; import { useTheme, CustomScrollbar, stylesFactory, Button } from '@grafana/ui'; import { GrafanaTheme } from '@grafana/data'; @@ -14,7 +14,7 @@ export interface Props { folder?: string; } -export const DashboardSearch: FC = ({ onCloseSearch, folder }) => { +export const DashboardSearch: FC = memo(({ onCloseSearch, folder }) => { const payload = folder ? { query: `folder:${folder}` } : {}; const { query, onQueryChange, onTagFilterChange, onTagAdd, onSortChange } = useSearchQuery(payload); const { results, loading, onToggleSection, onKeyDown } = useDashboardSearch(query, onCloseSearch); @@ -74,7 +74,7 @@ export const DashboardSearch: FC = ({ onCloseSearch, folder }) => { ); -}; +}); const getStyles = stylesFactory((theme: GrafanaTheme) => { return { diff --git a/public/app/features/search/components/ManageDashboards.tsx b/public/app/features/search/components/ManageDashboards.tsx index fa6a0fea650..22f61d4d853 100644 --- a/public/app/features/search/components/ManageDashboards.tsx +++ b/public/app/features/search/components/ManageDashboards.tsx @@ -1,6 +1,6 @@ -import React, { FC, useState, memo } from 'react'; +import React, { FC, memo, useState } from 'react'; import { css } from 'emotion'; -import { Icon, TagList, HorizontalGroup, stylesFactory, useTheme } from '@grafana/ui'; +import { HorizontalGroup, Icon, stylesFactory, TagList, useTheme } from '@grafana/ui'; import { GrafanaTheme } from '@grafana/data'; import { contextSrv } from 'app/core/services/context_srv'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; @@ -56,7 +56,8 @@ export const ManageDashboards: FC = memo(({ folderId, folderUid }) => { onMoveItems, } = useManageDashboards(query, { hasEditPermissionInFolders: contextSrv.hasEditPermissionInFolders }, folderUid); - const { layout, setLayout } = useSearchLayout(query); + const defaultLayout = folderId ? SearchLayout.List : SearchLayout.Folders; + const { layout, setLayout } = useSearchLayout(query, defaultLayout); const onMoveTo = () => { setIsMoveModalOpen(true); @@ -89,59 +90,61 @@ export const ManageDashboards: FC = memo(({ folderId, folderUid }) => { } return ( -
- - - - - - {hasFilters && ( - -
- {query.tag.length > 0 && ( -
- - -
- )} - {query.starred && ( - - )} - {query.sort && ( - - )} - -
+
+
+ + + - )} -
+ {hasFilters && ( + +
+ {query.tag.length > 0 && ( +
+ + +
+ )} + {query.starred && ( + + )} + {query.sort && ( + + )} + +
+
+ )} +
+ +
{results?.length > 0 && ( = memo(({ folderId, folderUid }) => { const getStyles = stylesFactory((theme: GrafanaTheme) => { return { + container: css` + height: 100%; + + .results-container { + padding: 5px 0 0; + } + `, searchField: css` height: auto; border-bottom: none; @@ -196,5 +206,12 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => { width: 400px; } `, + results: css` + display: flex; + flex-direction: column; + flex: 1; + height: 100%; + margin-top: ${theme.spacing.xl}; + `, }; }); diff --git a/public/app/features/search/components/SearchItem.tsx b/public/app/features/search/components/SearchItem.tsx index 29f27f42bac..b53ce369e3f 100644 --- a/public/app/features/search/components/SearchItem.tsx +++ b/public/app/features/search/components/SearchItem.tsx @@ -1,4 +1,4 @@ -import React, { FC, useCallback, useRef, useEffect } from 'react'; +import React, { FC, useCallback, useRef, useEffect, CSSProperties } from 'react'; import { css, cx } from 'emotion'; import { GrafanaTheme } from '@grafana/data'; import { e2e } from '@grafana/e2e'; @@ -12,11 +12,12 @@ export interface Props { editable?: boolean; onTagSelected: (name: string) => any; onToggleChecked?: OnToggleChecked; + style?: CSSProperties; } const { selectors } = e2e.pages.Dashboards; -export const SearchItem: FC = ({ item, editable, onToggleChecked, onTagSelected }) => { +export const SearchItem: FC = ({ item, editable, onToggleChecked, onTagSelected, style }) => { const theme = useTheme(); const styles = getResultsItemStyles(theme); const inputEl = useRef(null); @@ -60,6 +61,7 @@ export const SearchItem: FC = ({ item, editable, onToggleChecked, onTagSe return (
  • @@ -83,7 +85,6 @@ const getResultsItemStyles = stylesFactory((theme: GrafanaTheme) => ({ ${styleMixins.listItem(theme)}; display: flex; align-items: center; - margin: ${theme.spacing.xxs}; padding: 0 ${theme.spacing.sm}; min-height: 37px; diff --git a/public/app/features/search/components/SearchResults.tsx b/public/app/features/search/components/SearchResults.tsx index e7a3ccd8a53..b4829ed64c0 100644 --- a/public/app/features/search/components/SearchResults.tsx +++ b/public/app/features/search/components/SearchResults.tsx @@ -1,12 +1,14 @@ -import React, { FC } from 'react'; +import React, { FC, MutableRefObject } from 'react'; import { css, cx } from 'emotion'; +import { FixedSizeList } from 'react-window'; +import AutoSizer from 'react-virtualized-auto-sizer'; import { GrafanaTheme } from '@grafana/data'; -import { Icon, stylesFactory, useTheme, IconName, IconButton, Spinner } from '@grafana/ui'; -import appEvents from 'app/core/app_events'; -import { CoreEvents } from 'app/types'; +import { stylesFactory, useTheme, Spinner } from '@grafana/ui'; import { DashboardSection, OnToggleChecked, SearchLayout } from '../types'; +import { getVisibleItems } from '../utils'; +import { ITEM_HEIGHT } from '../constants'; import { SearchItem } from './SearchItem'; -import { SearchCheckbox } from './SearchCheckbox'; +import { SectionHeader } from './SectionHeader'; export interface Props { editable?: boolean; @@ -14,8 +16,9 @@ export interface Props { onTagSelected: (name: string) => any; onToggleChecked?: OnToggleChecked; onToggleSection: (section: DashboardSection) => void; - results: DashboardSection[] | undefined; + results: DashboardSection[]; layout?: string; + wrapperRef?: MutableRefObject; } export const SearchResults: FC = ({ @@ -25,19 +28,53 @@ export const SearchResults: FC = ({ onToggleChecked, onToggleSection, results, + wrapperRef, layout, }) => { const theme = useTheme(); const styles = getSectionStyles(theme); + const itemProps = { editable, onToggleChecked, onTagSelected }; - const renderItems = (section: DashboardSection) => { - if (!section.expanded && layout !== SearchLayout.List) { - return null; - } + const renderFolders = () => { + return ( +
      + {results.map(section => { + return ( +
    • + +
        + {section.expanded && section.items.map(item => )} +
      +
    • + ); + })} +
    + ); + }; - return section.items.map(item => ( - - )); + const items = getVisibleItems(results); + + const renderDashboards = () => { + return ( + + {({ height }) => ( + + {({ index, style }) => { + const item = items[index]; + return ; + }} + + )} + + ); }; if (loading) { @@ -45,28 +82,15 @@ export const SearchResults: FC = ({ } else if (!results || !results.length) { return
    No dashboards matching your query were found.
    ; } - return ( -
    -
      - {results.map(section => - layout !== SearchLayout.List ? ( -
    • - -
        - {renderItems(section)} -
      -
    • - ) : ( - renderItems(section) - ) - )} -
    +
    + {layout !== SearchLayout.List ? renderFolders() : renderDashboards()}
    ); }; const getSectionStyles = stylesFactory((theme: GrafanaTheme) => { + const { xs, sm, md } = theme.spacing; return { wrapper: css` list-style: none; @@ -74,7 +98,7 @@ const getSectionStyles = stylesFactory((theme: GrafanaTheme) => { section: css` background: ${theme.colors.panelBg}; border-bottom: solid 1px ${theme.isLight ? theme.palette.gray95 : theme.palette.gray25}; - padding: 0px 4px 4px 4px; + padding: 0px ${xs} ${xs}; margin-bottom: 3px; `, spinner: css` @@ -83,94 +107,15 @@ const getSectionStyles = stylesFactory((theme: GrafanaTheme) => { align-items: center; min-height: 100px; `, - }; -}); - -interface SectionHeaderProps { - editable?: boolean; - onSectionClick: (section: DashboardSection) => void; - onToggleChecked?: OnToggleChecked; - section: DashboardSection; -} - -const SectionHeader: FC = ({ section, onSectionClick, onToggleChecked, editable = false }) => { - const theme = useTheme(); - const styles = getSectionHeaderStyles(theme, section.selected); - - const onSectionExpand = () => { - onSectionClick(section); - }; - - const onSectionChecked = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (onToggleChecked) { - onToggleChecked(section); - } - }; - - return !section.hideHeader ? ( -
    - - - - {section.title} - {section.url && ( - appEvents.emit(CoreEvents.hideDashSearch, { target: 'search-item' })} - > - - - )} - -
    - ) : ( -
    - ); -}; - -const getSectionHeaderStyles = stylesFactory((theme: GrafanaTheme, selected = false) => { - const { sm, xs } = theme.spacing; - return { - wrapper: cx( - css` - display: flex; - align-items: center; - font-size: ${theme.typography.size.base}; - padding: ${sm} ${xs} ${xs}; - color: ${theme.colors.textWeak}; - - &:hover, - &.selected { - color: ${theme.colors.text}; - } - - &:hover { - a { - opacity: 1; - } - } - `, - 'pointer', - { selected } - ), - icon: css` - width: 43px; - `, - text: css` - flex-grow: 1; - line-height: 24px; - `, - link: css` - padding: 2px 10px 0; - color: ${theme.colors.textWeak}; - opacity: 0; - transition: opacity 150ms ease-in-out; - `, - button: css` - margin-top: 3px; + resultsContainer: css` + padding: ${sm}; + position: relative; + flex-grow: 10; + margin-bottom: ${md}; + background: ${theme.palette.gray10}; + border: 1px solid ${theme.palette.gray15}; + border-radius: 3px; + height: 100%; `, }; }); diff --git a/public/app/features/search/components/SectionHeader.tsx b/public/app/features/search/components/SectionHeader.tsx new file mode 100644 index 00000000000..c5013160fe8 --- /dev/null +++ b/public/app/features/search/components/SectionHeader.tsx @@ -0,0 +1,99 @@ +import React, { FC, useCallback } from 'react'; +import { css, cx } from 'emotion'; +import { GrafanaTheme } from '@grafana/data'; +import { Icon, IconButton, IconName, stylesFactory, useTheme } from '@grafana/ui'; +import { DashboardSection, OnToggleChecked } from '../types'; +import { SearchCheckbox } from './SearchCheckbox'; + +interface SectionHeaderProps { + editable?: boolean; + onSectionClick: (section: DashboardSection) => void; + onToggleChecked?: OnToggleChecked; + section: DashboardSection; +} + +export const SectionHeader: FC = ({ + section, + onSectionClick, + onToggleChecked, + editable = false, +}) => { + const theme = useTheme(); + const styles = getSectionHeaderStyles(theme, section.selected); + + const onSectionExpand = () => { + onSectionClick(section); + }; + + const onSectionChecked = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (onToggleChecked) { + onToggleChecked(section); + } + }, + [section] + ); + + return !section.hideHeader ? ( +
    + + + + {section.title} + {section.url && ( + + + + )} + +
    + ) : ( +
    + ); +}; + +const getSectionHeaderStyles = stylesFactory((theme: GrafanaTheme, selected = false) => { + const { sm, xs } = theme.spacing; + return { + wrapper: cx( + css` + display: flex; + align-items: center; + font-size: ${theme.typography.size.base}; + padding: ${sm} ${xs} ${xs}; + color: ${theme.colors.textWeak}; + + &:hover, + &.selected { + color: ${theme.colors.text}; + } + + &:hover { + a { + opacity: 1; + } + } + `, + 'pointer', + { selected } + ), + icon: css` + width: 43px; + `, + text: css` + flex-grow: 1; + line-height: 24px; + `, + link: css` + padding: 2px 10px 0; + color: ${theme.colors.textWeak}; + opacity: 0; + transition: opacity 150ms ease-in-out; + `, + button: css` + margin-top: 3px; + `, + }; +}); diff --git a/public/app/features/search/constants.ts b/public/app/features/search/constants.ts index 87a99520dab..7a68dffced8 100644 --- a/public/app/features/search/constants.ts +++ b/public/app/features/search/constants.ts @@ -1,2 +1,4 @@ export const NO_ID_SECTIONS = ['Recent', 'Starred']; +// Height of the search result item +export const ITEM_HEIGHT = 40; export const DEFAULT_SORT = { label: 'A-Z', value: 'alpha-asc' }; diff --git a/public/app/features/search/hooks/useSearchLayout.ts b/public/app/features/search/hooks/useSearchLayout.ts index 6b01145d483..68a9a6b5187 100644 --- a/public/app/features/search/hooks/useSearchLayout.ts +++ b/public/app/features/search/hooks/useSearchLayout.ts @@ -1,13 +1,13 @@ import { useEffect, useState } from 'react'; -import { SearchLayout } from '../types'; +import { DashboardQuery, SearchLayout } from '../types'; export const layoutOptions = [ { label: 'Folders', value: SearchLayout.Folders, icon: 'folder' }, { label: 'List', value: SearchLayout.List, icon: 'list-ul' }, ]; -export const useSearchLayout = (query: any) => { - const [layout, setLayout] = useState(layoutOptions[0].value); +export const useSearchLayout = (query: DashboardQuery, defaultLayout = SearchLayout.Folders) => { + const [layout, setLayout] = useState(defaultLayout); useEffect(() => { if (query.sort) { diff --git a/public/app/features/search/utils.ts b/public/app/features/search/utils.ts index 1538c84cacc..24801306bdb 100644 --- a/public/app/features/search/utils.ts +++ b/public/app/features/search/utils.ts @@ -28,6 +28,18 @@ export const getFlattenedSections = (sections: DashboardSection[]): string[] => }); }; +/** + * Get all items for currently expanded sections + * @param sections + */ +export const getVisibleItems = (sections: DashboardSection[]) => { + return sections.flatMap(section => { + if (section.expanded) { + return section.items; + } + return []; + }); +}; /** * Since Recent and Starred folders don't have id, title field is used as id * @param title - title field of the section