Browse Source

ref(ui): converted suggestedOwners to ts (#18900)

Priscila Oliveira 4 years ago
parent
commit
5073601a06

+ 1 - 0
src/sentry/static/sentry/app/actionCreators/modal.tsx

@@ -91,6 +91,7 @@ type CreateOwnershipRuleModalOptions = {
    * The project to create a rules for
    */
   project: Project;
+  issueId: string;
 };
 
 export async function openCreateOwnershipRule(options: CreateOwnershipRuleModalOptions) {

+ 13 - 3
src/sentry/static/sentry/app/components/avatar/actorAvatar.tsx

@@ -9,13 +9,18 @@ import MemberListStore from 'app/stores/memberListStore';
 import TeamStore from 'app/stores/teamStore';
 import {Actor} from 'app/types';
 
-type Props = {
+type DefaultProps = {
+  hasTooltip: boolean;
+};
+
+type Props = DefaultProps & {
   actor: Actor;
   size?: number;
   default?: string;
   title?: string;
   gravatar?: boolean;
   className?: string;
+  onClick?: () => void;
 };
 
 class ActorAvatar extends React.Component<Props> {
@@ -25,6 +30,11 @@ class ActorAvatar extends React.Component<Props> {
     default: PropTypes.string,
     title: PropTypes.string,
     gravatar: PropTypes.bool,
+    hasTooltip: PropTypes.bool,
+  };
+
+  static defaultProps: DefaultProps = {
+    hasTooltip: true,
   };
 
   render() {
@@ -32,12 +42,12 @@ class ActorAvatar extends React.Component<Props> {
 
     if (actor.type === 'user') {
       const user = actor.id ? MemberListStore.getById(actor.id) : actor;
-      return <UserAvatar user={user} hasTooltip {...props} />;
+      return <UserAvatar user={user} {...props} />;
     }
 
     if (actor.type === 'team') {
       const team = TeamStore.getById(actor.id);
-      return <TeamAvatar team={team} hasTooltip {...props} />;
+      return <TeamAvatar team={team} {...props} />;
     }
 
     Sentry.withScope(scope => {

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

@@ -16,7 +16,7 @@ import GuideAnchor from 'app/components/assistant/guideAnchor';
 import LoadingError from 'app/components/loadingError';
 import SentryTypes from 'app/sentryTypes';
 import SubscribeButton from 'app/components/subscribeButton';
-import SuggestedOwners from 'app/components/group/suggestedOwners';
+import SuggestedOwners from 'app/components/group/suggestedOwners/suggestedOwners';
 import withApi from 'app/utils/withApi';
 
 const SUBSCRIPTION_REASONS = {
@@ -234,7 +234,9 @@ class GroupSidebar extends React.Component {
 
     return (
       <div className="group-stats">
-        <SuggestedOwners project={project} group={group} event={this.props.event} />
+        {this.props.event && (
+          <SuggestedOwners project={project} group={group} event={this.props.event} />
+        )}
         <GroupReleaseStats
           group={this.props.group}
           project={project}

+ 0 - 275
src/sentry/static/sentry/app/components/group/suggestedOwners.jsx

@@ -1,275 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import styled from '@emotion/styled';
-import {ClassNames} from '@emotion/core';
-
-import {assignToUser, assignToActor} from 'app/actionCreators/group';
-import {IconInfo} from 'app/icons';
-import {openCreateOwnershipRule} from 'app/actionCreators/modal';
-import {t} from 'app/locale';
-import Access from 'app/components/acl/access';
-import ActorAvatar from 'app/components/avatar/actorAvatar';
-import Button from 'app/components/button';
-import GuideAnchor from 'app/components/assistant/guideAnchor';
-import Hovercard from 'app/components/hovercard';
-import SentryTypes from 'app/sentryTypes';
-import space from 'app/styles/space';
-import SuggestedOwnerHovercard from 'app/components/group/suggestedOwnerHovercard';
-import withApi from 'app/utils/withApi';
-import withOrganization from 'app/utils/withOrganization';
-
-class SuggestedOwners extends React.Component {
-  static propTypes = {
-    api: PropTypes.object,
-    organization: SentryTypes.Organization,
-    project: SentryTypes.Project,
-    group: SentryTypes.Group,
-    event: SentryTypes.Event,
-  };
-
-  constructor(props) {
-    super(props);
-    this.state = {
-      rules: null,
-      owners: [],
-      committers: [],
-    };
-  }
-
-  componentDidMount() {
-    this.fetchData(this.props.event);
-  }
-
-  UNSAFE_componentWillReceiveProps(nextProps) {
-    if (this.props.event && nextProps.event) {
-      if (this.props.event.id !== nextProps.event.id) {
-        //two events, with different IDs
-        this.fetchData(nextProps.event);
-      }
-    } else if (nextProps.event) {
-      //going from having no event to having an event
-      this.fetchData(nextProps.event);
-    }
-  }
-
-  fetchData(event) {
-    if (!event) {
-      return;
-    }
-
-    const {api, project, group, organization} = this.props;
-
-    // No committers if you don't have any releases
-    if (!!group.firstRelease) {
-      // TODO: move this into a store since `EventCause` makes this exact request as well
-      api.request(
-        `/projects/${organization.slug}/${project.slug}/events/${event.id}/committers/`,
-        {
-          success: data => {
-            this.setState({
-              committers: data.committers,
-            });
-          },
-          error: () => {
-            this.setState({
-              committers: [],
-            });
-          },
-        }
-      );
-    }
-
-    api.request(
-      `/projects/${organization.slug}/${project.slug}/events/${event.id}/owners/`,
-      {
-        success: data => {
-          this.setState({
-            owners: data.owners,
-            rules: data.rules,
-          });
-        },
-        error: () => {
-          this.setState({
-            owners: [],
-          });
-        },
-      }
-    );
-  }
-
-  assign(actor) {
-    if (actor.id === undefined) {
-      return;
-    }
-
-    if (actor.type === 'user') {
-      assignToUser({id: this.props.event.groupID, user: actor});
-    }
-
-    if (actor.type === 'team') {
-      assignToActor({id: this.props.event.groupID, actor});
-    }
-  }
-
-  /**
-   * 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: <
-   *    type,              # Either user or team
-   *    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);
-      }
-    });
-
-    return owners;
-  }
-
-  getInfoHovercardBody() {
-    return (
-      <HelpfulBody>
-        <p>
-          {t(
-            'Ownership rules allow you to associate file paths and URLs to specific teams or users, so alerts can be routed to the right people.'
-          )}
-        </p>
-        <Button href="https://docs.sentry.io/workflow/issue-owners/" priority="primary">
-          {t('Learn more')}
-        </Button>
-      </HelpfulBody>
-    );
-  }
-
-  render() {
-    const {group, organization, project} = this.props;
-    const owners = this.getOwnerList();
-
-    return (
-      <React.Fragment>
-        {owners.length > 0 && (
-          <div className="m-b-1">
-            <h6>
-              <span>{t('Suggested Assignees')}</span>
-              <small style={{background: '#FFFFFF'}}>{t('Click to assign')}</small>
-            </h6>
-
-            <div className="avatar-grid">
-              {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"
-                >
-                  <span onClick={() => this.assign(owner.actor)}>
-                    <ActorAvatar
-                      style={{cursor: 'pointer'}}
-                      hasTooltip={false}
-                      actor={owner.actor}
-                    />
-                  </span>
-                </SuggestedOwnerHovercard>
-              ))}
-            </div>
-          </div>
-        )}
-        <Access access={['project:write']}>
-          <div className="m-b-1">
-            <OwnerRuleHeading>
-              <span>{t('Ownership Rules')}</span>
-              <ClassNames>
-                {({css}) => (
-                  <Hovercard
-                    containerClassName={css`
-                      display: inline-flex;
-                      padding: 0 !important;
-                    `}
-                    body={this.getInfoHovercardBody()}
-                  >
-                    <IconInfo size="xs" />
-                  </Hovercard>
-                )}
-              </ClassNames>
-            </OwnerRuleHeading>
-            <GuideAnchor target="owners" position="bottom" offset={space(3)}>
-              <Button
-                onClick={() =>
-                  openCreateOwnershipRule({
-                    project,
-                    organization,
-                    issueId: group.id,
-                  })
-                }
-                size="small"
-                className="btn btn-default btn-sm btn-create-ownership-rule"
-              >
-                {t('Create Ownership Rule')}
-              </Button>
-            </GuideAnchor>
-          </div>
-        </Access>
-      </React.Fragment>
-    );
-  }
-}
-export {SuggestedOwners};
-export default withApi(withOrganization(SuggestedOwners));
-
-/**
- * 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 HelpfulBody = styled('div')`
-  padding: ${space(1)};
-  text-align: center;
-`;
-
-const OwnerRuleHeading = styled('h6')`
-  display: flex;
-  align-items: center;
-`;

+ 27 - 0
src/sentry/static/sentry/app/components/group/suggestedOwners/findMatchedRules.tsx

@@ -0,0 +1,27 @@
+import {Actor} from 'app/types';
+
+// TODO(ts): add the correct type
+export type Rules = Array<any> | null;
+
+/**
+ * Given a list of rule objects returned from the API, locate the matching
+ * rules for a specific owner.
+ */
+function findMatchedRules(rules: Rules, owner: Actor) {
+  if (!rules) {
+    return undefined;
+  }
+
+  const matchOwner = (actorType: Actor['type'], key: string) =>
+    (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);
+}
+
+export {findMatchedRules};

+ 73 - 0
src/sentry/static/sentry/app/components/group/suggestedOwners/ownershipRules.tsx

@@ -0,0 +1,73 @@
+import React from 'react';
+import styled from '@emotion/styled';
+import {ClassNames} from '@emotion/core';
+
+import {IconQuestion} from 'app/icons';
+import {openCreateOwnershipRule} from 'app/actionCreators/modal';
+import {t} from 'app/locale';
+import Button from 'app/components/button';
+import GuideAnchor from 'app/components/assistant/guideAnchor';
+import Hovercard from 'app/components/hovercard';
+import space from 'app/styles/space';
+import {Project, Organization} from 'app/types';
+
+import {Wrapper, Header, Heading} from './styles';
+
+type Props = {
+  project: Project;
+  organization: Organization;
+  issueId: string;
+};
+
+const OwnershipRules = ({project, organization, issueId}: Props) => {
+  const handleOpenCreateOwnershipRule = () => {
+    openCreateOwnershipRule({project, organization, issueId});
+  };
+
+  return (
+    <Wrapper>
+      <Header>
+        <Heading>{t('Ownership Rules')}</Heading>
+        <ClassNames>
+          {({css}) => (
+            <Hovercard
+              body={
+                <HelpfulBody>
+                  <p>
+                    {t(
+                      'Ownership rules allow you to associate file paths and URLs to specific teams or users, so alerts can be routed to the right people.'
+                    )}
+                  </p>
+                  <Button
+                    href="https://docs.sentry.io/workflow/issue-owners/"
+                    priority="primary"
+                  >
+                    {t('Learn more')}
+                  </Button>
+                </HelpfulBody>
+              }
+              containerClassName={css`
+                display: flex;
+                align-items: center;
+              `}
+            >
+              <IconQuestion size="xs" />
+            </Hovercard>
+          )}
+        </ClassNames>
+      </Header>
+      <GuideAnchor target="owners" position="bottom" offset={space(3)}>
+        <Button onClick={handleOpenCreateOwnershipRule} size="small">
+          {t('Create Ownership Rule')}
+        </Button>
+      </GuideAnchor>
+    </Wrapper>
+  );
+};
+
+export {OwnershipRules};
+
+const HelpfulBody = styled('div')`
+  padding: ${space(1)};
+  text-align: center;
+`;

+ 21 - 0
src/sentry/static/sentry/app/components/group/suggestedOwners/styles.tsx

@@ -0,0 +1,21 @@
+import styled from '@emotion/styled';
+
+import space from 'app/styles/space';
+
+const Heading = styled('h6')`
+  margin: 0 !important;
+  font-weight: 600;
+`;
+
+const Header = styled('div')`
+  display: grid;
+  grid-template-columns: max-content max-content;
+  grid-gap: ${space(0.5)};
+  margin-bottom: ${space(2)};
+`;
+
+const Wrapper = styled('div')`
+  margin-bottom: ${space(3)};
+`;
+
+export {Heading, Header, Wrapper};

+ 62 - 0
src/sentry/static/sentry/app/components/group/suggestedOwners/suggestedAssignees.tsx

@@ -0,0 +1,62 @@
+import React from 'react';
+import styled from '@emotion/styled';
+import {css} from '@emotion/core';
+
+import {t} from 'app/locale';
+import ActorAvatar from 'app/components/avatar/actorAvatar';
+import SuggestedOwnerHovercard from 'app/components/group/suggestedOwnerHovercard';
+import {Actor, Commit} from 'app/types';
+import space from 'app/styles/space';
+
+import {Wrapper, Header, Heading} from './styles';
+
+type Owner = {
+  actor: Actor;
+  commits?: Array<Commit>;
+  rules?: Array<any> | null;
+};
+
+type Props = {
+  owners: Array<Owner>;
+  onAssign: (actor: Actor) => () => void;
+};
+
+const SuggestedAssignees = ({owners, onAssign}: Props) => (
+  <Wrapper>
+    <Header>
+      <Heading>{t('Suggested Assignees')}</Heading>
+      <StyledSmall>{t('Click to assign')}</StyledSmall>
+    </Header>
+    <Content>
+      {owners.map((owner, i) => (
+        <SuggestedOwnerHovercard
+          key={`${owner.actor.id}:${owner.actor.email}:${owner.actor.name}:${i}`}
+          {...owner}
+        >
+          <ActorAvatar
+            css={css`
+              cursor: pointer;
+            `}
+            onClick={onAssign(owner.actor)}
+            hasTooltip={false}
+            actor={owner.actor}
+          />
+        </SuggestedOwnerHovercard>
+      ))}
+    </Content>
+  </Wrapper>
+);
+
+export {SuggestedAssignees};
+
+const StyledSmall = styled('small')`
+  font-size: ${p => p.theme.fontSizeExtraSmall};
+  color: ${p => p.theme.gray2};
+  line-height: 100%;
+`;
+
+const Content = styled('div')`
+  display: grid;
+  grid-gap: ${space(0.5)};
+  grid-template-columns: repeat(auto-fill, 20px);
+`;

+ 187 - 0
src/sentry/static/sentry/app/components/group/suggestedOwners/suggestedOwners.tsx

@@ -0,0 +1,187 @@
+import React from 'react';
+
+import {assignToUser, assignToActor} from 'app/actionCreators/group';
+import withApi from 'app/utils/withApi';
+import withOrganization from 'app/utils/withOrganization';
+import Access from 'app/components/acl/access';
+import {Organization, Group, Event, Actor, Commit, Project, User} from 'app/types';
+import {Client} from 'app/api';
+
+import {findMatchedRules, Rules} from './findMatchedRules';
+import {SuggestedAssignees} from './suggestedAssignees';
+import {OwnershipRules} from './ownershipRules';
+
+type OwnerList = React.ComponentProps<typeof SuggestedAssignees>['owners'];
+
+type Committer = {
+  author: User;
+  commits: Array<Commit>;
+};
+
+type Props = {
+  api: Client;
+  organization: Organization;
+  project: Project;
+  group: Group;
+  event: Event;
+};
+
+type State = {
+  rules: Rules;
+  owners: Array<Actor>;
+  committers: Array<Committer>;
+};
+
+class SuggestedOwners extends React.Component<Props, State> {
+  state: State = {
+    rules: null,
+    owners: [],
+    committers: [],
+  };
+
+  componentDidMount() {
+    this.fetchData(this.props.event);
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    if (this.props.event && prevProps.event) {
+      if (this.props.event.id !== prevProps.event.id) {
+        //two events, with different IDs
+        this.fetchData(this.props.event);
+      }
+      return;
+    }
+
+    if (this.props.event) {
+      //going from having no event to having an event
+      this.fetchData(this.props.event);
+    }
+  }
+
+  async fetchData(event: Event) {
+    // No committers if you don't have any releases
+    if (!!this.props.group.firstRelease) {
+      this.fetchCommitters(event.id);
+    }
+
+    this.fetchOwners(event.id);
+  }
+
+  fetchCommitters = async (eventId: Event['id']) => {
+    const {api, project, organization} = this.props;
+    // TODO: move this into a store since `EventCause` makes this exact request as well
+    try {
+      const data = await api.requestPromise(
+        `/projects/${organization.slug}/${project.slug}/events/${eventId}/committers/`
+      );
+      this.setState({
+        committers: data.committers,
+      });
+    } catch {
+      this.setState({
+        committers: [],
+      });
+    }
+  };
+
+  fetchOwners = async (eventId: Event['id']) => {
+    const {api, project, organization} = this.props;
+
+    try {
+      const data = await api.requestPromise(
+        `/projects/${organization.slug}/${project.slug}/events/${eventId}/owners/`
+      );
+
+      this.setState({
+        owners: data.owners,
+        rules: data.rules,
+      });
+    } catch {
+      this.setState({
+        committers: [],
+      });
+    }
+  };
+
+  /**
+   * 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: <
+   *    type,              # Either user or team
+   *    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: {...commiter.author, type: 'user' as Actor['type']},
+      commits: commiter.commits,
+    })) as OwnerList;
+
+    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]};
+        return;
+      }
+      owners.push(normalizedOwner);
+    });
+
+    return owners;
+  }
+
+  handleAssign = (actor: Actor) => () => {
+    if (actor.id === undefined) {
+      return;
+    }
+
+    const {event} = this.props;
+
+    if (actor.type === 'user') {
+      assignToUser({id: event.groupID, user: actor});
+    }
+
+    if (actor.type === 'team') {
+      assignToActor({id: event.groupID, actor});
+    }
+  };
+
+  render() {
+    const {organization, project, group} = this.props;
+    const owners = this.getOwnerList();
+
+    return (
+      <React.Fragment>
+        {owners.length > 0 && (
+          <SuggestedAssignees owners={owners} onAssign={this.handleAssign} />
+        )}
+        <Access access={['project:write']}>
+          <OwnershipRules
+            issueId={group.id}
+            project={project}
+            organization={organization}
+          />
+        </Access>
+      </React.Fragment>
+    );
+  }
+}
+export default withApi(withOrganization(SuggestedOwners));

+ 3 - 4
src/sentry/static/sentry/app/types/index.tsx

@@ -64,10 +64,8 @@ export type Avatar = {
   avatarType: 'letter_avatar' | 'upload' | 'gravatar';
 };
 
-export type Actor = {
-  id: string;
+export type Actor = User & {
   type: 'user' | 'team';
-  name: string;
 };
 
 /**
@@ -343,9 +341,10 @@ type UserEnrolledAuthenticator = {
   id: EnrolledAuthenticator['authId'];
 };
 
-export type User = AvatarUser & {
+export type User = Omit<AvatarUser, 'options'> & {
   lastLogin: string;
   isSuperuser: boolean;
+  isAuthenticated: boolean;
   emails: {
     is_verified: boolean;
     id: string;

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