Przeglądaj źródła

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
Hugo Häggmark 6 lat temu
rodzic
commit
ed7ad8f6ac

+ 3 - 0
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",

+ 2 - 1
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 };

+ 8 - 3
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<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>
     );

+ 102 - 77
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 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);

+ 90 - 0
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<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>
+    );
+  }
+}

+ 87 - 0
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: 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>
+  );
+};

+ 64 - 0
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;
+};

+ 25 - 5
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"