index.tsx 11 KB

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