index.spec.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  1. import {Organization} from 'sentry-fixture/organization';
  2. import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
  3. import ProjectSelector, {
  4. ProjectSelectorProps,
  5. } from 'sentry/components/organizations/projectSelector';
  6. import {ALL_ACCESS_PROJECTS} from 'sentry/constants/pageFilters';
  7. describe('ProjectSelector', function () {
  8. const testProject = TestStubs.Project({
  9. id: '1',
  10. slug: 'test-project',
  11. isBookmarked: true,
  12. isMember: true,
  13. });
  14. const anotherProject = TestStubs.Project({
  15. id: '2',
  16. slug: 'another-project',
  17. isMember: true,
  18. });
  19. const mockOrg = Organization({
  20. id: '1',
  21. slug: 'org',
  22. features: ['new-teams', 'global-views'],
  23. access: [],
  24. });
  25. const routerContext = TestStubs.routerContext([{organization: mockOrg}]);
  26. async function openMenu() {
  27. await userEvent.click(screen.getByRole('button'));
  28. }
  29. async function applyMenu() {
  30. await userEvent.click(screen.getByRole('button', {name: 'Apply Filter'}));
  31. }
  32. const props: ProjectSelectorProps = {
  33. customDropdownButton: () => <span>Project Picker</span>,
  34. customLoadingIndicator: () => 'Loading...',
  35. isGlobalSelectionReady: true,
  36. organization: mockOrg,
  37. memberProjects: [testProject, anotherProject],
  38. nonMemberProjects: [],
  39. value: [],
  40. onApplyChange: () => {},
  41. onChange: () => {},
  42. };
  43. it('should show empty message with no projects button, when no projects, and has no "project:write" access', async function () {
  44. render(
  45. <ProjectSelector
  46. {...props}
  47. memberProjects={[]}
  48. organization={Organization({
  49. id: 'org',
  50. slug: 'org-slug',
  51. access: [],
  52. features: [],
  53. })}
  54. />,
  55. {context: routerContext}
  56. );
  57. await openMenu();
  58. expect(screen.getByText('You have no projects')).toBeInTheDocument();
  59. // Should not have "Create Project" button
  60. const createProject = screen.getByLabelText('Add Project');
  61. expect(createProject).toBeInTheDocument();
  62. expect(createProject).toBeDisabled();
  63. });
  64. it('should show empty message and create project button, when no projects and has "project:write" access', async function () {
  65. render(
  66. <ProjectSelector
  67. {...props}
  68. memberProjects={[]}
  69. organization={Organization({
  70. id: 'org',
  71. slug: 'org-slug',
  72. access: ['project:write'],
  73. features: [],
  74. })}
  75. />,
  76. {context: routerContext}
  77. );
  78. await openMenu();
  79. expect(screen.getByText('You have no projects')).toBeInTheDocument();
  80. // Should not have "Create Project" button
  81. const createProject = screen.getByLabelText('Add Project');
  82. expect(createProject).toBeInTheDocument();
  83. expect(createProject).toBeEnabled();
  84. });
  85. it('does not open selector menu when disabled', async function () {
  86. render(<ProjectSelector {...props} disabled />, {context: routerContext});
  87. await openMenu();
  88. expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
  89. });
  90. it('lists projects and has filter', async function () {
  91. render(<ProjectSelector {...props} />, {context: routerContext});
  92. await openMenu();
  93. expect(screen.getByText(testProject.slug)).toBeInTheDocument();
  94. expect(screen.getByText(anotherProject.slug)).toBeInTheDocument();
  95. });
  96. it('can filter projects by project name', async function () {
  97. render(<ProjectSelector {...props} />, {context: routerContext});
  98. await openMenu();
  99. screen.getByRole('textbox').focus();
  100. await userEvent.keyboard('TEST');
  101. const item = screen.getByTestId('badge-display-name');
  102. expect(item).toBeInTheDocument();
  103. expect(item).toHaveTextContent(testProject.slug);
  104. });
  105. it('shows empty filter message when filtering has no results', async function () {
  106. render(<ProjectSelector {...props} />, {context: routerContext});
  107. await openMenu();
  108. screen.getByRole('textbox').focus();
  109. await userEvent.keyboard('Foo');
  110. expect(screen.queryByTestId('badge-display-name')).not.toBeInTheDocument();
  111. expect(screen.getByText('No projects found')).toBeInTheDocument();
  112. });
  113. it('does not close dropdown when input is clicked', async function () {
  114. render(<ProjectSelector {...props} />, {context: routerContext});
  115. await openMenu();
  116. await userEvent.click(screen.getByRole('textbox'));
  117. // Dropdown is still open
  118. expect(screen.getByText(testProject.slug)).toBeInTheDocument();
  119. });
  120. it('closes dropdown when project is selected', async function () {
  121. render(<ProjectSelector {...props} />, {context: routerContext});
  122. await openMenu();
  123. await userEvent.click(screen.getByText(testProject.slug));
  124. // Dropdown is closed
  125. expect(screen.queryByText(testProject.slug)).not.toBeInTheDocument();
  126. });
  127. it('calls callback when project is selected', async function () {
  128. const onApplyChangeMock = jest.fn();
  129. render(<ProjectSelector {...props} onApplyChange={onApplyChangeMock} />, {
  130. context: routerContext,
  131. });
  132. await openMenu();
  133. // Select first project
  134. await userEvent.click(screen.getByText(testProject.slug));
  135. expect(onApplyChangeMock).toHaveBeenCalledWith([parseInt(testProject.id, 10)]);
  136. });
  137. it('does not call `onUpdate` when using multi select', async function () {
  138. const onChangeMock = jest.fn();
  139. const onApplyChangeMock = jest.fn();
  140. render(
  141. <ProjectSelector
  142. {...props}
  143. onChange={onChangeMock}
  144. onApplyChange={onApplyChangeMock}
  145. />,
  146. {context: routerContext}
  147. );
  148. await openMenu();
  149. // Check the first project
  150. await userEvent.click(screen.getByRole('checkbox', {name: testProject.slug}));
  151. expect(onChangeMock).toHaveBeenCalled();
  152. expect(onApplyChangeMock).not.toHaveBeenCalled();
  153. });
  154. it('displays multi projects with non member projects', async function () {
  155. const nonMemberProject = TestStubs.Project({id: '2'});
  156. render(<ProjectSelector {...props} nonMemberProjects={[nonMemberProject]} />, {
  157. context: routerContext,
  158. });
  159. await openMenu();
  160. expect(screen.getByText("Projects I don't belong to")).toBeInTheDocument();
  161. expect(screen.getAllByTestId('badge-display-name')).toHaveLength(3);
  162. });
  163. it('displays projects in alphabetical order partitioned by project membership', async function () {
  164. const projectA = TestStubs.Project({id: '1', slug: 'a-project'});
  165. const projectB = TestStubs.Project({id: '2', slug: 'b-project'});
  166. const projectANonM = TestStubs.Project({id: '3', slug: 'a-non-m-project'});
  167. const projectBNonM = TestStubs.Project({id: '4', slug: 'b-non-m-project'});
  168. const multiProjectProps = {
  169. ...props,
  170. memberProjects: [projectB, projectA],
  171. nonMemberProjects: [projectBNonM, projectANonM],
  172. value: [],
  173. };
  174. render(<ProjectSelector {...multiProjectProps} />, {context: routerContext});
  175. await openMenu();
  176. expect(screen.getByText("Projects I don't belong to")).toBeInTheDocument();
  177. const projectLabels = screen.getAllByTestId('badge-display-name');
  178. expect(projectLabels).toHaveLength(4);
  179. expect(projectLabels[0]).toHaveTextContent(projectA.slug);
  180. expect(projectLabels[1]).toHaveTextContent(projectB.slug);
  181. expect(projectLabels[2]).toHaveTextContent(projectANonM.slug);
  182. expect(projectLabels[3]).toHaveTextContent(projectBNonM.slug);
  183. });
  184. it('displays multi projects in sort order rules: selected, bookmarked, alphabetical', async function () {
  185. const projectA = TestStubs.Project({id: '1', slug: 'a-project'});
  186. const projectBBookmarked = TestStubs.Project({
  187. id: '2',
  188. slug: 'b-project',
  189. isBookmarked: true,
  190. });
  191. const projectCBookmarked = TestStubs.Project({
  192. id: '3',
  193. slug: 'c-project',
  194. isBookmarked: true,
  195. });
  196. const projectDSelected = TestStubs.Project({id: '4', slug: 'd-project'});
  197. const projectESelected = TestStubs.Project({id: '5', slug: 'e-project'});
  198. const projectFSelectedBookmarked = TestStubs.Project({
  199. id: '6',
  200. slug: 'f-project',
  201. isBookmarked: true,
  202. });
  203. const projectGSelectedBookmarked = TestStubs.Project({
  204. id: '7',
  205. slug: 'g-project',
  206. isBookmarked: true,
  207. });
  208. const projectH = TestStubs.Project({id: '8', slug: 'h-project'});
  209. const projectJ = TestStubs.Project({id: '9', slug: 'j-project'});
  210. const projectKSelectedBookmarked = TestStubs.Project({
  211. id: '10',
  212. slug: 'k-project',
  213. isBookmarked: true,
  214. });
  215. const projectL = TestStubs.Project({id: '11', slug: 'l-project'});
  216. const multiProjectProps = {
  217. ...props,
  218. // XXX: Intentionally sorted arbitrarily
  219. memberProjects: [
  220. projectBBookmarked,
  221. projectFSelectedBookmarked,
  222. projectDSelected,
  223. projectA,
  224. projectESelected,
  225. projectGSelectedBookmarked,
  226. projectCBookmarked,
  227. projectH,
  228. ],
  229. nonMemberProjects: [projectL, projectJ, projectKSelectedBookmarked],
  230. value: [
  231. projectESelected.id,
  232. projectDSelected.id,
  233. projectGSelectedBookmarked.id,
  234. projectFSelectedBookmarked.id,
  235. projectKSelectedBookmarked.id,
  236. ].map(p => parseInt(p, 10)),
  237. };
  238. render(<ProjectSelector {...multiProjectProps} />, {context: routerContext});
  239. await openMenu();
  240. const projectLabels = screen.getAllByTestId('badge-display-name');
  241. expect(projectLabels).toHaveLength(11);
  242. // member projects
  243. expect(projectLabels[0]).toHaveTextContent(projectFSelectedBookmarked.slug);
  244. expect(projectLabels[1]).toHaveTextContent(projectGSelectedBookmarked.slug);
  245. expect(projectLabels[2]).toHaveTextContent(projectDSelected.slug);
  246. expect(projectLabels[3]).toHaveTextContent(projectESelected.slug);
  247. expect(projectLabels[4]).toHaveTextContent(projectBBookmarked.slug);
  248. expect(projectLabels[5]).toHaveTextContent(projectCBookmarked.slug);
  249. expect(projectLabels[6]).toHaveTextContent(projectA.slug);
  250. expect(projectLabels[7]).toHaveTextContent(projectH.slug);
  251. expect(projectLabels[6]).toHaveTextContent(projectA.slug);
  252. expect(projectLabels[7]).toHaveTextContent(projectH.slug);
  253. // non member projects
  254. expect(projectLabels[8]).toHaveTextContent(projectKSelectedBookmarked.slug);
  255. expect(projectLabels[9]).toHaveTextContent(projectJ.slug);
  256. expect(projectLabels[10]).toHaveTextContent(projectL.slug);
  257. });
  258. it('does not change sort order while selecting projects with the dropdown open', async function () {
  259. const projectA = TestStubs.Project({id: '1', slug: 'a-project'});
  260. const projectBBookmarked = TestStubs.Project({
  261. id: '2',
  262. slug: 'b-project',
  263. isBookmarked: true,
  264. });
  265. const projectDSelected = TestStubs.Project({id: '4', slug: 'd-project'});
  266. const multiProjectProps = {
  267. ...props,
  268. // XXX: Intentionally sorted arbitrarily
  269. memberProjects: [projectBBookmarked, projectDSelected, projectA],
  270. nonMemberProjects: [],
  271. value: [projectDSelected.id].map(p => parseInt(p, 10)),
  272. };
  273. const {rerender} = render(<ProjectSelector {...multiProjectProps} />, {
  274. context: routerContext,
  275. });
  276. await openMenu();
  277. const projectLabels = screen.getAllByTestId('badge-display-name');
  278. expect(projectLabels).toHaveLength(3);
  279. // member projects
  280. expect(projectLabels[0]).toHaveTextContent(projectDSelected.slug);
  281. expect(projectLabels[1]).toHaveTextContent(projectBBookmarked.slug);
  282. expect(projectLabels[2]).toHaveTextContent(projectA.slug);
  283. // Unselect project D (re-render with the updated selection value)
  284. await userEvent.click(screen.getByRole('checkbox', {name: projectDSelected.slug}));
  285. rerender(<ProjectSelector {...multiProjectProps} value={[]} />);
  286. // Project D is no longer checked
  287. expect(screen.getByRole('checkbox', {name: projectDSelected.slug})).not.toBeChecked();
  288. // Project D is still the first selected item
  289. expect(screen.getAllByTestId('badge-display-name')[0]).toHaveTextContent(
  290. projectDSelected.slug
  291. );
  292. // Open and close the menu
  293. await applyMenu();
  294. await openMenu();
  295. const resortedProjectLabels = screen.getAllByTestId('badge-display-name');
  296. // Project D has been moved to the bottom since it was unselected
  297. expect(resortedProjectLabels[0]).toHaveTextContent(projectBBookmarked.slug);
  298. expect(resortedProjectLabels[1]).toHaveTextContent(projectA.slug);
  299. expect(resortedProjectLabels[2]).toHaveTextContent(projectDSelected.slug);
  300. });
  301. it('can select all projects when role=owner', async function () {
  302. const mockOnApplyChange = jest.fn();
  303. render(
  304. <ProjectSelector
  305. {...props}
  306. nonMemberProjects={[anotherProject]}
  307. organization={Organization({...mockOrg, orgRole: 'owner'})}
  308. onApplyChange={mockOnApplyChange}
  309. />,
  310. {context: routerContext}
  311. );
  312. await openMenu();
  313. await userEvent.click(screen.getByRole('button', {name: 'Select All Projects'}));
  314. expect(mockOnApplyChange).toHaveBeenCalledTimes(1);
  315. expect(mockOnApplyChange).toHaveBeenCalledWith([ALL_ACCESS_PROJECTS]);
  316. });
  317. it('can select all projects when role=manager', async function () {
  318. const mockOnApplyChange = jest.fn();
  319. render(
  320. <ProjectSelector
  321. {...props}
  322. nonMemberProjects={[anotherProject]}
  323. organization={Organization({...mockOrg, orgRole: 'manager'})}
  324. onApplyChange={mockOnApplyChange}
  325. />,
  326. {context: routerContext}
  327. );
  328. await openMenu();
  329. await userEvent.click(screen.getByRole('button', {name: 'Select All Projects'}));
  330. expect(mockOnApplyChange).toHaveBeenCalledTimes(1);
  331. expect(mockOnApplyChange).toHaveBeenCalledWith([ALL_ACCESS_PROJECTS]);
  332. });
  333. it('can select all projects when org has open membership', async function () {
  334. const mockOnApplyChange = jest.fn();
  335. render(
  336. <ProjectSelector
  337. {...props}
  338. nonMemberProjects={[anotherProject]}
  339. organization={Organization({
  340. ...mockOrg,
  341. features: [...mockOrg.features, 'open-membership'],
  342. })}
  343. onApplyChange={mockOnApplyChange}
  344. />,
  345. {context: routerContext}
  346. );
  347. await openMenu();
  348. await userEvent.click(screen.getByRole('button', {name: 'Select All Projects'}));
  349. expect(mockOnApplyChange).toHaveBeenCalledTimes(1);
  350. expect(mockOnApplyChange).toHaveBeenCalledWith([ALL_ACCESS_PROJECTS]);
  351. });
  352. it('can select all projects after first selecting a project', async function () {
  353. const mockOnApplyChange = jest.fn();
  354. render(
  355. <ProjectSelector
  356. {...props}
  357. nonMemberProjects={[anotherProject]}
  358. organization={Organization({...mockOrg, orgRole: 'manager'})}
  359. onApplyChange={mockOnApplyChange}
  360. />,
  361. {context: routerContext}
  362. );
  363. await openMenu();
  364. // Check the first project
  365. await userEvent.click(screen.getByRole('checkbox', {name: testProject.slug}));
  366. await userEvent.click(screen.getByRole('button', {name: 'Select All Projects'}));
  367. // Menu should close and all projects should be selected, not previously selected project
  368. expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
  369. expect(mockOnApplyChange).toHaveBeenCalledTimes(1);
  370. expect(mockOnApplyChange).toHaveBeenCalledWith([ALL_ACCESS_PROJECTS]);
  371. });
  372. });