index.spec.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. import type {ComponentProps} from 'react';
  2. import styled from '@emotion/styled';
  3. import {OrganizationFixture} from 'sentry-fixture/organization';
  4. import {TeamFixture} from 'sentry-fixture/team';
  5. import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
  6. import selectEvent from 'sentry-test/selectEvent';
  7. import {textWithMarkupMatcher} from 'sentry-test/utils';
  8. import {makeCloseButton} from 'sentry/components/globalModal/components';
  9. import InviteMembersModal from 'sentry/components/modals/inviteMembersModal';
  10. import {ORG_ROLES} from 'sentry/constants';
  11. import TeamStore from 'sentry/stores/teamStore';
  12. import type {DetailedTeam, Scope} from 'sentry/types';
  13. describe('InviteMembersModal', function () {
  14. const styledWrapper = styled(c => c.children);
  15. type MockApiResponseFn = (
  16. client: typeof MockApiClient,
  17. orgSlug: string,
  18. roles?: object[]
  19. ) => jest.Mock;
  20. const defaultMockOrganizationRoles: MockApiResponseFn = (client, orgSlug, roles) => {
  21. return client.addMockResponse({
  22. url: `/organizations/${orgSlug}/members/me/`,
  23. method: 'GET',
  24. body: {orgRoleList: roles},
  25. });
  26. };
  27. const defaultMockPostOrganizationMember: MockApiResponseFn = (client, orgSlug, _) => {
  28. return client.addMockResponse({
  29. url: `/organizations/${orgSlug}/members/`,
  30. method: 'POST',
  31. });
  32. };
  33. const defaultMockModalProps = {
  34. Body: styledWrapper(),
  35. Header: p => <span>{p.children}</span>,
  36. Footer: styledWrapper(),
  37. closeModal: () => {},
  38. CloseButton: makeCloseButton(() => {}),
  39. };
  40. const setupView = ({
  41. orgTeams = [TeamFixture()],
  42. orgAccess = ['member:write'],
  43. roles = [
  44. {
  45. id: 'admin',
  46. name: 'Admin',
  47. desc: 'This is the admin role',
  48. isAllowed: true,
  49. isTeamRolesAllowed: true,
  50. },
  51. {
  52. id: 'member',
  53. name: 'Member',
  54. desc: 'This is the member role',
  55. isAllowed: true,
  56. isTeamRolesAllowed: true,
  57. },
  58. ],
  59. modalProps = defaultMockModalProps,
  60. mockApiResponses = [defaultMockOrganizationRoles],
  61. }: {
  62. mockApiResponses?: MockApiResponseFn[];
  63. modalProps?: ComponentProps<typeof InviteMembersModal>;
  64. orgAccess?: Scope[];
  65. orgTeams?: DetailedTeam[];
  66. roles?: object[];
  67. } = {}) => {
  68. const org = OrganizationFixture({access: orgAccess});
  69. TeamStore.reset();
  70. TeamStore.loadInitialData(orgTeams);
  71. MockApiClient.clearMockResponses();
  72. const mocks: jest.Mock[] = [];
  73. mockApiResponses.forEach(mockApiResponse => {
  74. mocks.push(mockApiResponse(MockApiClient, org.slug, roles));
  75. });
  76. return {
  77. ...render(<InviteMembersModal {...modalProps} />, {organization: org}),
  78. mocks,
  79. };
  80. };
  81. const setupMemberInviteState = async () => {
  82. // Setup two rows, one email each, the first with a admin role.
  83. await userEvent.click(screen.getByRole('button', {name: 'Add another'}));
  84. const emailInputs = screen.getAllByRole('textbox', {name: 'Email Addresses'});
  85. const roleInputs = screen.getAllByRole('textbox', {name: 'Role'});
  86. await userEvent.type(emailInputs[0], 'test1@test.com');
  87. await userEvent.tab();
  88. await selectEvent.select(roleInputs[0], 'Admin');
  89. await userEvent.type(emailInputs[1], 'test2@test.com');
  90. await userEvent.tab();
  91. };
  92. it('renders', async function () {
  93. setupView();
  94. await waitFor(() => {
  95. // Starts with one invite row
  96. expect(screen.getByRole('listitem')).toBeInTheDocument();
  97. });
  98. // We have two roles loaded from the members/me endpoint, defaulting to the
  99. // 'member' role.
  100. await userEvent.click(screen.getByRole('textbox', {name: 'Role'}));
  101. expect(screen.getAllByRole('menuitemradio')).toHaveLength(2);
  102. expect(screen.getByRole('menuitemradio', {name: 'Member'})).toBeChecked();
  103. });
  104. it('renders for superuser', async function () {
  105. jest.mock('sentry/utils/isActiveSuperuser', () => ({
  106. isActiveSuperuser: jest.fn(),
  107. }));
  108. const errorResponse: MockApiResponseFn = (client, orgSlug, _) => {
  109. return client.addMockResponse({
  110. url: `/organizations/${orgSlug}/members/me/`,
  111. method: 'GET',
  112. status: 404,
  113. });
  114. };
  115. setupView({mockApiResponses: [errorResponse]});
  116. expect(await screen.findByRole('listitem')).toBeInTheDocument();
  117. await userEvent.click(screen.getByRole('textbox', {name: 'Role'}));
  118. expect(screen.getAllByRole('menuitemradio')).toHaveLength(ORG_ROLES.length);
  119. expect(screen.getByRole('menuitemradio', {name: 'Member'})).toBeChecked();
  120. });
  121. it('renders without organization.access', async function () {
  122. setupView({orgAccess: undefined});
  123. expect(await screen.findByRole('listitem')).toBeInTheDocument();
  124. });
  125. it('can add a second row', async function () {
  126. setupView();
  127. expect(await screen.findByRole('listitem')).toBeInTheDocument();
  128. await userEvent.click(screen.getByRole('button', {name: 'Add another'}));
  129. expect(screen.getAllByRole('listitem')).toHaveLength(2);
  130. });
  131. it('errors on duplicate emails', async function () {
  132. setupView();
  133. expect(await screen.findByRole('button', {name: 'Add another'})).toBeInTheDocument();
  134. await userEvent.click(screen.getByRole('button', {name: 'Add another'}));
  135. const emailInputs = screen.getAllByRole('textbox', {name: 'Email Addresses'});
  136. await userEvent.type(emailInputs[0], 'test@test.com');
  137. await userEvent.tab();
  138. await userEvent.type(emailInputs[1], 'test@test.com');
  139. await userEvent.tab();
  140. expect(screen.getByText('Duplicate emails between invite rows.')).toBeInTheDocument();
  141. });
  142. it('indicates the total invites on the invite button', async function () {
  143. setupView();
  144. expect(
  145. await screen.findByRole('textbox', {name: 'Email Addresses'})
  146. ).toBeInTheDocument();
  147. const emailInput = screen.getByRole('textbox', {name: 'Email Addresses'});
  148. await userEvent.type(emailInput, 'test@test.com');
  149. await userEvent.tab();
  150. await userEvent.type(emailInput, 'test2@test.com');
  151. await userEvent.tab();
  152. expect(screen.getByRole('button', {name: 'Send invites (2)'})).toBeInTheDocument();
  153. });
  154. it('can be closed', async function () {
  155. const close = jest.fn();
  156. const modalProps = {
  157. ...defaultMockModalProps,
  158. closeModal: close,
  159. };
  160. setupView({modalProps});
  161. expect(await screen.findByRole('button', {name: 'Cancel'})).toBeInTheDocument();
  162. await userEvent.click(screen.getByRole('button', {name: 'Cancel'}));
  163. expect(close).toHaveBeenCalled();
  164. });
  165. it('sends all successful invites without team defaults', async function () {
  166. const {mocks} = setupView({
  167. mockApiResponses: [defaultMockOrganizationRoles, defaultMockPostOrganizationMember],
  168. });
  169. expect(await screen.findByRole('button', {name: 'Add another'})).toBeInTheDocument();
  170. await setupMemberInviteState();
  171. const teamInputs = screen.getAllByRole('textbox', {name: 'Add to Team'});
  172. await selectEvent.select(teamInputs[0], '#team-slug');
  173. await selectEvent.select(teamInputs[1], '#team-slug');
  174. await userEvent.click(screen.getByRole('button', {name: 'Send invites (2)'}));
  175. // Verify data sent to the backend
  176. const mockPostApi = mocks[1];
  177. expect(mockPostApi).toHaveBeenCalledTimes(2);
  178. expect(mockPostApi).toHaveBeenNthCalledWith(
  179. 1,
  180. `/organizations/org-slug/members/`,
  181. expect.objectContaining({
  182. data: {email: 'test1@test.com', role: 'admin', teams: []},
  183. })
  184. );
  185. expect(mockPostApi).toHaveBeenNthCalledWith(
  186. 2,
  187. `/organizations/org-slug/members/`,
  188. expect.objectContaining({
  189. data: {email: 'test2@test.com', role: 'member', teams: []},
  190. })
  191. );
  192. });
  193. it('can reset modal', async function () {
  194. setupView({
  195. mockApiResponses: [defaultMockOrganizationRoles, defaultMockPostOrganizationMember],
  196. });
  197. expect(await screen.findByRole('button', {name: 'Add another'})).toBeInTheDocument();
  198. await setupMemberInviteState();
  199. await userEvent.click(screen.getByRole('button', {name: 'Send invites (2)'}));
  200. // Wait for them to finish
  201. expect(
  202. await screen.findByText(textWithMarkupMatcher('Sent 2 invites'))
  203. ).toBeInTheDocument();
  204. // Reset the modal
  205. await userEvent.click(screen.getByRole('button', {name: 'Send more invites'}));
  206. expect(screen.getByRole('button', {name: 'Send invite'})).toBeDisabled();
  207. });
  208. it('sends all successful invites with team default', async function () {
  209. const {mocks} = setupView({
  210. mockApiResponses: [defaultMockOrganizationRoles, defaultMockPostOrganizationMember],
  211. });
  212. expect(await screen.findByRole('button', {name: 'Add another'})).toBeInTheDocument();
  213. await setupMemberInviteState();
  214. await userEvent.click(screen.getByRole('button', {name: 'Send invites (2)'}));
  215. const mockPostApi = mocks[1];
  216. expect(mockPostApi).toHaveBeenCalledTimes(2);
  217. expect(mockPostApi).toHaveBeenNthCalledWith(
  218. 1,
  219. `/organizations/org-slug/members/`,
  220. expect.objectContaining({
  221. data: {email: 'test1@test.com', role: 'admin', teams: ['team-slug']},
  222. })
  223. );
  224. expect(mockPostApi).toHaveBeenNthCalledWith(
  225. 2,
  226. `/organizations/org-slug/members/`,
  227. expect.objectContaining({
  228. data: {email: 'test2@test.com', role: 'member', teams: ['team-slug']},
  229. })
  230. );
  231. });
  232. it('does not use defaults when there are multiple teams', async function () {
  233. const another_team = TeamFixture({id: '2', slug: 'team2'});
  234. setupView({orgTeams: [TeamFixture(), another_team]});
  235. expect(await screen.findByRole('button', {name: 'Add another'})).toBeInTheDocument();
  236. await userEvent.click(screen.getByRole('button', {name: 'Add another'}));
  237. const teamInputs = screen.getAllByRole('textbox', {name: 'Add to Team'});
  238. expect(teamInputs).toHaveLength(2);
  239. expect(teamInputs[0]).toHaveValue('');
  240. expect(teamInputs[1]).toHaveValue('');
  241. });
  242. it('marks failed invites', async function () {
  243. const failedCreateMemberMock = (client, orgSlug, _) => {
  244. return client.addMockResponse({
  245. url: `/organizations/${orgSlug}/members/`,
  246. method: 'POST',
  247. statusCode: 400,
  248. });
  249. };
  250. const {mocks} = setupView({
  251. mockApiResponses: [defaultMockOrganizationRoles, failedCreateMemberMock],
  252. });
  253. expect(
  254. await screen.findByRole('textbox', {name: 'Email Addresses'})
  255. ).toBeInTheDocument();
  256. await userEvent.type(
  257. screen.getByRole('textbox', {name: 'Email Addresses'}),
  258. 'bademail'
  259. );
  260. await userEvent.tab();
  261. await userEvent.click(screen.getByRole('button', {name: 'Send invite'}));
  262. const failedApiMock = mocks[1];
  263. expect(failedApiMock).toHaveBeenCalled();
  264. expect(
  265. await screen.findByText(textWithMarkupMatcher('Sent 0 invites, 1 failed to send.'))
  266. ).toBeInTheDocument();
  267. });
  268. it('can send initial email', async function () {
  269. const initialEmail = 'test@gmail.com';
  270. const initialData = [{emails: new Set([initialEmail])}];
  271. const {mocks} = setupView({
  272. mockApiResponses: [defaultMockOrganizationRoles, defaultMockPostOrganizationMember],
  273. modalProps: {
  274. ...defaultMockModalProps,
  275. initialData,
  276. },
  277. });
  278. await waitFor(() => {
  279. expect(screen.getByText(initialEmail)).toBeInTheDocument();
  280. });
  281. // Just immediately click send
  282. await userEvent.click(screen.getByRole('button', {name: 'Send invite'}));
  283. const apiMock = mocks[1];
  284. expect(apiMock).toHaveBeenCalledWith(
  285. `/organizations/org-slug/members/`,
  286. expect.objectContaining({
  287. data: {email: initialEmail, role: 'member', teams: ['team-slug']},
  288. })
  289. );
  290. expect(
  291. await screen.findByText(textWithMarkupMatcher('Sent 1 invite'))
  292. ).toBeInTheDocument();
  293. });
  294. it('can send initial email with role and team', async function () {
  295. const initialEmail = 'test@gmail.com';
  296. const role = 'admin';
  297. const initialData = [
  298. {emails: new Set([initialEmail]), role, teams: new Set([TeamFixture().slug])},
  299. ];
  300. const {mocks} = setupView({
  301. mockApiResponses: [defaultMockOrganizationRoles, defaultMockPostOrganizationMember],
  302. modalProps: {
  303. ...defaultMockModalProps,
  304. initialData,
  305. },
  306. });
  307. expect(await screen.findByRole('button', {name: 'Send invite'})).toBeInTheDocument();
  308. // Just immediately click send
  309. await userEvent.click(screen.getByRole('button', {name: 'Send invite'}));
  310. expect(screen.getByText(initialEmail)).toBeInTheDocument();
  311. expect(screen.getByText('Admin')).toBeInTheDocument();
  312. const apiMock = mocks[1];
  313. expect(apiMock).toHaveBeenCalledWith(
  314. `/organizations/org-slug/members/`,
  315. expect.objectContaining({
  316. data: {email: initialEmail, role, teams: [TeamFixture().slug]},
  317. })
  318. );
  319. expect(
  320. await screen.findByText(textWithMarkupMatcher('Sent 1 invite'))
  321. ).toBeInTheDocument();
  322. });
  323. describe('member invite request mode', function () {
  324. it('has adjusted wording', async function () {
  325. setupView({orgAccess: []});
  326. expect(
  327. await screen.findByRole('button', {name: 'Send invite request'})
  328. ).toBeInTheDocument();
  329. });
  330. it('POSTS to the invite-request endpoint', async function () {
  331. const createInviteRequestMock = (client, orgSlug, _) => {
  332. return client.addMockResponse({
  333. url: `/organizations/${orgSlug}/invite-requests/`,
  334. method: 'POST',
  335. });
  336. };
  337. // Use initial data so we don't have to setup as much stuff
  338. const initialEmail = 'test@gmail.com';
  339. const initialData = [{emails: new Set([initialEmail])}];
  340. const {mocks} = setupView({
  341. orgAccess: [],
  342. mockApiResponses: [defaultMockOrganizationRoles, createInviteRequestMock],
  343. modalProps: {
  344. ...defaultMockModalProps,
  345. initialData,
  346. },
  347. });
  348. await waitFor(() => {
  349. expect(screen.getByText(initialEmail)).toBeInTheDocument();
  350. });
  351. await userEvent.click(screen.getByRole('button', {name: 'Send invite request'}));
  352. const apiMock = mocks[1];
  353. expect(apiMock).toHaveBeenCalledTimes(1);
  354. });
  355. });
  356. });