index.spec.tsx 14 KB

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