Browse Source

feat(support): Replace algolia search w/ SentryGlobalSearch (#20980)

Evan Purkhiser 4 years ago
parent
commit
e605579b6b

+ 1 - 1
package.json

@@ -22,6 +22,7 @@
     "@emotion/styled": "^10.0.27",
     "@sentry/integrations": "5.25.0-beta.2",
     "@sentry/react": "5.25.0-beta.2",
+    "@sentry-internal/global-search": "^0.0.30",
     "@sentry/release-parser": "^0.6.0",
     "@sentry/rrweb": "^0.1.1",
     "@sentry/tracing": "5.25.0-beta.2",
@@ -52,7 +53,6 @@
     "@types/reflexbox": "^4.0.0",
     "@types/reflux": "0.4.1",
     "@types/scroll-to-element": "^2.0.0",
-    "algoliasearch": "^3.32.0",
     "babel-loader": "^8.1.0",
     "babel-plugin-add-react-displayname": "^0.0.5",
     "babel-plugin-transform-react-remove-prop-types": "^0.4.24",

+ 6 - 2
src/sentry/static/sentry/app/actionCreators/modal.tsx

@@ -150,13 +150,17 @@ export async function redirectToProject(newProjectSlug: string) {
   openModal(deps => <Modal {...deps} slug={newProjectSlug} />, {});
 }
 
-export async function openHelpSearchModal() {
+type HelpSearchModalOptipons = {
+  organization: Organization;
+};
+
+export async function openHelpSearchModal(options: HelpSearchModalOptipons) {
   const mod = await import(
     /* webpackChunkName: "HelpSearchModal" */ 'app/components/modals/helpSearchModal'
   );
   const {default: Modal, modalCss} = mod;
 
-  openModal(deps => <Modal {...deps} />, {modalCss});
+  openModal(deps => <Modal {...deps} {...options} />, {modalCss});
 }
 
 export type SentryAppDetailsModalOptions = {

+ 89 - 0
src/sentry/static/sentry/app/components/helpSearch.tsx

@@ -0,0 +1,89 @@
+import React from 'react';
+import styled from '@emotion/styled';
+
+import {t, tn} from 'app/locale';
+import Search from 'app/components/search';
+import HelpSource from 'app/components/search/sources/helpSource';
+import SearchResult from 'app/components/search/searchResult';
+import SearchResultWrapper from 'app/components/search/searchResultWrapper';
+import space from 'app/styles/space';
+import {IconWindow} from 'app/icons';
+
+type HelpResult = Parameters<
+  React.ComponentProps<typeof HelpSource>['children']
+>[0]['results'][0];
+
+type ResultItemProps = HelpResult & {
+  highlighted: boolean;
+  // TODO(ts): Improve types when we've typed more of the search components
+  itemProps: any;
+};
+
+const renderResult = ({item, matches, itemProps, highlighted}: ResultItemProps) => {
+  const sectionHeading =
+    item.sectionHeading !== undefined ? (
+      <SectionHeading>
+        <IconWindow />
+        {t('From %s', item.sectionHeading)}
+        <Count>{tn('%s result', '%s results', item.sectionCount ?? 0)}</Count>
+      </SectionHeading>
+    ) : null;
+
+  if (item.empty) {
+    return (
+      <React.Fragment>
+        {sectionHeading}
+        <Empty>{t('No results from %s', item.sectionHeading)}</Empty>
+      </React.Fragment>
+    );
+  }
+
+  return (
+    <React.Fragment>
+      {sectionHeading}
+      <SearchResultWrapper {...itemProps} highlighted={highlighted}>
+        <SearchResult highlighted={highlighted} item={item} matches={matches} />
+      </SearchResultWrapper>
+    </React.Fragment>
+  );
+};
+
+// TODO(ts): Type based on Search props once that has types
+const HelpSearch = props => (
+  <Search
+    {...props}
+    sources={[HelpSource]}
+    minSearch={3}
+    closeOnSelect={false}
+    renderItem={renderResult}
+  />
+);
+
+const SectionHeading = styled('div')`
+  display: grid;
+  grid-template-columns: max-content 1fr max-content;
+  grid-gap: ${space(1)};
+  align-items: center;
+  background: ${p => p.theme.gray200};
+  padding: ${space(1)} ${space(2)};
+
+  &:not(:first-of-type) {
+    border-top: 1px solid ${p => p.theme.borderLight};
+  }
+`;
+
+const Count = styled('div')`
+  font-size: ${p => p.theme.fontSizeSmall};
+  color: ${p => p.theme.gray500};
+`;
+
+const Empty = styled('div')`
+  display: flex;
+  align-items: center;
+  padding: ${space(2)};
+  color: ${p => p.theme.gray600};
+  font-size: ${p => p.theme.fontSizeMedium};
+  border-top: 1px solid ${p => p.theme.borderLight};
+`;
+
+export default HelpSearch;

+ 0 - 80
src/sentry/static/sentry/app/components/modals/helpSearchModal.jsx

@@ -1,80 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import styled from '@emotion/styled';
-import {ClassNames, css} from '@emotion/core';
-
-import {t} from 'app/locale';
-import Search from 'app/components/search';
-import HelpSource from 'app/components/search/sources/helpSource';
-import theme from 'app/utils/theme';
-
-class HelpSearchModal extends React.Component {
-  static propTypes = {
-    Body: PropTypes.oneOfType([PropTypes.func, PropTypes.node]).isRequired,
-  };
-
-  render() {
-    const {Body} = this.props;
-
-    return (
-      <Body>
-        <ClassNames>
-          {({css: injectedCss}) => (
-            <Search
-              {...this.props}
-              sources={[HelpSource]}
-              entryPoint="sidebar_help"
-              minSearch={3}
-              maxResults={10}
-              dropdownStyle={injectedCss`
-                width: 100%;
-                border: transparent;
-                border-top-left-radius: 0;
-                border-top-right-radius: 0;
-                position: initial;
-                box-shadow: none;
-                border-top: 1px solid ${theme.borderLight};
-              `}
-              closeOnSelect={false}
-              renderInput={({getInputProps}) => (
-                <InputWrapper>
-                  <Input
-                    autoFocus
-                    {...getInputProps({
-                      type: 'text',
-                      placeholder: t('Search for Docs and FAQs...'),
-                    })}
-                  />
-                </InputWrapper>
-              )}
-            />
-          )}
-        </ClassNames>
-      </Body>
-    );
-  }
-}
-
-const InputWrapper = styled('div')`
-  padding: 2px;
-`;
-
-const Input = styled('input')`
-  width: 100%;
-  padding: 8px;
-  border: none;
-  border-radius: 8px;
-  outline: none;
-
-  &:focus {
-    outline: none;
-  }
-`;
-
-export const modalCss = css`
-  .modal-content {
-    padding: 0;
-  }
-`;
-
-export default HelpSearchModal;

+ 71 - 0
src/sentry/static/sentry/app/components/modals/helpSearchModal.tsx

@@ -0,0 +1,71 @@
+import React from 'react';
+import styled from '@emotion/styled';
+import {ClassNames, css} from '@emotion/core';
+
+import {t} from 'app/locale';
+import theme from 'app/utils/theme';
+import {ModalRenderProps} from 'app/actionCreators/modal';
+import HelpSearch from 'app/components/helpSearch';
+import Hook from 'app/components/hook';
+import {Organization} from 'app/types';
+import space from 'app/styles/space';
+
+type Props = ModalRenderProps & {organization: Organization};
+
+const HelpSearchModal = ({Body, closeModal, organization, ...props}: Props) => (
+  <Body>
+    <ClassNames>
+      {({css: injectedCss}) => (
+        <HelpSearch
+          {...props}
+          entryPoint="sidebar_help"
+          dropdownStyle={injectedCss`
+                width: 100%;
+                border: transparent;
+                border-top-left-radius: 0;
+                border-top-right-radius: 0;
+                position: initial;
+                box-shadow: none;
+                border-top: 1px solid ${theme.borderLight};
+              `}
+          renderInput={({getInputProps}) => (
+            <InputWrapper>
+              <Input
+                autoFocus
+                {...getInputProps({
+                  type: 'text',
+                  placeholder: t('Search for documentation, FAQs, blog posts...'),
+                })}
+              />
+            </InputWrapper>
+          )}
+          resultFooter={<Hook name="help-modal:footer" {...{organization, closeModal}} />}
+        />
+      )}
+    </ClassNames>
+  </Body>
+);
+
+const InputWrapper = styled('div')`
+  padding: ${space(0.25)};
+`;
+
+const Input = styled('input')`
+  width: 100%;
+  padding: ${space(1)};
+  border: none;
+  border-radius: 8px;
+  outline: none;
+
+  &:focus {
+    outline: none;
+  }
+`;
+
+export const modalCss = css`
+  .modal-content {
+    padding: 0;
+  }
+`;
+
+export default HelpSearchModal;

+ 15 - 0
src/sentry/static/sentry/app/components/search/index.jsx

@@ -60,6 +60,10 @@ class Search extends React.Component {
     searchOptions: PropTypes.object,
     // Passed to the underlying AutoComplete component
     closeOnSelect: PropTypes.bool,
+    /**
+     * Adds a footer below the results when the search is complete
+     */
+    resultFooter: PropTypes.node,
   };
 
   static defaultProps = {
@@ -178,6 +182,7 @@ class Search extends React.Component {
       renderInput,
       sources,
       closeOnSelect,
+      resultFooter,
     } = this.props;
 
     return (
@@ -233,6 +238,9 @@ class Search extends React.Component {
                       {!isLoading && !hasAnyResults && (
                         <EmptyItem>{t('No results found')}</EmptyItem>
                       )}
+                      {!isLoading && resultFooter && (
+                        <ResultFooter>{resultFooter}</ResultFooter>
+                      )}
                     </DropdownBox>
                   )}
                 </SearchSources>
@@ -264,6 +272,13 @@ const SearchWrapper = styled('div')`
   position: relative;
 `;
 
+const ResultFooter = styled('div')`
+  position: sticky;
+  bottom: 0;
+  left: 0;
+  right: 0;
+`;
+
 const EmptyItem = styled(SearchResultWrapper)`
   text-align: center;
   padding: 16px;

+ 15 - 4
src/sentry/static/sentry/app/components/search/searchResult.jsx

@@ -8,6 +8,7 @@ import {IconInput, IconLink, IconSettings} from 'app/icons';
 import PluginIcon from 'app/plugins/components/pluginIcon';
 import SettingsSearch from 'app/views/settings/components/settingsSearch';
 import highlightFuseMatches from 'app/utils/highlightFuseMatches';
+import space from 'app/styles/space';
 
 class SearchResult extends React.Component {
   static propTypes = {
@@ -45,13 +46,16 @@ class SearchResult extends React.Component {
         'issue',
         'event',
         'integration',
-        'doc',
-        'faq',
+        'help-docs',
+        'help-develop',
+        'help-help-center',
+        'help-blog',
       ]),
 
       resultIcon: PropTypes.node,
       title: PropTypes.node,
       description: PropTypes.node,
+      extra: PropTypes.node,
       model: PropTypes.object,
     }),
     matches: PropTypes.array,
@@ -59,7 +63,7 @@ class SearchResult extends React.Component {
 
   renderContent() {
     const {highlighted, item, matches, params} = this.props;
-    const {sourceType, model} = item;
+    const {sourceType, model, extra} = item;
     let {title, description} = item;
 
     if (matches) {
@@ -101,6 +105,7 @@ class SearchResult extends React.Component {
           <SearchTitle>{title}</SearchTitle>
         </div>
         {description && <SearchDetail>{description}</SearchDetail>}
+        {extra && <ExtraDetail>{extra}</ExtraDetail>}
       </React.Fragment>
     );
   }
@@ -159,6 +164,12 @@ const SearchDetail = styled('div')`
   opacity: 0.8;
 `;
 
+const ExtraDetail = styled('div')`
+  font-size: ${p => p.theme.fontSizeSmall};
+  color: ${p => p.theme.gray500};
+  margin-top: ${space(0.5)};
+`;
+
 const BadgeDetail = styled('div')`
   line-height: 1.3;
   color: ${p => (p.highlighted ? p.theme.purple500 : null)};
@@ -189,5 +200,5 @@ const HighlightMarker = styled('mark')`
   padding: 0;
   background: transparent;
   font-weight: bold;
-  color: inherit;
+  color: ${p => p.theme.pink400};
 `;

+ 0 - 175
src/sentry/static/sentry/app/components/search/sources/helpSource.jsx

@@ -1,175 +0,0 @@
-import debounce from 'lodash/debounce';
-import {withRouter} from 'react-router';
-import PropTypes from 'prop-types';
-import React from 'react';
-import algoliasearch from 'algoliasearch';
-import styled from '@emotion/styled';
-
-import {
-  ALGOLIA_APP_ID,
-  ALGOLIA_READ_ONLY,
-  ALGOLIA_DOCS_INDEX,
-  ALGOLIA_ZENDESK_INDEX,
-} from 'app/constants';
-import parseHtmlMarks from 'app/utils/parseHtmlMarks';
-import withLatestContext from 'app/utils/withLatestContext';
-
-/**
- * Use unique markers for highlighting so we can parse these into fuse-style
- * indicidies.
- */
-const HIGHLIGHT_TAGS = {
-  highlightPreTag: '<algolia-highlight-0000000000>',
-  highlightPostTag: '</algolia-highlight-0000000000>',
-};
-
-const SNIPPET_LENGTH = 260;
-
-class HelpSource extends React.Component {
-  static propTypes = {
-    // search term
-    query: PropTypes.string,
-
-    /**
-     * Render function that passes:
-     * `isLoading` - loading state
-     * `allResults` - All results returned from all queries: [searchIndex, model, type]
-     * `results` - Results array filtered by `this.props.query`: [searchIndex, model, type]
-     */
-    children: PropTypes.func.isRequired,
-  };
-
-  constructor(props, ...args) {
-    super(props, ...args);
-    this.state = {
-      loading: false,
-      results: null,
-    };
-
-    this.algolia = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_READ_ONLY);
-  }
-
-  componentDidMount() {
-    if (typeof this.props.query !== 'undefined') {
-      this.doSearch(this.props.query);
-    }
-  }
-
-  UNSAFE_componentWillReceiveProps(nextProps) {
-    if (nextProps.query !== this.props.query) {
-      this.doSearch(nextProps.query);
-    }
-  }
-
-  async searchAlgolia(query) {
-    this.setState({loading: true});
-
-    const params = {
-      hitsPerPage: 5,
-      ...HIGHLIGHT_TAGS,
-    };
-
-    const response = await this.algolia.search([
-      {
-        query,
-        params,
-        indexName: ALGOLIA_DOCS_INDEX,
-        attributesToSnippet: ['title', `content: ${SNIPPET_LENGTH}`],
-      },
-      {
-        query,
-        params,
-        indexName: ALGOLIA_ZENDESK_INDEX,
-        attributesToSnippet: ['title', `body_safe: ${SNIPPET_LENGTH}`],
-      },
-    ]);
-    const [docResults, faqResults] = response.results;
-
-    const results = [
-      ...docResults.hits.map(result =>
-        buildHit(result, {
-          descriptionKey: 'content',
-          type: 'doc',
-          badge: <DocsBadge />,
-          makeUrl: ({url}) => `https://docs.sentry.io${url}`,
-        })
-      ),
-      ...faqResults.hits.map(result =>
-        buildHit(result, {
-          descriptionKey: 'body_safe',
-          type: 'faq',
-          badge: <FaqsBadge />,
-          makeUrl: ({id}) => `https://help.sentry.io/hc/en-us/articles/${id}`,
-        })
-      ),
-    ];
-
-    this.setState({loading: false, results});
-  }
-
-  doSearch = debounce(this.searchAlgolia, 300);
-
-  render() {
-    return this.props.children({
-      isLoading: this.state.loading,
-      allResults: this.state.results,
-      results: this.state.results,
-    });
-  }
-}
-
-/**
- * Maps an Algolia hit response over to a SearchResult item.
- */
-function buildHit(hit, options) {
-  const {_highlightResult, _snippetResult} = hit;
-  const {descriptionKey, type, makeUrl, badge} = options;
-
-  const title = parseHtmlMarks({
-    key: 'title',
-    htmlString: _highlightResult.title.value,
-    markTags: HIGHLIGHT_TAGS,
-  });
-  const description = _snippetResult
-    ? parseHtmlMarks({
-        key: 'description',
-        htmlString: _snippetResult[descriptionKey].value,
-        markTags: HIGHLIGHT_TAGS,
-      })
-    : {};
-
-  const item = {
-    sourceType: 'help',
-    resultType: type,
-    resultIcon: badge,
-    title: title.value,
-    description: description.value,
-    to: makeUrl(hit),
-  };
-
-  return {
-    item,
-    matches: [title, description],
-  };
-}
-
-const ResultIcon = styled('div')`
-  display: inline-block;
-  font-size: 0.8em;
-  line-height: 1;
-  padding: 4px 6px;
-  margin-left: 8px;
-  border-radius: 11px;
-  color: #fff;
-`;
-
-const DocsBadge = styled(p => <ResultIcon {...p}>docs</ResultIcon>)`
-  background: ${p => p.theme.blue300};
-`;
-
-const FaqsBadge = styled(p => <ResultIcon {...p}>faqs</ResultIcon>)`
-  background: ${p => p.theme.green300};
-`;
-
-export {HelpSource};
-export default withLatestContext(withRouter(HelpSource));

+ 182 - 0
src/sentry/static/sentry/app/components/search/sources/helpSource.tsx

@@ -0,0 +1,182 @@
+import React from 'react';
+import debounce from 'lodash/debounce';
+import dompurify from 'dompurify';
+import {withRouter, WithRouterProps} from 'react-router';
+import {
+  SentryGlobalSearch,
+  standardSDKSlug,
+  Result as SearchResult,
+} from '@sentry-internal/global-search';
+
+import withLatestContext from 'app/utils/withLatestContext';
+import {Organization, Project} from 'app/types';
+import parseHtmlMarks from 'app/utils/parseHtmlMarks';
+
+type MarkedText = ReturnType<typeof parseHtmlMarks>;
+
+type ResultItem = {
+  sourceType: 'help';
+  resultType: string;
+  title: string;
+  description?: string;
+  to?: string;
+  /**
+   * Context will be mapped into the extra node
+   */
+  extra?: string;
+  /**
+   * Section heading is declared when the first result designates a section of the
+   * global search results.
+   */
+  sectionHeading?: string;
+  sectionCount?: number;
+  empty?: boolean;
+} & (
+  | SearchResult['hits'][0]
+  | {
+      /**
+       * When we have no results for a section we mark the result item as empty
+       */
+      empty?: true;
+    }
+);
+
+type Result = {
+  item: ResultItem;
+  matches?: MarkedText[];
+};
+
+type RenderProps = {
+  isLoading: boolean;
+  /**
+   * Matched results
+   */
+  results: Result[];
+  allResults: Result[];
+};
+
+type Props = WithRouterProps & {
+  organization: Organization;
+  project: Project;
+  /**
+   * Specific platforms to filter reults to
+   */
+  platforms: string[];
+  /**
+   * The string to search the navigation routes for
+   */
+  query: string;
+  /**
+   * Render function that renders the global search result
+   */
+  children: (props: RenderProps) => React.ReactNode;
+};
+
+type State = {
+  loading: boolean;
+  results: Result[];
+};
+
+const MARK_TAGS = {
+  highlightPreTag: '<mark>',
+  highlightPostTag: '</mark>',
+};
+
+class HelpSource extends React.Component<Props, State> {
+  state: State = {
+    loading: false,
+    results: [],
+  };
+
+  componentDidMount() {
+    if (this.props.query !== undefined) {
+      this.doSearch(this.props.query);
+    }
+  }
+
+  componentDidUpdate(nextProps: Props) {
+    if (nextProps.query !== this.props.query) {
+      this.doSearch(nextProps.query);
+    }
+  }
+
+  search = new SentryGlobalSearch(['docs', 'help-center', 'develop', 'blog']);
+
+  async unbouncedSearch(query: string) {
+    this.setState({loading: true});
+    const {platforms = []} = this.props;
+
+    const searchResults = await this.search.query(query, {
+      platforms: platforms.map(platform => standardSDKSlug(platform)?.slug!),
+    });
+    const results = mapSearchResults(searchResults);
+
+    this.setState({loading: false, results});
+  }
+
+  doSearch = debounce(this.unbouncedSearch, 300);
+
+  render() {
+    return this.props.children({
+      isLoading: this.state.loading,
+      allResults: this.state.results,
+      results: this.state.results,
+    });
+  }
+}
+
+function mapSearchResults(results: SearchResult[]) {
+  const items: Result[] = [];
+
+  results.forEach(section => {
+    const sectionItems = section.hits.map<Result>(hit => {
+      const title = parseHtmlMarks({
+        key: 'title',
+        htmlString: hit.title ?? '',
+        markTags: MARK_TAGS,
+      });
+      const description = parseHtmlMarks({
+        key: 'description',
+        htmlString: hit.text ?? '',
+        markTags: MARK_TAGS,
+      });
+
+      const item: ResultItem = {
+        ...hit,
+        sourceType: 'help',
+        resultType: `help-${hit.site}`,
+        title: dompurify.sanitize(hit.title ?? ''),
+        extra: hit.context.context1,
+        description: hit.text ? dompurify.sanitize(hit.text) : undefined,
+        to: hit.url,
+      };
+
+      return {item, matches: [title, description]};
+    });
+
+    // The first element should indicate the section.
+    if (sectionItems.length > 0) {
+      sectionItems[0].item.sectionHeading = section.name;
+      sectionItems[0].item.sectionCount = sectionItems.length;
+
+      items.push(...sectionItems);
+      return;
+    }
+
+    // If we didn't have any results for this section mark it as empty
+    const emptyHeaderItem: ResultItem = {
+      sourceType: 'help',
+      resultType: `help-${section.site}`,
+      title: `No results in ${section.name}`,
+      sectionHeading: section.name,
+      empty: true,
+    };
+
+    items.push({item: emptyHeaderItem});
+  });
+
+  return items;
+}
+
+export {HelpSource};
+export default withLatestContext(withRouter(HelpSource));

+ 1 - 1
src/sentry/static/sentry/app/components/sidebar/help.tsx

@@ -35,7 +35,7 @@ const SidebarHelp = ({orientation, collapsed, hidePanel, organization}: Props) =
         {isOpen && (
           <HelpMenu {...getMenuProps({})}>
             <Hook name="sidebar:help-menu" organization={organization} />
-            <SidebarMenuItem onClick={openHelpSearchModal}>
+            <SidebarMenuItem onClick={() => openHelpSearchModal({organization})}>
               {t('Search Docs and FAQs')}
             </SidebarMenuItem>
             <SidebarMenuItem href="https://forum.sentry.io/">

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