inviteBanner.tsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. import {useCallback, useEffect, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {promptsCheck, promptsUpdate} from 'sentry/actionCreators/prompts';
  4. import {Button} from 'sentry/components/button';
  5. import Card from 'sentry/components/card';
  6. import Carousel from 'sentry/components/carousel';
  7. import {openConfirmModal} from 'sentry/components/confirm';
  8. import {DropdownMenu, MenuItemProps} from 'sentry/components/dropdownMenu';
  9. import ExternalLink from 'sentry/components/links/externalLink';
  10. import QuestionTooltip from 'sentry/components/questionTooltip';
  11. import {IconCommit, IconEllipsis, IconGithub, IconMail} from 'sentry/icons';
  12. import {t, tct} from 'sentry/locale';
  13. import {space} from 'sentry/styles/space';
  14. import {MissingMember, Organization} from 'sentry/types';
  15. import {promptIsDismissed} from 'sentry/utils/promptIsDismissed';
  16. import useApi from 'sentry/utils/useApi';
  17. import withOrganization from 'sentry/utils/withOrganization';
  18. type Props = {
  19. missingMembers: {integration: string; users: MissingMember[]};
  20. onSendInvite: (email: string) => void;
  21. organization: Organization;
  22. };
  23. export function InviteBanner({missingMembers, onSendInvite, organization}: Props) {
  24. // NOTE: this is currently used for Github only
  25. const hideBanner =
  26. !organization.features.includes('integrations-gh-invite') ||
  27. !organization.access.includes('org:write') ||
  28. !missingMembers?.users ||
  29. missingMembers?.users.length === 0;
  30. const [sendingInvite, setSendingInvite] = useState<boolean>(false);
  31. const [showBanner, setShowBanner] = useState<boolean>(false);
  32. const api = useApi();
  33. const integrationName = missingMembers?.integration;
  34. const promptsFeature = `${integrationName}_missing_members`;
  35. const snoozePrompt = useCallback(async () => {
  36. setShowBanner(false);
  37. await promptsUpdate(api, {
  38. organizationId: organization.id,
  39. feature: promptsFeature,
  40. status: 'snoozed',
  41. });
  42. }, [api, organization, promptsFeature]);
  43. useEffect(() => {
  44. if (hideBanner) {
  45. return;
  46. }
  47. promptsCheck(api, {
  48. organizationId: organization.id,
  49. feature: promptsFeature,
  50. }).then(prompt => {
  51. setShowBanner(!promptIsDismissed(prompt));
  52. });
  53. }, [api, organization, promptsFeature, hideBanner]);
  54. if (hideBanner || !showBanner) {
  55. return null;
  56. }
  57. // TODO(cathy): include docs link
  58. const menuItems: MenuItemProps[] = [
  59. {
  60. key: 'invite-banner-snooze',
  61. label: t('Hide Missing Members'),
  62. onAction: () => {
  63. openConfirmModal({
  64. message: t('Are you sure you want to snooze this banner?'),
  65. onConfirm: snoozePrompt,
  66. });
  67. },
  68. },
  69. ];
  70. const handleSendInvite = async (email: string) => {
  71. if (sendingInvite) {
  72. return;
  73. }
  74. setSendingInvite(true);
  75. await onSendInvite(email);
  76. setSendingInvite(false);
  77. };
  78. const users = missingMembers.users;
  79. const cards = users.slice(0, 5).map(member => (
  80. <MemberCard key={member.externalId} data-test-id={`member-card-${member.externalId}`}>
  81. <MemberCardContent>
  82. <MemberCardContentRow>
  83. <IconGithub size="sm" />
  84. {/* TODO: create mapping from integration to lambda external link function */}
  85. <StyledExternalLink href={`http://github.com/${member.externalId}`}>
  86. {tct('@[externalId]', {externalId: member.externalId})}
  87. </StyledExternalLink>
  88. </MemberCardContentRow>
  89. <MemberCardContentRow>
  90. <IconCommit size="xs" />
  91. {tct('[commitCount] Recent Commits', {commitCount: member.commitCount})}
  92. </MemberCardContentRow>
  93. <Subtitle>{member.email}</Subtitle>
  94. </MemberCardContent>
  95. <Button
  96. size="sm"
  97. onClick={() => handleSendInvite(member.email)}
  98. data-test-id="invite-missing-member"
  99. icon={<IconMail />}
  100. >
  101. {t('Invite')}
  102. </Button>
  103. </MemberCard>
  104. ));
  105. cards.push(<SeeMoreCard key="see-more" missingUsers={users} />);
  106. return (
  107. <StyledCard>
  108. <CardTitleContainer>
  109. <CardTitleContent>
  110. <CardTitle>{t('Bring your full GitHub team on board in Sentry')}</CardTitle>
  111. <Subtitle>
  112. {tct('[missingMemberCount] missing members', {
  113. missingMemberCount: users.length,
  114. })}
  115. <QuestionTooltip
  116. title={t(
  117. "Based on the last 30 days of GitHub commit data, there are team members committing code to Sentry projects that aren't in your Sentry organization"
  118. )}
  119. size="xs"
  120. />
  121. </Subtitle>
  122. </CardTitleContent>
  123. <ButtonContainer>
  124. <Button
  125. priority="primary"
  126. size="xs"
  127. // TODO(cathy): open up invite modal
  128. >
  129. {t('View All')}
  130. </Button>
  131. <DropdownMenu
  132. items={menuItems}
  133. triggerProps={{
  134. size: 'xs',
  135. showChevron: false,
  136. icon: <IconEllipsis direction="down" size="sm" />,
  137. 'aria-label': t('Actions'),
  138. }}
  139. />
  140. </ButtonContainer>
  141. </CardTitleContainer>
  142. <Carousel>{cards}</Carousel>
  143. </StyledCard>
  144. );
  145. }
  146. export default withOrganization(InviteBanner);
  147. type SeeMoreCardProps = {
  148. missingUsers: MissingMember[];
  149. };
  150. function SeeMoreCard({missingUsers}: SeeMoreCardProps) {
  151. return (
  152. <MemberCard data-test-id="see-more-card">
  153. <MemberCardContent>
  154. <MemberCardContentRow>
  155. <SeeMore>
  156. {tct('See all [missingMembersCount] missing members', {
  157. missingMembersCount: missingUsers.length,
  158. })}
  159. </SeeMore>
  160. </MemberCardContentRow>
  161. <Subtitle>
  162. {tct('Accounting for [totalCommits] recent commits', {
  163. totalCommits: missingUsers.reduce((acc, curr) => acc + curr.commitCount, 0),
  164. })}
  165. </Subtitle>
  166. </MemberCardContent>
  167. <Button
  168. size="sm"
  169. priority="primary"
  170. // TODO(cathy): open up invite modal
  171. >
  172. {t('View All')}
  173. </Button>
  174. </MemberCard>
  175. );
  176. }
  177. const StyledCard = styled(Card)`
  178. display: flex;
  179. padding: ${space(2)};
  180. padding-bottom: ${space(1.5)};
  181. overflow: hidden;
  182. `;
  183. const CardTitleContainer = styled('div')`
  184. display: flex;
  185. justify-content: space-between;
  186. margin-bottom: ${space(1)};
  187. `;
  188. const CardTitleContent = styled('div')`
  189. display: flex;
  190. flex-direction: column;
  191. `;
  192. const CardTitle = styled('h6')`
  193. margin: 0;
  194. font-size: ${p => p.theme.fontSizeLarge};
  195. font-weight: bold;
  196. color: ${p => p.theme.gray400};
  197. `;
  198. const Subtitle = styled('div')`
  199. display: flex;
  200. align-items: center;
  201. font-size: ${p => p.theme.fontSizeSmall};
  202. font-weight: 400;
  203. color: ${p => p.theme.gray300};
  204. & > *:first-child {
  205. margin-left: ${space(0.5)};
  206. display: flex;
  207. align-items: center;
  208. }
  209. `;
  210. const ButtonContainer = styled('div')`
  211. display: grid;
  212. grid-auto-flow: column;
  213. grid-column-gap: ${space(1)};
  214. `;
  215. const MemberCard = styled(Card)`
  216. display: flex;
  217. flex-direction: row;
  218. flex-wrap: wrap;
  219. min-width: 30%;
  220. margin: ${space(1)} ${space(0.5)} 0 0;
  221. padding: ${space(2)} 18px;
  222. justify-content: center;
  223. align-items: center;
  224. `;
  225. const MemberCardContent = styled('div')`
  226. display: flex;
  227. flex-direction: column;
  228. flex: 1 1;
  229. width: 75%;
  230. `;
  231. const MemberCardContentRow = styled('div')`
  232. display: flex;
  233. align-items: center;
  234. margin-bottom: ${space(0.25)};
  235. font-size: ${p => p.theme.fontSizeSmall};
  236. & > *:first-child {
  237. margin-right: ${space(0.75)};
  238. }
  239. `;
  240. const StyledExternalLink = styled(ExternalLink)`
  241. font-size: ${p => p.theme.fontSizeMedium};
  242. `;
  243. const SeeMore = styled('div')`
  244. font-size: ${p => p.theme.fontSizeLarge};
  245. `;