index.spec.tsx 14 KB

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