Commit ed7ad8f6 by Hugo Häggmark Committed by GitHub

Feat: Suggestion list in Explore is virtualized (#16342)

* Wip: virtualize suggestions list

* Refactor: Separate components to different files

* Refactor: Made TypeaheadItem a FunctionComponent using emotion

* Refactor: Use theme to calculate width instead of hardcoded values

* Refactor: Calculate list height and item size

* Style: Adds labels to emotion classes

* Refactor: Flattens CompletionItems to one list

* Chore: merge yarn.lock

* Refactor: Adds documentation popup on the side

* Refactor: Makes position of TypeaheadInfo dynamic

* Refactor: Calculations moved to separate file
parent f0eddcd8
......@@ -36,6 +36,7 @@
"@types/react-select": "^2.0.4",
"@types/react-transition-group": "^2.0.15",
"@types/react-virtualized": "^9.18.12",
"@types/react-window": "1.7.0",
"angular-mocks": "1.6.6",
"autoprefixer": "^9.4.10",
"axios": "^0.18.0",
......@@ -180,6 +181,7 @@
"angular-sanitize": "1.6.6",
"baron": "^3.0.3",
"brace": "^0.10.0",
"calculate-size": "1.1.1",
"classnames": "^2.2.6",
"clipboard": "^2.0.4",
"d3": "^4.11.0",
......@@ -207,6 +209,7 @@
"react-table": "^6.8.6",
"react-transition-group": "^2.2.1",
"react-virtualized": "^9.21.0",
"react-window": "1.7.1",
"redux": "^4.0.0",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.3.0",
......
import { ThemeContext, withTheme } from './ThemeContext';
import { getTheme, mockTheme } from './getTheme';
import { selectThemeVariant } from './selectThemeVariant';
export { ThemeContext, withTheme, mockTheme, getTheme };
export { ThemeContext, withTheme, mockTheme, getTheme, selectThemeVariant };
......@@ -15,7 +15,7 @@ import { CompletionItem, CompletionItemGroup, TypeaheadOutput } from 'app/types/
import ClearPlugin from './slate-plugins/clear';
import NewlinePlugin from './slate-plugins/newline';
import Typeahead from './Typeahead';
import { TypeaheadWithTheme } from './Typeahead';
import { makeFragment, makeValue } from './Value';
import PlaceholdersBuffer from './PlaceholdersBuffer';
......@@ -359,7 +359,11 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
if (this.menuEl) {
// Select next suggestion
event.preventDefault();
this.setState({ typeaheadIndex: typeaheadIndex + 1 });
const itemsCount =
this.state.suggestions.length > 0
? this.state.suggestions.reduce((totalCount, current) => totalCount + current.items.length, 0)
: 0;
this.setState({ typeaheadIndex: Math.min(itemsCount - 1, typeaheadIndex + 1) });
}
break;
}
......@@ -461,12 +465,13 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
// Create typeahead in DOM root so we can later position it absolutely
return (
<Portal origin={portalOrigin}>
<Typeahead
<TypeaheadWithTheme
menuRef={this.menuRef}
selectedItem={selectedItem}
onClickItem={this.onClickMenu}
prefix={typeaheadPrefix}
groupedItems={suggestions}
typeaheadIndex={typeaheadIndex}
/>
</Portal>
);
......
import React from 'react';
import Highlighter from 'react-highlight-words';
import React, { createRef } from 'react';
// @ts-ignore
import _ from 'lodash';
import { FixedSizeList } from 'react-window';
import { Themeable, withTheme } from '@grafana/ui';
import { CompletionItem, CompletionItemGroup } from 'app/types/explore';
import { TypeaheadItem } from './TypeaheadItem';
import { TypeaheadInfo } from './TypeaheadInfo';
import { flattenGroupItems, calculateLongestLabel, calculateListSizes } from './utils/typeahead';
function scrollIntoView(el: HTMLElement) {
if (!el || !el.offsetParent) {
return;
}
const container = el.offsetParent as HTMLElement;
if (el.offsetTop > container.scrollTop + container.offsetHeight || el.offsetTop < container.scrollTop) {
container.scrollTop = el.offsetTop - container.offsetTop;
}
interface Props extends Themeable {
groupedItems: CompletionItemGroup[];
menuRef: any;
selectedItem: CompletionItem | null;
onClickItem: (suggestion: CompletionItem) => void;
prefix?: string;
typeaheadIndex: number;
}
interface TypeaheadItemProps {
isSelected: boolean;
item: CompletionItem;
onClickItem: (Suggestion) => void;
prefix?: string;
interface State {
allItems: CompletionItem[];
listWidth: number;
listHeight: number;
itemHeight: number;
}
class TypeaheadItem extends React.PureComponent<TypeaheadItemProps> {
el: HTMLElement;
export class Typeahead extends React.PureComponent<Props, State> {
listRef: any = createRef();
documentationRef: any = createRef();
componentDidUpdate(prevProps) {
if (this.props.isSelected && !prevProps.isSelected) {
requestAnimationFrame(() => {
scrollIntoView(this.el);
});
}
constructor(props: Props) {
super(props);
const allItems = flattenGroupItems(props.groupedItems);
const longestLabel = calculateLongestLabel(allItems);
const { listWidth, listHeight, itemHeight } = calculateListSizes(props.theme, allItems, longestLabel);
this.state = { listWidth, listHeight, itemHeight, allItems };
}
getRef = el => {
this.el = el;
componentDidUpdate = (prevProps: Readonly<Props>) => {
if (prevProps.typeaheadIndex !== this.props.typeaheadIndex && this.listRef && this.listRef.current) {
if (prevProps.typeaheadIndex === 1 && this.props.typeaheadIndex === 0) {
this.listRef.current.scrollToItem(0); // special case for handling the first group label
this.refreshDocumentation();
return;
}
const index = this.state.allItems.findIndex(item => item === this.props.selectedItem);
this.listRef.current.scrollToItem(index);
this.refreshDocumentation();
}
if (_.isEqual(prevProps.groupedItems, this.props.groupedItems) === false) {
const allItems = flattenGroupItems(this.props.groupedItems);
const longestLabel = calculateLongestLabel(allItems);
const { listWidth, listHeight, itemHeight } = calculateListSizes(this.props.theme, allItems, longestLabel);
this.setState({ listWidth, listHeight, itemHeight, allItems }, () => this.refreshDocumentation());
}
};
onClick = () => {
this.props.onClickItem(this.props.item);
refreshDocumentation = () => {
if (!this.documentationRef.current) {
return;
}
const index = this.state.allItems.findIndex(item => item === this.props.selectedItem);
const item = this.state.allItems[index];
if (item) {
this.documentationRef.current.refresh(item);
}
};
render() {
const { isSelected, item, prefix } = this.props;
const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item';
const label = item.label || '';
return (
<li ref={this.getRef} className={className} onClick={this.onClick}>
<Highlighter textToHighlight={label} searchWords={[prefix]} highlightClassName="typeahead-match" />
{item.documentation && isSelected ? <div className="typeahead-item-hint">{item.documentation}</div> : null}
</li>
);
}
}
onMouseEnter = (item: CompletionItem) => {
this.documentationRef.current.refresh(item);
};
interface TypeaheadGroupProps {
items: CompletionItem[];
label: string;
onClickItem: (suggestion: CompletionItem) => void;
selected: CompletionItem;
prefix?: string;
}
onMouseLeave = () => {
this.documentationRef.current.hide();
};
class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps> {
render() {
const { items, label, selected, onClickItem, prefix } = this.props;
const { menuRef, selectedItem, onClickItem, prefix, theme } = this.props;
const { listWidth, listHeight, itemHeight, allItems } = this.state;
return (
<li className="typeahead-group">
<div className="typeahead-group__title">{label}</div>
<ul className="typeahead-group__list">
{items.map(item => {
<ul className="typeahead" ref={menuRef}>
<TypeaheadInfo
ref={this.documentationRef}
width={listWidth}
height={listHeight}
theme={theme}
initialItem={selectedItem}
/>
<FixedSizeList
ref={this.listRef}
itemCount={allItems.length}
itemSize={itemHeight}
itemKey={index => {
const item = allItems && allItems[index];
const key = item ? `${index}-${item.label}` : `${index}`;
return key;
}}
width={listWidth}
height={listHeight}
>
{({ index, style }) => {
const item = allItems && allItems[index];
if (!item) {
return null;
}
return (
<TypeaheadItem
key={item.label}
onClickItem={onClickItem}
isSelected={selected === item}
isSelected={selectedItem === item}
item={item}
prefix={prefix}
style={style}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
/>
);
})}
</ul>
</li>
);
}
}
interface TypeaheadProps {
groupedItems: CompletionItemGroup[];
menuRef: any;
selectedItem: CompletionItem | null;
onClickItem: (Suggestion) => void;
prefix?: string;
}
class Typeahead extends React.PureComponent<TypeaheadProps> {
render() {
const { groupedItems, menuRef, selectedItem, onClickItem, prefix } = this.props;
return (
<ul className="typeahead" ref={menuRef}>
{groupedItems.map(g => (
<TypeaheadGroup key={g.label} onClickItem={onClickItem} prefix={prefix} selected={selectedItem} {...g} />
))}
}}
</FixedSizeList>
</ul>
);
}
}
export default Typeahead;
export const TypeaheadWithTheme = withTheme(Typeahead);
import React, { PureComponent } from 'react';
import { Themeable, selectThemeVariant } from '@grafana/ui';
import { css, cx } from 'emotion';
import { CompletionItem } from 'app/types/explore';
interface Props extends Themeable {
initialItem: CompletionItem;
width: number;
height: number;
}
interface State {
item: CompletionItem;
}
export class TypeaheadInfo extends PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = { item: props.initialItem };
}
getStyles = (visible: boolean) => {
const { width, height, theme } = this.props;
const selection = window.getSelection();
const node = selection.anchorNode;
if (!node) {
return {};
}
// Read from DOM
const rect = node.parentElement.getBoundingClientRect();
const scrollX = window.scrollX;
const scrollY = window.scrollY;
const left = `${rect.left + scrollX + width + parseInt(theme.spacing.xs, 10)}px`;
const top = `${rect.top + scrollY + rect.height + 6}px`;
return {
typeaheadItem: css`
label: type-ahead-item;
z-index: auto;
padding: ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.md};
border-radius: ${theme.border.radius.md};
border: ${selectThemeVariant(
{ light: `solid 1px ${theme.colors.gray5}`, dark: `solid 1px ${theme.colors.dark1}` },
theme.type
)};
overflow-y: scroll;
overflow-x: hidden;
outline: none;
background: ${selectThemeVariant({ light: theme.colors.white, dark: theme.colors.dark4 }, theme.type)};
color: ${theme.colors.text};
box-shadow: ${selectThemeVariant(
{ light: `0 5px 10px 0 ${theme.colors.gray5}`, dark: `0 5px 10px 0 ${theme.colors.black}` },
theme.type
)};
visibility: ${visible === true ? 'visible' : 'hidden'};
left: ${left};
top: ${top};
width: 250px;
height: ${height + parseInt(theme.spacing.xxs, 10)}px;
position: fixed;
`,
};
};
refresh = (item: CompletionItem) => {
this.setState({ item });
};
hide = () => {
this.setState({ item: null });
};
render() {
const { item } = this.state;
const visible = item && !!item.documentation;
const label = item ? item.label : '';
const documentation = item && item.documentation ? item.documentation : '';
const styles = this.getStyles(visible);
return (
<div className={cx([styles.typeaheadItem])}>
<b>{label}</b>
<hr />
<span>{documentation}</span>
</div>
);
}
}
import React, { FunctionComponent, useContext } from 'react';
// @ts-ignore
import Highlighter from 'react-highlight-words';
import { css, cx } from 'emotion';
import { GrafanaTheme, ThemeContext, selectThemeVariant } from '@grafana/ui';
import { CompletionItem } from 'app/types/explore';
export const GROUP_TITLE_KIND = 'GroupTitle';
export const isGroupTitle = (item: CompletionItem) => {
return item.kind && item.kind === GROUP_TITLE_KIND ? true : false;
};
interface Props {
isSelected: boolean;
item: CompletionItem;
onClickItem: (suggestion: CompletionItem) => void;
prefix?: string;
style: any;
onMouseEnter: (item: CompletionItem) => void;
onMouseLeave: (item: CompletionItem) => void;
}
const getStyles = (theme: GrafanaTheme) => ({
typeaheadItem: css`
label: type-ahead-item;
height: auto;
font-family: ${theme.typography.fontFamily.monospace};
padding: ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.md};
font-size: ${theme.typography.size.sm};
text-overflow: ellipsis;
overflow: hidden;
z-index: 1;
display: block;
white-space: nowrap;
cursor: pointer;
transition: color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), border-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
background 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), padding 0.15s cubic-bezier(0.645, 0.045, 0.355, 1);
`,
typeaheadItemSelected: css`
label: type-ahead-item-selected;
background-color: ${selectThemeVariant({ light: theme.colors.gray6, dark: theme.colors.dark9 }, theme.type)};
`,
typeaheadItemMatch: css`
label: type-ahead-item-match;
color: ${theme.colors.yellow};
border-bottom: 1px solid ${theme.colors.yellow};
padding: inherit;
background: inherit;
`,
typeaheadItemGroupTitle: css`
label: type-ahead-item-group-title;
color: ${theme.colors.textWeak};
font-size: ${theme.typography.size.sm};
line-height: ${theme.typography.lineHeight.lg};
padding: ${theme.spacing.sm};
`,
});
export const TypeaheadItem: FunctionComponent<Props> = (props: Props) => {
const theme = useContext(ThemeContext);
const styles = getStyles(theme);
const { isSelected, item, prefix, style, onClickItem } = props;
const onClick = () => onClickItem(item);
const onMouseEnter = () => props.onMouseEnter(item);
const onMouseLeave = () => props.onMouseLeave(item);
const className = isSelected ? cx([styles.typeaheadItem, styles.typeaheadItemSelected]) : cx([styles.typeaheadItem]);
const highlightClassName = cx([styles.typeaheadItemMatch]);
const itemGroupTitleClassName = cx([styles.typeaheadItemGroupTitle]);
const label = item.label || '';
if (isGroupTitle(item)) {
return (
<li className={itemGroupTitleClassName} style={style}>
<span>{label}</span>
</li>
);
}
return (
<li className={className} onClick={onClick} style={style} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<Highlighter textToHighlight={label} searchWords={[prefix]} highlightClassName={highlightClassName} />
</li>
);
};
import { GrafanaTheme } from '@grafana/ui';
import { default as calculateSize } from 'calculate-size';
import { CompletionItemGroup, CompletionItem } from 'app/types';
import { GROUP_TITLE_KIND } from '../TypeaheadItem';
export const flattenGroupItems = (groupedItems: CompletionItemGroup[]): CompletionItem[] => {
return groupedItems.reduce((all, current) => {
const titleItem: CompletionItem = {
label: current.label,
kind: GROUP_TITLE_KIND,
};
return all.concat(titleItem, current.items);
}, []);
};
export const calculateLongestLabel = (allItems: CompletionItem[]): string => {
return allItems.reduce((longest, current) => {
return longest.length < current.label.length ? current.label : longest;
}, '');
};
export const calculateListSizes = (theme: GrafanaTheme, allItems: CompletionItem[], longestLabel: string) => {
const size = calculateSize(longestLabel, {
font: theme.typography.fontFamily.monospace,
fontSize: theme.typography.size.sm,
fontWeight: 'normal',
});
const listWidth = calculateListWidth(size.width, theme);
const itemHeight = calculateItemHeight(size.height, theme);
const listHeight = calculateListHeight(itemHeight, allItems);
return {
listWidth,
listHeight,
itemHeight,
};
};
export const calculateItemHeight = (longestLabelHeight: number, theme: GrafanaTheme) => {
const horizontalPadding = parseInt(theme.spacing.sm, 10) * 2;
const itemHeight = longestLabelHeight + horizontalPadding;
return itemHeight;
};
export const calculateListWidth = (longestLabelWidth: number, theme: GrafanaTheme) => {
const verticalPadding = parseInt(theme.spacing.sm, 10) + parseInt(theme.spacing.md, 10);
const maxWidth = 800;
const listWidth = Math.min(Math.max(longestLabelWidth + verticalPadding, 200), maxWidth);
return listWidth;
};
export const calculateListHeight = (itemHeight: number, allItems: CompletionItem[]) => {
const numberOfItemsToShow = Math.min(allItems.length, 10);
const minHeight = 100;
const itemsInView = allItems.slice(0, numberOfItemsToShow);
const totalHeight = itemsInView.length * itemHeight;
const listHeight = Math.max(totalHeight, minHeight);
return listHeight;
};
......@@ -2439,6 +2439,13 @@
"@types/prop-types" "*"
"@types/react" "*"
"@types/react-window@1.7.0":
version "1.7.0"
resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.7.0.tgz#8dd99822c54380c9c05df213b7b4400c24c9877e"
integrity sha512-HyxhB3TFL/2WKRi69paA1Ch7kowomhR2eSZe7sJz8OtKuNhzRrlYSteSID7GIUpV95k246iVOlxEXmG2bjZQFA==
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@16.8.8", "@types/react@^16.8.8":
version "16.8.8"
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.8.8.tgz#4b60a469fd2469f7aa6eaa0f8cfbc51f6d76e662"
......@@ -4331,6 +4338,11 @@ cache-base@^1.0.1:
union-value "^1.0.0"
unset-value "^1.0.0"
calculate-size@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/calculate-size/-/calculate-size-1.1.1.tgz#ae7caa1c7795f82c4f035dc7be270e3581dae3ee"
integrity sha1-rnyqHHeV+CxPA13HvicONYHa4+4=
call-limit@~1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/call-limit/-/call-limit-1.1.0.tgz#6fd61b03f3da42a2cd0ec2b60f02bd0e71991fea"
......@@ -11171,16 +11183,16 @@ mem@^4.0.0:
mimic-fn "^2.0.0"
p-is-promise "^2.0.0"
"memoize-one@>=3.1.1 <6", memoize-one@^5.0.0:
version "5.0.2"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.0.2.tgz#6aba5276856d72fb44ead3efab86432f94ba203d"
integrity sha512-o7lldN4fs/axqctc03NF+PMhd2veRrWeJ2n2GjEzUPBD4F9rmNg4A+bQCACIzwjHJEXuYv4aFFMaH35KZfHUrw==
memoize-one@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-4.1.0.tgz#a2387c58c03fff27ca390c31b764a79addf3f906"
integrity sha512-2GApq0yI/b22J2j9rhbrAlsHb0Qcz+7yWxeLG8h+95sl1XPUgeLimQSOdur4Vw7cUhrBHwaUZxWFZueojqNRzA==
memoize-one@^5.0.0:
version "5.0.2"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.0.2.tgz#6aba5276856d72fb44ead3efab86432f94ba203d"
integrity sha512-o7lldN4fs/axqctc03NF+PMhd2veRrWeJ2n2GjEzUPBD4F9rmNg4A+bQCACIzwjHJEXuYv4aFFMaH35KZfHUrw==
memoizerific@^1.11.3:
version "1.11.3"
resolved "https://registry.yarnpkg.com/memoizerific/-/memoizerific-1.11.3.tgz#7c87a4646444c32d75438570905f2dbd1b1a805a"
......@@ -14324,6 +14336,14 @@ react-virtualized@^9.21.0:
prop-types "^15.6.0"
react-lifecycles-compat "^3.0.4"
react-window@1.7.1:
version "1.7.1"
resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.7.1.tgz#c1db640415b97b85bc0a1c66eb82dadabca39b86"
integrity sha512-y4/Qc98agCtHulpeI5b6K2Hh8J7TeZIfvccBVesfqOFx4CS+TSUpnJl1/ipeXzhfvzPwvVEmaU/VosQ6O5VhTg==
dependencies:
"@babel/runtime" "^7.0.0"
memoize-one ">=3.1.1 <6"
react@^16.8.1:
version "16.8.6"
resolved "https://registry.yarnpkg.com/react/-/react-16.8.6.tgz#ad6c3a9614fd3a4e9ef51117f54d888da01f2bbe"
......
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