index.spec.tsx 15 KB

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