Commit fb8a555f by Alex Khomenko Committed by GitHub

Search/virtualize list (#23710)

* Search: Add FixedSizeList for result items

* Search: Move SectionHeader to a separate file

* Search: Add useListHeight hook

* Search: Fix horizontal scrollbar

* Search: Remove custom scrollbar

* Search: Do not fetch dashboard folder on route change

* Search: Update tests

* Search: Remove extra checkbox renders

* Search: Move wrapper ref outside search results

* Search: Fix param type

* Search: Fix merge conflicts

* Search: Virtualize dashboard list

* Search: Update layout

* Search: Pass wrapper to search results

* Search: Update dashboard redirect

* Search: Remove unused css

* Search: Revert config

* Search: Use AutoSizer

* Search: Remove redundant appEvents call

* Search: Use List layout in folder view
parent cf23f15a
......@@ -124,7 +124,7 @@ export class CustomScrollbar extends Component<Props> {
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}
......
......@@ -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;
......@@ -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<Props> = memo(({ navModel, uid }) => {
export const DashboardListPage: FC<Props> = 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<Props> = memo(({ navModel, uid }) => {
);
});
const mapStateToProps: MapStateToProps<Props, {}, StoreState> = state => ({
navModel: getNavModel(state.navIndex, 'manage-dashboards'),
uid: getRouteParams(state.location).uid as string | undefined,
});
const mapStateToProps: MapStateToProps<Props, {}, StoreState> = 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);
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<Props> = ({ onCloseSearch, folder }) => {
export const DashboardSearch: FC<Props> = 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<Props> = ({ onCloseSearch, folder }) => {
</Button>
</div>
);
};
});
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
......
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<Props> = 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<Props> = memo(({ folderId, folderUid }) => {
}
return (
<div className="dashboard-list">
<HorizontalGroup justify="space-between">
<SearchField query={query} onChange={onQueryChange} className={styles.searchField} />
<DashboardActions isEditor={isEditor} canEdit={hasEditPermissionInFolders || canSave} folderId={folderId} />
</HorizontalGroup>
<div className={styles.container}>
<div>
<HorizontalGroup justify="space-between">
<SearchField query={query} onChange={onQueryChange} className={styles.searchField} />
<DashboardActions isEditor={isEditor} canEdit={hasEditPermissionInFolders || canSave} folderId={folderId} />
</HorizontalGroup>
{hasFilters && (
<HorizontalGroup>
<div className="gf-form-inline">
{query.tag.length > 0 && (
<div className="gf-form">
<label className="gf-form-label width-4">Tags</label>
<TagList tags={query.tag} onClick={onTagRemove} />
</div>
)}
{query.starred && (
<div className="gf-form">
<label className="gf-form-label">
<a className="pointer" onClick={onRemoveStarred}>
<Icon name="check" />
Starred
</a>
</label>
</div>
)}
{query.sort && (
{hasFilters && (
<HorizontalGroup>
<div className="gf-form-inline">
{query.tag.length > 0 && (
<div className="gf-form">
<label className="gf-form-label width-4">Tags</label>
<TagList tags={query.tag} onClick={onTagRemove} />
</div>
)}
{query.starred && (
<div className="gf-form">
<label className="gf-form-label">
<a className="pointer" onClick={onRemoveStarred}>
<Icon name="check" />
Starred
</a>
</label>
</div>
)}
{query.sort && (
<div className="gf-form">
<label className="gf-form-label">
<a className="pointer" onClick={() => onSortChange(null)}>
Sort: {query.sort.label}
</a>
</label>
</div>
)}
<div className="gf-form">
<label className="gf-form-label">
<a className="pointer" onClick={() => onSortChange(null)}>
Sort: {query.sort.label}
<a
className="pointer"
onClick={() => {
onClearFilters();
setLayout(SearchLayout.Folders);
}}
>
<Icon name="times" />
&nbsp;Clear
</a>
</label>
</div>
)}
<div className="gf-form">
<label className="gf-form-label">
<a
className="pointer"
onClick={() => {
onClearFilters();
setLayout(SearchLayout.Folders);
}}
>
<Icon name="times" />
&nbsp;Clear
</a>
</label>
</div>
</div>
</HorizontalGroup>
)}
</HorizontalGroup>
)}
</div>
<div className="search-results">
<div className={styles.results}>
{results?.length > 0 && (
<SearchResultsFilter
allChecked={allChecked}
......@@ -187,6 +190,13 @@ export const ManageDashboards: FC<Props> = 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};
`,
};
});
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<Props> = ({ item, editable, onToggleChecked, onTagSelected }) => {
export const SearchItem: FC<Props> = ({ item, editable, onToggleChecked, onTagSelected, style }) => {
const theme = useTheme();
const styles = getResultsItemStyles(theme);
const inputEl = useRef<HTMLInputElement>(null);
......@@ -60,6 +61,7 @@ export const SearchItem: FC<Props> = ({ item, editable, onToggleChecked, onTagSe
return (
<li
style={style}
aria-label={selectors.dashboards(item.title)}
className={cx(styles.wrapper, { [styles.selected]: item.selected })}
>
......@@ -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;
......
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<HTMLDivElement | null>;
}
export const SearchResults: FC<Props> = ({
......@@ -25,19 +28,53 @@ export const SearchResults: FC<Props> = ({
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 (
<ul className={styles.wrapper}>
{results.map(section => {
return (
<li aria-label="Search section" className={styles.section} key={section.title}>
<SectionHeader onSectionClick={onToggleSection} {...{ onToggleChecked, editable, section }} />
<ul aria-label="Search items">
{section.expanded && section.items.map(item => <SearchItem key={item.id} {...itemProps} item={item} />)}
</ul>
</li>
);
})}
</ul>
);
};
return section.items.map(item => (
<SearchItem key={item.id} {...{ item, editable, onToggleChecked, onTagSelected }} />
));
const items = getVisibleItems(results);
const renderDashboards = () => {
return (
<AutoSizer disableWidth>
{({ height }) => (
<FixedSizeList
aria-label="Search items"
className={styles.wrapper}
innerElementType="ul"
itemSize={ITEM_HEIGHT}
height={height}
itemCount={items.length}
width="100%"
>
{({ index, style }) => {
const item = items[index];
return <SearchItem key={item.id} {...itemProps} item={item} style={style} />;
}}
</FixedSizeList>
)}
</AutoSizer>
);
};
if (loading) {
......@@ -45,28 +82,15 @@ export const SearchResults: FC<Props> = ({
} else if (!results || !results.length) {
return <h6>No dashboards matching your query were found.</h6>;
}
return (
<div className="search-results-container">
<ul className={styles.wrapper}>
{results.map(section =>
layout !== SearchLayout.List ? (
<li aria-label="Search section" className={styles.section} key={section.title}>
<SectionHeader onSectionClick={onToggleSection} {...{ onToggleChecked, editable, section, layout }} />
<ul aria-label="Search items" className={styles.wrapper}>
{renderItems(section)}
</ul>
</li>
) : (
renderItems(section)
)
)}
</ul>
<div className={cx('results-container', styles.resultsContainer)}>
{layout !== SearchLayout.List ? renderFolders() : renderDashboards()}
</div>
);
};
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<SectionHeaderProps> = ({ 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 ? (
<div className={styles.wrapper} onClick={onSectionExpand}>
<SearchCheckbox editable={editable} checked={section.checked} onClick={onSectionChecked} />
<Icon className={styles.icon} name={section.icon as IconName} />
<span className={styles.text}>{section.title}</span>
{section.url && (
<a
href={section.url}
className={styles.link}
onClick={() => appEvents.emit(CoreEvents.hideDashSearch, { target: 'search-item' })}
>
<IconButton name="cog" className={styles.button} />
</a>
)}
<Icon name={section.expanded ? 'angle-down' : 'angle-right'} />
</div>
) : (
<div className={styles.wrapper} />
);
};
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%;
`,
};
});
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<SectionHeaderProps> = ({
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 ? (
<div className={styles.wrapper} onClick={onSectionExpand}>
<SearchCheckbox editable={editable} checked={section.checked} onClick={onSectionChecked} />
<Icon className={styles.icon} name={section.icon as IconName} />
<span className={styles.text}>{section.title}</span>
{section.url && (
<a href={section.url} className={styles.link}>
<IconButton name="cog" className={styles.button} />
</a>
)}
<Icon name={section.expanded ? 'angle-down' : 'angle-right'} />
</div>
) : (
<div className={styles.wrapper} />
);
};
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;
`,
};
});
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' };
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<string>(layoutOptions[0].value);
export const useSearchLayout = (query: DashboardQuery, defaultLayout = SearchLayout.Folders) => {
const [layout, setLayout] = useState<string>(defaultLayout);
useEffect(() => {
if (query.sort) {
......
......@@ -29,6 +29,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
*/
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment