Browse Source

Assistant frontend prototype (#7209)

Frontend changes for assistant
adhiraj 7 years ago
parent
commit
63fb239a86

+ 1 - 0
src/sentry/features/__init__.py

@@ -33,6 +33,7 @@ default_manager.add('projects:discard-groups', ProjectFeature)  # NOQA
 default_manager.add('projects:custom-inbound-filters', ProjectFeature)  # NOQA
 default_manager.add('projects:minidump', ProjectFeature)  # NOQA
 default_manager.add('organizations:environments', OrganizationFeature)  # NOQA
+default_manager.add('user:assistant')
 
 # expose public api
 add = default_manager.add

+ 50 - 0
src/sentry/static/sentry/app/actionCreators/guides.jsx

@@ -0,0 +1,50 @@
+import {Client} from '../api';
+import GuideActions from '../actions/guideActions';
+
+const api = new Client();
+
+export function fetchGuides() {
+  api.request('/assistant/', {
+    method: 'GET',
+    success: data => {
+      GuideActions.fetchSucceeded(data);
+    },
+  });
+}
+
+export function registerAnchor(anchor) {
+  GuideActions.registerAnchor(anchor);
+}
+
+export function unregisterAnchor(anchor) {
+  GuideActions.unregisterAnchor(anchor);
+}
+
+export function nextStep() {
+  GuideActions.nextStep();
+}
+
+export function closeGuide() {
+  GuideActions.closeGuide();
+}
+
+export function markUseful(guideId, useful) {
+  api.request('/assistant/', {
+    method: 'PUT',
+    data: {
+      guide_id: guideId,
+      status: 'viewed',
+      useful,
+    },
+  });
+}
+
+export function dismiss(guideId) {
+  api.request('/assistant/', {
+    method: 'PUT',
+    data: {
+      guide_id: guideId,
+      status: 'dismissed',
+    },
+  });
+}

+ 11 - 0
src/sentry/static/sentry/app/actions/guideActions.jsx

@@ -0,0 +1,11 @@
+import Reflux from 'reflux';
+
+let GuideActions = Reflux.createActions([
+  'closeGuide',
+  'fetchSucceeded',
+  'nextStep',
+  'registerAnchor',
+  'unregisterAnchor',
+]);
+
+export default GuideActions;

+ 77 - 0
src/sentry/static/sentry/app/components/assistant/guideAnchor.jsx

@@ -0,0 +1,77 @@
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import React from 'react';
+import $ from 'jquery';
+import createReactClass from 'create-react-class';
+import Reflux from 'reflux';
+import {registerAnchor, unregisterAnchor} from '../../actionCreators/guides';
+import GuideStore from '../../stores/guideStore';
+
+// A guide anchor provides a ripple-effect on an element on the page to draw attention
+// to it. Guide anchors register with the guide store, which uses this information to
+// determine which guides can be shown on the page.
+const GuideAnchor = createReactClass({
+  propTypes: {
+    target: PropTypes.string.isRequired,
+    type: PropTypes.oneOf(['text', 'button']),
+  },
+
+  mixins: [Reflux.listenTo(GuideStore, 'onGuideStateChange')],
+
+  getInitialState() {
+    return {
+      active: false,
+    };
+  },
+
+  componentDidMount() {
+    registerAnchor(this);
+  },
+
+  componentDidUpdate(prevProps, prevState) {
+    if (!prevState.active && this.state.active) {
+      $('html, body').animate(
+        {
+          scrollTop: $(this.anchorElement).offset().top,
+        },
+        1000
+      );
+    }
+  },
+
+  componentWillUnmount() {
+    unregisterAnchor(this);
+  },
+
+  onGuideStateChange(data) {
+    if (
+      data.currentGuide &&
+      data.currentStep > 0 &&
+      data.currentGuide.steps[data.currentStep - 1].target == this.props.target
+    ) {
+      this.setState({active: true});
+    } else {
+      this.setState({active: false});
+    }
+  },
+
+  render() {
+    let {target, type} = this.props;
+
+    return (
+      <div
+        ref={el => (this.anchorElement = el)}
+        className={classNames('guide-anchor', type)}
+      >
+        {this.props.children}
+        <span
+          className={classNames(target, 'guide-anchor-ping', {
+            active: this.state.active,
+          })}
+        />
+      </div>
+    );
+  },
+});
+
+export default GuideAnchor;

+ 55 - 0
src/sentry/static/sentry/app/components/assistant/guideDrawer.jsx

@@ -0,0 +1,55 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Button from '../buttons/button';
+import {t} from '../../locale';
+import {dismiss, markUseful, nextStep} from '../../actionCreators/guides';
+
+// GuideDrawer is what slides up when the user clicks on a guide cue.
+export default class GuideDrawer extends React.Component {
+  static propTypes = {
+    guide: PropTypes.object.isRequired,
+    step: PropTypes.number.isRequired,
+    onClose: PropTypes.func.isRequired,
+  };
+
+  handleUseful = useful => {
+    markUseful(this.props.guide.id, useful);
+    this.props.onClose();
+  };
+
+  handleDismiss = () => {
+    dismiss(this.props.guide.id);
+    this.props.onClose();
+  };
+
+  render() {
+    return (
+      <div>
+        <div className="assistant-drawer-title">
+          {this.props.guide.steps[this.props.step - 1].title}
+        </div>
+        <div className="assistant-drawer-message">
+          {this.props.guide.steps[this.props.step - 1].message}
+        </div>
+        <div>
+          {this.props.step < this.props.guide.steps.length ? (
+            <div>
+              <Button onClick={nextStep}>{t('Next')} &rarr;</Button>
+              <Button onClick={this.handleDismiss}>{t('Dismiss')}</Button>
+            </div>
+          ) : (
+            <div>
+              <p>{t('Did you find this guide useful?')}</p>
+              <Button onClick={() => this.handleUseful(true)}>
+                {t('Yes')} &nbsp; &#x2714;
+              </Button>
+              <Button onClick={() => this.handleUseful(false)}>
+                {t('No')} &nbsp; &#x2716;
+              </Button>
+            </div>
+          )}
+        </div>
+      </div>
+    );
+  }
+}

+ 98 - 0
src/sentry/static/sentry/app/components/assistant/helper.jsx

@@ -0,0 +1,98 @@
+import React from 'react';
+import Reflux from 'reflux';
+import createReactClass from 'create-react-class';
+import {t} from '../../locale';
+import {closeGuide, fetchGuides, nextStep} from '../../actionCreators/guides';
+import SupportDrawer from './supportDrawer';
+import GuideDrawer from './guideDrawer';
+import GuideStore from '../../stores/guideStore';
+
+// AssistantHelper is responsible for rendering the cue message, guide drawer and support drawer.
+const AssistantHelper = createReactClass({
+  displayName: 'AssistantHelper',
+
+  mixins: [Reflux.listenTo(GuideStore, 'onGuideStateChange')],
+
+  getInitialState() {
+    return {
+      isDrawerOpen: false,
+      // currentGuide and currentStep are obtained from GuideStore.
+      // Even though this component doesn't need currentStep, it's
+      // child GuideDrawer does, and it's cleaner for only the parent
+      // to subscribe to GuideStore and pass down the guide and step,
+      // rather than have both parent and child subscribe to GuideStore.
+      currentGuide: null,
+      currentStep: 0,
+    };
+  },
+
+  componentDidMount() {
+    fetchGuides();
+  },
+
+  onGuideStateChange(data) {
+    let newState = {
+      currentGuide: data.currentGuide,
+      currentStep: data.currentStep,
+    };
+    if (this.state.currentGuide != data.currentGuide) {
+      newState.isDrawerOpen = false;
+    }
+    this.setState(newState);
+  },
+
+  handleDrawerOpen() {
+    this.setState({
+      isDrawerOpen: true,
+    });
+    nextStep();
+  },
+
+  handleSupportDrawerClose() {
+    this.setState({
+      isDrawerOpen: false,
+    });
+  },
+
+  render() {
+    const cueText = this.state.currentGuide
+      ? this.state.currentGuide.cue
+      : t('Need Help?');
+    // isDrawerOpen and currentGuide/currentStep live in different places and are updated
+    // non-atomically. So we need to guard against the inconsistent state of the drawer
+    // being open and a guide being present, but currentStep not updated yet.
+    // If this gets too complicated, it would be better to move isDrawerOpen into
+    // GuideStore so we can update the state atomically in onGuideStateChange.
+    let showDrawer = false;
+    if (
+      this.state.isDrawerOpen &&
+      (!this.state.currentGuide || this.state.currentStep > 0)
+    ) {
+      showDrawer = true;
+    }
+
+    return (
+      <div className="assistant-container">
+        {showDrawer ? (
+          <div className="assistant-drawer">
+            {this.state.currentGuide ? (
+              <GuideDrawer
+                guide={this.state.currentGuide}
+                step={this.state.currentStep}
+                onClose={closeGuide}
+              />
+            ) : (
+              <SupportDrawer onClose={this.handleSupportDrawerClose} />
+            )}
+          </div>
+        ) : (
+          <a onClick={this.handleDrawerOpen} className="assistant-cue">
+            {cueText}
+          </a>
+        )}
+      </div>
+    );
+  },
+});
+
+export default AssistantHelper;

+ 137 - 0
src/sentry/static/sentry/app/components/assistant/supportDrawer.jsx

@@ -0,0 +1,137 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import _ from 'lodash';
+import createReactClass from 'create-react-class';
+import $ from 'jquery';
+import {t} from '../../locale';
+import ExternalLink from '../externalLink';
+import HookStore from '../../stores/hookStore';
+
+// SupportDrawer slides up when the user clicks on a "Need Help?" cue.
+const SupportDrawer = createReactClass({
+  displayName: 'SupportDrawer',
+
+  propTypes: {
+    onClose: PropTypes.func.isRequired,
+  },
+
+  getInitialState() {
+    return {
+      inputVal: '',
+      docResults: [],
+      helpcenterResults: [],
+    };
+  },
+
+  componentWillReceiveProps(props) {
+    this.setState({inputVal: ''});
+  },
+
+  handleSubmit(evt) {
+    evt.preventDefault();
+  },
+
+  search: _.debounce(function() {
+    if (this.state.inputVal.length <= 2) {
+      this.setState({
+        docResults: [],
+        helpcenterResults: [],
+      });
+      return;
+    }
+    $.ajax({
+      method: 'GET',
+      url: 'https://rigidsearch.getsentry.net/api/search',
+      data: {
+        q: this.state.inputVal,
+        page: 1,
+        section: 'hosted',
+      },
+      success: data => {
+        this.setState({docResults: data.items});
+      },
+    });
+    $.ajax({
+      method: 'GET',
+      url: 'https://sentry.zendesk.com/api/v2/help_center/articles/search.json',
+      data: {
+        query: this.state.inputVal,
+      },
+      success: data => {
+        this.setState({helpcenterResults: data.results});
+      },
+    });
+  }, 300),
+
+  handleChange(evt) {
+    this.setState({inputVal: evt.currentTarget.value}, this.search);
+  },
+
+  renderDocsResults() {
+    return this.state.docResults.map((result, i) => {
+      let {title} = result;
+      let link = `https://docs.sentry.io/${result.path}/`;
+
+      return (
+        <li
+          className="search-tag search-tag-docs search-autocomplete-item"
+          key={i + 'doc'}
+        >
+          <ExternalLink href={link}>
+            <span className="title">{title}</span>
+          </ExternalLink>
+        </li>
+      );
+    });
+  },
+
+  renderHelpCenterResults() {
+    return this.state.helpcenterResults.map((result, i) => {
+      return (
+        <li className="search-tag search-tag-qa search-autocomplete-item" key={i}>
+          <ExternalLink href={result.html_url}>
+            <span className="title">{result.title}</span>
+          </ExternalLink>
+        </li>
+      );
+    });
+  },
+
+  renderDropdownResults() {
+    let docsResults = this.renderDocsResults();
+    let helpcenterResults = this.renderHelpCenterResults();
+    let results = helpcenterResults.concat(docsResults);
+
+    return (
+      <div className="results">
+        <ul className="search-autocomplete-list">{results}</ul>
+      </div>
+    );
+  },
+
+  render() {
+    return (
+      <div className="search">
+        <form onSubmit={this.handleSubmit}>
+          <input
+            className="search-input form-control"
+            type="text"
+            placeholder={t('Search FAQs and docs...')}
+            onChange={this.handleChange}
+            value={this.state.inputVal}
+            autoFocus
+          />
+          <span className="icon-search" />
+          <a
+            className="icon-close pull-right search-close"
+            onClick={this.props.onClose}
+          />
+          {this.renderDropdownResults()}
+        </form>
+        {HookStore.get('assistant:support-button').map(cb => cb(this.state.inputVal))}
+      </div>
+    );
+  },
+});
+
+export default SupportDrawer;

+ 4 - 0
src/sentry/static/sentry/app/components/events/eventDataSection.jsx

@@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
 import React from 'react';
 import SentryTypes from '../../proptypes';
 import {t} from '../../locale';
+import GuideAnchor from '../../components/assistant/guideAnchor';
 
 class GroupEventDataSection extends React.Component {
   static propTypes = {
@@ -52,6 +53,9 @@ class GroupEventDataSection extends React.Component {
             ) : (
               <div>{this.props.title}</div>
             )}
+            {this.props.type === 'extra' ? (
+              <GuideAnchor target="extra" type="text" />
+            ) : null}
             {this.props.type === 'extra' && (
               <div className="btn-group pull-right">
                 <a

+ 3 - 1
src/sentry/static/sentry/app/components/events/interfaces/breadcrumbs.jsx

@@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
 import React from 'react';
 import GroupEventDataSection from '../eventDataSection';
 import SentryTypes from '../../../proptypes';
+import GuideAnchor from '../../../components/assistant/guideAnchor';
 import Breadcrumb from './breadcrumbs/breadcrumb';
 import {t} from '../../../locale';
 
@@ -174,8 +175,9 @@ class BreadcrumbsInterface extends React.Component {
 
     let title = (
       <div>
+        <GuideAnchor target="breadcrumbs" type="text" />
         <h3>
-          <strong>{'Breadcrumbs'}</strong>
+          <strong>{t('Breadcrumbs')}</strong>
         </h3>
         {this.getSearchField()}
       </div>

+ 6 - 1
src/sentry/static/sentry/app/components/events/interfaces/crashHeader.jsx

@@ -4,6 +4,7 @@ import createReactClass from 'create-react-class';
 import SentryTypes from '../../../proptypes';
 import TooltipMixin from '../../../mixins/tooltip';
 import {t} from '../../../locale';
+import GuideAnchor from '../../../components/assistant/guideAnchor';
 
 const CrashHeader = createReactClass({
   displayName: 'CrashHeader',
@@ -20,6 +21,7 @@ const CrashHeader = createReactClass({
     newestFirst: PropTypes.bool.isRequired,
     stackType: PropTypes.string, // 'original', 'minified', or falsy (none)
     onChange: PropTypes.func,
+    hasGuideAnchor: PropTypes.bool,
   },
 
   mixins: [
@@ -97,8 +99,11 @@ const CrashHeader = createReactClass({
     return (
       <div className="crash-title">
         {this.props.beforeTitle}
+        {this.props.hasGuideAnchor ? (
+          <GuideAnchor target="exception" type="text" />
+        ) : null}
         <h3 className="pull-left">
-          {this.props.title !== undefined ? this.props.title : t('Exception')}
+          {this.props.title}
           <small style={{marginLeft: 5}}>
             (<a
               onClick={this.toggleOrder}

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