index.spec.tsx 14 KB

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