index.spec.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683
  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 {initializeOrg} from 'sentry-test/initializeOrg';
  6. import {
  7. act,
  8. render,
  9. screen,
  10. userEvent,
  11. waitFor,
  12. within,
  13. } from 'sentry-test/reactTestingLibrary';
  14. import * as projectsActions from 'sentry/actionCreators/projects';
  15. import ProjectsStatsStore from 'sentry/stores/projectsStatsStore';
  16. import ProjectsStore from 'sentry/stores/projectsStore';
  17. import TeamStore from 'sentry/stores/teamStore';
  18. import {Dashboard} from 'sentry/views/projectsDashboard';
  19. jest.unmock('lodash/debounce');
  20. jest.mock('lodash/debounce', () => {
  21. const debounceMap = new Map();
  22. const mockDebounce =
  23. (fn: (...args: any[]) => void, timeout: number) =>
  24. (...args: any[]) => {
  25. if (debounceMap.has(fn)) {
  26. clearTimeout(debounceMap.get(fn));
  27. }
  28. debounceMap.set(
  29. fn,
  30. setTimeout(() => {
  31. fn.apply(fn, args);
  32. debounceMap.delete(fn);
  33. }, timeout)
  34. );
  35. };
  36. return mockDebounce;
  37. });
  38. describe('ProjectsDashboard', function () {
  39. const api = new MockApiClient();
  40. const org = OrganizationFixture();
  41. const team = TeamFixture();
  42. const teams = [team];
  43. beforeEach(function () {
  44. TeamStore.loadInitialData(teams);
  45. MockApiClient.addMockResponse({
  46. url: `/teams/${org.slug}/${team.slug}/members/`,
  47. body: [],
  48. });
  49. MockApiClient.addMockResponse({
  50. url: `/organizations/${org.slug}/projects/`,
  51. body: [],
  52. });
  53. ProjectsStatsStore.reset();
  54. ProjectsStore.loadInitialData([]);
  55. });
  56. afterEach(function () {
  57. TeamStore.reset();
  58. projectsActions._projectStatsToFetch.clear();
  59. MockApiClient.clearMockResponses();
  60. });
  61. describe('empty state', function () {
  62. it('renders with no projects', async function () {
  63. const noProjectTeams = [TeamFixture({isMember: false, projects: []})];
  64. render(
  65. <Dashboard
  66. api={api}
  67. error={null}
  68. loadingTeams={false}
  69. teams={noProjectTeams}
  70. organization={org}
  71. {...RouteComponentPropsFixture()}
  72. />
  73. );
  74. expect(
  75. await screen.findByRole('button', {name: 'Join a Team'})
  76. ).toBeInTheDocument();
  77. expect(screen.getByTestId('create-project')).toBeInTheDocument();
  78. expect(screen.queryByTestId('loading-placeholder')).not.toBeInTheDocument();
  79. });
  80. it('renders with 1 project, with no first event', async function () {
  81. const projects = [ProjectFixture({teams, firstEvent: null, stats: []})];
  82. ProjectsStore.loadInitialData(projects);
  83. const teamsWithOneProject = [TeamFixture({projects})];
  84. render(
  85. <Dashboard
  86. api={api}
  87. error={null}
  88. loadingTeams={false}
  89. teams={teamsWithOneProject}
  90. organization={org}
  91. {...RouteComponentPropsFixture()}
  92. />
  93. );
  94. expect(await screen.findByTestId('join-team')).toBeInTheDocument();
  95. expect(screen.getByTestId('create-project')).toBeInTheDocument();
  96. expect(
  97. screen.getByPlaceholderText('Search for projects by name')
  98. ).toBeInTheDocument();
  99. expect(screen.getByText('My Teams')).toBeInTheDocument();
  100. expect(screen.getByText('Resources')).toBeInTheDocument();
  101. expect(screen.getByTestId('badge-display-name')).toBeInTheDocument();
  102. expect(screen.queryByTestId('loading-placeholder')).not.toBeInTheDocument();
  103. });
  104. });
  105. describe('with projects', function () {
  106. it('renders with two projects', async function () {
  107. const teamA = TeamFixture({slug: 'team1', isMember: true});
  108. const projects = [
  109. ProjectFixture({
  110. id: '1',
  111. slug: 'project1',
  112. teams: [teamA],
  113. firstEvent: new Date().toISOString(),
  114. stats: [],
  115. }),
  116. ProjectFixture({
  117. id: '2',
  118. slug: 'project2',
  119. teams: [teamA],
  120. isBookmarked: true,
  121. firstEvent: new Date().toISOString(),
  122. stats: [],
  123. }),
  124. ];
  125. ProjectsStore.loadInitialData(projects);
  126. const teamsWithTwoProjects = [TeamFixture({projects})];
  127. render(
  128. <Dashboard
  129. api={api}
  130. error={null}
  131. loadingTeams={false}
  132. organization={org}
  133. teams={teamsWithTwoProjects}
  134. {...RouteComponentPropsFixture()}
  135. />
  136. );
  137. expect(await screen.findByText('My Teams')).toBeInTheDocument();
  138. expect(screen.getAllByTestId('badge-display-name')).toHaveLength(2);
  139. expect(screen.queryByTestId('loading-placeholder')).not.toBeInTheDocument();
  140. });
  141. it('renders only projects for my teams by default', async function () {
  142. const teamA = TeamFixture({slug: 'team1', isMember: true});
  143. const teamProjects = [
  144. ProjectFixture({
  145. id: '1',
  146. slug: 'project1',
  147. teams: [teamA],
  148. firstEvent: new Date().toISOString(),
  149. stats: [],
  150. }),
  151. ];
  152. ProjectsStore.loadInitialData([
  153. ...teamProjects,
  154. ProjectFixture({
  155. id: '2',
  156. slug: 'project2',
  157. teams: [],
  158. isBookmarked: true,
  159. firstEvent: new Date().toISOString(),
  160. stats: [],
  161. }),
  162. ]);
  163. const teamsWithTwoProjects = [TeamFixture({projects: teamProjects})];
  164. render(
  165. <Dashboard
  166. api={api}
  167. error={null}
  168. loadingTeams={false}
  169. organization={org}
  170. teams={teamsWithTwoProjects}
  171. {...RouteComponentPropsFixture()}
  172. />
  173. );
  174. expect(await screen.findByText('My Teams')).toBeInTheDocument();
  175. expect(screen.getAllByTestId('badge-display-name')).toHaveLength(1);
  176. });
  177. it('renders all projects if open membership is enabled and user selects all teams', async function () {
  178. const {
  179. organization: openOrg,
  180. router,
  181. routerProps,
  182. } = initializeOrg({
  183. organization: {features: ['open-membership']},
  184. router: {
  185. // team='' removes the default selection of 'myteams', same as clicking "clear"
  186. location: {query: {team: ''}},
  187. },
  188. });
  189. const teamA = TeamFixture({slug: 'team1', isMember: true});
  190. const teamB = TeamFixture({id: '2', slug: 'team2', name: 'team2', isMember: false});
  191. TeamStore.loadInitialData([teamA, teamB]);
  192. const teamProjects = [
  193. ProjectFixture({
  194. id: '1',
  195. slug: 'project1',
  196. teams: [teamA],
  197. firstEvent: new Date().toISOString(),
  198. stats: [],
  199. }),
  200. ];
  201. ProjectsStore.loadInitialData([
  202. ...teamProjects,
  203. ProjectFixture({
  204. id: '2',
  205. slug: 'project2',
  206. teams: [teamB],
  207. firstEvent: new Date().toISOString(),
  208. stats: [],
  209. }),
  210. ]);
  211. const teamsWithTwoProjects = [TeamFixture({projects: teamProjects})];
  212. render(
  213. <Dashboard
  214. api={api}
  215. error={null}
  216. loadingTeams={false}
  217. organization={openOrg}
  218. teams={teamsWithTwoProjects}
  219. {...routerProps}
  220. />,
  221. {
  222. router,
  223. organization: openOrg,
  224. }
  225. );
  226. expect(await screen.findByText('All Teams')).toBeInTheDocument();
  227. expect(screen.getAllByTestId('badge-display-name')).toHaveLength(2);
  228. await userEvent.click(screen.getByText('All Teams'));
  229. expect(await screen.findByText('Other Teams')).toBeInTheDocument();
  230. expect(screen.getByText('#team2')).toBeInTheDocument();
  231. });
  232. it('renders only projects for my teams if open membership is disabled', async function () {
  233. const {
  234. organization: closedOrg,
  235. router,
  236. routerProps,
  237. } = initializeOrg({
  238. organization: {features: []},
  239. router: {
  240. location: {query: {team: ''}},
  241. },
  242. });
  243. const teamA = TeamFixture({slug: 'team1', isMember: true});
  244. const teamProjects = [
  245. ProjectFixture({
  246. id: '1',
  247. slug: 'project1',
  248. teams: [teamA],
  249. firstEvent: new Date().toISOString(),
  250. stats: [],
  251. }),
  252. ];
  253. ProjectsStore.loadInitialData([
  254. ...teamProjects,
  255. ProjectFixture({
  256. id: '2',
  257. slug: 'project2',
  258. teams: [],
  259. firstEvent: new Date().toISOString(),
  260. stats: [],
  261. }),
  262. ]);
  263. const teamsWithTwoProjects = [TeamFixture({projects: teamProjects})];
  264. render(
  265. <Dashboard
  266. api={api}
  267. error={null}
  268. loadingTeams={false}
  269. organization={closedOrg}
  270. teams={teamsWithTwoProjects}
  271. {...routerProps}
  272. />,
  273. {
  274. router,
  275. organization: closedOrg,
  276. }
  277. );
  278. expect(await screen.findByText('All Teams')).toBeInTheDocument();
  279. expect(screen.getAllByTestId('badge-display-name')).toHaveLength(1);
  280. });
  281. it('renders correct project with selected team', async function () {
  282. const teamC = TeamFixture({
  283. id: '1',
  284. slug: 'teamC',
  285. isMember: true,
  286. projects: [
  287. ProjectFixture({
  288. id: '1',
  289. slug: 'project1',
  290. stats: [],
  291. }),
  292. ProjectFixture({
  293. id: '2',
  294. slug: 'project2',
  295. stats: [],
  296. }),
  297. ],
  298. });
  299. const teamD = TeamFixture({
  300. id: '2',
  301. slug: 'teamD',
  302. isMember: true,
  303. projects: [
  304. ProjectFixture({
  305. id: '3',
  306. slug: 'project3',
  307. }),
  308. ],
  309. });
  310. const teamsWithSpecificProjects = [teamC, teamD];
  311. MockApiClient.addMockResponse({
  312. url: `/organizations/${org.slug}/teams/?team=2`,
  313. body: teamsWithSpecificProjects,
  314. });
  315. const projects = [
  316. ProjectFixture({
  317. id: '1',
  318. slug: 'project1',
  319. teams: [teamC],
  320. firstEvent: new Date().toISOString(),
  321. stats: [],
  322. }),
  323. ProjectFixture({
  324. id: '2',
  325. slug: 'project2',
  326. teams: [teamC],
  327. isBookmarked: true,
  328. firstEvent: new Date().toISOString(),
  329. stats: [],
  330. }),
  331. ProjectFixture({
  332. id: '3',
  333. slug: 'project3',
  334. teams: [teamD],
  335. firstEvent: new Date().toISOString(),
  336. stats: [],
  337. }),
  338. ];
  339. ProjectsStore.loadInitialData(projects);
  340. MockApiClient.addMockResponse({
  341. url: `/organizations/${org.slug}/projects/`,
  342. body: projects,
  343. });
  344. render(
  345. <Dashboard
  346. api={api}
  347. error={null}
  348. loadingTeams={false}
  349. teams={teamsWithSpecificProjects}
  350. organization={org}
  351. {...RouteComponentPropsFixture({
  352. location: {
  353. pathname: '',
  354. hash: '',
  355. state: '',
  356. action: 'PUSH',
  357. key: '',
  358. query: {team: '2'},
  359. search: '?team=2`',
  360. },
  361. })}
  362. />
  363. );
  364. expect(await screen.findByText('project3')).toBeInTheDocument();
  365. expect(screen.queryByText('project2')).not.toBeInTheDocument();
  366. });
  367. it('renders projects by search', async function () {
  368. const teamA = TeamFixture({slug: 'team1', isMember: true});
  369. MockApiClient.addMockResponse({
  370. url: `/organizations/${org.slug}/projects/`,
  371. body: [],
  372. });
  373. const projects = [
  374. ProjectFixture({
  375. id: '1',
  376. slug: 'project1',
  377. teams: [teamA],
  378. firstEvent: new Date().toISOString(),
  379. stats: [],
  380. }),
  381. ProjectFixture({
  382. id: '2',
  383. slug: 'project2',
  384. teams: [teamA],
  385. isBookmarked: true,
  386. firstEvent: new Date().toISOString(),
  387. stats: [],
  388. }),
  389. ];
  390. ProjectsStore.loadInitialData(projects);
  391. const teamsWithTwoProjects = [TeamFixture({projects})];
  392. render(
  393. <Dashboard
  394. api={api}
  395. error={null}
  396. loadingTeams={false}
  397. teams={teamsWithTwoProjects}
  398. organization={org}
  399. {...RouteComponentPropsFixture()}
  400. />
  401. );
  402. await userEvent.type(
  403. screen.getByPlaceholderText('Search for projects by name'),
  404. 'project2{enter}'
  405. );
  406. expect(screen.getByText('project2')).toBeInTheDocument();
  407. await waitFor(() => {
  408. expect(screen.queryByText('project1')).not.toBeInTheDocument();
  409. });
  410. expect(screen.queryByTestId('loading-placeholder')).not.toBeInTheDocument();
  411. });
  412. it('renders bookmarked projects first in team list', async function () {
  413. const teamA = TeamFixture({slug: 'team1', isMember: true});
  414. const projects = [
  415. ProjectFixture({
  416. id: '11',
  417. slug: 'm',
  418. teams: [teamA],
  419. isBookmarked: false,
  420. stats: [],
  421. }),
  422. ProjectFixture({
  423. id: '12',
  424. slug: 'm-fave',
  425. teams: [teamA],
  426. isBookmarked: true,
  427. stats: [],
  428. }),
  429. ProjectFixture({
  430. id: '13',
  431. slug: 'a-fave',
  432. teams: [teamA],
  433. isBookmarked: true,
  434. stats: [],
  435. }),
  436. ProjectFixture({
  437. id: '14',
  438. slug: 'z-fave',
  439. teams: [teamA],
  440. isBookmarked: true,
  441. stats: [],
  442. }),
  443. ProjectFixture({
  444. id: '15',
  445. slug: 'a',
  446. teams: [teamA],
  447. isBookmarked: false,
  448. stats: [],
  449. }),
  450. ProjectFixture({
  451. id: '16',
  452. slug: 'z',
  453. teams: [teamA],
  454. isBookmarked: false,
  455. stats: [],
  456. }),
  457. ];
  458. ProjectsStore.loadInitialData(projects);
  459. const teamsWithFavProjects = [TeamFixture({projects})];
  460. MockApiClient.addMockResponse({
  461. url: `/organizations/${org.slug}/projects/`,
  462. body: [
  463. ProjectFixture({
  464. teams,
  465. stats: [
  466. [1517281200, 2],
  467. [1517310000, 1],
  468. ],
  469. }),
  470. ],
  471. });
  472. render(
  473. <Dashboard
  474. api={api}
  475. error={null}
  476. loadingTeams={false}
  477. organization={org}
  478. teams={teamsWithFavProjects}
  479. {...RouteComponentPropsFixture()}
  480. />
  481. );
  482. // check that all projects are displayed
  483. await waitFor(() =>
  484. expect(screen.getAllByTestId('badge-display-name')).toHaveLength(6)
  485. );
  486. const projectName = screen.getAllByTestId('badge-display-name');
  487. // check that projects are in the correct order - alphabetical with bookmarked projects in front
  488. expect(within(projectName[0]!).getByText('a-fave')).toBeInTheDocument();
  489. expect(within(projectName[1]!).getByText('m-fave')).toBeInTheDocument();
  490. expect(within(projectName[2]!).getByText('z-fave')).toBeInTheDocument();
  491. expect(within(projectName[3]!).getByText('a')).toBeInTheDocument();
  492. expect(within(projectName[4]!).getByText('m')).toBeInTheDocument();
  493. expect(within(projectName[5]!).getByText('z')).toBeInTheDocument();
  494. });
  495. });
  496. describe('ProjectsStatsStore', function () {
  497. const teamA = TeamFixture({slug: 'team1', isMember: true});
  498. const projects = [
  499. ProjectFixture({
  500. id: '1',
  501. slug: 'm',
  502. teams,
  503. isBookmarked: false,
  504. }),
  505. ProjectFixture({
  506. id: '2',
  507. slug: 'm-fave',
  508. teams: [teamA],
  509. isBookmarked: true,
  510. }),
  511. ProjectFixture({
  512. id: '3',
  513. slug: 'a-fave',
  514. teams: [teamA],
  515. isBookmarked: true,
  516. }),
  517. ProjectFixture({
  518. id: '4',
  519. slug: 'z-fave',
  520. teams: [teamA],
  521. isBookmarked: true,
  522. }),
  523. ProjectFixture({
  524. id: '5',
  525. slug: 'a',
  526. teams: [teamA],
  527. isBookmarked: false,
  528. }),
  529. ProjectFixture({
  530. id: '6',
  531. slug: 'z',
  532. teams: [teamA],
  533. isBookmarked: false,
  534. }),
  535. ];
  536. const teamsWithStatTestProjects = [TeamFixture({projects})];
  537. it('uses ProjectsStatsStore to load stats', async function () {
  538. ProjectsStore.loadInitialData(projects);
  539. jest.useFakeTimers();
  540. ProjectsStatsStore.onStatsLoadSuccess([
  541. {...projects[0]!, stats: [[1517281200, 2]]},
  542. ]);
  543. const loadStatsSpy = jest.spyOn(projectsActions, 'loadStatsForProject');
  544. const mock = MockApiClient.addMockResponse({
  545. url: `/organizations/${org.slug}/projects/`,
  546. body: projects.map(project => ({
  547. ...project,
  548. stats: [
  549. [1517281200, 2],
  550. [1517310000, 1],
  551. ],
  552. })),
  553. });
  554. const {unmount} = render(
  555. <Dashboard
  556. api={api}
  557. error={null}
  558. loadingTeams={false}
  559. teams={teamsWithStatTestProjects}
  560. organization={org}
  561. {...RouteComponentPropsFixture()}
  562. />
  563. );
  564. expect(loadStatsSpy).toHaveBeenCalledTimes(6);
  565. expect(mock).not.toHaveBeenCalled();
  566. const projectSummary = screen.getAllByTestId('summary-links');
  567. // Has 5 Loading Cards because 1 project has been loaded in store already
  568. expect(
  569. within(projectSummary[0]!).getByTestId('loading-placeholder')
  570. ).toBeInTheDocument();
  571. expect(
  572. within(projectSummary[1]!).getByTestId('loading-placeholder')
  573. ).toBeInTheDocument();
  574. expect(
  575. within(projectSummary[2]!).getByTestId('loading-placeholder')
  576. ).toBeInTheDocument();
  577. expect(
  578. within(projectSummary[3]!).getByTestId('loading-placeholder')
  579. ).toBeInTheDocument();
  580. expect(within(projectSummary[4]!).getByText('Errors: 2')).toBeInTheDocument();
  581. expect(
  582. within(projectSummary[5]!).getByTestId('loading-placeholder')
  583. ).toBeInTheDocument();
  584. // Advance timers so that batched request fires
  585. act(() => jest.advanceTimersByTime(51));
  586. expect(mock).toHaveBeenCalledTimes(1);
  587. // query ids = 3, 2, 4 = bookmarked
  588. // 1 - already loaded in store so shouldn't be in query
  589. expect(mock).toHaveBeenCalledWith(
  590. expect.anything(),
  591. expect.objectContaining({
  592. query: expect.objectContaining({
  593. query: 'id:3 id:2 id:4 id:5 id:6',
  594. }),
  595. })
  596. );
  597. jest.useRealTimers();
  598. // All cards have loaded
  599. await waitFor(() => {
  600. expect(within(projectSummary[0]!).getByText('Errors: 3')).toBeInTheDocument();
  601. });
  602. expect(within(projectSummary[1]!).getByText('Errors: 3')).toBeInTheDocument();
  603. expect(within(projectSummary[2]!).getByText('Errors: 3')).toBeInTheDocument();
  604. expect(within(projectSummary[3]!).getByText('Errors: 3')).toBeInTheDocument();
  605. expect(within(projectSummary[4]!).getByText('Errors: 3')).toBeInTheDocument();
  606. expect(within(projectSummary[5]!).getByText('Errors: 3')).toBeInTheDocument();
  607. // Resets store when it unmounts
  608. unmount();
  609. expect(ProjectsStatsStore.getAll()).toEqual({});
  610. });
  611. it('renders an error from withTeamsForUser', function () {
  612. ProjectsStore.loadInitialData(projects);
  613. render(
  614. <Dashboard
  615. api={api}
  616. loadingTeams={false}
  617. error={Error('uhoh')}
  618. organization={org}
  619. teams={[]}
  620. {...RouteComponentPropsFixture()}
  621. />
  622. );
  623. expect(
  624. screen.getByText('An error occurred while fetching your projects')
  625. ).toBeInTheDocument();
  626. });
  627. });
  628. });