Browse Source

feat(ui): Update owners to use hovercard over tooltip (#11017)

Evan Purkhiser 6 years ago
parent
commit
738c5262dd

+ 2 - 1
src/sentry/static/sentry/app/components/actorAvatar.jsx

@@ -18,8 +18,9 @@ class ActorAvatar extends React.Component {
 
   render() {
     let {actor, ...props} = this.props;
+
     if (actor.type == 'user') {
-      let user = MemberListStore.getById(actor.id);
+      let user = actor.id ? MemberListStore.getById(actor.id) : actor;
       return <Avatar user={user} hasTooltip {...props} />;
     }
     if (actor.type == 'team') {

+ 8 - 3
src/sentry/static/sentry/app/components/alert.jsx

@@ -8,7 +8,7 @@ import InlineSvg from 'app/components/inlineSvg';
 import space from 'app/styles/space';
 
 const StyledInlineSvg = styled(InlineSvg)`
-  margin-right: 12px;
+  margin-right: calc(${p => p.size} / 2);
 `;
 
 const getAlertColorStyles = ({backgroundLight, border, iconColor}, textColor) => `
@@ -69,7 +69,7 @@ const StyledTextBlock = styled(TextBlock)`
   align-self: center;
 `;
 
-const Alert = ({type, icon, children, className, ...props}) => {
+const Alert = ({type, icon, iconSize, children, className, ...props}) => {
   let refClass;
 
   if (type) {
@@ -78,7 +78,7 @@ const Alert = ({type, icon, children, className, ...props}) => {
 
   return (
     <AlertWrapper type={type} {...props} className={cx(refClass, className)}>
-      {icon && <StyledInlineSvg src={icon} size="24px" />}
+      {icon && <StyledInlineSvg src={icon} size={iconSize} />}
       <StyledTextBlock>{children}</StyledTextBlock>
     </AlertWrapper>
   );
@@ -87,6 +87,11 @@ const Alert = ({type, icon, children, className, ...props}) => {
 Alert.propTypes = {
   type: PropTypes.string,
   icon: PropTypes.string,
+  iconSize: PropTypes.string,
+};
+
+Alert.defaultProps = {
+  iconSize: '24px',
 };
 
 export default Alert;

+ 197 - 0
src/sentry/static/sentry/app/components/group/suggestedOwnerHovercard.jsx

@@ -0,0 +1,197 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import moment from 'moment';
+import styled from 'react-emotion';
+
+import {t, tct} from 'app/locale';
+import ActorAvatar from 'app/components/actorAvatar';
+import Alert from 'app/components/alert';
+import Hovercard from 'app/components/hovercard';
+import InlineSvg from 'app/components/inlineSvg';
+import Link from 'app/components/link';
+import SentryTypes from 'app/sentryTypes';
+import space from 'app/styles/space';
+import theme from 'app/utils/theme';
+
+const SuggestedOwnerHovercard = ({actor, commits, rules, ...props}) => (
+  <Hovercard
+    header={
+      <React.Fragment>
+        <HovercardHeader>
+          <HovercardActorAvatar actor={actor} />
+          {actor.name || actor.email}
+        </HovercardHeader>
+        {actor.id === undefined && (
+          <EmailAlert icon="icon-warning-sm" type="warning">
+            {tct(
+              'The email [actorEmail]  has no associated Sentry account. Make sure to link alternative emails in [accountSettings:Account Settings].',
+              {
+                actorEmail: <strong>{actor.email}</strong>,
+                accountSettings: <Link to="/settings/account/emails/" />,
+              }
+            )}
+          </EmailAlert>
+        )}
+      </React.Fragment>
+    }
+    body={
+      <HovercardBody>
+        {commits !== undefined && (
+          <React.Fragment>
+            <div className="divider">
+              <h6>{t('Commits')}</h6>
+            </div>
+            <div>
+              {commits.slice(0, 6).map(({message, dateCreated}, i) => (
+                <ReasonItem key={i}>
+                  <CommitIcon />
+                  <CommitMessage message={message} date={dateCreated} />
+                </ReasonItem>
+              ))}
+            </div>
+          </React.Fragment>
+        )}
+        {rules !== undefined && (
+          <React.Fragment>
+            <div className="divider">
+              <h6>{t('Ownership Rules')}</h6>
+            </div>
+            <div>
+              {rules.map(([type, matched], i) => (
+                <ReasonItem key={i}>
+                  <OwnershipTag tagType={type} />
+                  <OwnershipValue>{matched}</OwnershipValue>
+                </ReasonItem>
+              ))}
+            </div>
+          </React.Fragment>
+        )}
+      </HovercardBody>
+    }
+    {...props}
+  />
+);
+
+SuggestedOwnerHovercard.propTypes = {
+  /**
+   * The suggested actor.
+   */
+  actor: PropTypes.oneOfType([
+    // eslint-disable-next-line react/no-typos
+    SentryTypes.User,
+    // Sentry user which has *not* been expanded
+    PropTypes.shape({
+      email: PropTypes.string.isRequired,
+      id: PropTypes.string.isRequired,
+      name: PropTypes.string.isRequired,
+    }),
+    // Unidentifier user (from commits)
+    PropTypes.shape({
+      email: PropTypes.string.isRequired,
+      name: PropTypes.string.isRequired,
+    }),
+    // Sentry team
+    PropTypes.shape({
+      id: PropTypes.string.isRequired,
+      name: PropTypes.string.isRequired,
+    }),
+  ]),
+
+  /**
+   * The list of commits the actor is suggested for. May be left blank if the
+   * actor is not suggested for commits.
+   */
+  commits: PropTypes.arrayOf(
+    PropTypes.shape({
+      message: PropTypes.string.isRequired,
+      dateCreated: PropTypes.string.isRequired,
+    })
+  ),
+
+  /**
+   * The list of ownership rules the actor is suggested for. Maybe left blank
+   * if the actor is not suggested based on ownership rules.
+   */
+  rules: PropTypes.arrayOf(PropTypes.array),
+};
+
+const tagColors = {
+  url: theme.greenLight,
+  path: theme.blueLight,
+};
+
+const CommitIcon = styled(p => <InlineSvg src="icon-commit" size="16px" {...p} />)`
+  margin-right: ${space(0.5)};
+  flex-shrink: 0;
+`;
+
+const CommitMessage = styled(({message, date, ...props}) => (
+  <div {...props}>
+    {message.split('\n')[0]}
+    <CommitDate date={date} />
+  </div>
+))`
+  color: ${p => p.theme.gray5};
+  font-size: 11px;
+  margin-top: ${space(0.25)};
+`;
+
+const CommitDate = styled(({date, ...props}) => (
+  <div {...props}>{moment(date).fromNow()}</div>
+))`
+  margin-top: ${space(0.5)};
+  color: ${p => p.theme.gray2};
+`;
+
+const ReasonItem = styled('div')`
+  display: flex;
+  align-items: flex-start;
+
+  &:not(:last-child) {
+    margin-bottom: ${space(1)};
+  }
+`;
+
+const OwnershipTag = styled(({tagType, ...props}) => <div {...props}>{tagType}</div>)`
+  background: ${p => tagColors[p.tagType]};
+  color: #fff;
+  font-size: 11px;
+  padding: ${space(0.25)} ${space(0.5)};
+  margin: ${space(0.25)} ${space(0.5)} ${space(0.25)} 0;
+  border-radius: 2px;
+  font-weight: bold;
+  min-width: 34px;
+  text-align: center;
+`;
+
+const OwnershipValue = styled('code')`
+  word-break: break-all;
+  line-height: 1.2;
+`;
+
+const EmailAlert = styled(p => <Alert iconSize="16px" {...p} />)`
+  margin: 10px -13px -9px;
+  border-radius: 0;
+  border-color: #ece0b0;
+  padding: 10px;
+  font-size: ${p => p.theme.fontSizeSmall};
+  font-weight: normal;
+  box-shadow: none;
+`;
+
+const HovercardHeader = styled('div')`
+  display: flex;
+  align-items: center;
+`;
+
+const HovercardActorAvatar = styled(p => (
+  <ActorAvatar size={20} hasTooltip={false} {...p} />
+))`
+  margin-right: ${space(1)};
+`;
+
+const HovercardBody = styled('div')`
+  margin-top: -${space(2)};
+`;
+
+export default SuggestedOwnerHovercard;

+ 91 - 118
src/sentry/static/sentry/app/components/group/suggestedOwners.jsx

@@ -1,31 +1,47 @@
-import PropTypes from 'prop-types';
 import React from 'react';
 import createReactClass from 'create-react-class';
-import ReactDOMServer from 'react-dom/server';
-import moment from 'moment';
 
-import Avatar from 'app/components/avatar';
+import {assignToUser, assignToActor} from 'app/actionCreators/group';
+import {openCreateOwnershipRule} from 'app/actionCreators/modal';
+import {t} from 'app/locale';
+import Access from 'app/components/acl/access';
 import ActorAvatar from 'app/components/actorAvatar';
-import Tooltip from 'app/components/tooltip';
 import ApiMixin from 'app/mixins/apiMixin';
+import Button from 'app/components/button';
 import GroupState from 'app/mixins/groupState';
-import {assignToUser, assignToActor} from 'app/actionCreators/group';
-import {t} from 'app/locale';
-import {openCreateOwnershipRule} from 'app/actionCreators/modal';
 import GuideAnchor from 'app/components/assistant/guideAnchor';
+import SentryTypes from 'app/sentryTypes';
+import SuggestedOwnerHovercard from 'app/components/group/suggestedOwnerHovercard';
+
+/**
+ * Given a list of rule objects returned from the API, locate the matching
+ * rules for a specific owner.
+ */
+function findMatchedRules(rules, owner) {
+  const matchOwner = (actorType, key) =>
+    (actorType == 'user' && key === owner.email) ||
+    (actorType == 'team' && key == owner.name);
+
+  const actorHasOwner = ([actorType, key]) =>
+    actorType === owner.type && matchOwner(actorType, key);
+
+  return rules
+    .filter(([_, ruleActors]) => ruleActors.find(actorHasOwner))
+    .map(([rule]) => rule);
+}
 
 const SuggestedOwners = createReactClass({
   displayName: 'SuggestedOwners',
 
   propTypes: {
-    event: PropTypes.object,
+    event: SentryTypes.Event,
   },
 
   mixins: [ApiMixin, GroupState],
 
   getInitialState() {
     return {
-      rule: null,
+      rules: null,
       owners: [],
       committers: [],
     };
@@ -70,7 +86,7 @@ const SuggestedOwners = createReactClass({
       success: (data, _, jqXHR) => {
         this.setState({
           owners: data.owners,
-          rule: data.rule,
+          rules: data.rules,
         });
       },
       error: error => {
@@ -96,116 +112,60 @@ const SuggestedOwners = createReactClass({
     }
   },
 
-  renderCommitter(committer) {
-    let {author, commits} = committer;
-    return (
-      <span
-        key={author.id || author.email}
-        className="avatar-grid-item"
-        style={{cursor: 'pointer'}}
-        onClick={() => this.assignTo(author)}
-      >
-        <Tooltip
-          tooltipOptions={{
-            html: true,
-            container: 'body',
-            template:
-              '<div class="tooltip" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner tooltip-owners"></div></div>',
-          }}
-          title={ReactDOMServer.renderToStaticMarkup(
-            <div>
-              {author.id ? (
-                <div className="tooltip-owners-name">{author.name}</div>
-              ) : (
-                <div className="tooltip-owners-unknown">
-                  <p className="tooltip-owners-unknown-email">
-                    <span className="icon icon-circle-cross" />
-                    <strong>{author.email}</strong>
-                  </p>
-                  <p>
-                    {t(`Sorry, we don't recognize this member. Make sure to link alternative
-                    emails in Account Settings.`)}
-                  </p>
-                  <hr />
-                </div>
-              )}
-              <ul className="tooltip-owners-commits">
-                {commits.slice(0, 6).map(c => {
-                  return (
-                    <li key={c.id} className="tooltip-owners-commit">
-                      <div style={{whiteSpace: 'pre-line'}}>
-                        {c.message.replace(
-                          /\n\s*\n/g,
-                          '\n'
-                        ) /*repress repeated newlines*/}
-                      </div>
-                      <span className="tooltip-owners-date">
-                        {' '}
-                        - {moment(c.dateCreated).fromNow()}
-                      </span>
-                    </li>
-                  );
-                })}
-              </ul>
-            </div>
-          )}
-        >
-          <Avatar user={author} />
-        </Tooltip>
-      </span>
-    );
-  },
+  /**
+   * Combine the commiter and ownership data into a single array, merging
+   * users who are both owners based on having commits, and owners matching
+   * project ownership rules into one array.
+   *
+   * The return array will include objects of the format:
+   *
+   * {
+   *   actor: <
+   *    SentryTypes.User,  # API expanded user object
+   *    {email, id, name}  # Sentry user which is *not* expanded
+   *    {email, name}      # Unidentified user (from commits)
+   *    {id, name},        # Sentry team (check `type`)
+   *   >,
+   *
+   *   # One or both of commits and rules will be present
+   *
+   *   commits: [...]  # List of commits made by this owner
+   *   rules:   [...]  # Project rules matched for this owner
+   * }
+   */
+  getOwnerList() {
+    const owners = this.state.committers.map(commiter => ({
+      actor: {type: 'user', ...commiter.author},
+      commits: commiter.commits,
+    }));
+
+    this.state.owners.forEach(owner => {
+      const normalizedOwner = {
+        actor: owner,
+        rules: findMatchedRules(this.state.rules || [], owner),
+      };
+
+      const existingIdx = owners.findIndex(o => o.actor.email === owner.email);
+      if (existingIdx > -1) {
+        owners[existingIdx] = {...normalizedOwner, ...owners[existingIdx]};
+      } else {
+        owners.push(normalizedOwner);
+      }
+    });
 
-  renderOwner(owner) {
-    let {rule} = this.state;
-    return (
-      <span
-        key={`${owner.id}:${owner.type}`}
-        className="avatar-grid-item"
-        style={{cursor: 'pointer'}}
-        onClick={() => this.assignToActor(owner)}
-      >
-        <Tooltip
-          tooltipOptions={{
-            html: true,
-            container: 'body',
-            template:
-              '<div class="tooltip" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner tooltip-owners"></div></div>',
-          }}
-          title={ReactDOMServer.renderToStaticMarkup(
-            <div>
-              <div className="tooltip-owners-name">{owner.name}</div>
-              <ul className="tooltip-owners-commits">
-                {t("Assigned based on your Project's Issue Ownership settings")}
-              </ul>
-              <ul className="tooltip-owners-commits">
-                {rule[0] + t(' matched: ') + rule[1]}
-              </ul>
-            </div>
-          )}
-        >
-          <ActorAvatar actor={owner} hasTooltip={false} />
-        </Tooltip>
-      </span>
-    );
+    return owners;
   },
 
   render() {
-    let {committers, owners} = this.state;
+    const owners = this.getOwnerList();
 
     let group = this.getGroup();
     let project = this.getProject();
     let org = this.getOrganization();
-    let access = new Set(org.access);
-
-    let showCreateRule = access.has('project:write');
-
-    let showSuggestedAssignees =
-      (committers && committers.length > 0) || (owners && owners.length > 0);
 
     return (
       <React.Fragment>
-        {showSuggestedAssignees && (
+        {owners.length > 0 && (
           <div className="m-b-1">
             <h6>
               <span>{t('Suggested Assignees')}</span>
@@ -213,34 +173,47 @@ const SuggestedOwners = createReactClass({
             </h6>
 
             <div className="avatar-grid">
-              {committers.map(this.renderCommitter)}
-              {owners.map(this.renderOwner)}
+              {owners.map((owner, i) => (
+                <SuggestedOwnerHovercard
+                  key={`${owner.actor.id}:${owner.actor.email}:${owner.actor.name}:${i}`}
+                  actor={owner.actor}
+                  rules={owner.rules}
+                  commits={owner.commits}
+                  containerClassName="avatar-grid-item"
+                >
+                  <ActorAvatar
+                    style={{cursor: 'pointer'}}
+                    hasTooltip={false}
+                    actor={owner.actor}
+                    onClick={() => this.assignToActor(owner)}
+                  />
+                </SuggestedOwnerHovercard>
+              ))}
             </div>
           </div>
         )}
-        {showCreateRule && (
+        <Access access={['project:write']}>
           <div className="m-b-1">
             <h6>
               <GuideAnchor target="owners" type="text" />
               <span>{t('Ownership Rules')}</span>
             </h6>
-
-            <a
+            <Button
               onClick={() =>
                 openCreateOwnershipRule({
                   project,
                   organization: org,
                   issueId: group.id,
                 })}
+              size="small"
               className="btn btn-default btn-sm btn-create-ownership-rule"
             >
               {t('Create Ownership Rule')}
-            </a>
+            </Button>
           </div>
-        )}
+        </Access>
       </React.Fragment>
     );
   },
 });
-
 export default SuggestedOwners;

+ 1 - 1
src/sentry/static/sentry/less/sentry-hovercard.less

@@ -67,7 +67,7 @@
 
   h6 {
     color: @50 !important;
-    font-size: 11px;
+    font-size: 11px !important;
     margin-bottom: 4px !important;
     text-transform: uppercase;
   }

+ 1 - 0
tests/js/spec/components/__snapshots__/confirmDelete.spec.jsx.snap

@@ -31,6 +31,7 @@ exports[`ConfirmDelete renders 1`] = `
           className="modal-body"
         >
           <Alert
+            iconSize="24px"
             type="error"
           >
             <AlertWrapper

+ 0 - 379
tests/js/spec/components/group/__snapshots__/suggestedOwners.spec.jsx.snap

@@ -1,379 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`SuggestedOwners render() should not show owners committers without featureflag 1`] = `
-<SuggestedOwners
-  event={
-    Object {
-      "eventID": "12345678901234567890123456789012",
-      "groupID": "1",
-      "id": "1",
-      "message": "ApiException",
-    }
-  }
->
-  <div
-    className="m-b-1"
-  >
-    <h6>
-      <span>
-        Suggested Assignees
-      </span>
-      <small
-        style={
-          Object {
-            "background": "#FFFFFF",
-          }
-        }
-      >
-        Click to assign
-      </small>
-    </h6>
-    <div
-      className="avatar-grid"
-    >
-      <span
-        className="avatar-grid-item"
-        key="1:user"
-        onClick={[Function]}
-        style={
-          Object {
-            "cursor": "pointer",
-          }
-        }
-      >
-        <Tooltip
-          title="<div><div class=\\"tooltip-owners-name\\">Jane Doe</div><ul class=\\"tooltip-owners-commits\\">Assigned based on your Project&#x27;s Issue Ownership settings</ul><ul class=\\"tooltip-owners-commits\\">path matched: sentry/tagstore/*</ul></div>"
-          tooltipOptions={
-            Object {
-              "container": "body",
-              "html": true,
-              "template": "<div class=\\"tooltip\\" role=\\"tooltip\\"><div class=\\"tooltip-arrow\\"></div><div class=\\"tooltip-inner tooltip-owners\\"></div></div>",
-            }
-          }
-        >
-          <ActorAvatar
-            actor={
-              Object {
-                "id": "1",
-                "name": "Jane Doe",
-                "type": "user",
-              }
-            }
-            className="tip"
-            hasTooltip={false}
-            title="<div><div class=\\"tooltip-owners-name\\">Jane Doe</div><ul class=\\"tooltip-owners-commits\\">Assigned based on your Project&#x27;s Issue Ownership settings</ul><ul class=\\"tooltip-owners-commits\\">path matched: sentry/tagstore/*</ul></div>"
-          >
-            <Avatar
-              className="tip"
-              hasTooltip={false}
-              title="<div><div class=\\"tooltip-owners-name\\">Jane Doe</div><ul class=\\"tooltip-owners-commits\\">Assigned based on your Project&#x27;s Issue Ownership settings</ul><ul class=\\"tooltip-owners-commits\\">path matched: sentry/tagstore/*</ul></div>"
-              user={
-                Object {
-                  "email": "janedoe@example.com",
-                  "id": "1",
-                  "name": "Jane Doe",
-                }
-              }
-            >
-              <UserAvatar
-                className="tip"
-                gravatar={false}
-                hasTooltip={false}
-                title="<div><div class=\\"tooltip-owners-name\\">Jane Doe</div><ul class=\\"tooltip-owners-commits\\">Assigned based on your Project&#x27;s Issue Ownership settings</ul><ul class=\\"tooltip-owners-commits\\">path matched: sentry/tagstore/*</ul></div>"
-                user={
-                  Object {
-                    "email": "janedoe@example.com",
-                    "id": "1",
-                    "name": "Jane Doe",
-                  }
-                }
-              >
-                <BaseAvatar
-                  className="tip"
-                  gravatarId="janedoe@example.com"
-                  hasTooltip={false}
-                  letterId="janedoe@example.com"
-                  round={true}
-                  style={Object {}}
-                  title="Jane Doe"
-                  tooltip="Jane Doe (janedoe@example.com)"
-                  type="letter_avatar"
-                  uploadPath="avatar"
-                >
-                  <Tooltip
-                    disabled={true}
-                    title="Jane Doe (janedoe@example.com)"
-                  >
-                    <StyledBaseAvatar
-                      className="avatar tip"
-                      loaded={true}
-                      round={true}
-                      style={Object {}}
-                    >
-                      <span
-                        className="avatar tip css-3f9bmx-StyledBaseAvatar e1z0ohzl0"
-                        style={Object {}}
-                      >
-                        <LetterAvatar
-                          displayName="Jane Doe"
-                          identifier="janedoe@example.com"
-                          round={true}
-                        >
-                          <Svg
-                            round={true}
-                            viewBox="0 0 120 120"
-                          >
-                            <svg
-                              className="css-1r4mnb9-Svg e1knxa8x0"
-                              viewBox="0 0 120 120"
-                            >
-                              <rect
-                                fill="#f868bc"
-                                height="120"
-                                rx="15"
-                                ry="15"
-                                width="120"
-                                x="0"
-                                y="0"
-                              />
-                              <text
-                                fill="#FFFFFF"
-                                fontSize="65"
-                                style={
-                                  Object {
-                                    "dominantBaseline": "central",
-                                  }
-                                }
-                                textAnchor="middle"
-                                x="50%"
-                                y="50%"
-                              >
-                                JD
-                              </text>
-                            </svg>
-                          </Svg>
-                        </LetterAvatar>
-                      </span>
-                    </StyledBaseAvatar>
-                  </Tooltip>
-                </BaseAvatar>
-              </UserAvatar>
-            </Avatar>
-          </ActorAvatar>
-        </Tooltip>
-      </span>
-    </div>
-  </div>
-  <div
-    className="m-b-1"
-  >
-    <h6>
-      <GuideAnchor
-        target="owners"
-        type="text"
-      >
-        <GuideAnchorContainer
-          innerRef={[Function]}
-          type="text"
-        >
-          <div
-            className="css-9u5for-GuideAnchorContainer e130o4350"
-            type="text"
-          >
-            <StyledGuideAnchor
-              active={false}
-              className="guide-anchor-ping owners"
-            >
-              <div
-                className="guide-anchor-ping owners css-1yndvnf-StyledGuideAnchor e130o4351"
-              >
-                <StyledGuideAnchorRipples>
-                  <div
-                    className="css-3zj3g7-StyledGuideAnchorRipples e130o4352"
-                  />
-                </StyledGuideAnchorRipples>
-              </div>
-            </StyledGuideAnchor>
-          </div>
-        </GuideAnchorContainer>
-      </GuideAnchor>
-      <span>
-        Ownership Rules
-      </span>
-    </h6>
-    <a
-      className="btn btn-default btn-sm btn-create-ownership-rule"
-      onClick={[Function]}
-    >
-      Create Ownership Rule
-    </a>
-  </div>
-</SuggestedOwners>
-`;
-
-exports[`SuggestedOwners render() should show owners when enable 1`] = `
-<SuggestedOwners
-  event={
-    Object {
-      "eventID": "12345678901234567890123456789012",
-      "groupID": "1",
-      "id": "1",
-      "message": "ApiException",
-    }
-  }
->
-  <div
-    className="m-b-1"
-  >
-    <h6>
-      <span>
-        Suggested Assignees
-      </span>
-      <small
-        style={
-          Object {
-            "background": "#FFFFFF",
-          }
-        }
-      >
-        Click to assign
-      </small>
-    </h6>
-    <div
-      className="avatar-grid"
-    >
-      <span
-        className="avatar-grid-item"
-        key="1:user"
-        onClick={[Function]}
-        style={
-          Object {
-            "cursor": "pointer",
-          }
-        }
-      >
-        <Tooltip
-          title="<div><div class=\\"tooltip-owners-name\\">Jane Doe</div><ul class=\\"tooltip-owners-commits\\">Assigned based on your Project&#x27;s Issue Ownership settings</ul><ul class=\\"tooltip-owners-commits\\">path matched: sentry/tagstore/*</ul></div>"
-          tooltipOptions={
-            Object {
-              "container": "body",
-              "html": true,
-              "template": "<div class=\\"tooltip\\" role=\\"tooltip\\"><div class=\\"tooltip-arrow\\"></div><div class=\\"tooltip-inner tooltip-owners\\"></div></div>",
-            }
-          }
-        >
-          <ActorAvatar
-            actor={
-              Object {
-                "id": "1",
-                "name": "Jane Doe",
-                "type": "user",
-              }
-            }
-            className="tip"
-            hasTooltip={false}
-            title="<div><div class=\\"tooltip-owners-name\\">Jane Doe</div><ul class=\\"tooltip-owners-commits\\">Assigned based on your Project&#x27;s Issue Ownership settings</ul><ul class=\\"tooltip-owners-commits\\">path matched: sentry/tagstore/*</ul></div>"
-          >
-            <Avatar
-              className="tip"
-              hasTooltip={false}
-              title="<div><div class=\\"tooltip-owners-name\\">Jane Doe</div><ul class=\\"tooltip-owners-commits\\">Assigned based on your Project&#x27;s Issue Ownership settings</ul><ul class=\\"tooltip-owners-commits\\">path matched: sentry/tagstore/*</ul></div>"
-              user={
-                Object {
-                  "email": "janedoe@example.com",
-                  "id": "1",
-                  "name": "Jane Doe",
-                }
-              }
-            >
-              <UserAvatar
-                className="tip"
-                gravatar={false}
-                hasTooltip={false}
-                title="<div><div class=\\"tooltip-owners-name\\">Jane Doe</div><ul class=\\"tooltip-owners-commits\\">Assigned based on your Project&#x27;s Issue Ownership settings</ul><ul class=\\"tooltip-owners-commits\\">path matched: sentry/tagstore/*</ul></div>"
-                user={
-                  Object {
-                    "email": "janedoe@example.com",
-                    "id": "1",
-                    "name": "Jane Doe",
-                  }
-                }
-              >
-                <BaseAvatar
-                  className="tip"
-                  gravatarId="janedoe@example.com"
-                  hasTooltip={false}
-                  letterId="janedoe@example.com"
-                  round={true}
-                  style={Object {}}
-                  title="Jane Doe"
-                  tooltip="Jane Doe (janedoe@example.com)"
-                  type="letter_avatar"
-                  uploadPath="avatar"
-                >
-                  <Tooltip
-                    disabled={true}
-                    title="Jane Doe (janedoe@example.com)"
-                  >
-                    <StyledBaseAvatar
-                      className="avatar tip"
-                      loaded={true}
-                      round={true}
-                      style={Object {}}
-                    >
-                      <span
-                        className="avatar tip css-3f9bmx-StyledBaseAvatar e1z0ohzl0"
-                        style={Object {}}
-                      >
-                        <LetterAvatar
-                          displayName="Jane Doe"
-                          identifier="janedoe@example.com"
-                          round={true}
-                        >
-                          <Svg
-                            round={true}
-                            viewBox="0 0 120 120"
-                          >
-                            <svg
-                              className="css-1r4mnb9-Svg e1knxa8x0"
-                              viewBox="0 0 120 120"
-                            >
-                              <rect
-                                fill="#f868bc"
-                                height="120"
-                                rx="15"
-                                ry="15"
-                                width="120"
-                                x="0"
-                                y="0"
-                              />
-                              <text
-                                fill="#FFFFFF"
-                                fontSize="65"
-                                style={
-                                  Object {
-                                    "dominantBaseline": "central",
-                                  }
-                                }
-                                textAnchor="middle"
-                                x="50%"
-                                y="50%"
-                              >
-                                JD
-                              </text>
-                            </svg>
-                          </Svg>
-                        </LetterAvatar>
-                      </span>
-                    </StyledBaseAvatar>
-                  </Tooltip>
-                </BaseAvatar>
-              </UserAvatar>
-            </Avatar>
-          </ActorAvatar>
-        </Tooltip>
-      </span>
-    </div>
-  </div>
-</SuggestedOwners>
-`;

+ 72 - 48
tests/js/spec/components/group/suggestedOwners.spec.jsx

@@ -3,72 +3,96 @@ import {mount} from 'enzyme';
 import SuggestedOwners from 'app/components/group/suggestedOwners';
 import MemberListStore from 'app/stores/memberListStore';
 import {Client} from 'app/api';
-import SentryTypes from 'app/sentryTypes';
 
 describe('SuggestedOwners', function() {
-  let sandbox;
   const event = TestStubs.Event();
-  const USER = {
-    id: '1',
-    name: 'Jane Doe',
-    email: 'janedoe@example.com',
-  };
-  const org = TestStubs.Organization();
+  const user = TestStubs.User();
+
+  const organization = TestStubs.Organization();
   const project = TestStubs.Project();
+
+  const routerContext = TestStubs.routerContext([
+    {
+      group: TestStubs.Group(),
+      project,
+      organization,
+    },
+  ]);
+
+  const endpoint = `/projects/${organization.slug}/${project.slug}/events/${event.id}`;
+
   beforeEach(function() {
-    let endpoint = `/projects/${org.slug}/${project.slug}/events/${event.id}`;
+    MemberListStore.loadInitialData([user, TestStubs.CommitAuthor()]);
+  });
+
+  afterEach(function() {
+    Client.clearMockResponses();
+  });
 
-    sandbox = sinon.sandbox.create();
-    MemberListStore.loadInitialData([USER]);
+  it('Renders suggested owners', function() {
     Client.addMockResponse({
       url: `${endpoint}/committers/`,
-      body: {committers: []},
-    });
-    Client.addMockResponse({
-      url: `${endpoint}/owners/`,
       body: {
-        owners: [
+        committers: [
           {
-            type: 'user',
-            id: '1',
-            name: 'Jane Doe',
+            author: TestStubs.CommitAuthor(),
+            commits: [TestStubs.Commit()],
           },
         ],
-        rule: ['path', 'sentry/tagstore/*'],
       },
     });
-  });
 
-  afterEach(function() {
-    sandbox.restore();
-    Client.clearMockResponses();
+    Client.addMockResponse({
+      url: `${endpoint}/owners/`,
+      body: {
+        owners: [{type: 'user', ...user}],
+        rules: [[['path', 'sentry/tagstore/*'], [['user', user.email]]]],
+      },
+    });
+
+    const wrapper = mount(<SuggestedOwners event={event} />, routerContext);
+
+    expect(wrapper.find('ActorAvatar')).toHaveLength(2);
+
+    // One includes committers the other includes ownership rules
+    expect(
+      wrapper
+        .find('SuggestedOwnerHovercard')
+        .map(node => node.props())
+        .some(p => p.commits === undefined && p.rules !== undefined)
+    ).toBe(true);
+    expect(
+      wrapper
+        .find('SuggestedOwnerHovercard')
+        .map(node => node.props())
+        .some(p => p.commits !== undefined && p.rules === undefined)
+    ).toBe(true);
   });
 
-  describe('render()', function() {
-    it('should show owners when enable', function() {
-      let wrapper = mount(
-        <SuggestedOwners event={event} />,
-        TestStubs.routerContext([
-          {project: TestStubs.Project(), group: TestStubs.Group()},
-          {group: SentryTypes.Group, project: SentryTypes.Project},
-        ])
-      );
-
-      wrapper.setContext({
-        organization: {id: '1', features: new Set(['code-owners'])},
-      });
-
-      expect(wrapper).toMatchSnapshot();
+  it('Merges owner matching rules and having suspect commits', function() {
+    const author = TestStubs.CommitAuthor();
+
+    Client.addMockResponse({
+      url: `${endpoint}/committers/`,
+      body: {
+        committers: [{author, commits: [TestStubs.Commit()]}],
+      },
     });
-    it('should not show owners committers without featureflag', function() {
-      let wrapper = mount(
-        <SuggestedOwners event={event} />,
-        TestStubs.routerContext([
-          {project: TestStubs.Project(), group: TestStubs.Group()},
-          {group: SentryTypes.Group, project: SentryTypes.Project},
-        ])
-      );
-      expect(wrapper).toMatchSnapshot();
+
+    Client.addMockResponse({
+      url: `${endpoint}/owners/`,
+      body: {
+        owners: [{type: 'user', ...author}],
+        rules: [[['path', 'sentry/tagstore/*'], [['user', author.email]]]],
+      },
     });
+
+    const wrapper = mount(<SuggestedOwners event={event} />, routerContext);
+
+    expect(wrapper.find('ActorAvatar')).toHaveLength(1);
+
+    const hovercardProps = wrapper.find('SuggestedOwnerHovercard').props();
+    expect(hovercardProps.commits).not.toBeUndefined();
+    expect(hovercardProps.rules).not.toBeUndefined();
   });
 });

+ 1 - 0
tests/js/spec/components/modals/__snapshots__/integrationDetailsModal.spec.jsx.snap

@@ -323,6 +323,7 @@ exports[`IntegrationDetailsModal renders simple integration 1`] = `
     </Base>
   </Metadata>
   <Alert
+    iconSize="24px"
     key="0"
     type="warning"
   >

+ 10 - 6
tests/js/spec/views/settings/organizationIntegrations/__snapshots__/index.spec.jsx.snap

@@ -140,6 +140,7 @@ exports[`OrganizationIntegrations render() with installed integrations Displays
       >
         <Alert
           icon="icon-warning-sm"
+          iconSize="24px"
           type="warning"
         >
           <AlertWrapper
@@ -160,18 +161,18 @@ exports[`OrganizationIntegrations render() with installed integrations Displays
                   src="icon-warning-sm"
                 >
                   <InlineSvg
-                    className="css-1gojvm3-StyledInlineSvg e1xb5l7j0"
+                    className="css-1e3iblq-StyledInlineSvg e1xb5l7j0"
                     size="24px"
                     src="icon-warning-sm"
                   >
                     <StyledSvg
-                      className="css-1gojvm3-StyledInlineSvg e1xb5l7j0"
+                      className="css-1e3iblq-StyledInlineSvg e1xb5l7j0"
                       height="24px"
                       viewBox={Object {}}
                       width="24px"
                     >
                       <svg
-                        className="e1xb5l7j0 css-1et2325-StyledSvg-StyledInlineSvg e2idor0"
+                        className="e1xb5l7j0 css-lbx2sj-StyledSvg-StyledInlineSvg e2idor0"
                         height="24px"
                         viewBox={Object {}}
                         width="24px"
@@ -1602,6 +1603,7 @@ exports[`OrganizationIntegrations render() with installed integrations Displays
                                               <React.Fragment>
                                                 <Alert
                                                   icon="icon-circle-exclamation"
+                                                  iconSize="24px"
                                                   type="error"
                                                 >
                                                   Deleting this integration has consequences!
@@ -2994,6 +2996,7 @@ exports[`OrganizationIntegrations render() with installed integrations Displays
                                               <React.Fragment>
                                                 <Alert
                                                   icon="icon-circle-exclamation"
+                                                  iconSize="24px"
                                                   type="error"
                                                 >
                                                   Deleting this integration has consequences!
@@ -3323,6 +3326,7 @@ exports[`OrganizationIntegrations render() without integrations Displays integra
       >
         <Alert
           icon="icon-warning-sm"
+          iconSize="24px"
           type="warning"
         >
           <AlertWrapper
@@ -3343,18 +3347,18 @@ exports[`OrganizationIntegrations render() without integrations Displays integra
                   src="icon-warning-sm"
                 >
                   <InlineSvg
-                    className="css-1gojvm3-StyledInlineSvg e1xb5l7j0"
+                    className="css-1e3iblq-StyledInlineSvg e1xb5l7j0"
                     size="24px"
                     src="icon-warning-sm"
                   >
                     <StyledSvg
-                      className="css-1gojvm3-StyledInlineSvg e1xb5l7j0"
+                      className="css-1e3iblq-StyledInlineSvg e1xb5l7j0"
                       height="24px"
                       viewBox={Object {}}
                       width="24px"
                     >
                       <svg
-                        className="e1xb5l7j0 css-1et2325-StyledSvg-StyledInlineSvg e2idor0"
+                        className="e1xb5l7j0 css-lbx2sj-StyledSvg-StyledInlineSvg e2idor0"
                         height="24px"
                         viewBox={Object {}}
                         width="24px"