index.tsx 11 KB

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