index.spec.jsx 13 KB

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