useInviteModal.tsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
  2. import type {
  3. InviteRow,
  4. NormalizedInvite,
  5. } from 'sentry/components/modals/inviteMembersModal/types';
  6. import {t} from 'sentry/locale';
  7. import type {Member, Organization} from 'sentry/types';
  8. import {trackAnalytics} from 'sentry/utils/analytics';
  9. import {uniqueId} from 'sentry/utils/guid';
  10. import {useApiQuery} from 'sentry/utils/queryClient';
  11. import useApi from 'sentry/utils/useApi';
  12. interface Props {
  13. organization: Organization;
  14. initialData?: Partial<InviteRow>[];
  15. source?: string;
  16. }
  17. function defaultInvite(): InviteRow {
  18. return {
  19. emails: new Set<string>(),
  20. teams: new Set<string>(),
  21. role: 'member',
  22. };
  23. }
  24. function useLogInviteModalOpened({
  25. organization,
  26. sessionId,
  27. source,
  28. }: {
  29. organization: Organization;
  30. sessionId: string;
  31. source: string | undefined;
  32. }) {
  33. useEffect(() => {
  34. trackAnalytics('invite_modal.opened', {
  35. organization,
  36. modal_session: sessionId,
  37. can_invite: organization.access?.includes('member:write'),
  38. source,
  39. });
  40. }, [organization, sessionId, source]);
  41. }
  42. export default function useInviteModal({organization, initialData, source}: Props) {
  43. const api = useApi();
  44. const willInvite = organization.access?.includes('member:write');
  45. /**
  46. * Used for analytics tracking of the modals usage.
  47. */
  48. const sessionId = useRef(uniqueId());
  49. useLogInviteModalOpened({organization, sessionId: sessionId.current, source});
  50. const memberResult = useApiQuery<Member>(
  51. [`/organizations/${organization.slug}/members/me/`],
  52. {
  53. staleTime: 0,
  54. }
  55. );
  56. const [state, setState] = useState(() => {
  57. return {
  58. pendingInvites: initialData
  59. ? initialData.map(initial => ({
  60. ...defaultInvite(),
  61. ...initial,
  62. }))
  63. : [defaultInvite()],
  64. inviteStatus: {},
  65. complete: false,
  66. sendingInvites: false,
  67. error: undefined,
  68. };
  69. });
  70. const invites = useMemo(() => {
  71. return state.pendingInvites.reduce<NormalizedInvite[]>(
  72. (acc, row) =>
  73. acc.concat(
  74. Array.from(row.emails).map(email => ({email, teams: row.teams, role: row.role}))
  75. ),
  76. []
  77. );
  78. }, [state.pendingInvites]);
  79. const reset = useCallback(() => {
  80. setState({
  81. pendingInvites: [defaultInvite()],
  82. inviteStatus: {},
  83. complete: false,
  84. sendingInvites: false,
  85. error: undefined,
  86. });
  87. trackAnalytics('invite_modal.add_more', {
  88. organization,
  89. modal_session: sessionId.current,
  90. });
  91. }, [organization]);
  92. const sendInvite = useCallback(
  93. async (invite: NormalizedInvite) => {
  94. const data = {
  95. email: invite.email,
  96. teams: [...invite.teams],
  97. role: invite.role,
  98. };
  99. setState(prev => ({
  100. ...prev,
  101. inviteStatus: {
  102. ...prev.inviteStatus,
  103. [invite.email]: {sent: false},
  104. },
  105. }));
  106. const endpoint = willInvite
  107. ? `/organizations/${organization.slug}/members/`
  108. : `/organizations/${organization.slug}/invite-requests/`;
  109. try {
  110. await api.requestPromise(endpoint, {method: 'POST', data});
  111. } catch (err) {
  112. const errorResponse = err.responseJSON;
  113. // Use the email error message if available. This inconsistently is
  114. // returned as either a list of errors for the field, or a single error.
  115. const emailError =
  116. !errorResponse || !errorResponse.email
  117. ? false
  118. : Array.isArray(errorResponse.email)
  119. ? errorResponse.email[0]
  120. : errorResponse.email;
  121. const orgLevelError = errorResponse?.organization;
  122. const error = orgLevelError || emailError || t('Could not invite user');
  123. setState(prev => ({
  124. ...prev,
  125. inviteStatus: {...prev.inviteStatus, [invite.email]: {sent: false, error}},
  126. error: orgLevelError,
  127. }));
  128. return;
  129. }
  130. setState(prev => ({
  131. ...prev,
  132. inviteStatus: {...prev.inviteStatus, [invite.email]: {sent: true}},
  133. }));
  134. },
  135. [api, organization, willInvite]
  136. );
  137. const sendInvites = useCallback(async () => {
  138. setState(prev => ({...prev, sendingInvites: true}));
  139. await Promise.all(invites.map(sendInvite));
  140. setState(prev => ({...prev, sendingInvites: false, complete: true}));
  141. trackAnalytics(
  142. willInvite ? 'invite_modal.invites_sent' : 'invite_modal.requests_sent',
  143. {
  144. organization,
  145. modal_session: sessionId.current,
  146. }
  147. );
  148. }, [organization, invites, sendInvite, willInvite]);
  149. const addInviteRow = useCallback(() => {
  150. setState(prev => ({
  151. ...prev,
  152. pendingInvites: [...prev.pendingInvites, defaultInvite()],
  153. }));
  154. }, []);
  155. const setEmails = useCallback((emails: string[], index: number) => {
  156. setState(prev => {
  157. const pendingInvites = [...prev.pendingInvites];
  158. pendingInvites[index] = {...pendingInvites[index], emails: new Set(emails)};
  159. return {...prev, pendingInvites};
  160. });
  161. }, []);
  162. const setTeams = useCallback((teams: string[], index: number) => {
  163. setState(prev => {
  164. const pendingInvites = [...prev.pendingInvites];
  165. pendingInvites[index] = {...pendingInvites[index], teams: new Set(teams)};
  166. return {...prev, pendingInvites};
  167. });
  168. }, []);
  169. const setRole = useCallback((role: string, index: number) => {
  170. setState(prev => {
  171. const pendingInvites = [...prev.pendingInvites];
  172. pendingInvites[index] = {...pendingInvites[index], role};
  173. return {...prev, pendingInvites};
  174. });
  175. }, []);
  176. const removeInviteRow = useCallback((index: number) => {
  177. setState(prev => {
  178. const pendingInvites = [...prev.pendingInvites];
  179. pendingInvites.splice(index, 1);
  180. return {...prev, pendingInvites};
  181. });
  182. }, []);
  183. return {
  184. addInviteRow,
  185. invites,
  186. memberResult,
  187. removeInviteRow,
  188. reset,
  189. sendInvites,
  190. sessionId: sessionId.current,
  191. setEmails,
  192. setRole,
  193. setTeams,
  194. willInvite,
  195. complete: state.complete,
  196. inviteStatus: state.inviteStatus,
  197. pendingInvites: state.pendingInvites,
  198. sendingInvites: state.sendingInvites,
  199. error: state.error,
  200. };
  201. }