Browse Source

feat(team-workflow): Update avatar list to handle teams (#56295)

this pr updates `AvatarList` to also be able to show teams. in the
`groupSidebar` we will have participants that will have a type, to
distinguish users from teams. if we see a team, we will use the
`TeamAvatar` to render it , in all other cases we will continue to use
`UserAvatar` as we were doing before.

<img width="163" alt="Screenshot 2023-09-15 at 10 45 03 AM"
src="https://github.com/getsentry/sentry/assets/46740234/f7221c9c-c8ad-4f37-a879-8b273700ce67">

Closes: https://github.com/getsentry/sentry/issues/55559
Richard Roggenkemper 1 year ago
parent
commit
8246f8b992

+ 31 - 8
static/app/components/avatar/avatarList.spec.tsx

@@ -2,14 +2,19 @@ import {render, screen} from 'sentry-test/reactTestingLibrary';
 
 import AvatarList from 'sentry/components/avatar/avatarList';
 
-function renderComponent(
-  avatarUsersSixUsers: React.ComponentProps<typeof AvatarList>['users']
-) {
-  return render(<AvatarList users={avatarUsersSixUsers} />);
+function renderComponent({
+  users,
+  teams,
+}: {
+  users: React.ComponentProps<typeof AvatarList>['users'];
+  teams?: React.ComponentProps<typeof AvatarList>['teams'];
+}) {
+  return render(<AvatarList users={users} teams={teams} />);
 }
 
 describe('AvatarList', () => {
   const user = TestStubs.User();
+  const team = TestStubs.Team();
 
   it('renders with user letter avatars', () => {
     const users = [
@@ -17,10 +22,10 @@ describe('AvatarList', () => {
       {...user, id: '2', name: 'BC'},
     ];
 
-    renderComponent(users);
+    renderComponent({users});
     expect(screen.getByText('A')).toBeInTheDocument();
     expect(screen.getByText('B')).toBeInTheDocument();
-    expect(screen.queryByTestId('avatarList-collapsedusers')).not.toBeInTheDocument();
+    expect(screen.queryByTestId('avatarList-collapsedavatars')).not.toBeInTheDocument();
   });
 
   it('renders with collapsed avatar count if > 5 users', () => {
@@ -33,13 +38,31 @@ describe('AvatarList', () => {
       {...user, id: '6', name: 'FG'},
     ];
 
-    renderComponent(users);
+    renderComponent({users});
     expect(screen.getByText(users[0].name.charAt(0))).toBeInTheDocument();
     expect(screen.getByText(users[1].name.charAt(0))).toBeInTheDocument();
     expect(screen.getByText(users[2].name.charAt(0))).toBeInTheDocument();
     expect(screen.getByText(users[3].name.charAt(0))).toBeInTheDocument();
     expect(screen.getByText(users[4].name.charAt(0))).toBeInTheDocument();
     expect(screen.queryByText(users[5].name.charAt(0))).not.toBeInTheDocument();
-    expect(screen.getByTestId('avatarList-collapsedusers')).toBeInTheDocument();
+    expect(screen.getByTestId('avatarList-collapsedavatars')).toBeInTheDocument();
+  });
+
+  it('renders with team avatars', () => {
+    const users = [
+      {...user, id: '1', name: 'CD'},
+      {...user, id: '2', name: 'DE'},
+    ];
+    const teams = [
+      {...team, id: '1', name: 'A', slug: 'A', type: 'team'},
+      {...team, id: '2', name: 'B', slug: 'B', type: 'team'},
+    ];
+
+    renderComponent({users, teams});
+    expect(screen.getByText('A')).toBeInTheDocument();
+    expect(screen.getByText('B')).toBeInTheDocument();
+    expect(screen.getByText('C')).toBeInTheDocument();
+    expect(screen.getByText('D')).toBeInTheDocument();
+    expect(screen.queryByTestId('avatarList-collapsedavatars')).not.toBeInTheDocument();
   });
 });

+ 42 - 19
static/app/components/avatar/avatarList.tsx

@@ -1,9 +1,10 @@
 import {css} from '@emotion/react';
 import styled from '@emotion/styled';
 
+import TeamAvatar from 'sentry/components/avatar/teamAvatar';
 import UserAvatar from 'sentry/components/avatar/userAvatar';
 import {Tooltip} from 'sentry/components/tooltip';
-import {AvatarUser} from 'sentry/types';
+import {AvatarUser, Team} from 'sentry/types';
 
 type UserAvatarProps = React.ComponentProps<typeof UserAvatar>;
 
@@ -13,21 +14,28 @@ type Props = {
   className?: string;
   maxVisibleAvatars?: number;
   renderTooltip?: UserAvatarProps['renderTooltip'];
+  teams?: Team[];
   tooltipOptions?: UserAvatarProps['tooltipOptions'];
-  typeMembers?: string;
+  typeAvatars?: string;
 };
 
 function AvatarList({
   avatarSize = 28,
   maxVisibleAvatars = 5,
-  typeMembers = 'users',
+  typeAvatars = 'users',
   tooltipOptions = {},
   className,
   users,
+  teams,
   renderTooltip,
 }: Props) {
-  const visibleUsers = users.slice(0, maxVisibleAvatars);
-  const numCollapsedUsers = users.length - visibleUsers.length;
+  const numTeams = teams ? teams.length : 0;
+  const numVisibleTeams = maxVisibleAvatars - numTeams > 0 ? numTeams : maxVisibleAvatars;
+  const maxVisibleUsers =
+    maxVisibleAvatars - numVisibleTeams > 0 ? maxVisibleAvatars - numVisibleTeams : 0;
+  const visibleTeamAvatars = teams?.slice(0, numVisibleTeams);
+  const visibleUserAvatars = users.slice(0, maxVisibleUsers);
+  const numCollapsedAvatars = users.length - visibleUserAvatars.length;
 
   if (!tooltipOptions.position) {
     tooltipOptions.position = 'top';
@@ -35,16 +43,25 @@ function AvatarList({
 
   return (
     <AvatarListWrapper className={className}>
-      {!!numCollapsedUsers && (
-        <Tooltip title={`${numCollapsedUsers} other ${typeMembers}`}>
-          <CollapsedUsers size={avatarSize} data-test-id="avatarList-collapsedusers">
-            {numCollapsedUsers < 99 && <Plus>+</Plus>}
-            {numCollapsedUsers}
-          </CollapsedUsers>
+      {!!numCollapsedAvatars && (
+        <Tooltip title={`${numCollapsedAvatars} other ${typeAvatars}`}>
+          <CollapsedAvatars size={avatarSize} data-test-id="avatarList-collapsedavatars">
+            {numCollapsedAvatars < 99 && <Plus>+</Plus>}
+            {numCollapsedAvatars}
+          </CollapsedAvatars>
         </Tooltip>
       )}
-      {visibleUsers.map(user => (
-        <StyledAvatar
+      {visibleTeamAvatars?.map(team => (
+        <StyledTeamAvatar
+          key={`${team.id}-${team.name}`}
+          team={team}
+          size={avatarSize}
+          tooltipOptions={tooltipOptions}
+          hasTooltip
+        />
+      ))}
+      {visibleUserAvatars.map(user => (
+        <StyledUserAvatar
           key={`${user.id}-${user.email}`}
           user={user}
           size={avatarSize}
@@ -65,8 +82,7 @@ export const AvatarListWrapper = styled('div')`
   flex-direction: row-reverse;
 `;
 
-const Circle = p => css`
-  border-radius: 50%;
+const AvatarStyle = p => css`
   border: 2px solid ${p.theme.background};
   margin-left: -8px;
   cursor: default;
@@ -76,12 +92,18 @@ const Circle = p => css`
   }
 `;
 
-const StyledAvatar = styled(UserAvatar)`
+const StyledUserAvatar = styled(UserAvatar)`
   overflow: hidden;
-  ${Circle};
+  border-radius: 50%;
+  ${AvatarStyle};
 `;
 
-const CollapsedUsers = styled('div')<{size: number}>`
+const StyledTeamAvatar = styled(TeamAvatar)`
+  overflow: hidden;
+  ${AvatarStyle}
+`;
+
+const CollapsedAvatars = styled('div')<{size: number}>`
   display: flex;
   align-items: center;
   justify-content: center;
@@ -93,7 +115,8 @@ const CollapsedUsers = styled('div')<{size: number}>`
   font-size: ${p => Math.floor(p.size / 2.3)}px;
   width: ${p => p.size}px;
   height: ${p => p.size}px;
-  ${Circle};
+  border-radius: 50%;
+  ${AvatarStyle};
 `;
 
 const Plus = styled('span')`

+ 1 - 1
static/app/components/fileChange.tsx

@@ -24,7 +24,7 @@ function FileChange({filename, authors, className}: Props) {
         <AvatarList
           users={authors as AvatarUser[]}
           avatarSize={25}
-          typeMembers="authors"
+          typeAvatars="authors"
         />
       </div>
     </FileItem>

+ 1 - 1
static/app/components/versionHoverCard.tsx

@@ -114,7 +114,7 @@ class VersionHoverCard extends Component<Props, State> {
                 users={release.authors}
                 avatarSize={25}
                 tooltipOptions={{container: 'body'} as any}
-                typeMembers="authors"
+                typeAvatars="authors"
               />
             </div>
           </div>

+ 10 - 1
static/app/types/group.tsx

@@ -555,6 +555,14 @@ interface ReprocessingStatusDetails {
   pendingEvents: number;
 }
 
+export interface UserParticipant extends User {
+  type: 'user';
+}
+
+export interface TeamParticipant extends Team {
+  type: 'team';
+}
+
 /**
  * The payload sent when marking reviewed
  */
@@ -564,6 +572,7 @@ export interface MarkReviewed {
 /**
  * The payload sent when updating a group's status
  */
+
 export interface GroupStatusResolution {
   status: GroupStatus.RESOLVED | GroupStatus.UNRESOLVED | GroupStatus.IGNORED;
   statusDetails: ResolvedStatusDetails | IgnoredStatusDetails | {};
@@ -607,7 +616,7 @@ export interface BaseGroup {
   logger: string | null;
   metadata: EventMetadata;
   numComments: number;
-  participants: User[];
+  participants: Array<UserParticipant | TeamParticipant>;
   permalink: string;
   platform: PlatformKey;
   pluginActions: any[]; // TODO(ts)

+ 16 - 1
static/app/views/issueDetails/groupSidebar.tsx

@@ -30,6 +30,8 @@ import {
   Organization,
   OrganizationSummary,
   Project,
+  TeamParticipant,
+  UserParticipant,
 } from 'sentry/types';
 import {Event} from 'sentry/types/event';
 import {trackAnalytics} from 'sentry/utils/analytics';
@@ -134,6 +136,13 @@ export default function GroupSidebar({
       return null;
     }
 
+    const userParticipants = participants.filter(
+      (p): p is UserParticipant => p.type === 'user'
+    );
+    const teamParticipants = participants.filter(
+      (p): p is TeamParticipant => p.type === 'team'
+    );
+
     return (
       <SidebarSection.Wrap>
         <SidebarSection.Title>
@@ -145,7 +154,13 @@ export default function GroupSidebar({
           />
         </SidebarSection.Title>
         <SidebarSection.Content>
-          <StyledAvatarList users={participants} avatarSize={28} maxVisibleAvatars={13} />
+          <StyledAvatarList
+            users={userParticipants}
+            teams={teamParticipants}
+            avatarSize={28}
+            maxVisibleAvatars={13}
+            typeAvatars="participants"
+          />
         </SidebarSection.Content>
       </SidebarSection.Wrap>
     );

+ 1 - 1
static/app/views/releases/list/releaseCard/releaseCardCommits.tsx

@@ -27,7 +27,7 @@ function ReleaseCardCommits({release, withHeading = true}: Props) {
     <div className="release-stats">
       {withHeading && <ReleaseSummaryHeading>{releaseSummary}</ReleaseSummaryHeading>}
       <span style={{display: 'inline-block'}}>
-        <AvatarList users={release.authors} avatarSize={25} typeMembers="authors" />
+        <AvatarList users={release.authors} avatarSize={25} typeAvatars="authors" />
       </span>
     </div>
   );