inviteBanner.tsx 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. import {useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {Button} from 'sentry/components/button';
  4. import Card from 'sentry/components/card';
  5. import ExternalLink from 'sentry/components/links/externalLink';
  6. import {Tooltip} from 'sentry/components/tooltip';
  7. import {IconCommit, IconGithub, IconInfo, IconMail} from 'sentry/icons';
  8. import {t, tct} from 'sentry/locale';
  9. import {space} from 'sentry/styles/space';
  10. import {MissingMember, Organization} from 'sentry/types';
  11. import withOrganization from 'sentry/utils/withOrganization';
  12. type Props = {
  13. missingMembers: {integration: string; users: MissingMember[]};
  14. onSendInvite: (email: string) => Promise<void>;
  15. organization: Organization;
  16. };
  17. export function InviteBanner({missingMembers, onSendInvite, organization}: Props) {
  18. // NOTE: this is currently used for Github only
  19. // TODO(cathy): include snoozing, docs link
  20. const [sendingInvite, setSendingInvite] = useState<boolean>(false);
  21. if (
  22. !organization.access.includes('org:write') ||
  23. !missingMembers?.users ||
  24. missingMembers?.users.length === 0
  25. ) {
  26. return null;
  27. }
  28. const handleSendInvite = async (email: string) => {
  29. if (sendingInvite) {
  30. return;
  31. }
  32. setSendingInvite(true);
  33. await onSendInvite(email);
  34. setSendingInvite(false);
  35. };
  36. const users = missingMembers.users;
  37. const cards = users.slice(0, 5).map(member => (
  38. <MemberCard key={member.externalId} data-test-id={`member-card-${member.externalId}`}>
  39. <MemberCardContent>
  40. <MemberCardContentRow>
  41. <IconGithub size="sm" />
  42. {/* TODO: create mapping from integration to lambda external link function */}
  43. <StyledExternalLink href={`http://github.com/${member.externalId}`}>
  44. {tct('@[externalId]', {externalId: member.externalId})}
  45. </StyledExternalLink>
  46. </MemberCardContentRow>
  47. <MemberCardContentRow>
  48. <IconCommit size="xs" />
  49. {tct('[commitCount] Recent Commits', {commitCount: member.commitCount})}
  50. </MemberCardContentRow>
  51. <Subtitle>{member.email}</Subtitle>
  52. </MemberCardContent>
  53. <Button
  54. size="sm"
  55. onClick={() => handleSendInvite(member.email)}
  56. data-test-id="invite-missing-member"
  57. icon={<IconMail />}
  58. >
  59. {t('Invite')}
  60. </Button>
  61. </MemberCard>
  62. ));
  63. cards.push(<SeeMoreCard key="see-more" missingUsers={users} />);
  64. return (
  65. <StyledCard data-test-id="invite-banner">
  66. <CardTitleContainer>
  67. <CardTitleContent>
  68. <CardTitle>{t('Bring your full GitHub team on board in Sentry')}</CardTitle>
  69. <Subtitle>
  70. {tct('[missingMemberCount] missing members that are active in your GitHub', {
  71. missingMemberCount: users.length,
  72. })}
  73. <Tooltip title="Based on the last 30 days of commit data">
  74. <IconInfo size="xs" />
  75. </Tooltip>
  76. </Subtitle>
  77. </CardTitleContent>
  78. <ButtonContainer>
  79. <Button
  80. priority="primary"
  81. size="xs"
  82. // TODO(cathy): open up invite modal
  83. data-test-id="view-all-missing-members"
  84. >
  85. {t('View All')}
  86. </Button>
  87. </ButtonContainer>
  88. </CardTitleContainer>
  89. <MemberCardsContainer>{cards}</MemberCardsContainer>
  90. </StyledCard>
  91. );
  92. }
  93. export default withOrganization(InviteBanner);
  94. type SeeMoreCardProps = {
  95. missingUsers: MissingMember[];
  96. };
  97. function SeeMoreCard({missingUsers}: SeeMoreCardProps) {
  98. return (
  99. <MemberCard data-test-id="see-more-card">
  100. <MemberCardContent>
  101. <MemberCardContentRow>
  102. <SeeMore>
  103. {tct('See all [missingMembersCount] missing members', {
  104. missingMembersCount: missingUsers.length,
  105. })}
  106. </SeeMore>
  107. </MemberCardContentRow>
  108. <Subtitle>
  109. {tct('Accounting for [totalCommits] recent commits', {
  110. totalCommits: missingUsers.reduce((acc, curr) => acc + curr.commitCount, 0),
  111. })}
  112. </Subtitle>
  113. </MemberCardContent>
  114. <Button
  115. size="sm"
  116. priority="primary"
  117. // TODO(cathy): open up invite modal
  118. data-test-id="view-all-missing-members"
  119. >
  120. {t('View All')}
  121. </Button>
  122. </MemberCard>
  123. );
  124. }
  125. const StyledCard = styled(Card)`
  126. display: flex;
  127. padding: ${space(2)};
  128. overflow: hidden;
  129. `;
  130. const CardTitleContainer = styled('div')`
  131. display: flex;
  132. justify-content: space-between;
  133. `;
  134. const CardTitleContent = styled('div')`
  135. display: flex;
  136. flex-direction: column;
  137. `;
  138. const CardTitle = styled('div')`
  139. font-size: ${p => p.theme.fontSizeLarge};
  140. font-weight: bold;
  141. color: ${p => p.theme.gray400};
  142. `;
  143. const Subtitle = styled('div')`
  144. display: flex;
  145. align-items: center;
  146. font-size: ${p => p.theme.fontSizeSmall};
  147. font-weight: 400;
  148. color: ${p => p.theme.gray300};
  149. & > *:first-child {
  150. margin-left: ${space(0.5)};
  151. display: flex;
  152. align-items: center;
  153. }
  154. `;
  155. const ButtonContainer = styled('div')`
  156. display: grid;
  157. grid-auto-flow: column;
  158. grid-column-gap: ${space(1)};
  159. `;
  160. const MemberCard = styled(Card)`
  161. display: flex;
  162. flex-direction: row;
  163. flex-wrap: wrap;
  164. min-width: 30%;
  165. margin: ${space(1)} ${space(0.5)} 0 0;
  166. padding: ${space(2)} 18px;
  167. justify-content: center;
  168. align-items: center;
  169. `;
  170. const MemberCardsContainer = styled('div')`
  171. position: relative;
  172. display: flex;
  173. overflow-x: scroll;
  174. `;
  175. const MemberCardContent = styled('div')`
  176. display: flex;
  177. flex-direction: column;
  178. flex: 1 1;
  179. width: 75%;
  180. `;
  181. const MemberCardContentRow = styled('div')`
  182. display: flex;
  183. align-items: center;
  184. margin-bottom: ${space(0.25)};
  185. font-size: ${p => p.theme.fontSizeSmall};
  186. & > *:first-child {
  187. margin-right: ${space(0.75)};
  188. }
  189. `;
  190. const StyledExternalLink = styled(ExternalLink)`
  191. font-size: ${p => p.theme.fontSizeMedium};
  192. `;
  193. const SeeMore = styled('div')`
  194. font-size: ${p => p.theme.fontSizeLarge};
  195. `;