index.spec.jsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. import {
  2. act,
  3. render,
  4. screen,
  5. userEvent,
  6. waitFor,
  7. within,
  8. } from 'sentry-test/reactTestingLibrary';
  9. import * as projectsActions from 'sentry/actionCreators/projects';
  10. import ProjectsStatsStore from 'sentry/stores/projectsStatsStore';
  11. import ProjectsStore from 'sentry/stores/projectsStore';
  12. import {Dashboard} from 'sentry/views/projectsDashboard';
  13. jest.unmock('lodash/debounce');
  14. jest.mock('lodash/debounce', () => {
  15. const debounceMap = new Map();
  16. const mockDebounce =
  17. (fn, timeout) =>
  18. (...args) => {
  19. if (debounceMap.has(fn)) {
  20. clearTimeout(debounceMap.get(fn));
  21. }
  22. debounceMap.set(
  23. fn,
  24. setTimeout(() => {
  25. fn.apply(fn, args);
  26. debounceMap.delete(fn);
  27. }, timeout)
  28. );
  29. };
  30. return mockDebounce;
  31. });
  32. describe('ProjectsDashboard', function () {
  33. const org = TestStubs.Organization();
  34. const team = TestStubs.Team();
  35. const teams = [team];
  36. beforeEach(function () {
  37. MockApiClient.addMockResponse({
  38. url: `/teams/${org.slug}/${team.slug}/members/`,
  39. body: [],
  40. });
  41. ProjectsStatsStore.reset();
  42. ProjectsStore.loadInitialData([]);
  43. });
  44. afterEach(function () {
  45. MockApiClient.clearMockResponses();
  46. });
  47. describe('empty state', function () {
  48. it('renders with no projects', function () {
  49. const noProjectTeams = [TestStubs.Team({isMember: false, projects: []})];
  50. render(
  51. <Dashboard teams={noProjectTeams} organization={org} params={{orgId: org.slug}} />
  52. );
  53. expect(screen.getByRole('button', {name: 'Join a Team'})).toBeInTheDocument();
  54. });
  55. it('renders with 1 project, with no first event', function () {
  56. const projects = [TestStubs.Project({teams, firstEvent: false})];
  57. ProjectsStore.loadInitialData(projects);
  58. const teamsWithOneProject = [TestStubs.Team({projects})];
  59. render(
  60. <Dashboard
  61. teams={teamsWithOneProject}
  62. organization={org}
  63. params={{orgId: org.slug}}
  64. />
  65. );
  66. expect(screen.getByTestId('join-team')).toBeInTheDocument();
  67. expect(screen.getByTestId('create-project')).toBeInTheDocument();
  68. expect(
  69. screen.getByPlaceholderText('Search for projects by name')
  70. ).toBeInTheDocument();
  71. expect(screen.getByText('My Teams')).toBeInTheDocument();
  72. expect(screen.getByText('Resources')).toBeInTheDocument();
  73. expect(screen.getByTestId('badge-display-name')).toBeInTheDocument();
  74. });
  75. });
  76. describe('with projects', function () {
  77. it('renders with two projects', function () {
  78. const teamA = TestStubs.Team({slug: 'team1', isMember: true});
  79. const projects = [
  80. TestStubs.Project({
  81. id: '1',
  82. slug: 'project1',
  83. teams: [teamA],
  84. firstEvent: true,
  85. }),
  86. TestStubs.Project({
  87. id: '2',
  88. slug: 'project2',
  89. teams: [teamA],
  90. isBookmarked: true,
  91. firstEvent: true,
  92. }),
  93. ];
  94. ProjectsStore.loadInitialData(projects);
  95. const teamsWithTwoProjects = [TestStubs.Team({projects})];
  96. render(
  97. <Dashboard
  98. teams={teamsWithTwoProjects}
  99. organization={org}
  100. params={{orgId: org.slug}}
  101. />
  102. );
  103. expect(screen.getByText('My Teams')).toBeInTheDocument();
  104. expect(screen.getAllByTestId('badge-display-name')).toHaveLength(2);
  105. });
  106. it('renders correct project with selected team', function () {
  107. const teamC = TestStubs.Team({
  108. id: '1',
  109. slug: 'teamC',
  110. isMember: true,
  111. projects: [
  112. TestStubs.Project({
  113. id: '1',
  114. slug: 'project1',
  115. }),
  116. TestStubs.Project({
  117. id: '2',
  118. slug: 'project2',
  119. }),
  120. ],
  121. });
  122. const teamD = TestStubs.Team({
  123. id: '2',
  124. slug: 'teamD',
  125. isMember: true,
  126. projects: [
  127. TestStubs.Project({
  128. id: '3',
  129. slug: 'project3',
  130. }),
  131. ],
  132. });
  133. const teamsWithSpecificProjects = [teamC, teamD];
  134. MockApiClient.addMockResponse({
  135. url: `/organizations/${org.slug}/teams/?team=2`,
  136. body: teamsWithSpecificProjects,
  137. });
  138. const projects = [
  139. TestStubs.Project({
  140. id: '1',
  141. slug: 'project1',
  142. teams: [teamC],
  143. firstEvent: true,
  144. stats: [],
  145. }),
  146. TestStubs.Project({
  147. id: '2',
  148. slug: 'project2',
  149. teams: [teamC],
  150. isBookmarked: true,
  151. firstEvent: true,
  152. stats: [],
  153. }),
  154. TestStubs.Project({
  155. id: '3',
  156. slug: 'project3',
  157. teams: [teamD],
  158. firstEvent: true,
  159. stats: [],
  160. }),
  161. ];
  162. ProjectsStore.loadInitialData(projects);
  163. MockApiClient.addMockResponse({
  164. url: `/organizations/${org.slug}/projects/`,
  165. body: projects,
  166. });
  167. render(
  168. <Dashboard
  169. teams={teamsWithSpecificProjects}
  170. organization={org}
  171. params={{orgId: org.slug}}
  172. location={{
  173. query: {team: '2'},
  174. search: '?team=2`',
  175. }}
  176. />
  177. );
  178. expect(screen.getByText('project3')).toBeInTheDocument();
  179. expect(screen.queryByText('project2')).not.toBeInTheDocument();
  180. });
  181. it('renders projects by search', async function () {
  182. const teamA = TestStubs.Team({slug: 'team1', isMember: true});
  183. MockApiClient.addMockResponse({
  184. url: `/organizations/${org.slug}/projects/`,
  185. body: [],
  186. });
  187. const projects = [
  188. TestStubs.Project({
  189. id: '1',
  190. slug: 'project1',
  191. teams: [teamA],
  192. firstEvent: true,
  193. }),
  194. TestStubs.Project({
  195. id: '2',
  196. slug: 'project2',
  197. teams: [teamA],
  198. isBookmarked: true,
  199. firstEvent: true,
  200. }),
  201. ];
  202. ProjectsStore.loadInitialData(projects);
  203. const teamsWithTwoProjects = [TestStubs.Team({projects})];
  204. render(
  205. <Dashboard
  206. teams={teamsWithTwoProjects}
  207. organization={org}
  208. params={{orgId: org.slug}}
  209. />
  210. );
  211. userEvent.type(
  212. screen.getByPlaceholderText('Search for projects by name'),
  213. 'project2',
  214. '{enter}'
  215. );
  216. expect(screen.getByText('project2')).toBeInTheDocument();
  217. await waitFor(() => {
  218. expect(screen.queryByText('project1')).not.toBeInTheDocument();
  219. });
  220. });
  221. it('renders bookmarked projects first in team list', function () {
  222. const teamA = TestStubs.Team({slug: 'team1', isMember: true});
  223. const projects = [
  224. TestStubs.Project({
  225. id: '11',
  226. slug: 'm',
  227. teams: [teamA],
  228. isBookmarked: false,
  229. stats: [],
  230. }),
  231. TestStubs.Project({
  232. id: '12',
  233. slug: 'm-fave',
  234. teams: [teamA],
  235. isBookmarked: true,
  236. stats: [],
  237. }),
  238. TestStubs.Project({
  239. id: '13',
  240. slug: 'a-fave',
  241. teams: [teamA],
  242. isBookmarked: true,
  243. stats: [],
  244. }),
  245. TestStubs.Project({
  246. id: '14',
  247. slug: 'z-fave',
  248. teams: [teamA],
  249. isBookmarked: true,
  250. stats: [],
  251. }),
  252. TestStubs.Project({
  253. id: '15',
  254. slug: 'a',
  255. teams: [teamA],
  256. isBookmarked: false,
  257. stats: [],
  258. }),
  259. TestStubs.Project({
  260. id: '16',
  261. slug: 'z',
  262. teams: [teamA],
  263. isBookmarked: false,
  264. stats: [],
  265. }),
  266. ];
  267. ProjectsStore.loadInitialData(projects);
  268. const teamsWithFavProjects = [TestStubs.Team({projects})];
  269. MockApiClient.addMockResponse({
  270. url: `/organizations/${org.slug}/projects/`,
  271. body: [
  272. TestStubs.Project({
  273. teams,
  274. stats: [
  275. [1517281200, 2],
  276. [1517310000, 1],
  277. ],
  278. }),
  279. ],
  280. });
  281. jest.useFakeTimers();
  282. render(
  283. <Dashboard
  284. teams={teamsWithFavProjects}
  285. organization={org}
  286. params={{orgId: org.slug}}
  287. />
  288. );
  289. jest.runAllTimers();
  290. jest.useRealTimers();
  291. // check that all projects are displayed
  292. expect(screen.getAllByTestId('badge-display-name')).toHaveLength(6);
  293. const projectName = screen.getAllByTestId('badge-display-name');
  294. // check that projects are in the correct order - alphabetical with bookmarked projects in front
  295. expect(within(projectName[0]).getByText('a-fave')).toBeInTheDocument();
  296. expect(within(projectName[1]).getByText('m-fave')).toBeInTheDocument();
  297. expect(within(projectName[2]).getByText('z-fave')).toBeInTheDocument();
  298. expect(within(projectName[3]).getByText('a')).toBeInTheDocument();
  299. expect(within(projectName[4]).getByText('m')).toBeInTheDocument();
  300. expect(within(projectName[5]).getByText('z')).toBeInTheDocument();
  301. });
  302. });
  303. describe('ProjectsStatsStore', function () {
  304. const teamA = TestStubs.Team({slug: 'team1', isMember: true});
  305. const projects = [
  306. TestStubs.Project({
  307. id: '1',
  308. slug: 'm',
  309. teams,
  310. isBookmarked: false,
  311. }),
  312. TestStubs.Project({
  313. id: '2',
  314. slug: 'm-fave',
  315. teams: [teamA],
  316. isBookmarked: true,
  317. }),
  318. TestStubs.Project({
  319. id: '3',
  320. slug: 'a-fave',
  321. teams: [teamA],
  322. isBookmarked: true,
  323. }),
  324. TestStubs.Project({
  325. id: '4',
  326. slug: 'z-fave',
  327. teams: [teamA],
  328. isBookmarked: true,
  329. }),
  330. TestStubs.Project({
  331. id: '5',
  332. slug: 'a',
  333. teams: [teamA],
  334. isBookmarked: false,
  335. }),
  336. TestStubs.Project({
  337. id: '6',
  338. slug: 'z',
  339. teams: [teamA],
  340. isBookmarked: false,
  341. }),
  342. ];
  343. const teamsWithStatTestProjects = [TestStubs.Team({projects})];
  344. it('uses ProjectsStatsStore to load stats', async function () {
  345. ProjectsStore.loadInitialData(projects);
  346. jest.useFakeTimers();
  347. ProjectsStatsStore.onStatsLoadSuccess([{...projects[0], stats: [[1517281200, 2]]}]);
  348. const loadStatsSpy = jest.spyOn(projectsActions, 'loadStatsForProject');
  349. const mock = MockApiClient.addMockResponse({
  350. url: `/organizations/${org.slug}/projects/`,
  351. body: projects.map(project => ({
  352. ...project,
  353. stats: [
  354. [1517281200, 2],
  355. [1517310000, 1],
  356. ],
  357. })),
  358. });
  359. const {unmount} = render(
  360. <Dashboard
  361. teams={teamsWithStatTestProjects}
  362. organization={org}
  363. params={{orgId: org.slug}}
  364. />
  365. );
  366. expect(loadStatsSpy).toHaveBeenCalledTimes(6);
  367. expect(mock).not.toHaveBeenCalled();
  368. const projectSummary = screen.getAllByTestId('summary-links');
  369. // Has 5 Loading Cards because 1 project has been loaded in store already
  370. expect(
  371. within(projectSummary[0]).getByTestId('loading-placeholder')
  372. ).toBeInTheDocument();
  373. expect(
  374. within(projectSummary[1]).getByTestId('loading-placeholder')
  375. ).toBeInTheDocument();
  376. expect(
  377. within(projectSummary[2]).getByTestId('loading-placeholder')
  378. ).toBeInTheDocument();
  379. expect(
  380. within(projectSummary[3]).getByTestId('loading-placeholder')
  381. ).toBeInTheDocument();
  382. expect(within(projectSummary[4]).getByText('Errors: 2')).toBeInTheDocument();
  383. expect(
  384. within(projectSummary[5]).getByTestId('loading-placeholder')
  385. ).toBeInTheDocument();
  386. // Advance timers so that batched request fires
  387. act(() => jest.advanceTimersByTime(51));
  388. expect(mock).toHaveBeenCalledTimes(1);
  389. // query ids = 3, 2, 4 = bookmarked
  390. // 1 - already loaded in store so shouldn't be in query
  391. expect(mock).toHaveBeenCalledWith(
  392. expect.anything(),
  393. expect.objectContaining({
  394. query: expect.objectContaining({
  395. query: 'id:3 id:2 id:4 id:5 id:6',
  396. }),
  397. })
  398. );
  399. jest.useRealTimers();
  400. // All cards have loaded
  401. await waitFor(() => {
  402. expect(within(projectSummary[0]).getByText('Errors: 3')).toBeInTheDocument();
  403. });
  404. expect(within(projectSummary[1]).getByText('Errors: 3')).toBeInTheDocument();
  405. expect(within(projectSummary[2]).getByText('Errors: 3')).toBeInTheDocument();
  406. expect(within(projectSummary[3]).getByText('Errors: 3')).toBeInTheDocument();
  407. expect(within(projectSummary[4]).getByText('Errors: 3')).toBeInTheDocument();
  408. expect(within(projectSummary[5]).getByText('Errors: 3')).toBeInTheDocument();
  409. // Resets store when it unmounts
  410. unmount();
  411. expect(ProjectsStatsStore.getAll()).toEqual({});
  412. });
  413. it('renders an error from withTeamsForUser', function () {
  414. ProjectsStore.loadInitialData(projects);
  415. render(
  416. <Dashboard error={Error('uhoh')} organization={org} params={{orgId: org.slug}} />
  417. );
  418. expect(
  419. screen.getByText('An error occurred while fetching your projects')
  420. ).toBeInTheDocument();
  421. });
  422. });
  423. });