Просмотр исходного кода

ref(ui-search): Use Algolia for Docs / FAQ search (#11520)

Evan Purkhiser 6 лет назад
Родитель
Сommit
f2fd7adfb1

+ 1 - 0
package.json

@@ -19,6 +19,7 @@
     "@babel/preset-react": "^7.0.0",
     "@babel/runtime": "^7.0.0",
     "@sentry/browser": "^4.4.2",
+    "algoliasearch": "^3.32.0",
     "babel-core": "^7.0.0-bridge.0",
     "babel-loader": "^8.0.0",
     "babel-plugin-emotion": "9.1.2",

+ 3 - 3
src/sentry/static/sentry/app/actionCreators/modal.jsx

@@ -132,12 +132,12 @@ export function redirectToProject(newProjectSlug) {
     });
 }
 
-export function openDocsSearchModal() {
-  import(/* webpackChunkName: "DocsSearchModal" */ 'app/components/modals/docsSearchModal')
+export function openHelpSearchModal() {
+  import(/* webpackChunkName: "HelpSearchModal" */ 'app/components/modals/helpSearchModal')
     .then(mod => mod.default)
     .then(Modal => {
       openModal(deps => <Modal {...deps} />, {
-        modalClassName: 'docs-search-modal',
+        modalClassName: 'help-search-modal',
       });
     });
 }

+ 0 - 144
src/sentry/static/sentry/app/components/modals/docsSearchModal.jsx

@@ -1,144 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import styled, {css} from 'react-emotion';
-import {flatMap} from 'lodash';
-
-import {t} from 'app/locale';
-import Search from 'app/components/search';
-import DocsSource from 'app/components/search/sources/docsSource';
-import FaqSource from 'app/components/search/sources/faqSource';
-
-const dropdownStyle = css`
-  list-style: none;
-  margin-bottom: 0;
-  width: 100%;
-  border: none;
-  position: initial;
-  box-shadow: none;
-`;
-
-class DocsSearchModal extends React.Component {
-  static propTypes = {
-    Body: PropTypes.oneOfType([PropTypes.func, PropTypes.node]).isRequired,
-  };
-
-  render() {
-    let {Body} = this.props;
-
-    return (
-      <Body>
-        <Search
-          {...this.props}
-          sources={[DocsSource, FaqSource]}
-          entryPoint="sidebar_help"
-          minSearch={3}
-          maxResults={10}
-          dropdownStyle={dropdownStyle}
-          closeOnSelect={false}
-          renderInput={({getInputProps}) => (
-            <InputWrapper>
-              <Input
-                autoFocus
-                innerRef={ref => (this.searchInput = ref)}
-                {...getInputProps({
-                  type: 'text',
-                  placeholder: t('Search for Docs and FAQs...'),
-                })}
-              />
-            </InputWrapper>
-          )}
-          renderItem={({item}) => {
-            let {result, type} = item;
-            if (type === 'docs') {
-              let link = `https://docs.sentry.io/${result.path}/`;
-              let path = flatMap(result.path.split(/[#\/]/), part => [
-                part,
-                <span className="divider" key={part}>
-                  {' '}
-                  &gt;&gt;{' '}
-                </span>,
-              ]);
-              path.pop();
-              return (
-                <a href={link}>
-                  <SearchResultWrapper type={type}>
-                    <span className="title">{result.title}</span>
-                    <p>{path}</p>
-                  </SearchResultWrapper>
-                </a>
-              );
-            }
-            return (
-              <a href={result.html_url}>
-                <SearchResultWrapper type={type}>
-                  <span className="title">{result.title}</span>
-                </SearchResultWrapper>
-              </a>
-            );
-          }}
-        />
-      </Body>
-    );
-  }
-}
-
-export default DocsSearchModal;
-
-const SearchResultWrapper = styled('li')`
-  position: relative;
-  padding: 8px 14px 8px 70px;
-  cursor: pointer;
-
-  &:before {
-    position: absolute;
-    left: 15px;
-    top: 50%;
-    transform: translateY(-50%);
-    padding: 4px;
-    color: darkgrey;
-    width: 40px;
-    height: 20px;
-    background: 0 0;
-    border: 1px solid;
-    border-radius: 9px;
-    text-align: center;
-    font-size: 11px;
-    line-height: 1;
-    content: ${p => (p.type == 'docs' ? "'Docs'" : "'Q&A'")};
-  }
-
-  p {
-    margin: 2px 0 0;
-    font-size: 13px;
-    color: @gray-light;
-    line-height: 1;
-  }
-
-  .title {
-    font-weight: bold;
-  }
-
-  a {
-    color: black;
-  }
-
-  .divider {
-    opacity: 0.45 !important;
-  }
-`;
-
-const InputWrapper = styled('div')`
-  padding: 2px;
-`;
-
-const Input = styled('input')`
-  width: 100%;
-  padding: 8px;
-  border: none;
-  border-radius: 8px;
-  outline: none;
-
-  &:focus {
-    outline: none;
-  }
-`;

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

@@ -0,0 +1,71 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled, {css} from 'react-emotion';
+
+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';
+
+const dropdownStyle = css`
+  width: 100%;
+  border: transparent;
+  border-top-left-radius: 0;
+  border-top-right-radius: 0;
+  position: initial;
+  box-shadow: none;
+  border-top: 1px solid ${p => theme.borderLight};
+`;
+
+class HelpSearchModal extends React.Component {
+  static propTypes = {
+    Body: PropTypes.oneOfType([PropTypes.func, PropTypes.node]).isRequired,
+  };
+
+  render() {
+    let {Body} = this.props;
+
+    return (
+      <Body>
+        <Search
+          {...this.props}
+          sources={[HelpSource]}
+          entryPoint="sidebar_help"
+          minSearch={3}
+          maxResults={10}
+          dropdownStyle={dropdownStyle}
+          closeOnSelect={false}
+          renderInput={({getInputProps}) => (
+            <InputWrapper>
+              <Input
+                autoFocus
+                {...getInputProps({
+                  type: 'text',
+                  placeholder: t('Search for Docs and FAQs...'),
+                })}
+              />
+            </InputWrapper>
+          )}
+        />
+      </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 default HelpSearchModal;

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

@@ -30,6 +30,7 @@ class SearchResult extends React.Component {
         'event',
         'plugin',
         'integration',
+        'help',
       ]),
       /**
      * The type of result this is, for example:
@@ -45,8 +46,10 @@ class SearchResult extends React.Component {
         'issue',
         'event',
         'integration',
+        'doc',
+        'faq',
       ]),
-      title: PropTypes.string,
+      title: PropTypes.node,
       description: PropTypes.node,
       model: PropTypes.oneOfType([
         SentryTypes.Organization,
@@ -101,21 +104,24 @@ class SearchResult extends React.Component {
         <div>
           <SearchTitle>{title}</SearchTitle>
         </div>
-
-        <SearchDetail>{description}</SearchDetail>
+        {description && <SearchDetail>{description}</SearchDetail>}
       </React.Fragment>
     );
   }
 
   renderResultType() {
     let {item} = this.props;
-    let {resultType, model} = item;
+    let {resultIcon, resultType, model} = item;
 
     let isSettings = resultType === 'settings';
     let isField = resultType === 'field';
     let isRoute = resultType === 'route';
     let isIntegration = resultType === 'integration';
 
+    if (resultIcon) {
+      return resultIcon;
+    }
+
     if (isSettings) {
       return <ResultTypeIcon src="icon-settings" />;
     }

+ 2 - 2
src/sentry/static/sentry/app/components/search/sources/commandSource.jsx

@@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
 import React from 'react';
 
 import {createFuzzySearch} from 'app/utils/createFuzzySearch';
-import {openSudo, openDocsSearchModal} from 'app/actionCreators/modal';
+import {openSudo, openHelpSearchModal} from 'app/actionCreators/modal';
 import Access from 'app/components/acl/access';
 
 const ACTIONS = [
@@ -28,7 +28,7 @@ const ACTIONS = [
   {
     title: 'Search Documentation and FAQ',
     description: 'Open the Documentation and FAQ search modal.',
-    action: () => openDocsSearchModal(),
+    action: () => openHelpSearchModal(),
   },
 ];
 

+ 0 - 78
src/sentry/static/sentry/app/components/search/sources/docsSource.jsx

@@ -1,78 +0,0 @@
-import {debounce} from 'lodash';
-import {withRouter} from 'react-router';
-import PropTypes from 'prop-types';
-import React from 'react';
-
-import withLatestContext from 'app/utils/withLatestContext';
-
-class DocsSource 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,
-      docResults: null,
-    };
-  }
-
-  componentDidMount() {
-    if (typeof this.props.query !== 'undefined') this.doSearch(this.props.query);
-  }
-
-  componentWillReceiveProps(nextProps) {
-    if (nextProps.query !== this.props.query) {
-      this.doSearch(nextProps.query);
-    }
-  }
-
-  // Debounced method to handle querying all API endpoints (when necessary)
-  doSearch = debounce(query => {
-    let term = encodeURIComponent(query);
-    if (term.length > 2) {
-      $.get(
-        `https://rigidsearch.getsentry.net/api/search?q=${term}&page=1&section=hosted`,
-        data => {
-          this.setState({
-            loading: false,
-            docResults: data.items.map(result => ({
-              item: {
-                result,
-                type: 'docs',
-              },
-              matches: null,
-              score: 10, // Should be larger than FaqSource
-            })),
-          });
-        }
-      );
-    } else {
-      this.setState({
-        loading: false,
-        docResults: [],
-      });
-    }
-  }, 300);
-
-  render() {
-    return this.props.children({
-      isLoading: this.state.loading,
-      allResults: this.state.docResults,
-      results: this.state.docResults,
-    });
-  }
-}
-
-export {DocsSource};
-export default withLatestContext(withRouter(DocsSource));

+ 0 - 79
src/sentry/static/sentry/app/components/search/sources/faqSource.jsx

@@ -1,79 +0,0 @@
-import $ from 'jquery';
-import {debounce} from 'lodash';
-import {withRouter} from 'react-router';
-import PropTypes from 'prop-types';
-import React from 'react';
-
-import withLatestContext from 'app/utils/withLatestContext';
-
-class FaqSource 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,
-    };
-  }
-
-  componentDidMount() {
-    if (typeof this.props.query !== 'undefined') this.doSearch(this.props.query);
-  }
-
-  componentWillReceiveProps(nextProps) {
-    if (nextProps.query !== this.props.query) {
-      this.doSearch(nextProps.query);
-    }
-  }
-
-  // Debounced method to handle querying all API endpoints (when necessary)
-  doSearch = debounce(query => {
-    let term = encodeURIComponent(query);
-    if (term.length > 2) {
-      $.get(
-        `https://sentry.zendesk.com/api/v2/help_center/articles/search.json?query=${term}`,
-        data => {
-          this.setState({
-            loading: false,
-            results: data.results.map(result => ({
-              item: {
-                result,
-                type: 'faq',
-              },
-              matches: null,
-              score: 1, // Should be smaller than DocsSource
-            })),
-          });
-        }
-      );
-    } else {
-      this.setState({
-        loading: false,
-        results: [],
-      });
-    }
-  }, 300);
-
-  render() {
-    return this.props.children({
-      isLoading: this.state.loading,
-      allResults: this.state.results,
-      results: this.state.results,
-    });
-  }
-}
-
-export {FaqSource};
-export default withLatestContext(withRouter(FaqSource));

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

@@ -0,0 +1,173 @@
+import {debounce} from 'lodash';
+import {withRouter} from 'react-router';
+import PropTypes from 'prop-types';
+import React from 'react';
+import algoliasearch from 'algoliasearch';
+import styled from 'react-emotion';
+
+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);
+    }
+  }
+
+  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 = 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.blueLight};
+`;
+
+const FaqsBadge = styled(p => <ResultIcon {...p}>faqs</ResultIcon>)`
+  background: ${p => p.theme.greenLight};
+`;
+
+export {HelpSource};
+export default withLatestContext(withRouter(HelpSource));

+ 2 - 2
src/sentry/static/sentry/app/components/sidebar/help.jsx

@@ -3,7 +3,7 @@ import React from 'react';
 import styled from 'react-emotion';
 
 import SentryTypes from 'app/sentryTypes';
-import {openDocsSearchModal} from 'app/actionCreators/modal';
+import {openHelpSearchModal} from 'app/actionCreators/modal';
 import {t} from 'app/locale';
 import DropdownMenu from 'app/components/dropdownMenu';
 import InlineSvg from 'app/components/inlineSvg';
@@ -47,7 +47,7 @@ class SidebarHelp extends React.Component {
   };
 
   handleSearchClick = () => {
-    openDocsSearchModal();
+    openHelpSearchModal();
   };
 
   render() {

Некоторые файлы не были показаны из-за большого количества измененных файлов