Browse Source

feat(issues): Expand participants/viewers in issue sidebar (#58098)

Scott Cooper 1 year ago
parent
commit
1b4020da60

+ 12 - 11
static/app/components/avatar/avatarList.tsx

@@ -33,8 +33,9 @@ function AvatarList({
   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);
+  // Reverse the order since css flex-reverse is used to display the avatars
+  const visibleTeamAvatars = teams?.slice(0, numVisibleTeams).reverse();
+  const visibleUserAvatars = users.slice(0, maxVisibleUsers).reverse();
   const numCollapsedAvatars = users.length - visibleUserAvatars.length;
 
   if (!tooltipOptions.position) {
@@ -51,15 +52,6 @@ function AvatarList({
           </CollapsedAvatars>
         </Tooltip>
       )}
-      {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}`}
@@ -70,6 +62,15 @@ function AvatarList({
           hasTooltip
         />
       ))}
+      {visibleTeamAvatars?.map(team => (
+        <StyledTeamAvatar
+          key={`${team.id}-${team.name}`}
+          team={team}
+          size={avatarSize}
+          tooltipOptions={tooltipOptions}
+          hasTooltip
+        />
+      ))}
     </AvatarListWrapper>
   );
 }

+ 60 - 3
static/app/views/issueDetails/groupSidebar.spec.tsx

@@ -1,7 +1,13 @@
 import {Tags} from 'sentry-fixture/tags';
 
 import {initializeOrg} from 'sentry-test/initializeOrg';
-import {render, screen, waitFor, within} from 'sentry-test/reactTestingLibrary';
+import {
+  render,
+  screen,
+  userEvent,
+  waitFor,
+  within,
+} from 'sentry-test/reactTestingLibrary';
 
 import MemberListStore from 'sentry/stores/memberListStore';
 
@@ -208,8 +214,59 @@ describe('GroupSidebar', function () {
       />
     );
 
-    expect(await screen.findByText('Participants (2)')).toBeInTheDocument();
-    expect(screen.getByText('Viewers (2)')).toBeInTheDocument();
+    expect(
+      await screen.findByRole('heading', {name: 'Participants (2)'})
+    ).toBeInTheDocument();
+    expect(screen.getByRole('heading', {name: 'Viewers (2)'})).toBeInTheDocument();
+  });
+
+  it('expands participants and viewers', async () => {
+    const org = {
+      ...organization,
+      features: ['participants-purge'],
+    };
+    const teams = [{...TestStubs.Team(), type: 'team'}];
+    const users = [
+      TestStubs.User({
+        id: '2',
+        name: 'John Smith',
+        email: 'johnsmith@example.com',
+        type: 'user',
+      }),
+      TestStubs.User({
+        id: '3',
+        name: 'Sohn Jmith',
+        email: 'sohnjmith@example.com',
+        type: 'user',
+      }),
+    ];
+    render(
+      <GroupSidebar
+        group={{
+          ...group,
+          participants: [...teams, ...users],
+          seenBy: users,
+        }}
+        project={project}
+        organization={org}
+        event={TestStubs.Event()}
+        environments={[]}
+      />,
+      {
+        organization: org,
+      }
+    );
+
+    expect(
+      await screen.findByRole('heading', {name: 'Participants (1 Team, 2 Individuals)'})
+    ).toBeInTheDocument();
+    expect(screen.queryByText('#team-slug')).not.toBeInTheDocument();
+
+    await userEvent.click(
+      screen.getAllByRole('button', {name: 'Expand Participants'})[0]
+    );
+
+    await waitFor(() => expect(screen.getByText('#team-slug')).toBeVisible());
   });
 
   describe('displays mobile tags when issue platform is mobile', function () {

+ 87 - 22
static/app/views/issueDetails/groupSidebar.tsx

@@ -19,7 +19,7 @@ import TagFacets, {
 import QuestionTooltip from 'sentry/components/questionTooltip';
 import * as SidebarSection from 'sentry/components/sidebarSection';
 import {backend, frontend} from 'sentry/data/platformCategories';
-import {t} from 'sentry/locale';
+import {t, tn} from 'sentry/locale';
 import ConfigStore from 'sentry/stores/configStore';
 import {space} from 'sentry/styles/space';
 import {
@@ -44,6 +44,8 @@ import {useApiQuery} from 'sentry/utils/queryClient';
 import {useLocation} from 'sentry/utils/useLocation';
 import {getGroupDetailsQueryData} from 'sentry/views/issueDetails/utils';
 
+import {ParticipantList} from './participantList';
+
 type Props = {
   environments: string[];
   group: Group;
@@ -131,6 +133,7 @@ export default function GroupSidebar({
     );
   };
 
+  const hasParticipantsFeature = organization.features.includes('participants-purge');
   const renderParticipantData = () => {
     const {participants} = group;
     if (!participants.length) {
@@ -144,10 +147,47 @@ export default function GroupSidebar({
       (p): p is TeamParticipant => p.type === 'team'
     );
 
+    const getParticipantTitle = (): React.ReactNode => {
+      if (!hasParticipantsFeature) {
+        return `${group.participants.length}`;
+      }
+
+      const individualText = tn(
+        '%s Individual',
+        '%s Individuals',
+        userParticipants.length
+      );
+      const teamText = tn('%s Team', '%s Teams', teamParticipants.length);
+
+      if (teamParticipants.length === 0) {
+        return individualText;
+      }
+
+      if (userParticipants.length === 0) {
+        return teamText;
+      }
+
+      return (
+        <Fragment>
+          {teamText}, {individualText}
+        </Fragment>
+      );
+    };
+
+    const avatars = (
+      <StyledAvatarList
+        users={userParticipants}
+        teams={teamParticipants}
+        avatarSize={28}
+        maxVisibleAvatars={hasParticipantsFeature ? 12 : 13}
+        typeAvatars="participants"
+      />
+    );
+
     return (
       <SidebarSection.Wrap>
         <SidebarSection.Title>
-          {t('Participants (%s)', participants.length)}
+          {t('Participants')} <TitleNumber>({getParticipantTitle()})</TitleNumber>
           <QuestionTooltip
             size="xs"
             position="top"
@@ -157,13 +197,23 @@ export default function GroupSidebar({
           />
         </SidebarSection.Title>
         <SidebarSection.Content>
-          <StyledAvatarList
-            users={userParticipants}
-            teams={teamParticipants}
-            avatarSize={28}
-            maxVisibleAvatars={13}
-            typeAvatars="participants"
-          />
+          {hasParticipantsFeature ? (
+            <ParticipantList
+              users={userParticipants}
+              teams={teamParticipants}
+              description={t('participants')}
+            >
+              {avatars}
+            </ParticipantList>
+          ) : (
+            <StyledAvatarList
+              users={userParticipants}
+              teams={teamParticipants}
+              avatarSize={28}
+              maxVisibleAvatars={13}
+              typeAvatars="participants"
+            />
+          )}
         </SidebarSection.Content>
       </SidebarSection.Wrap>
     );
@@ -178,10 +228,26 @@ export default function GroupSidebar({
       return null;
     }
 
+    const avatars = (
+      <StyledAvatarList
+        users={displayUsers}
+        avatarSize={28}
+        maxVisibleAvatars={hasParticipantsFeature ? 12 : 13}
+        renderTooltip={user => (
+          <Fragment>
+            {userDisplayName(user)}
+            <br />
+            <DateTime date={(user as AvatarUser).lastSeen} />
+          </Fragment>
+        )}
+      />
+    );
+
     return (
       <SidebarSection.Wrap>
         <SidebarSection.Title>
-          {t('Viewers (%s)', displayUsers.length)}{' '}
+          {t('Viewers')}
+          <TitleNumber>({displayUsers.length})</TitleNumber>
           <QuestionTooltip
             size="xs"
             position="top"
@@ -189,18 +255,13 @@ export default function GroupSidebar({
           />
         </SidebarSection.Title>
         <SidebarSection.Content>
-          <StyledAvatarList
-            users={displayUsers}
-            avatarSize={28}
-            maxVisibleAvatars={13}
-            renderTooltip={user => (
-              <Fragment>
-                {userDisplayName(user)}
-                <br />
-                <DateTime date={(user as AvatarUser).lastSeen} />
-              </Fragment>
-            )}
-          />
+          {hasParticipantsFeature ? (
+            <ParticipantList users={displayUsers} teams={[]} description={t('users')}>
+              {avatars}
+            </ParticipantList>
+          ) : (
+            avatars
+          )}
         </SidebarSection.Content>
       </SidebarSection.Wrap>
     );
@@ -270,3 +331,7 @@ const StyledAvatarList = styled(AvatarList)`
   justify-content: flex-end;
   padding-left: ${space(0.75)};
 `;
+
+const TitleNumber = styled('span')`
+  font-weight: normal;
+`;

+ 47 - 0
static/app/views/issueDetails/participantList.spec.tsx

@@ -0,0 +1,47 @@
+import {Team} from 'sentry-fixture/team';
+import {User} from 'sentry-fixture/user';
+
+import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import {ParticipantList} from './participantList';
+
+describe('ParticipantList', () => {
+  const users = [
+    User({id: '1', name: 'John Doe', email: 'john.doe@example.com'}),
+    User({id: '2', name: 'Jane Doe', email: 'jane.doe@example.com'}),
+  ];
+
+  const teams = [
+    Team({id: '1', slug: 'team-1', memberCount: 3}),
+    Team({id: '2', slug: 'team-2', memberCount: 5}),
+  ];
+
+  it('expands and collapses the list when clicked', async () => {
+    render(
+      <ParticipantList teams={teams} users={users} description="Participants">
+        Click Me
+      </ParticipantList>
+    );
+    expect(screen.queryByText('#team-1')).not.toBeInTheDocument();
+    await userEvent.click(screen.getByText('Click Me'));
+    await waitFor(() => expect(screen.getByText('#team-1')).toBeVisible());
+
+    expect(screen.getByText('Teams (2)')).toBeInTheDocument();
+    expect(screen.getByText('Individuals (2)')).toBeInTheDocument();
+
+    await userEvent.click(screen.getByText('Click Me'));
+    await waitFor(() => expect(screen.getByText('#team-1')).not.toBeVisible());
+  });
+
+  it('does not display section headers when there is only users or teams', async () => {
+    render(
+      <ParticipantList teams={[]} users={users} description="Participants">
+        Click Me
+      </ParticipantList>
+    );
+    await userEvent.click(screen.getByRole('button', {name: 'Click Me'}));
+    await waitFor(() => expect(screen.getByText('John Doe')).toBeVisible());
+
+    expect(screen.queryByText('Individuals (2)')).not.toBeInTheDocument();
+  });
+});

+ 153 - 0
static/app/views/issueDetails/participantList.tsx

@@ -0,0 +1,153 @@
+import {Fragment, useState} from 'react';
+import styled from '@emotion/styled';
+import {AnimatePresence, motion} from 'framer-motion';
+
+import Avatar from 'sentry/components/avatar';
+import TeamAvatar from 'sentry/components/avatar/teamAvatar';
+import {Button} from 'sentry/components/button';
+import {IconChevron} from 'sentry/icons';
+import {t, tn} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {Team, User} from 'sentry/types';
+
+interface ParticipantScrollboxProps {
+  teams: Team[];
+  users: User[];
+}
+
+function ParticipantScrollbox({users, teams}: ParticipantScrollboxProps) {
+  if (!users.length && !teams.length) {
+    return null;
+  }
+
+  const showHeaders = users.length > 0 && teams.length > 0;
+
+  return (
+    <ParticipantListWrapper>
+      {showHeaders && <ListTitle>{t('Teams (%s)', teams.length)}</ListTitle>}
+      {teams.map(team => (
+        <UserRow key={team.id}>
+          <TeamAvatar team={team} size={28} />
+          <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={28} />
+          <div>
+            {user.name}
+            <SubText>{user.email}</SubText>
+          </div>
+        </UserRow>
+      ))}
+    </ParticipantListWrapper>
+  );
+}
+
+interface ParticipantListProps {
+  children: React.ReactNode;
+  description: string;
+  users: User[];
+  teams?: Team[];
+}
+
+export function ParticipantList({teams = [], users, children}: ParticipantListProps) {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  return (
+    <Fragment>
+      <ParticipantWrapper onClick={() => setIsExpanded(!isExpanded)} role="button">
+        {children}
+        <Button
+          borderless
+          size="zero"
+          icon={
+            <IconChevron
+              direction={isExpanded ? 'up' : 'down'}
+              size="xs"
+              color="gray300"
+            />
+          }
+          aria-label={t('%s Participants', isExpanded ? t('Collapse') : t('Expand'))}
+          onClick={() => setIsExpanded(!isExpanded)}
+        />
+      </ParticipantWrapper>
+      <AnimatePresence>
+        {isExpanded && (
+          <motion.div
+            variants={{
+              open: {height: '100%', opacity: 1, marginTop: space(1)},
+              closed: {height: '0', opacity: 0},
+            }}
+            initial="closed"
+            animate="open"
+            exit="closed"
+          >
+            <ParticipantScrollbox users={users} teams={teams} />
+          </motion.div>
+        )}
+      </AnimatePresence>
+    </Fragment>
+  );
+}
+
+const ParticipantWrapper = styled('div')`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  cursor: pointer;
+
+  & > span {
+    cursor: pointer;
+  }
+`;
+
+const ParticipantListWrapper = styled('div')`
+  max-height: 325px;
+  overflow-y: auto;
+  border: 1px solid ${p => p.theme.border};
+  border-radius: ${p => p.theme.borderRadius};
+
+  & > div:not(:last-child) {
+    border-bottom: 1px solid ${p => p.theme.border};
+  }
+
+  & > div:first-child {
+    border-top-left-radius: ${p => p.theme.borderRadius};
+    border-top-right-radius: ${p => p.theme.borderRadius};
+  }
+
+  & > div:last-child {
+    border-bottom-left-radius: ${p => p.theme.borderRadius};
+    border-bottom-right-radius: ${p => p.theme.borderRadius};
+  }
+`;
+
+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: 600;
+  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};
+`;