index.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. import {Fragment, useState} from 'react';
  2. import {css} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {ModalRenderProps} from 'sentry/actionCreators/modal';
  5. import {Button} from 'sentry/components/button';
  6. import ButtonBar from 'sentry/components/buttonBar';
  7. import Checkbox from 'sentry/components/checkbox';
  8. import LoadingIndicator from 'sentry/components/loadingIndicator';
  9. import {
  10. InviteModalHook,
  11. InviteModalRenderFunc,
  12. StatusMessage,
  13. } from 'sentry/components/modals/inviteMembersModal';
  14. import {InviteStatus} from 'sentry/components/modals/inviteMembersModal/types';
  15. import {MissingMemberInvite} from 'sentry/components/modals/inviteMissingMembersModal/types';
  16. import PanelItem from 'sentry/components/panels/panelItem';
  17. import PanelTable from 'sentry/components/panels/panelTable';
  18. import RoleSelectControl from 'sentry/components/roleSelectControl';
  19. import TeamSelector from 'sentry/components/teamSelector';
  20. import {Tooltip} from 'sentry/components/tooltip';
  21. import {IconCheckmark, IconCommit, IconGithub, IconInfo} from 'sentry/icons';
  22. import {t, tct, tn} from 'sentry/locale';
  23. import {space} from 'sentry/styles/space';
  24. import {MissingMember, Organization, OrgRole} from 'sentry/types';
  25. import {trackAnalytics} from 'sentry/utils/analytics';
  26. import useApi from 'sentry/utils/useApi';
  27. import {StyledExternalLink} from 'sentry/views/settings/organizationMembers/inviteBanner';
  28. export interface InviteMissingMembersModalProps extends ModalRenderProps {
  29. allowedRoles: OrgRole[];
  30. missingMembers: {integration: string; users: MissingMember[]};
  31. organization: Organization;
  32. }
  33. export function InviteMissingMembersModal({
  34. missingMembers,
  35. organization,
  36. allowedRoles,
  37. closeModal,
  38. }: InviteMissingMembersModalProps) {
  39. const initialMemberInvites = (missingMembers.users || []).map(member => ({
  40. email: member.email,
  41. commitCount: member.commitCount,
  42. role: organization.defaultRole,
  43. teamSlugs: new Set<string>(),
  44. externalId: member.externalId,
  45. selected: true,
  46. }));
  47. const [memberInvites, setMemberInvites] =
  48. useState<MissingMemberInvite[]>(initialMemberInvites);
  49. const referrer = missingMembers.integration + '_nudge_invite';
  50. const [inviteStatus, setInviteStatus] = useState<InviteStatus>({});
  51. const [sendingInvites, setSendingInvites] = useState(false);
  52. const [complete, setComplete] = useState(false);
  53. const api = useApi();
  54. if (memberInvites.length === 0 || !organization.access.includes('org:write')) {
  55. return null;
  56. }
  57. const setRole = (role: string, index: number) => {
  58. setMemberInvites(currentMemberInvites =>
  59. currentMemberInvites.map((member, i) => {
  60. if (i === index) {
  61. member.role = role;
  62. }
  63. return member;
  64. })
  65. );
  66. };
  67. const setTeams = (teamSlugs: string[], index: number) => {
  68. setMemberInvites(currentMemberInvites =>
  69. currentMemberInvites.map((member, i) => {
  70. if (i === index) {
  71. member.teamSlugs = new Set(teamSlugs);
  72. }
  73. return member;
  74. })
  75. );
  76. };
  77. const selectAll = (checked: boolean) => {
  78. const selectedMembers = memberInvites.map(m => ({...m, selected: checked}));
  79. setMemberInvites(selectedMembers);
  80. };
  81. const toggleCheckbox = (checked: boolean, index: number) => {
  82. const selectedMembers = [...memberInvites];
  83. selectedMembers[index].selected = checked;
  84. setMemberInvites(selectedMembers);
  85. };
  86. const renderStatusMessage = () => {
  87. if (sendingInvites) {
  88. return (
  89. <StatusMessage>
  90. <LoadingIndicator mini relative hideMessage size={16} />
  91. {t('Sending organization invitations\u2026')}
  92. </StatusMessage>
  93. );
  94. }
  95. if (complete) {
  96. const statuses = Object.values(inviteStatus);
  97. const sentCount = statuses.filter(i => i.sent).length;
  98. const errorCount = statuses.filter(i => i.error).length;
  99. const invites = <strong>{tn('%s invite', '%s invites', sentCount)}</strong>;
  100. const tctComponents = {
  101. invites,
  102. failed: errorCount,
  103. };
  104. return (
  105. <StatusMessage status="success">
  106. <IconCheckmark size="sm" />
  107. {errorCount > 0
  108. ? tct('Sent [invites], [failed] failed to send.', tctComponents)
  109. : tct('Sent [invites]', tctComponents)}
  110. </StatusMessage>
  111. );
  112. }
  113. return null;
  114. };
  115. const sendMemberInvite = async (invite: MissingMemberInvite) => {
  116. const data = {
  117. email: invite.email,
  118. teams: [...invite.teamSlugs],
  119. role: invite.role,
  120. };
  121. try {
  122. await api.requestPromise(
  123. `/organizations/${organization?.slug}/members/?referrer=${referrer}`,
  124. {
  125. method: 'POST',
  126. data,
  127. }
  128. );
  129. } catch (err) {
  130. const errorResponse = err.responseJSON;
  131. // Use the email error message if available. This inconsistently is
  132. // returned as either a list of errors for the field, or a single error.
  133. const emailError =
  134. !errorResponse || !errorResponse.email
  135. ? false
  136. : Array.isArray(errorResponse.email)
  137. ? errorResponse.email[0]
  138. : errorResponse.email;
  139. const error = emailError || t('Could not invite user');
  140. setInviteStatus(prevInviteStatus => {
  141. return {...prevInviteStatus, [invite.email]: {sent: false, error}};
  142. });
  143. }
  144. setInviteStatus(prevInviteStatus => {
  145. return {...prevInviteStatus, [invite.email]: {sent: true}};
  146. });
  147. };
  148. const sendMemberInvites = async () => {
  149. setSendingInvites(true);
  150. await Promise.all(memberInvites.filter(i => i.selected).map(sendMemberInvite));
  151. setSendingInvites(false);
  152. setComplete(true);
  153. if (organization) {
  154. trackAnalytics(
  155. 'missing_members_invite_modal.requests_sent',
  156. {
  157. organization,
  158. },
  159. {startSession: true}
  160. );
  161. }
  162. };
  163. const selectedCount = memberInvites.filter(i => i.selected).length;
  164. const selectedAll = memberInvites.length === selectedCount;
  165. const inviteButtonLabel = () => {
  166. return tct('Invite [memberCount] missing member[isPlural]', {
  167. memberCount:
  168. memberInvites.length === selectedCount
  169. ? `all ${selectedCount}`
  170. : selectedCount === 0
  171. ? ''
  172. : selectedCount,
  173. isPlural: selectedCount !== 1 ? 's' : '',
  174. });
  175. };
  176. const hookRenderer: InviteModalRenderFunc = ({sendInvites, canSend, headerInfo}) => (
  177. <Fragment>
  178. <h4>{t('Invite Your Dev Team')}</h4>
  179. {headerInfo}
  180. <StyledPanelTable
  181. headers={[
  182. <Checkbox
  183. key={0}
  184. aria-label={selectedAll ? t('Deselect All') : t('Select All')}
  185. onChange={() => selectAll(!selectedAll)}
  186. checked={selectedAll}
  187. />,
  188. t('User Information'),
  189. <StyledHeader key={1}>
  190. {t('Recent Commits')}
  191. <Tooltip title={t('Based on the last 30 days of commit data')}>
  192. <IconInfo size="xs" />
  193. </Tooltip>
  194. </StyledHeader>,
  195. t('Role'),
  196. t('Team'),
  197. ]}
  198. >
  199. {memberInvites?.map((member, i) => {
  200. const checked = memberInvites[i].selected;
  201. const username = member.externalId.split(':').pop();
  202. return (
  203. <Fragment key={i}>
  204. <div>
  205. <Checkbox
  206. aria-label={t('Select %s', member.email)}
  207. checked={checked}
  208. onChange={() => toggleCheckbox(!checked, i)}
  209. />
  210. </div>
  211. <StyledPanelItem>
  212. <ContentRow>
  213. <IconGithub size="sm" />
  214. <StyledExternalLink href={`https://github.com/${username}`}>
  215. @{username}
  216. </StyledExternalLink>
  217. </ContentRow>
  218. <MemberEmail>{member.email}</MemberEmail>
  219. </StyledPanelItem>
  220. <ContentRow>
  221. <IconCommit size="sm" />
  222. {member.commitCount}
  223. </ContentRow>
  224. <RoleSelectControl
  225. aria-label={t('Role')}
  226. data-test-id="select-role"
  227. disabled={false}
  228. roles={allowedRoles}
  229. disableUnallowed
  230. onChange={value => setRole(value?.value, i)}
  231. />
  232. <TeamSelector
  233. organization={organization}
  234. aria-label={t('Add to Team')}
  235. data-test-id="select-teams"
  236. disabled={false}
  237. placeholder={t('Add to teams\u2026')}
  238. onChange={opts => setTeams(opts ? opts.map(v => v.value) : [], i)}
  239. multiple
  240. clearable
  241. />
  242. </Fragment>
  243. );
  244. })}
  245. </StyledPanelTable>
  246. <Footer>
  247. <div>{renderStatusMessage()}</div>
  248. <ButtonBar gap={1}>
  249. <Button
  250. size="sm"
  251. onClick={() => {
  252. closeModal();
  253. }}
  254. >
  255. {t('Cancel')}
  256. </Button>
  257. <Button
  258. size="sm"
  259. priority="primary"
  260. aria-label={t('Send Invites')}
  261. onClick={sendInvites}
  262. disabled={!canSend || selectedCount === 0}
  263. analyticsEventName="Github Invite Modal: Invite"
  264. analyticsEventKey="github_invite_modal.invite"
  265. analyticsParams={{
  266. invited_all: memberInvites.length === selectedCount,
  267. invited_count: selectedCount,
  268. }}
  269. >
  270. {inviteButtonLabel()}
  271. </Button>
  272. </ButtonBar>
  273. </Footer>
  274. </Fragment>
  275. );
  276. return (
  277. <InviteModalHook
  278. organization={organization}
  279. willInvite
  280. onSendInvites={sendMemberInvites}
  281. >
  282. {hookRenderer}
  283. </InviteModalHook>
  284. );
  285. }
  286. export default InviteMissingMembersModal;
  287. const StyledPanelTable = styled(PanelTable)`
  288. grid-template-columns: max-content 1fr max-content 1fr 1fr;
  289. overflow: visible;
  290. `;
  291. const StyledHeader = styled('div')`
  292. display: flex;
  293. gap: ${space(0.5)};
  294. `;
  295. const StyledPanelItem = styled(PanelItem)`
  296. flex-direction: column;
  297. `;
  298. const Footer = styled('div')`
  299. display: flex;
  300. justify-content: space-between;
  301. `;
  302. const ContentRow = styled('div')`
  303. display: flex;
  304. align-items: center;
  305. font-size: ${p => p.theme.fontSizeMedium};
  306. gap: ${space(0.75)};
  307. `;
  308. const MemberEmail = styled('div')`
  309. display: block;
  310. max-width: 150px;
  311. font-size: ${p => p.theme.fontSizeSmall};
  312. font-weight: 400;
  313. color: ${p => p.theme.gray300};
  314. text-overflow: ellipsis;
  315. overflow: hidden;
  316. `;
  317. export const modalCss = css`
  318. width: 80%;
  319. max-width: 870px;
  320. `;