Browse Source

ref(search): function component + rtl tests (#31605)

* ref(search): convert to fc

* test(search): add tests

* ref(search): changes for code review + remove defaultProps

* style(lint): Auto commit lint changes

* fix(tsconfig): oups

Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
Jonas 3 years ago
parent
commit
e861741990

+ 1 - 1
static/app/components/helpSearch.tsx

@@ -1,7 +1,7 @@
 import * as React from 'react';
 import styled from '@emotion/styled';
 
-import Search from 'sentry/components/search';
+import {Search} from 'sentry/components/search';
 import SearchResult from 'sentry/components/search/searchResult';
 import SearchResultWrapper from 'sentry/components/search/searchResultWrapper';
 import HelpSource from 'sentry/components/search/sources/helpSource';

+ 1 - 1
static/app/components/modals/commandPalette.tsx

@@ -3,7 +3,7 @@ import {ClassNames, css, useTheme} from '@emotion/react';
 import styled from '@emotion/styled';
 
 import {ModalRenderProps} from 'sentry/actionCreators/modal';
-import Search from 'sentry/components/search';
+import {Search} from 'sentry/components/search';
 import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
 import {analytics} from 'sentry/utils/analytics';

+ 151 - 168
static/app/components/search/index.tsx

@@ -21,12 +21,11 @@ import replaceRouterParams from 'sentry/utils/replaceRouterParams';
 
 import {Result} from './sources/types';
 
-type Item = Result['item'];
-
-type InputProps = Pick<
-  Parameters<AutoComplete<Item>['props']['children']>[0],
-  'getInputProps'
->;
+interface InputProps
+  extends Pick<
+    Parameters<AutoComplete<Result['item']>['props']['children']>[0],
+    'getInputProps'
+  > {}
 
 /**
  * Render prop for search results
@@ -38,32 +37,14 @@ type InputProps = Pick<
  *  itemProps: props that should be spread for root item
  * }
  */
-type ItemProps = {
+interface ItemProps {
   highlighted: boolean;
   index: number;
-  item: Item;
+  item: Result['item'];
   itemProps: React.ComponentProps<typeof SearchResultWrapper>;
   matches: Result['matches'];
-};
-
-// Not using typeof defaultProps because of the wrapping HOC which
-// causes defaultProp magic to fall off.
-const defaultProps = {
-  renderItem: ({
-    item,
-    matches,
-    itemProps,
-    highlighted,
-  }: ItemProps): React.ReactElement => (
-    <SearchResultWrapper {...itemProps} highlighted={highlighted}>
-      <SearchResult highlighted={highlighted} item={item} matches={matches} />
-    </SearchResultWrapper>
-  ),
-  sources: [ApiSource, FormSource, RouteSource, CommandSource] as React.ComponentType[],
-  closeOnSelect: true,
-};
-
-type Props = WithRouterProps<{orgId: string}> & {
+}
+interface SearchProps extends WithRouterProps<{orgId: string}> {
   /**
    * For analytics
    */
@@ -97,171 +78,173 @@ type Props = WithRouterProps<{orgId: string}> & {
    * Adds a footer below the results when the search is complete
    */
   resultFooter?: React.ReactElement;
+  /**
+   * Fuse search options
+   */
   searchOptions?: Fuse.FuseOptions<any>;
   /**
    * The sources to query
    */
   sources?: React.ComponentType[];
-};
-
-// "Omni" search
-class Search extends React.Component<Props> {
-  static defaultProps = defaultProps;
+}
 
-  componentDidMount() {
-    trackAdvancedAnalyticsEvent(`${this.props.entryPoint}.open`, {
+function Search(props: SearchProps): React.ReactElement {
+  React.useEffect(() => {
+    trackAdvancedAnalyticsEvent(`${props.entryPoint}.open`, {
       organization: null,
     });
-  }
+  }, [props.entryPoint]);
 
-  handleSelect = (item: Item, state?: AutoComplete<Item>['state']) => {
-    if (!item) {
-      return;
-    }
-    trackAdvancedAnalyticsEvent(`${this.props.entryPoint}.select`, {
-      query: state && state.inputValue,
-      result_type: item.resultType,
-      source_type: item.sourceType,
-      organization: null,
-    });
+  const handleSelectItem = React.useCallback(
+    (item: Result['item'], state?: AutoComplete<Result['item']>['state']) => {
+      if (!item) {
+        return;
+      }
 
-    const {to, action, configUrl} = item;
+      trackAdvancedAnalyticsEvent(`${props.entryPoint}.select`, {
+        query: state?.inputValue,
+        result_type: item.resultType,
+        source_type: item.sourceType,
+        organization: null,
+      });
+
+      // `action` refers to a callback function while
+      // `to` is a react-router route
+      if (typeof item.action === 'function') {
+        item.action(item, state);
+        return;
+      }
+
+      if (!item.to) {
+        return;
+      }
 
-    // `action` refers to a callback function while
-    // `to` is a react-router route
-    if (action) {
-      action(item, state);
-      return;
-    }
+      if (item.to.startsWith('http')) {
+        const open = window.open();
 
-    if (!to) {
-      return;
-    }
+        if (open) {
+          open.opener = null;
+          open.location.href = item.to;
+          return;
+        }
 
-    if (to.startsWith('http')) {
-      const open = window.open();
-      if (open === null) {
         addErrorMessage(
           t('Unable to open search result (a popup blocker may have caused this).')
         );
         return;
       }
 
-      open.opener = null;
-      open.location.href = to;
-      return;
-    }
-
-    const {params, router} = this.props;
-    const nextPath = replaceRouterParams(to, params);
-
-    navigateTo(nextPath, router, configUrl);
-  };
-
-  saveQueryMetrics = debounce(query => {
-    if (!query) {
-      return;
-    }
-    trackAdvancedAnalyticsEvent(`${this.props.entryPoint}.query`, {
-      query,
-      organization: null,
-    });
-  }, 200);
-
-  renderItem = ({resultObj, index, highlightedIndex, getItemProps}) => {
-    // resultObj is a fuse.js result object with {item, matches, score}
-    const {renderItem} = this.props;
-    const highlighted = index === highlightedIndex;
-    const {item, matches} = resultObj;
-    const key = `${item.title}-${index}`;
-    const itemProps = {...getItemProps({item, index})};
-
-    if (typeof renderItem !== 'function') {
-      throw new Error('Invalid `renderItem`');
-    }
+      const nextPath = replaceRouterParams(item.to, props.params);
 
-    const renderedItem = renderItem({
-      item,
-      matches,
-      index,
-      highlighted,
-      itemProps,
-    });
-
-    return React.cloneElement(renderedItem, {key});
-  };
-
-  render() {
-    const {
-      params,
-      dropdownStyle,
-      searchOptions,
-      minSearch,
-      maxResults,
-      renderInput,
-      sources,
-      closeOnSelect,
-      resultFooter,
-    } = this.props;
-
-    return (
-      <AutoComplete
-        defaultHighlightedIndex={0}
-        onSelect={this.handleSelect}
-        closeOnSelect={closeOnSelect}
-      >
-        {({getInputProps, getItemProps, isOpen, inputValue, highlightedIndex}) => {
-          const searchQuery = inputValue.toLowerCase().trim();
-          const isValidSearch = inputValue.length >= minSearch;
+      navigateTo(nextPath, props.router, item.configUrl);
+    },
+    [props.entryPoint, props.router, props.params]
+  );
 
-          this.saveQueryMetrics(searchQuery);
-
-          return (
-            <SearchWrapper>
-              {renderInput({getInputProps})}
+  const saveQueryMetrics = React.useCallback(
+    (query: string) => {
+      if (!query) {
+        return;
+      }
 
-              {isValidSearch && isOpen ? (
-                <SearchSources
-                  searchOptions={searchOptions}
-                  query={searchQuery}
-                  params={params}
-                  sources={sources ?? defaultProps.sources}
-                >
-                  {({isLoading, results, hasAnyResults}) => (
-                    <DropdownBox className={dropdownStyle}>
-                      {isLoading && (
-                        <LoadingWrapper>
-                          <LoadingIndicator mini hideMessage relative />
-                        </LoadingWrapper>
-                      )}
-                      {!isLoading &&
-                        results.slice(0, maxResults).map((resultObj, index) =>
-                          this.renderItem({
-                            resultObj,
+      trackAdvancedAnalyticsEvent(`${props.entryPoint}.query`, {
+        query,
+        organization: null,
+      });
+    },
+    [props.entryPoint]
+  );
+
+  const debouncedSaveQueryMetrics = React.useMemo(() => {
+    return debounce(saveQueryMetrics, 200);
+  }, [props.entryPoint, saveQueryMetrics]);
+
+  return (
+    <AutoComplete
+      defaultHighlightedIndex={0}
+      onSelect={handleSelectItem}
+      closeOnSelect={props.closeOnSelect ?? true}
+    >
+      {({getInputProps, getItemProps, isOpen, inputValue, highlightedIndex}) => {
+        const searchQuery = inputValue.toLowerCase().trim();
+        const isValidSearch = inputValue.length >= props.minSearch;
+
+        debouncedSaveQueryMetrics(searchQuery);
+
+        const renderItem =
+          typeof props.renderItem === 'function'
+            ? props.renderItem
+            : ({
+                item,
+                matches,
+                itemProps,
+                highlighted,
+              }: ItemProps): React.ReactElement => (
+                <SearchResultWrapper {...itemProps} highlighted={highlighted}>
+                  <SearchResult highlighted={highlighted} item={item} matches={matches} />
+                </SearchResultWrapper>
+              );
+
+        return (
+          <SearchWrapper>
+            {props.renderInput({getInputProps})}
+
+            {isValidSearch && isOpen ? (
+              <SearchSources
+                searchOptions={props.searchOptions}
+                query={searchQuery}
+                params={props.params}
+                sources={
+                  props.sources ??
+                  ([
+                    ApiSource,
+                    FormSource,
+                    RouteSource,
+                    CommandSource,
+                  ] as React.ComponentType[])
+                }
+              >
+                {({isLoading, results, hasAnyResults}) => (
+                  <DropdownBox className={props.dropdownStyle}>
+                    {isLoading ? (
+                      <LoadingWrapper>
+                        <LoadingIndicator mini hideMessage relative />
+                      </LoadingWrapper>
+                    ) : !hasAnyResults ? (
+                      <EmptyItem>{t('No results found')}</EmptyItem>
+                    ) : (
+                      results.slice(0, props.maxResults).map((resultObj, index) => {
+                        return React.cloneElement(
+                          renderItem({
                             index,
-                            highlightedIndex,
-                            getItemProps,
-                          })
-                        )}
-                      {!isLoading && !hasAnyResults && (
-                        <EmptyItem>{t('No results found')}</EmptyItem>
-                      )}
-                      {!isLoading && resultFooter && (
-                        <ResultFooter>{resultFooter}</ResultFooter>
-                      )}
-                    </DropdownBox>
-                  )}
-                </SearchSources>
-              ) : null}
-            </SearchWrapper>
-          );
-        }}
-      </AutoComplete>
-    );
-  }
+                            item: resultObj.item,
+                            matches: resultObj.matches,
+                            highlighted: index === highlightedIndex,
+                            itemProps: getItemProps({
+                              item: resultObj.item,
+                              index,
+                            }),
+                          }),
+                          {key: `${resultObj.item.title}-${index}`}
+                        );
+                      })
+                    )}
+                    {!isLoading && props.resultFooter ? (
+                      <ResultFooter>{props.resultFooter}</ResultFooter>
+                    ) : null}
+                  </DropdownBox>
+                )}
+              </SearchSources>
+            ) : null}
+          </SearchWrapper>
+        );
+      }}
+    </AutoComplete>
+  );
 }
 
-export default withRouter(Search);
+const WithRouterSearch = withRouter(Search);
+export {WithRouterSearch as Search, SearchProps};
 
 const DropdownBox = styled('div')`
   background: ${p => p.theme.background};

+ 5 - 2
static/app/components/search/sources/index.tsx

@@ -63,8 +63,11 @@ class SearchSources extends React.Component<Props> {
   }
 
   render() {
-    const {sources} = this.props;
-    return this.renderSources(sources, new Array(sources.length), 0);
+    return this.renderSources(
+      this.props.sources,
+      new Array(this.props.sources.length),
+      0
+    );
   }
 }
 

+ 1 - 1
static/app/views/settings/components/settingsSearch/index.tsx

@@ -2,7 +2,7 @@ import {useRef} from 'react';
 import {useHotkeys} from 'react-hotkeys-hook';
 import styled from '@emotion/styled';
 
-import Search from 'sentry/components/search';
+import {Search} from 'sentry/components/search';
 import {IconSearch} from 'sentry/icons';
 import {t} from 'sentry/locale';
 

+ 220 - 0
tests/js/spec/components/search/search.spec.tsx

@@ -0,0 +1,220 @@
+import * as React from 'react';
+import Fuse from 'fuse.js';
+
+import {mountWithTheme, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+import {textWithMarkupMatcher} from 'sentry-test/utils';
+
+import {Search, SearchProps} from 'sentry/components/search';
+import {ChildProps, Result, ResultItem} from 'sentry/components/search/sources/types';
+
+function makeSearchResultsMock(items?: ResultItem[], threshold?: number) {
+  return function SearchResultsMock({
+    loading,
+    children,
+    query,
+  }: {
+    children: (props: ChildProps) => React.ReactElement | null;
+    loading: boolean;
+    query: string;
+  }): React.ReactElement<any, any> | null {
+    const searchableItems: ResultItem[] = items ?? [
+      {
+        resultType: 'integration',
+        sourceType: 'organization',
+        title: 'Vandelay Industries - Import',
+        model: {slug: 'vdl-imp'},
+      },
+      {
+        resultType: 'integration',
+        model: {slug: 'vdl-exp'},
+        sourceType: 'organization',
+        title: 'Vandelay Industries - Export',
+      },
+    ];
+    const results = new Fuse(searchableItems, {
+      keys: ['title'],
+      includeMatches: true,
+      includeScore: true,
+      threshold: threshold ?? 0.3,
+    })
+      .search(query)
+      .map(item => {
+        const result: Result = {
+          item: item.item,
+          score: item.score,
+          matches: item.matches,
+        };
+        return result;
+      });
+
+    return children({
+      isLoading: loading,
+      results,
+    });
+  } as React.ComponentType;
+}
+const makeSearchProps = (partial: Partial<SearchProps> = {}): SearchProps => {
+  return {
+    renderInput: ({getInputProps}) => {
+      return <input {...getInputProps({placeholder: 'Search Input'})} />;
+    },
+    sources: [makeSearchResultsMock()],
+    caseSensitive: false,
+    minSearch: 0,
+    ...partial,
+  } as SearchProps;
+};
+
+describe('Search', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+    jest.useFakeTimers();
+  });
+  afterEach(() => {
+    jest.useRealTimers();
+  });
+  it('renders search results from source', () => {
+    mountWithTheme(<Search {...makeSearchProps()} />, {
+      context: TestStubs.routerContext(),
+    });
+
+    userEvent.click(screen.getByPlaceholderText('Search Input'));
+    userEvent.keyboard('Export');
+
+    jest.advanceTimersByTime(500);
+
+    expect(
+      screen.getByText(textWithMarkupMatcher(/Vandelay Industries - Export/))
+    ).toBeInTheDocument();
+    expect(
+      screen.queryByText(textWithMarkupMatcher(/Vandelay Industries - Import/))
+    ).not.toBeInTheDocument();
+  });
+
+  it('navigates to a route when item has to prop', () => {
+    mountWithTheme(
+      <Search
+        {...makeSearchProps({
+          sources: [
+            makeSearchResultsMock([
+              {
+                resultType: 'integration',
+                sourceType: 'organization',
+                title: 'Vandelay Industries - Import',
+                to: 'https://vandelayindustries.io/import',
+                model: {slug: 'vdl-imp'},
+              },
+            ]),
+          ],
+        })}
+      />,
+      {
+        context: TestStubs.routerContext(),
+      }
+    );
+
+    const opener = {opener: 'Sentry.io', location: {href: null}};
+
+    // @ts-ignore this is a partial mock of the window object
+    const windowSpy = jest.spyOn(window, 'open').mockReturnValue(opener);
+
+    userEvent.click(screen.getByPlaceholderText('Search Input'));
+    userEvent.keyboard('Import');
+
+    userEvent.click(
+      screen.getByText(textWithMarkupMatcher(/Vandelay Industries - Import/))
+    );
+
+    expect(windowSpy).toHaveBeenCalledTimes(1);
+    expect(opener.opener).not.toBe('Sentry.io');
+    expect(opener.location.href).toBe('https://vandelayindustries.io/import');
+  });
+
+  it('calls item action when it is a function', () => {
+    mountWithTheme(
+      <Search
+        {...makeSearchProps({
+          sources: [
+            makeSearchResultsMock([
+              {
+                resultType: 'integration',
+                sourceType: 'organization',
+                title: 'Vandelay Industries - Import',
+                to: 'https://vandelayindustries.io/import',
+                model: {slug: 'vdl-imp'},
+              },
+            ]),
+          ],
+        })}
+      />,
+      {
+        context: TestStubs.routerContext(),
+      }
+    );
+
+    const opener = {opener: 'Sentry.io', location: {href: null}};
+
+    // @ts-ignore this is a partial mock of the window object
+    const windowSpy = jest.spyOn(window, 'open').mockReturnValue(opener);
+
+    userEvent.click(screen.getByPlaceholderText('Search Input'));
+    userEvent.keyboard('Import');
+
+    userEvent.click(
+      screen.getByText(textWithMarkupMatcher(/Vandelay Industries - Import/))
+    );
+
+    expect(windowSpy).toHaveBeenCalledTimes(1);
+    expect(opener.opener).not.toBe('Sentry.io');
+    expect(opener.location.href).toBe('https://vandelayindustries.io/import');
+  });
+  it('renders max search results', async () => {
+    const results: ResultItem[] = new Array(10).fill(0).map((_, i) => ({
+      resultType: 'integration',
+      sourceType: 'organization',
+      title: `${i} Vandelay Industries - Import`,
+      to: 'https://vandelayindustries.io/import',
+      model: {slug: 'vdl-imp'},
+    }));
+
+    mountWithTheme(
+      <Search
+        {...makeSearchProps({
+          maxResults: 5,
+          sources: [makeSearchResultsMock(results)],
+        })}
+      />,
+      {
+        context: TestStubs.routerContext(),
+      }
+    );
+
+    userEvent.click(screen.getByPlaceholderText('Search Input'));
+    userEvent.keyboard('Vandelay');
+
+    expect(await screen.findAllByText(/Vandelay/)).toHaveLength(5);
+    for (let i = 0; i < 5; i++) {
+      expect(
+        screen.getByText(textWithMarkupMatcher(`${i} Vandelay Industries - Import`))
+      ).toBeInTheDocument();
+    }
+  });
+  it('shows no search result', () => {
+    mountWithTheme(
+      <Search
+        {...makeSearchProps({
+          maxResults: 5,
+          sources: [makeSearchResultsMock([])],
+        })}
+      />,
+      {
+        context: TestStubs.routerContext(),
+      }
+    );
+
+    userEvent.click(screen.getByPlaceholderText('Search Input'));
+    userEvent.keyboard('Vandelay');
+
+    expect(screen.getByText(/No results/)).toBeInTheDocument();
+  });
+});