index.spec.tsx 15 KB

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