index.spec.tsx 14 KB

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