index.spec.tsx 19 KB

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