Browse Source

ref(ui): Convert dropdownAutocomplete to typescript (#20782)

Priscila Oliveira 4 years ago
parent
commit
b6cba3ae8a

+ 12 - 14
docs-ui/components/dropdownAutoComplete.stories.js

@@ -89,7 +89,7 @@ export default {
 };
 
 export const Ungrouped = withInfo('The item label can be a component or a string')(() => (
-  <DropdownAutoComplete items={items} alignMenu="left">
+  <DropdownAutoComplete items={items}>
     {({selectedItem}) => (selectedItem ? selectedItem.label : 'Click me!')}
   </DropdownAutoComplete>
 ));
@@ -101,7 +101,6 @@ Ungrouped.story = {
 export const Grouped = withInfo('Group labels can receive a component too')(() => (
   <DropdownAutoComplete
     items={groupedItems}
-    alignMenu="left"
     virtualizedHeight={44}
     virtualizedLabelHeight={28}
   >
@@ -113,17 +112,17 @@ Grouped.story = {
   name: 'grouped',
 };
 
-export const WithDropdownButton = withInfo('Use it with dropdownbutton for maximum fun')(
-  () => (
-    <DropdownAutoComplete items={groupedItems} alignMenu="left">
-      {({isOpen, selectedItem}) => (
-        <DropdownButton isOpen={isOpen}>
-          {selectedItem ? selectedItem.label : 'Click me!'}
-        </DropdownButton>
-      )}
-    </DropdownAutoComplete>
-  )
-);
+export const WithDropdownButton = withInfo(
+  'Use it with dropdownbutton for maximum fun'
+)(() => (
+  <DropdownAutoComplete items={groupedItems}>
+    {({isOpen, selectedItem}) => (
+      <DropdownButton isOpen={isOpen}>
+        {selectedItem ? selectedItem.label : 'Click me!'}
+      </DropdownButton>
+    )}
+  </DropdownAutoComplete>
+));
 
 WithDropdownButton.story = {
   name: 'with dropdownButton',
@@ -133,7 +132,6 @@ export const WithExtraAction = withInfo('Add a call to action button')(() => (
   <DropdownAutoComplete
     items={items}
     action={<Button priority="primary">Now click me!</Button>}
-    alignMenu="left"
   >
     {({isOpen, selectedItem}) => (
       <DropdownButton isOpen={isOpen}>

+ 2 - 3
src/sentry/static/sentry/app/components/assigneeSelector.tsx

@@ -246,7 +246,6 @@ const AssigneeSelectorComponent = createReactClass<Props, State>({
         {!loading && (
           <DropdownAutoComplete
             maxHeight={400}
-            zIndex={2}
             onOpen={e => {
               // This can be called multiple times and does not always have `event`
               if (!e) {
@@ -260,8 +259,6 @@ const AssigneeSelectorComponent = createReactClass<Props, State>({
             onSelect={this.handleAssign}
             itemSize="small"
             searchPlaceholder={t('Filter teams and people')}
-            menuWithArrow
-            emptyHidesInput
             menuHeader={
               assignedTo && (
                 <MenuItemWrapper
@@ -291,6 +288,8 @@ const AssigneeSelectorComponent = createReactClass<Props, State>({
                 </MenuItemWrapper>
               </InviteMemberLink>
             }
+            menuWithArrow
+            emptyHidesInput
           >
             {({getActorProps}) => (
               <DropdownButton {...getActorProps({})}>

+ 0 - 58
src/sentry/static/sentry/app/components/dropdownAutoComplete.jsx

@@ -1,58 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import styled from '@emotion/styled';
-
-import DropdownAutoCompleteMenu from 'app/components/dropdownAutoCompleteMenu';
-
-class DropdownAutoComplete extends React.Component {
-  static propTypes = {
-    ...DropdownAutoCompleteMenu.propTypes,
-
-    // Should clicking the actor toggle visibility?
-    allowActorToggle: PropTypes.bool,
-
-    children: PropTypes.func,
-  };
-
-  static defaultProps = {
-    alignMenu: 'right',
-  };
-
-  render() {
-    const {children, allowActorToggle, ...props} = this.props;
-
-    return (
-      <DropdownAutoCompleteMenu {...props}>
-        {renderProps => {
-          // Don't pass `onClick` from `getActorProps`
-          const {onClick: _onClick, ...actorProps} = renderProps.getActorProps();
-
-          return (
-            <Actor
-              isOpen={renderProps.isOpen}
-              role="button"
-              tabIndex="0"
-              onClick={
-                renderProps.isOpen && allowActorToggle
-                  ? renderProps.actions.close
-                  : renderProps.actions.open
-              }
-              {...actorProps}
-            >
-              {children(renderProps)}
-            </Actor>
-          );
-        }}
-      </DropdownAutoCompleteMenu>
-    );
-  }
-}
-
-const Actor = styled('div')`
-  position: relative;
-  width: 100%;
-  /* This is needed to be able to cover dropdown menu so that it looks like one unit */
-  ${p => p.isOpen && `z-index: ${p.theme.zIndex.dropdownAutocomplete.actor}`};
-`;
-
-export default DropdownAutoComplete;

+ 50 - 0
src/sentry/static/sentry/app/components/dropdownAutoComplete/autoCompleteFilter.jsx

@@ -0,0 +1,50 @@
+import flatMap from 'lodash/flatMap';
+
+function filterItems(items, inputValue) {
+  return items.filter(
+    item =>
+      (item.searchKey || `${item.value} ${item.label}`)
+        .toLowerCase()
+        .indexOf(inputValue.toLowerCase()) > -1
+  );
+}
+
+function filterGroupedItems(groups, inputValue) {
+  return groups
+    .map(group => ({
+      ...group,
+      items: filterItems(group.items, inputValue),
+    }))
+    .filter(group => group.items.length > 0);
+}
+
+function autoCompleteFilter(items, inputValue) {
+  let itemCount = 0;
+
+  if (!items) {
+    return [];
+  }
+
+  if (items[0] && items[0].items) {
+    //if the first item has children, we assume it is a group
+    return flatMap(filterGroupedItems(items, inputValue), item => {
+      const groupItems = item.items.map(groupedItem => ({
+        ...groupedItem,
+        index: itemCount++,
+      }));
+
+      // Make sure we don't add the group label to list of items
+      // if we try to hide it, otherwise it will render if the list
+      // is using virtualized rows (because of fixed row heights)
+      if (item.hideGroupLabel) {
+        return groupItems;
+      }
+
+      return [{...item, groupLabel: true}, ...groupItems];
+    });
+  }
+
+  return filterItems(items, inputValue).map((item, index) => ({...item, index}));
+}
+
+export default autoCompleteFilter;

+ 41 - 0
src/sentry/static/sentry/app/components/dropdownAutoComplete/index.tsx

@@ -0,0 +1,41 @@
+import React from 'react';
+import styled from '@emotion/styled';
+
+import Menu from './menu';
+
+type MenuProps = React.ComponentProps<typeof Menu>;
+
+type Props = {
+  // Should clicking the actor toggle visibility
+  allowActorToggle?: boolean;
+} & MenuProps;
+
+const DropdownAutoComplete = ({allowActorToggle = false, children, ...props}: Props) => (
+  <Menu {...props}>
+    {renderProps => {
+      const {isOpen, actions, getActorProps} = renderProps;
+      // Don't pass `onClick` from `getActorProps`
+      const {onClick: _onClick, ...actorProps} = getActorProps();
+      return (
+        <Actor
+          isOpen={isOpen}
+          role="button"
+          tabIndex={0}
+          onClick={isOpen && allowActorToggle ? actions.close : actions.open}
+          {...actorProps}
+        >
+          {children(renderProps)}
+        </Actor>
+      );
+    }}
+  </Menu>
+);
+
+const Actor = styled('div')<{isOpen: boolean}>`
+  position: relative;
+  width: 100%;
+  /* This is needed to be able to cover dropdown menu so that it looks like one unit */
+  ${p => p.isOpen && `z-index: ${p.theme.zIndex.dropdownAutocomplete.actor}`};
+`;
+
+export default DropdownAutoComplete;

+ 129 - 0
src/sentry/static/sentry/app/components/dropdownAutoComplete/list.tsx

@@ -0,0 +1,129 @@
+import React from 'react';
+import styled from '@emotion/styled';
+import {AutoSizer, List as ReactVirtualizedList, ListRowProps} from 'react-virtualized';
+
+import {Item} from './types';
+import Row from './row';
+
+type Items = Array<
+  Item & {
+    items?: Array<Item>;
+    hideGroupLabel?: boolean; // Should hide group label
+  }
+>;
+
+type RowProps = Pick<
+  React.ComponentProps<typeof Row>,
+  'itemSize' | 'highlightedIndex' | 'inputValue' | 'getItemProps'
+>;
+
+type Props = {
+  // flat item array | grouped item array
+  items: Items;
+  /**
+   * Max height of dropdown menu. Units are assumed as `px`
+   */
+  maxHeight: number;
+  /**
+   * Callback for when dropdown menu is being scrolled
+   */
+  onScroll?: () => void;
+  /**
+   * If you use grouping with virtualizedHeight, the labels will be that height unless specified here
+   */
+  virtualizedLabelHeight?: number;
+
+  /**
+   * Supplying this height will force the dropdown menu to be a virtualized list.
+   * This is very useful (and probably required) if you have a large list. e.g. Project selector with many projects.
+   *
+   * Currently, our implementation of the virtualized list requires a fixed height.
+   */
+  virtualizedHeight?: number;
+} & RowProps;
+
+function getHeight(
+  items: Items,
+  maxHeight: number,
+  virtualizedHeight: number,
+  virtualizedLabelHeight?: number
+) {
+  const minHeight = virtualizedLabelHeight
+    ? items.reduce(
+        (a, r) => a + (r.groupLabel ? virtualizedLabelHeight : virtualizedHeight),
+        0
+      )
+    : items.length * virtualizedHeight;
+  return Math.min(minHeight, maxHeight);
+}
+
+const List = ({
+  virtualizedHeight,
+  virtualizedLabelHeight,
+  onScroll,
+  items,
+  itemSize,
+  highlightedIndex,
+  inputValue,
+  getItemProps,
+  maxHeight,
+}: Props) => {
+  if (virtualizedHeight) {
+    return (
+      <AutoSizer disableHeight>
+        {({width}) => (
+          <StyledList
+            width={width}
+            height={getHeight(
+              items,
+              maxHeight,
+              virtualizedHeight,
+              virtualizedLabelHeight
+            )}
+            onScroll={onScroll}
+            rowCount={items.length}
+            rowHeight={({index}) =>
+              items[index].groupLabel && virtualizedLabelHeight
+                ? virtualizedLabelHeight
+                : virtualizedHeight
+            }
+            rowRenderer={({key, index, style}: ListRowProps) => (
+              <Row
+                key={key}
+                item={items[index]}
+                style={style}
+                itemSize={itemSize}
+                highlightedIndex={highlightedIndex}
+                inputValue={inputValue}
+                getItemProps={getItemProps}
+              />
+            )}
+          />
+        )}
+      </AutoSizer>
+    );
+  }
+
+  return (
+    <React.Fragment>
+      {items.map((item, index) => (
+        <Row
+          // Using only the index of the row might not re-render properly,
+          // because the items shift around the list
+          key={`${item.value}-${index}`}
+          item={item}
+          itemSize={itemSize}
+          highlightedIndex={highlightedIndex}
+          inputValue={inputValue}
+          getItemProps={getItemProps}
+        />
+      ))}
+    </React.Fragment>
+  );
+};
+
+export default List;
+
+const StyledList = styled(ReactVirtualizedList)`
+  outline: none;
+`;

+ 454 - 0
src/sentry/static/sentry/app/components/dropdownAutoComplete/menu.tsx

@@ -0,0 +1,454 @@
+import React from 'react';
+import styled from '@emotion/styled';
+
+import {t} from 'app/locale';
+import AutoComplete from 'app/components/autoComplete';
+import DropdownBubble from 'app/components/dropdownBubble';
+import Input from 'app/views/settings/components/forms/controls/input';
+import LoadingIndicator from 'app/components/loadingIndicator';
+import space from 'app/styles/space';
+
+import List from './list';
+import {Item} from './types';
+import autoCompleteFilter from './autoCompleteFilter';
+
+type Items = Array<
+  Omit<Item, 'index'> & {
+    items?: Array<Omit<Item, 'index'>>;
+    hideGroupLabel?: boolean; // Should hide group label
+  }
+>;
+
+type MenuFooterChildProps = {
+  actions: Actions;
+};
+
+type EventHandles = {
+  onClick?: (event: React.MouseEvent<Element>) => void;
+  onMouseEnter?: (event: React.MouseEvent<Element>) => void;
+  onMouseLeave?: (event: React.MouseEvent<Element>) => void;
+  onKeyDown?: (event: React.KeyboardEvent<Element>) => void;
+};
+
+type MenuProps = Omit<EventHandles, 'onKeyDown'> & {
+  className?: string;
+};
+
+// Props for the "actor" element of `<DropdownMenu>`
+// This is the element that handles visibility of the dropdown menu
+type ActorProps = Required<EventHandles>;
+
+type GetActorArgs = EventHandles & {
+  style?: React.CSSProperties;
+  className?: string;
+};
+
+type Actions = {
+  open: () => void;
+  close: () => void;
+};
+
+type ChildrenArgs = {
+  getInputProps: () => void;
+  getActorProps: (args?: GetActorArgs) => ActorProps;
+  actions: Actions;
+  isOpen: boolean;
+  selectedItem?: Item;
+  selectedItemIndex?: number;
+};
+
+type ListProps = React.ComponentProps<typeof List>;
+
+type Props = {
+  items: Items;
+  children: (args: ChildrenArgs) => React.ReactNode;
+
+  menuHeader?: React.ReactElement;
+  menuFooter?: React.ReactElement | ((props: MenuFooterChildProps) => React.ReactElement);
+
+  /**
+   * Hide's the input when there are no items. Avoid using this when querying
+   * results in an async fashion.
+   */
+  emptyHidesInput?: boolean;
+
+  /**
+   * Search input's placeholder text
+   */
+  searchPlaceholder?: string;
+
+  /**
+   * Message to display when there are no items initially
+   */
+  emptyMessage?: React.ReactNode;
+
+  /**
+   * Message to display when there are no items that match the search
+   */
+  noResultsMessage?: React.ReactNode;
+
+  /**
+   * Show loading indicator next to input and "Searching..." text in the list
+   */
+  busy?: boolean;
+
+  /**
+   * Dropdown menu alignment.
+   */
+  alignMenu?: 'left' | 'right';
+
+  /**
+   * If this is undefined, autocomplete filter will use this value instead of the
+   * current value in the filter input element.
+   *
+   * This is useful if you need to strip characters out of the search
+   */
+  filterValue?: string;
+
+  /**
+   * Hides the default filter input
+   */
+
+  hideInput?: boolean;
+
+  /**
+   * Props to pass to menu component
+   */
+  menuProps?: MenuProps;
+
+  /**
+   * Show loading indicator next to input but don't hide list items
+   */
+  busyItemsStillVisible?: boolean;
+
+  /**
+   * Changes the menu style to have an arrow at the top
+   */
+  menuWithArrow?: boolean;
+
+  /**
+   * Props to pass to input/filter component
+   */
+  inputProps?: Record<string, any>;
+
+  /**
+   * Should menu visually lock to a direction (so we don't display a rounded corner)
+   */
+  blendCorner?: boolean;
+
+  /**
+   * Used to control dropdown state (optional)
+   */
+  isOpen?: boolean;
+
+  /**
+   * Callback for when dropdown menu opens
+   */
+  onOpen?: (event?: React.MouseEvent) => void;
+
+  /**
+   * Callback for when dropdown menu closes
+   */
+  onClose?: () => void;
+
+  /**
+   * When AutoComplete input changes
+   */
+  onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
+
+  /**
+   * When an item is selected (via clicking dropdown, or keyboard navigation)
+   */
+  onSelect?: (item: Item) => void;
+
+  /**
+   * AutoComplete prop
+   */
+  closeOnSelect?: boolean;
+
+  /**
+   * renderProp for the end (right side) of the search input
+   */
+  inputActions?: React.ReactElement;
+
+  /**
+   * passed down to the AutoComplete Component
+   */
+  disabled?: boolean;
+
+  /**
+   * Max height of dropdown menu. Units are assumed as `px`
+   */
+  maxHeight?: ListProps['maxHeight'];
+
+  /**
+   * for passing  styles to the DropdownBubble
+   */
+  className?: string;
+
+  /**
+   * the styles are forward to the Autocomplete's getMenuProps func
+   */
+  style?: React.CSSProperties;
+
+  /**
+   * for passing simple styles to the root container
+   */
+  rootClassName?: string;
+
+  css?: any;
+} & Pick<
+  ListProps,
+  'virtualizedHeight' | 'virtualizedLabelHeight' | 'itemSize' | 'onScroll'
+>;
+
+const Menu = ({
+  maxHeight = 300,
+  emptyMessage = t('No items'),
+  searchPlaceholder = t('Filter search'),
+  blendCorner = true,
+  alignMenu = 'left',
+  hideInput = false,
+  busy = false,
+  busyItemsStillVisible = false,
+  menuWithArrow = false,
+  disabled = false,
+  itemSize,
+  virtualizedHeight,
+  virtualizedLabelHeight,
+  menuProps,
+  noResultsMessage,
+  inputProps,
+  children,
+  rootClassName,
+  className,
+  emptyHidesInput,
+  menuHeader,
+  filterValue,
+  items,
+  menuFooter,
+  style,
+  onScroll,
+  inputActions,
+  onChange,
+  onSelect,
+  onOpen,
+  onClose,
+  css,
+  closeOnSelect,
+  ...props
+}: Props) => (
+  <AutoComplete
+    itemToString={() => ''}
+    onSelect={onSelect}
+    inputIsActor={false}
+    onOpen={onOpen}
+    onClose={onClose}
+    disabled={disabled}
+    closeOnSelect={closeOnSelect}
+    resetInputOnClose
+    {...props}
+  >
+    {({
+      getActorProps,
+      getRootProps,
+      getInputProps,
+      getMenuProps,
+      getItemProps,
+      inputValue,
+      selectedItem,
+      highlightedIndex,
+      isOpen,
+      actions,
+    }) => {
+      // This is the value to use to filter (default to value in filter input)
+      const filterValueOrInput: string = filterValue ?? inputValue;
+
+      // Can't search if there are no items
+      const hasItems = items && !!items.length;
+
+      // Only filter results if menu is open and there are items
+      const autoCompleteResults =
+        (isOpen && hasItems && autoCompleteFilter(items, filterValueOrInput)) || [];
+
+      // Items are loading if null
+      const itemsLoading = items === null;
+
+      // Has filtered results
+      const hasResults = !!autoCompleteResults.length;
+
+      // No items to display
+      const showNoItems = !busy && !filterValueOrInput && !hasItems;
+
+      // Results mean there was an attempt to search
+      const showNoResultsMessage =
+        !busy && !busyItemsStillVisible && filterValueOrInput && !hasResults;
+
+      // Hide the input when we have no items to filter, only if
+      // emptyHidesInput is set to true.
+      const showInput = !hideInput && (hasItems || !emptyHidesInput);
+
+      // When virtualization is turned on, we need to pass in the number of
+      // selecteable items for arrow-key limits
+      const itemCount =
+        virtualizedHeight && autoCompleteResults.filter(i => !i.groupLabel).length;
+
+      const renderedFooter =
+        typeof menuFooter === 'function' ? menuFooter({actions}) : menuFooter;
+
+      return (
+        <AutoCompleteRoot {...getRootProps()} className={rootClassName}>
+          {children({
+            getInputProps,
+            getActorProps,
+            actions,
+            isOpen,
+            selectedItem,
+          })}
+          {isOpen && (
+            <BubbleWithMinWidth
+              className={className}
+              {...getMenuProps({
+                ...menuProps,
+                style,
+                css,
+                itemCount,
+                blendCorner,
+                alignMenu,
+                menuWithArrow,
+              })}
+            >
+              {itemsLoading && <LoadingIndicator mini />}
+              {showInput && (
+                <InputWrapper>
+                  <StyledInput
+                    autoFocus
+                    placeholder={searchPlaceholder}
+                    {...getInputProps({...inputProps, onChange})}
+                  />
+                  <InputLoadingWrapper>
+                    {(busy || busyItemsStillVisible) && (
+                      <LoadingIndicator size={16} mini />
+                    )}
+                  </InputLoadingWrapper>
+                  {inputActions}
+                </InputWrapper>
+              )}
+              <div>
+                {menuHeader && <LabelWithPadding>{menuHeader}</LabelWithPadding>}
+                <ItemList data-test-id="autocomplete-list" maxHeight={maxHeight}>
+                  {showNoItems && <EmptyMessage>{emptyMessage}</EmptyMessage>}
+                  {showNoResultsMessage && (
+                    <EmptyMessage>
+                      {noResultsMessage ?? `${emptyMessage} ${t('found')}`}
+                    </EmptyMessage>
+                  )}
+                  {busy && (
+                    <BusyMessage>
+                      <EmptyMessage>{t('Searching\u2026')}</EmptyMessage>
+                    </BusyMessage>
+                  )}
+                  {!busy && (
+                    <List
+                      items={autoCompleteResults}
+                      maxHeight={maxHeight}
+                      highlightedIndex={highlightedIndex}
+                      inputValue={inputValue}
+                      onScroll={onScroll}
+                      getItemProps={getItemProps}
+                      virtualizedLabelHeight={virtualizedLabelHeight}
+                      virtualizedHeight={virtualizedHeight}
+                      itemSize={itemSize}
+                    />
+                  )}
+                </ItemList>
+                {renderedFooter && <LabelWithPadding>{renderedFooter}</LabelWithPadding>}
+              </div>
+            </BubbleWithMinWidth>
+          )}
+        </AutoCompleteRoot>
+      );
+    }}
+  </AutoComplete>
+);
+
+export default Menu;
+
+const StyledInput = styled(Input)`
+  flex: 1;
+  border: 1px solid transparent;
+  &,
+  &:focus,
+  &:active,
+  &:hover {
+    border: 0;
+    box-shadow: none;
+    font-size: 13px;
+    padding: ${space(1)};
+    font-weight: normal;
+    color: ${p => p.theme.gray500};
+  }
+`;
+
+const InputLoadingWrapper = styled('div')`
+  display: flex;
+  background: ${p => p.theme.white};
+  align-items: center;
+  flex-shrink: 0;
+  width: 30px;
+  .loading.mini {
+    height: 16px;
+    margin: 0;
+  }
+`;
+
+const EmptyMessage = styled('div')`
+  color: ${p => p.theme.gray400};
+  padding: ${space(2)};
+  text-align: center;
+  text-transform: none;
+`;
+
+export const AutoCompleteRoot = styled(({isOpen: _isOpen, ...props}) => (
+  <div {...props} />
+))`
+  position: relative;
+  display: inline-block;
+`;
+
+const BubbleWithMinWidth = styled(DropdownBubble)`
+  min-width: 250px;
+`;
+
+const InputWrapper = styled('div')`
+  display: flex;
+  border-bottom: 1px solid ${p => p.theme.borderLight};
+  border-radius: ${p => `${p.theme.borderRadius} ${p.theme.borderRadius} 0 0`};
+  align-items: center;
+`;
+
+const LabelWithPadding = styled('div')`
+  background-color: ${p => p.theme.gray100};
+  border-bottom: 1px solid ${p => p.theme.borderLight};
+  border-width: 1px 0;
+  color: ${p => p.theme.gray600};
+  font-size: ${p => p.theme.fontSizeMedium};
+  &:first-child {
+    border-top: none;
+  }
+  &:last-child {
+    border-bottom: none;
+  }
+  padding: ${space(0.25)} ${space(1)};
+`;
+
+const ItemList = styled('div')<{maxHeight: NonNullable<Props['maxHeight']>}>`
+  max-height: ${p => `${p.maxHeight}px`};
+  overflow-y: auto;
+`;
+
+const BusyMessage = styled('div')`
+  display: flex;
+  justify-content: center;
+  padding: ${space(1)};
+`;

+ 107 - 0
src/sentry/static/sentry/app/components/dropdownAutoComplete/row.tsx

@@ -0,0 +1,107 @@
+import React from 'react';
+import styled from '@emotion/styled';
+
+import space from 'app/styles/space';
+
+import {GetItemArgs} from './types';
+
+type ItemSize = 'zero' | 'small';
+
+type Props = {
+  // The highlight index according the search
+  highlightedIndex: number;
+  getItemProps: (args: GetItemArgs) => void;
+  /**
+   * Search field's input value
+   */
+  inputValue: string;
+
+  /**
+   * Size for dropdown items
+   */
+  itemSize?: ItemSize;
+} & Omit<GetItemArgs, 'index'>;
+
+const Row = ({
+  item,
+  style,
+  itemSize,
+  highlightedIndex,
+  inputValue,
+  getItemProps,
+}: Props) => {
+  const {index} = item;
+
+  if (item?.groupLabel) {
+    return (
+      <LabelWithBorder style={style}>
+        {item.label && <GroupLabel>{item.label}</GroupLabel>}
+      </LabelWithBorder>
+    );
+  }
+
+  return (
+    <AutoCompleteItem
+      itemSize={itemSize}
+      hasGrayBackground={index === highlightedIndex}
+      {...getItemProps({item, index, style})}
+    >
+      {typeof item.label === 'function' ? item.label({inputValue}) : item.label}
+    </AutoCompleteItem>
+  );
+};
+
+export default Row;
+
+const LabelWithBorder = styled('div')`
+  background-color: ${p => p.theme.gray100};
+  border-bottom: 1px solid ${p => p.theme.borderLight};
+  border-width: 1px 0;
+  color: ${p => p.theme.gray600};
+  font-size: ${p => p.theme.fontSizeMedium};
+
+  :first-child {
+    border-top: none;
+  }
+  :last-child {
+    border-bottom: none;
+  }
+`;
+
+const GroupLabel = styled('div')`
+  padding: ${space(0.25)} ${space(1)};
+`;
+
+const getItemPaddingForSize = (itemSize?: ItemSize) => {
+  if (itemSize === 'small') {
+    return `${space(0.5)} ${space(1)}`;
+  }
+
+  if (itemSize === 'zero') {
+    return '0';
+  }
+
+  return space(1);
+};
+
+const AutoCompleteItem = styled('div')<{hasGrayBackground: boolean; itemSize?: ItemSize}>`
+  /* needed for virtualized lists that do not fill parent height */
+  /* e.g. breadcrumbs (org height > project, but want same fixed height for both) */
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+
+  font-size: 0.9em;
+  background-color: ${p => (p.hasGrayBackground ? p.theme.gray100 : 'transparent')};
+  padding: ${p => getItemPaddingForSize(p.itemSize)};
+  cursor: pointer;
+  border-bottom: 1px solid ${p => p.theme.borderLight};
+
+  :last-child {
+    border-bottom: none;
+  }
+
+  :hover {
+    background-color: ${p => p.theme.gray100};
+  }
+`;

+ 9 - 0
src/sentry/static/sentry/app/components/dropdownAutoComplete/types.tsx

@@ -0,0 +1,9 @@
+export type Item = {
+  value: any;
+  label: ((value: any) => React.ReactNode) | React.ReactNode;
+  index: number;
+  searchKey?: string;
+  groupLabel?: boolean;
+} & Record<string, any>;
+
+export type GetItemArgs = {item: Item; index: number; style?: React.CSSProperties};

+ 0 - 612
src/sentry/static/sentry/app/components/dropdownAutoCompleteMenu.jsx

@@ -1,612 +0,0 @@
-import {AutoSizer, List} from 'react-virtualized';
-import PropTypes from 'prop-types';
-import React from 'react';
-import flatMap from 'lodash/flatMap';
-import styled from '@emotion/styled';
-
-import {t} from 'app/locale';
-import AutoComplete from 'app/components/autoComplete';
-import DropdownBubble from 'app/components/dropdownBubble';
-import Input from 'app/views/settings/components/forms/controls/input';
-import LoadingIndicator from 'app/components/loadingIndicator';
-import space from 'app/styles/space';
-
-const ItemObjectPropType = {
-  value: PropTypes.any,
-  searchKey: PropTypes.string,
-  label: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
-};
-const ItemShapePropType = PropTypes.shape(ItemObjectPropType);
-
-class DropdownAutoCompleteMenu extends React.Component {
-  static propTypes = {
-    items: PropTypes.oneOfType([
-      // flat item array
-      PropTypes.arrayOf(ItemShapePropType),
-
-      // grouped item array
-      PropTypes.arrayOf(
-        PropTypes.shape({
-          ...ItemObjectPropType,
-          items: PropTypes.arrayOf(ItemShapePropType),
-          // Should hide group label
-          hideGroupLabel: PropTypes.bool,
-        })
-      ),
-    ]),
-
-    /**
-     * If this is undefined, autocomplete filter will use this value instead of the
-     * current value in the filter input element.
-     *
-     * This is useful if you need to strip characters out of the search
-     */
-    filterValue: PropTypes.string,
-
-    /**
-     * Used to control dropdown state (optional)
-     */
-    isOpen: PropTypes.bool,
-
-    /**
-     * Show loading indicator next to input and "Searching..." text in the list
-     */
-    busy: PropTypes.bool,
-
-    /**
-     * Show loading indicator next to input but don't hide list items
-     */
-    busyItemsStillVisible: PropTypes.bool,
-
-    /**
-     * Hide's the input when there are no items. Avoid using this when querying
-     * results in an async fashion.
-     */
-    emptyHidesInput: PropTypes.bool,
-
-    /**
-     * When an item is selected (via clicking dropdown, or keyboard navigation)
-     */
-    onSelect: PropTypes.func,
-    /**
-     * When AutoComplete input changes
-     */
-    onChange: PropTypes.func,
-
-    /**
-     * Callback for when dropdown menu opens
-     */
-    onOpen: PropTypes.func,
-
-    /**
-     * Callback for when dropdown menu closes
-     */
-    onClose: PropTypes.func,
-
-    /**
-     * Callback for when dropdown menu is being scrolled
-     */
-    onScroll: PropTypes.func,
-
-    /**
-     * Message to display when there are no items initially
-     */
-    emptyMessage: PropTypes.node,
-
-    /**
-     * Message to display when there are no items that match the search
-     */
-    noResultsMessage: PropTypes.node,
-
-    /**
-     * Presentational properties
-     */
-
-    /**
-     * Dropdown menu alignment.
-     */
-    alignMenu: PropTypes.oneOf(['left', 'right']),
-
-    /**
-     * Should menu visually lock to a direction (so we don't display a rounded corner)
-     */
-    blendCorner: PropTypes.bool,
-
-    /**
-     * Hides the default filter input
-     */
-    hideInput: PropTypes.bool,
-
-    /**
-     * Max height of dropdown menu. Units are assumed as `px` if number, otherwise will assume string has units
-     */
-    maxHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
-
-    /**
-     * Supplying this height will force the dropdown menu to be a virtualized list.
-     * This is very useful (and probably required) if you have a large list. e.g. Project selector with many projects.
-     *
-     * Currently, our implementation of the virtualized list requires a fixed height.
-     */
-    virtualizedHeight: PropTypes.number,
-
-    /**
-     * If you use grouping with virtualizedHeight, the labels will be that height unless specified here
-     */
-    virtualizedLabelHeight: PropTypes.number,
-
-    /**
-     * Search input's placeholder text
-     */
-    searchPlaceholder: PropTypes.string,
-
-    /**
-     * Size for dropdown items
-     */
-    itemSize: PropTypes.oneOf(['zero', 'small', '']),
-
-    /**
-     * Changes the menu style to have an arrow at the top
-     */
-    menuWithArrow: PropTypes.bool,
-
-    menuFooter: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
-    menuHeader: PropTypes.node,
-    /**
-     * Props to pass to menu component
-     */
-    menuProps: PropTypes.object,
-
-    /**
-     * for passing simple styles to the root container
-     */
-    rootClassName: PropTypes.string,
-
-    /**
-     * Props to pass to input/filter component
-     */
-    inputProps: PropTypes.object,
-
-    /**
-     * renderProp for the end (right side) of the search input
-     */
-    inputActions: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
-
-    css: PropTypes.object,
-    style: PropTypes.object,
-  };
-
-  static defaultProps = {
-    onSelect: () => {},
-    maxHeight: 300,
-    blendCorner: true,
-    emptyMessage: t('No items'),
-    searchPlaceholder: t('Filter search'),
-  };
-
-  filterItems = (items, inputValue) =>
-    items.filter(
-      item =>
-        (item.searchKey || `${item.value} ${item.label}`)
-          .toLowerCase()
-          .indexOf(inputValue.toLowerCase()) > -1
-    );
-
-  filterGroupedItems = (groups, inputValue) =>
-    groups
-      .map(group => ({
-        ...group,
-        items: this.filterItems(group.items, inputValue),
-      }))
-      .filter(group => group.items.length > 0);
-
-  autoCompleteFilter = (items, inputValue) => {
-    let itemCount = 0;
-
-    if (!items) {
-      return [];
-    }
-
-    if (items[0] && items[0].items) {
-      //if the first item has children, we assume it is a group
-      return flatMap(this.filterGroupedItems(items, inputValue), item => {
-        const groupItems = item.items.map(groupedItem => ({
-          ...groupedItem,
-          index: itemCount++,
-        }));
-
-        // Make sure we don't add the group label to list of items
-        // if we try to hide it, otherwise it will render if the list
-        // is using virtualized rows (because of fixed row heights)
-        if (item.hideGroupLabel) {
-          return groupItems;
-        }
-
-        return [{...item, groupLabel: true}, ...groupItems];
-      });
-    }
-
-    return this.filterItems(items, inputValue).map((item, index) => ({...item, index}));
-  };
-
-  getHeight = items => {
-    const {maxHeight, virtualizedHeight, virtualizedLabelHeight} = this.props;
-    const minHeight = virtualizedLabelHeight
-      ? items.reduce(
-          (a, r) => a + (r.groupLabel ? virtualizedLabelHeight : virtualizedHeight),
-          0
-        )
-      : items.length * virtualizedHeight;
-    return Math.min(minHeight, maxHeight);
-  };
-
-  renderList = ({items, onScroll, ...otherProps}) => {
-    const {virtualizedHeight, virtualizedLabelHeight} = this.props;
-
-    // If `virtualizedHeight` is defined, use a virtualized list
-    if (typeof virtualizedHeight !== 'undefined') {
-      return (
-        <AutoSizer disableHeight>
-          {({width}) => (
-            <List
-              width={width}
-              style={{outline: 'none'}}
-              height={this.getHeight(items)}
-              onScroll={onScroll}
-              rowCount={items.length}
-              rowHeight={({index}) =>
-                items[index].groupLabel && virtualizedLabelHeight
-                  ? virtualizedLabelHeight
-                  : virtualizedHeight
-              }
-              rowRenderer={({key, index, style}) => {
-                const item = items[index];
-                return this.renderRow({
-                  item,
-                  style,
-                  key,
-                  ...otherProps,
-                });
-              }}
-            />
-          )}
-        </AutoSizer>
-      );
-    }
-
-    return items.map(item => {
-      const {index} = item;
-      const key = `${item.value}-${index}`;
-
-      return this.renderRow({item, key, ...otherProps});
-    });
-  };
-
-  renderRow = ({
-    item,
-    style,
-    itemSize,
-    key,
-    highlightedIndex,
-    inputValue,
-    getItemProps,
-  }) => {
-    const {index} = item;
-
-    return item.groupLabel ? (
-      <LabelWithBorder style={style} key={item.id}>
-        {item.label && <GroupLabel>{item.label}</GroupLabel>}
-      </LabelWithBorder>
-    ) : (
-      <AutoCompleteItem
-        size={itemSize}
-        key={key}
-        index={index}
-        highlightedIndex={highlightedIndex}
-        {...getItemProps({item, index, style})}
-      >
-        {typeof item.label === 'function' ? item.label({inputValue}) : item.label}
-      </AutoCompleteItem>
-    );
-  };
-
-  render() {
-    const {
-      onSelect,
-      onChange,
-      onOpen,
-      onClose,
-      children,
-      items,
-      menuProps,
-      inputProps,
-      alignMenu,
-      blendCorner,
-      maxHeight,
-      emptyMessage,
-      noResultsMessage,
-      style,
-      rootClassName,
-      className,
-      menuHeader,
-      menuFooter,
-      inputActions,
-      menuWithArrow,
-      searchPlaceholder,
-      itemSize,
-      busy,
-      busyItemsStillVisible,
-      onScroll,
-      hideInput,
-      filterValue,
-      emptyHidesInput,
-      ...props
-    } = this.props;
-
-    return (
-      <AutoComplete
-        resetInputOnClose
-        itemToString={() => ''}
-        onSelect={onSelect}
-        inputIsActor={false}
-        onOpen={onOpen}
-        onClose={onClose}
-        {...props}
-      >
-        {({
-          getActorProps,
-          getRootProps,
-          getInputProps,
-          getMenuProps,
-          getItemProps,
-          inputValue,
-          selectedItem,
-          highlightedIndex,
-          isOpen,
-          actions,
-        }) => {
-          // This is the value to use to filter (default to value in filter input)
-          const filterValueOrInput =
-            typeof filterValue !== 'undefined' ? filterValue : inputValue;
-          // Only filter results if menu is open and there are items
-          const autoCompleteResults =
-            (isOpen &&
-              items &&
-              this.autoCompleteFilter(items, filterValueOrInput || '')) ||
-            [];
-
-          // Can't search if there are no items
-          const hasItems = items && !!items.length;
-          // Items are loading if null
-          const itemsLoading = items === null;
-          // Has filtered results
-          const hasResults = !!autoCompleteResults.length;
-          // No items to display
-          const showNoItems = !busy && !filterValueOrInput && !hasItems;
-          // Results mean there was an attempt to search
-          const showNoResultsMessage =
-            !busy && !busyItemsStillVisible && filterValueOrInput && !hasResults;
-
-          // Hide the input when we have no items to filter, only if
-          // emptyHidesInput is set to true.
-          const showInput = !hideInput && (hasItems || !emptyHidesInput);
-
-          // When virtualization is turned on, we need to pass in the number of
-          // selecteable items for arrow-key limits
-          const itemCount =
-            this.props.virtualizedHeight &&
-            autoCompleteResults.filter(i => !i.groupLabel).length;
-
-          const renderedFooter =
-            typeof menuFooter === 'function' ? menuFooter({actions}) : menuFooter;
-
-          const renderedInputActions =
-            typeof inputActions === 'function' ? inputActions() : inputActions;
-
-          return (
-            <AutoCompleteRoot {...getRootProps()} className={rootClassName}>
-              {children({
-                getInputProps,
-                getActorProps,
-                actions,
-                isOpen,
-                selectedItem,
-              })}
-
-              {isOpen && (
-                <BubbleWithMinWidth
-                  className={className}
-                  {...getMenuProps({
-                    ...menuProps,
-                    style,
-                    css: this.props.css,
-                    itemCount,
-                    blendCorner,
-                    alignMenu,
-                    menuWithArrow,
-                  })}
-                >
-                  {itemsLoading && <LoadingIndicator mini />}
-                  {showInput && (
-                    <StyledInputWrapper>
-                      <StyledInput
-                        autoFocus
-                        placeholder={searchPlaceholder}
-                        {...getInputProps({...inputProps, onChange})}
-                      />
-                      <InputLoadingWrapper>
-                        {(busy || busyItemsStillVisible) && (
-                          <LoadingIndicator size={16} mini />
-                        )}
-                      </InputLoadingWrapper>
-                      {renderedInputActions}
-                    </StyledInputWrapper>
-                  )}
-                  <div>
-                    {menuHeader && <LabelWithPadding>{menuHeader}</LabelWithPadding>}
-
-                    <StyledItemList
-                      data-test-id="autocomplete-list"
-                      maxHeight={maxHeight}
-                    >
-                      {showNoItems && <EmptyMessage>{emptyMessage}</EmptyMessage>}
-                      {showNoResultsMessage && (
-                        <EmptyMessage>
-                          {noResultsMessage || `${emptyMessage} ${t('found')}`}
-                        </EmptyMessage>
-                      )}
-                      {busy && (
-                        <BusyMessage>
-                          <EmptyMessage>{t('Searching...')}</EmptyMessage>
-                        </BusyMessage>
-                      )}
-                      {!busy &&
-                        this.renderList({
-                          items: autoCompleteResults,
-                          itemSize,
-                          highlightedIndex,
-                          inputValue,
-                          getItemProps,
-                          onScroll,
-                        })}
-                    </StyledItemList>
-
-                    {renderedFooter && (
-                      <LabelWithPadding>{renderedFooter}</LabelWithPadding>
-                    )}
-                  </div>
-                </BubbleWithMinWidth>
-              )}
-            </AutoCompleteRoot>
-          );
-        }}
-      </AutoComplete>
-    );
-  }
-}
-
-const AutoCompleteRoot = styled(({isOpen: _isOpen, ...props}) => <div {...props} />)`
-  position: relative;
-  display: inline-block;
-`;
-
-const InputLoadingWrapper = styled('div')`
-  display: flex;
-  background: #fff;
-  align-items: center;
-  flex-shrink: 0;
-  width: 30px;
-
-  .loading.mini {
-    height: 16px;
-    margin: 0;
-  }
-`;
-
-const StyledInputWrapper = styled('div')`
-  display: flex;
-  border-bottom: 1px solid ${p => p.theme.borderLight};
-  border-radius: ${p => `${p.theme.borderRadius} ${p.theme.borderRadius} 0 0`};
-  align-items: center;
-`;
-
-const StyledInput = styled(Input)`
-  flex: 1;
-  border: 1px solid transparent;
-
-  &,
-  &:focus,
-  &:active,
-  &:hover {
-    border: 0;
-    box-shadow: none;
-    font-size: 13px;
-    padding: ${space(1)};
-    font-weight: normal;
-    color: ${p => p.gray500};
-  }
-`;
-
-const getItemPaddingForSize = size => {
-  if (size === 'small') {
-    return `${space(0.5)} ${space(1)}`;
-  }
-  if (size === 'zero') {
-    return '0';
-  }
-
-  return space(1);
-};
-
-const AutoCompleteItem = styled('div')`
-  /* needed for virtualized lists that do not fill parent height */
-  /* e.g. breadcrumbs (org height > project, but want same fixed height for both) */
-  display: flex;
-  flex-direction: column;
-  justify-content: center;
-
-  font-size: 0.9em;
-  background-color: ${p =>
-    p.index === p.highlightedIndex ? p.theme.gray100 : 'transparent'};
-  padding: ${p => getItemPaddingForSize(p.size)};
-  cursor: pointer;
-  border-bottom: 1px solid ${p => p.theme.borderLight};
-
-  &:last-child {
-    border-bottom: none;
-  }
-
-  &:hover {
-    background-color: ${p => p.theme.gray100};
-  }
-`;
-
-const LabelWithBorder = styled('div')`
-  background-color: ${p => p.theme.gray100};
-  border-bottom: 1px solid ${p => p.theme.borderLight};
-  border-width: 1px 0;
-  color: ${p => p.theme.gray600};
-  font-size: ${p => p.theme.fontSizeMedium};
-
-  &:first-child {
-    border-top: none;
-  }
-  &:last-child {
-    border-bottom: none;
-  }
-`;
-
-const LabelWithPadding = styled(LabelWithBorder)`
-  padding: ${space(0.25)} ${space(1)};
-`;
-
-const GroupLabel = styled('div')`
-  padding: ${space(0.25)} ${space(1)};
-`;
-
-const StyledItemList = styled('div')`
-  max-height: ${p =>
-    typeof p.maxHeight === 'number' ? `${p.maxHeight}px` : p.maxHeight};
-  overflow-y: auto;
-`;
-
-const BusyMessage = styled('div')`
-  display: flex;
-  justify-content: center;
-  padding: ${space(1)};
-`;
-
-const EmptyMessage = styled('div')`
-  color: ${p => p.theme.gray400};
-  padding: ${space(2)};
-  text-align: center;
-  text-transform: none;
-`;
-
-const BubbleWithMinWidth = styled(DropdownBubble)`
-  min-width: 250px;
-`;
-
-export default DropdownAutoCompleteMenu;
-
-export {AutoCompleteRoot};

Some files were not shown because too many files changed in this diff