index.spec.tsx 14 KB

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