From ed7ad8f6acc3b0f6be79567bed139f5b2bc10c6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Tue, 9 Apr 2019 14:25:13 +0200 Subject: [PATCH] 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 --- package.json | 3 + packages/grafana-ui/src/themes/index.ts | 3 +- public/app/features/explore/QueryField.tsx | 11 +- public/app/features/explore/Typeahead.tsx | 207 ++++++++++-------- public/app/features/explore/TypeaheadInfo.tsx | 90 ++++++++ public/app/features/explore/TypeaheadItem.tsx | 87 ++++++++ .../app/features/explore/utils/typeahead.ts | 64 ++++++ yarn.lock | 30 ++- 8 files changed, 395 insertions(+), 100 deletions(-) create mode 100644 public/app/features/explore/TypeaheadInfo.tsx create mode 100644 public/app/features/explore/TypeaheadItem.tsx create mode 100644 public/app/features/explore/utils/typeahead.ts diff --git a/package.json b/package.json index cfdfd996f9b..80489e8cd46 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/grafana-ui/src/themes/index.ts b/packages/grafana-ui/src/themes/index.ts index 2d29576d968..6d3e41e867e 100644 --- a/packages/grafana-ui/src/themes/index.ts +++ b/packages/grafana-ui/src/themes/index.ts @@ -1,4 +1,5 @@ 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 }; diff --git a/public/app/features/explore/QueryField.tsx b/public/app/features/explore/QueryField.tsx index 333bc577886..2eae12d240d 100644 --- a/public/app/features/explore/QueryField.tsx +++ b/public/app/features/explore/QueryField.tsx @@ -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 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 - ); diff --git a/public/app/features/explore/Typeahead.tsx b/public/app/features/explore/Typeahead.tsx index 6d6c60c0553..76647720c4d 100644 --- a/public/app/features/explore/Typeahead.tsx +++ b/public/app/features/explore/Typeahead.tsx @@ -1,107 +1,132 @@ -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 TypeaheadItemProps { - isSelected: boolean; - item: CompletionItem; - onClickItem: (Suggestion) => void; - prefix?: string; -} - -class TypeaheadItem extends React.PureComponent { - el: HTMLElement; - - componentDidUpdate(prevProps) { - if (this.props.isSelected && !prevProps.isSelected) { - requestAnimationFrame(() => { - scrollIntoView(this.el); - }); - } - } - - getRef = el => { - this.el = el; - }; - - onClick = () => { - this.props.onClickItem(this.props.item); - }; - - render() { - const { isSelected, item, prefix } = this.props; - const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item'; - const label = item.label || ''; - return ( -
  • - - {item.documentation && isSelected ?
    {item.documentation}
    : null} -
  • - ); - } -} - -interface TypeaheadGroupProps { - items: CompletionItem[]; - label: string; - onClickItem: (suggestion: CompletionItem) => void; - selected: CompletionItem; - prefix?: string; -} - -class TypeaheadGroup extends React.PureComponent { - render() { - const { items, label, selected, onClickItem, prefix } = this.props; - return ( -
  • -
    {label}
    -
      - {items.map(item => { - return ( - - ); - })} -
    -
  • - ); - } -} - -interface TypeaheadProps { +interface Props extends Themeable { groupedItems: CompletionItemGroup[]; menuRef: any; selectedItem: CompletionItem | null; - onClickItem: (Suggestion) => void; + onClickItem: (suggestion: CompletionItem) => void; prefix?: string; + typeaheadIndex: number; } -class Typeahead extends React.PureComponent { + +interface State { + allItems: CompletionItem[]; + listWidth: number; + listHeight: number; + itemHeight: number; +} + +export class Typeahead extends React.PureComponent { + listRef: any = createRef(); + documentationRef: any = createRef(); + + 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 }; + } + + componentDidUpdate = (prevProps: Readonly) => { + 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()); + } + }; + + 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); + } + }; + + onMouseEnter = (item: CompletionItem) => { + this.documentationRef.current.refresh(item); + }; + + onMouseLeave = () => { + this.documentationRef.current.hide(); + }; + render() { - const { groupedItems, menuRef, selectedItem, onClickItem, prefix } = this.props; + const { menuRef, selectedItem, onClickItem, prefix, theme } = this.props; + const { listWidth, listHeight, itemHeight, allItems } = this.state; + return (
      - {groupedItems.map(g => ( - - ))} + + { + 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 ( + + ); + }} +
    ); } } -export default Typeahead; +export const TypeaheadWithTheme = withTheme(Typeahead); diff --git a/public/app/features/explore/TypeaheadInfo.tsx b/public/app/features/explore/TypeaheadInfo.tsx new file mode 100644 index 00000000000..4b410c8b365 --- /dev/null +++ b/public/app/features/explore/TypeaheadInfo.tsx @@ -0,0 +1,90 @@ +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 { + 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 ( +
    + {label} +
    + {documentation} +
    + ); + } +} diff --git a/public/app/features/explore/TypeaheadItem.tsx b/public/app/features/explore/TypeaheadItem.tsx new file mode 100644 index 00000000000..f670330e44d --- /dev/null +++ b/public/app/features/explore/TypeaheadItem.tsx @@ -0,0 +1,87 @@ +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) => { + 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 ( +
  • + {label} +
  • + ); + } + + return ( +
  • + +
  • + ); +}; diff --git a/public/app/features/explore/utils/typeahead.ts b/public/app/features/explore/utils/typeahead.ts new file mode 100644 index 00000000000..7de817e4578 --- /dev/null +++ b/public/app/features/explore/utils/typeahead.ts @@ -0,0 +1,64 @@ +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; +}; diff --git a/yarn.lock b/yarn.lock index ab533838c92..301b9009602 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"