Browse Source

feat(issue-details): Add dropdown for participants and viewers (#75459)

this pr adds a dropdown to view all participants and viewers for an
issue. the design is slightly different than what it was before when it
was in the sidebar. now, it is an overlay since this information is in
the group header.
Richard Roggenkemper 7 months ago
parent
commit
86986f3bf4

+ 49 - 1
static/app/components/avatar/avatarList.tsx

@@ -1,11 +1,14 @@
+import {forwardRef} from 'react';
 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 {space} from 'sentry/styles/space';
 import type {Team} from 'sentry/types/organization';
 import type {AvatarUser} from 'sentry/types/user';
+import {useHasStreamlinedUI} from 'sentry/views/issueDetails/utils';
 
 type UserAvatarProps = React.ComponentProps<typeof UserAvatar>;
 
@@ -20,6 +23,26 @@ type Props = {
   users?: AvatarUser[];
 };
 
+const CollapsedAvatars = forwardRef(function CollapsedAvatars(
+  {size, children}: {children: React.ReactNode; size: number},
+  ref: React.ForwardedRef<HTMLDivElement>
+) {
+  const hasStreamlinedUI = useHasStreamlinedUI();
+
+  if (hasStreamlinedUI) {
+    return <CollapsedAvatarPill ref={ref}>{children}</CollapsedAvatarPill>;
+  }
+  return (
+    <CollapsedAvatarsCicle
+      ref={ref}
+      size={size}
+      data-test-id="avatarList-collapsedavatars"
+    >
+      {children}
+    </CollapsedAvatarsCicle>
+  );
+});
+
 function AvatarList({
   avatarSize = 28,
   maxVisibleAvatars = 5,
@@ -94,6 +117,11 @@ const AvatarStyle = p => css`
   &:hover {
     z-index: 1;
   }
+
+  ${AvatarListWrapper}:hover & {
+    border-color: ${p.theme.translucentBorder};
+    cursor: pointer;
+  }
 `;
 
 const StyledUserAvatar = styled(UserAvatar)`
@@ -107,7 +135,7 @@ const StyledTeamAvatar = styled(TeamAvatar)`
   ${AvatarStyle}
 `;
 
-const CollapsedAvatars = styled('div')<{size: number}>`
+const CollapsedAvatarsCicle = styled('div')<{size: number}>`
   display: flex;
   align-items: center;
   justify-content: center;
@@ -123,6 +151,26 @@ const CollapsedAvatars = styled('div')<{size: number}>`
   ${AvatarStyle};
 `;
 
+const CollapsedAvatarPill = styled('div')`
+  ${AvatarStyle};
+
+  display: flex;
+  align-items: center;
+  gap: ${space(0.25)};
+  font-weight: ${p => p.theme.fontWeightNormal};
+  color: ${p => p.theme.gray300};
+  height: 24px;
+  padding: 0 ${space(1)};
+  background-color: ${p => p.theme.surface400};
+  border: 1px solid ${p => p.theme.border};
+  border-radius: 24px;
+
+  ${AvatarListWrapper}:hover & {
+    background-color: ${p => p.theme.surface100};
+    cursor: pointer;
+  }
+`;
+
 const Plus = styled('span')`
   font-size: 10px;
   margin-left: 1px;

+ 40 - 0
static/app/components/group/streamlinedParticipantList.spec.tsx

@@ -0,0 +1,40 @@
+import {TeamFixture} from 'sentry-fixture/team';
+import {UserFixture} from 'sentry-fixture/user';
+
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import ParticipantList from 'sentry/components/group/streamlinedParticipantList';
+
+describe('ParticipantList', () => {
+  const users = [
+    UserFixture({id: '1', name: 'John Doe', email: 'john.doe@example.com'}),
+    UserFixture({id: '2', name: 'Bob Alice', email: 'bob.alice@example.com'}),
+  ];
+
+  const teams = [
+    TeamFixture({id: '1', slug: 'team-1', memberCount: 3}),
+    TeamFixture({id: '2', slug: 'team-2', memberCount: 5}),
+  ];
+
+  it('expands and collapses the list when clicked', async () => {
+    render(<ParticipantList teams={teams} users={users} />);
+    expect(screen.queryByText('#team-1')).not.toBeInTheDocument();
+    await userEvent.click(screen.getByText('JD'));
+    expect(await screen.findByText('#team-1')).toBeInTheDocument();
+    expect(await screen.findByText('Bob Alice')).toBeInTheDocument();
+
+    expect(screen.getByText('Teams (2)')).toBeInTheDocument();
+    expect(screen.getByText('Individuals (2)')).toBeInTheDocument();
+
+    await userEvent.click(screen.getAllByText('JD')[0]);
+    expect(screen.queryByText('Bob Alice')).not.toBeInTheDocument();
+  });
+
+  it('does not display section headers when there is only users or teams', async () => {
+    render(<ParticipantList users={users} />);
+    await userEvent.click(screen.getByText('JD'));
+    expect(await screen.findByText('Bob Alice')).toBeInTheDocument();
+
+    expect(screen.queryByText('Teams')).not.toBeInTheDocument();
+  });
+});

+ 118 - 0
static/app/components/group/streamlinedParticipantList.tsx

@@ -0,0 +1,118 @@
+import {useTheme} from '@emotion/react';
+import styled from '@emotion/styled';
+
+import Avatar from 'sentry/components/avatar';
+import AvatarList from 'sentry/components/avatar/avatarList';
+import TeamAvatar from 'sentry/components/avatar/teamAvatar';
+import {Button} from 'sentry/components/button';
+import {Overlay, PositionWrapper} from 'sentry/components/overlay';
+import {t, tn} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {Team, User} from 'sentry/types';
+import useOverlay from 'sentry/utils/useOverlay';
+
+interface DropdownListProps {
+  users: User[];
+  teams?: Team[];
+}
+
+export default function ParticipantList({users, teams}: DropdownListProps) {
+  const {overlayProps, isOpen, triggerProps} = useOverlay({
+    position: 'bottom-start',
+    shouldCloseOnBlur: true,
+    isKeyboardDismissDisabled: false,
+  });
+
+  const theme = useTheme();
+  const showHeaders = users.length > 0 && teams && teams.length > 0;
+
+  return (
+    <div>
+      <Button borderless translucentBorder size="zero" {...triggerProps}>
+        <StyledAvatarList
+          teams={teams}
+          users={users}
+          avatarSize={24}
+          maxVisibleAvatars={3}
+        />
+      </Button>
+      {isOpen && (
+        <PositionWrapper zIndex={theme.zIndex.dropdown} {...overlayProps}>
+          <StyledOverlay>
+            <ParticipantListWrapper>
+              {showHeaders && teams && teams.length > 0 && (
+                <ListTitle>{t('Teams (%s)', teams.length)}</ListTitle>
+              )}
+              {teams?.map(team => (
+                <UserRow key={team.id}>
+                  <TeamAvatar team={team} size={20} />
+                  <div>
+                    {`#${team.slug}`}
+                    <SubText>{tn('%s member', '%s members', team.memberCount)}</SubText>
+                  </div>
+                </UserRow>
+              ))}
+              {showHeaders && (
+                <ListTitle>{t('Individuals (%s)', users.length)}</ListTitle>
+              )}
+              {users.map(user => (
+                <UserRow key={user.id}>
+                  <Avatar user={user} size={20} />
+                  <div>
+                    {user.name}
+                    <SubText>{user.email}</SubText>
+                  </div>
+                </UserRow>
+              ))}
+            </ParticipantListWrapper>
+          </StyledOverlay>
+        </PositionWrapper>
+      )}
+    </div>
+  );
+}
+
+const StyledOverlay = styled(Overlay)`
+  display: flex;
+  flex-direction: column;
+`;
+
+const ParticipantListWrapper = styled('div')`
+  max-height: 325px;
+  overflow-y: auto;
+  border-radius: ${p => p.theme.borderRadius};
+
+  & > div:not(:last-child) {
+    border-bottom: 1px solid ${p => p.theme.border};
+  }
+`;
+
+const ListTitle = styled('div')`
+  display: flex;
+  align-items: center;
+  padding: ${space(1)} ${space(1.5)};
+  background-color: ${p => p.theme.backgroundSecondary};
+  color: ${p => p.theme.gray300};
+  text-transform: uppercase;
+  font-weight: ${p => p.theme.fontWeightBold};
+  font-size: ${p => p.theme.fontSizeSmall};
+`;
+
+const UserRow = styled('div')`
+  display: flex;
+  align-items: center;
+  padding: ${space(1)} ${space(1.5)};
+  gap: ${space(1)};
+  line-height: 1.2;
+  font-size: ${p => p.theme.fontSizeSmall};
+`;
+
+const SubText = styled('div')`
+  color: ${p => p.theme.subText};
+  font-size: ${p => p.theme.fontSizeExtraSmall};
+`;
+
+const StyledAvatarList = styled(AvatarList)`
+  justify-content: flex-end;
+  padding-left: ${space(0.75)};
+`;

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

@@ -44,13 +44,12 @@ import {isMobilePlatform} from 'sentry/utils/platform';
 import {getAnalyicsDataForProject} from 'sentry/utils/projects';
 import {useApiQuery} from 'sentry/utils/queryClient';
 import {useLocation} from 'sentry/utils/useLocation';
+import {ParticipantList} from 'sentry/views/issueDetails/participantList';
 import {
   getGroupDetailsQueryData,
   useHasStreamlinedUI,
 } from 'sentry/views/issueDetails/utils';
 
-import {ParticipantList} from './participantList';
-
 type Props = {
   environments: string[];
   group: Group;

+ 1 - 1
static/app/views/issueDetails/participantList.spec.tsx

@@ -3,7 +3,7 @@ import {UserFixture} from 'sentry-fixture/user';
 
 import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
 
-import {ParticipantList} from './participantList';
+import {ParticipantList} from 'sentry/views/issueDetails/participantList';
 
 describe('ParticipantList', () => {
   const users = [

+ 3 - 20
static/app/views/issueDetails/streamlinedHeader.tsx

@@ -1,7 +1,6 @@
 import {Fragment, useMemo} from 'react';
 import styled from '@emotion/styled';
 
-import AvatarList from 'sentry/components/avatar/avatarList';
 import {Breadcrumbs} from 'sentry/components/breadcrumbs';
 import EventOrGroupTitle from 'sentry/components/eventOrGroupTitle';
 import EventMessage from 'sentry/components/events/eventMessage';
@@ -10,6 +9,7 @@ import {
   AssigneeSelector,
   useHandleAssigneeChange,
 } from 'sentry/components/group/assigneeSelector';
+import ParticipantList from 'sentry/components/group/streamlinedParticipantList';
 import * as Layout from 'sentry/components/layouts/thirds';
 import Version from 'sentry/components/version';
 import VersionHoverCard from 'sentry/components/versionHoverCard';
@@ -174,25 +174,13 @@ export default function StreamlinedGroupHeader({
             {group.participants.length > 0 && (
               <Wrapper>
                 {t('Participants')}
-                <div>
-                  <StyledAvatarList
-                    users={userParticipants}
-                    teams={teamParticipants}
-                    avatarSize={18}
-                    maxVisibleAvatars={2}
-                    typeAvatars="participants"
-                  />
-                </div>
+                <ParticipantList users={userParticipants} teams={teamParticipants} />
               </Wrapper>
             )}
             {displayUsers.length > 0 && (
               <Wrapper>
                 {t('Viewers')}
-                <StyledAvatarList
-                  users={displayUsers}
-                  avatarSize={18}
-                  maxVisibleAvatars={2}
-                />
+                <ParticipantList users={displayUsers} />
               </Wrapper>
             )}
           </PriorityWorkflowWrapper>
@@ -263,11 +251,6 @@ const Wrapper = styled('div')`
   gap: ${space(0.5)};
 `;
 
-const StyledAvatarList = styled(AvatarList)`
-  justify-content: flex-end;
-  padding-left: ${space(0.75)};
-`;
-
 const ReleaseWrapper = styled('div')`
   display: flex;
   align-items: center;