index.spec.jsx 15 KB

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