globalSelectionHeader.spec.jsx 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200
  1. import {mountWithTheme} from 'sentry-test/enzyme';
  2. import {initializeOrg} from 'sentry-test/initializeOrg';
  3. import {mockRouterPush} from 'sentry-test/mockRouterPush';
  4. import * as globalActions from 'app/actionCreators/globalSelection';
  5. import OrganizationActions from 'app/actions/organizationActions';
  6. import GlobalSelectionHeader from 'app/components/organizations/globalSelectionHeader';
  7. import ConfigStore from 'app/stores/configStore';
  8. import GlobalSelectionStore from 'app/stores/globalSelectionStore';
  9. import ProjectsStore from 'app/stores/projectsStore';
  10. import {getItem} from 'app/utils/localStorage';
  11. const changeQuery = (routerContext, query) => ({
  12. ...routerContext,
  13. context: {
  14. ...routerContext.context,
  15. router: {
  16. ...routerContext.context.router,
  17. location: {
  18. query,
  19. },
  20. },
  21. },
  22. });
  23. jest.mock('app/utils/localStorage', () => ({
  24. getItem: jest.fn(),
  25. setItem: jest.fn(),
  26. }));
  27. describe('GlobalSelectionHeader', function () {
  28. let wrapper;
  29. const {organization, router, routerContext} = initializeOrg({
  30. organization: {features: ['global-views']},
  31. projects: [
  32. {
  33. id: 2,
  34. slug: 'project-2',
  35. },
  36. {
  37. id: 3,
  38. slug: 'project-3',
  39. environments: ['prod', 'staging'],
  40. },
  41. ],
  42. router: {
  43. location: {query: {}},
  44. params: {orgId: 'org-slug'},
  45. },
  46. });
  47. beforeAll(function () {
  48. jest.spyOn(globalActions, 'updateDateTime');
  49. jest.spyOn(globalActions, 'updateEnvironments');
  50. jest.spyOn(globalActions, 'updateProjects');
  51. jest.spyOn(globalActions, 'updateParams');
  52. jest.spyOn(globalActions, 'updateParamsWithoutHistory');
  53. });
  54. beforeEach(function () {
  55. MockApiClient.clearMockResponses();
  56. jest.spyOn(ProjectsStore, 'getState').mockImplementation(() => ({
  57. projects: organization.projects,
  58. loading: false,
  59. }));
  60. getItem.mockImplementation(() => null);
  61. MockApiClient.addMockResponse({
  62. url: '/organizations/org-slug/projects/',
  63. body: [],
  64. });
  65. });
  66. afterEach(function () {
  67. wrapper.unmount();
  68. [
  69. globalActions.updateDateTime,
  70. globalActions.updateProjects,
  71. globalActions.updateEnvironments,
  72. globalActions.updateParams,
  73. globalActions.updateParamsWithoutHistory,
  74. router.push,
  75. router.replace,
  76. getItem,
  77. ].forEach(mock => mock.mockClear());
  78. GlobalSelectionStore.reset();
  79. });
  80. it('does not update router if there is custom routing', function () {
  81. wrapper = mountWithTheme(
  82. <GlobalSelectionHeader organization={organization} hasCustomRouting />,
  83. routerContext
  84. );
  85. expect(router.push).not.toHaveBeenCalled();
  86. });
  87. it('does not update router if org in URL params is different than org in context/props', function () {
  88. wrapper = mountWithTheme(
  89. <GlobalSelectionHeader organization={organization} hasCustomRouting />,
  90. {
  91. ...routerContext,
  92. context: {
  93. ...routerContext.context,
  94. router: {...routerContext.context.router, params: {orgId: 'diff-org'}},
  95. },
  96. }
  97. );
  98. expect(router.push).not.toHaveBeenCalled();
  99. });
  100. it('does not replace URL with values from store when mounted with no query params', function () {
  101. wrapper = mountWithTheme(
  102. <GlobalSelectionHeader organization={organization} />,
  103. routerContext
  104. );
  105. expect(router.replace).not.toHaveBeenCalled();
  106. });
  107. it('only updates GlobalSelection store when mounted with query params', async function () {
  108. wrapper = mountWithTheme(
  109. <GlobalSelectionHeader
  110. organization={organization}
  111. params={{orgId: organization.slug}}
  112. />,
  113. changeQuery(routerContext, {
  114. statsPeriod: '7d',
  115. })
  116. );
  117. expect(router.push).not.toHaveBeenCalled();
  118. await tick();
  119. expect(GlobalSelectionStore.get().selection).toEqual({
  120. datetime: {
  121. period: '7d',
  122. utc: null,
  123. start: null,
  124. end: null,
  125. },
  126. environments: [],
  127. projects: [],
  128. });
  129. });
  130. it('updates environments when switching projects', async function () {
  131. wrapper = mountWithTheme(
  132. <GlobalSelectionHeader
  133. organization={organization}
  134. projects={organization.projects}
  135. />,
  136. routerContext
  137. );
  138. await tick();
  139. wrapper.update();
  140. mockRouterPush(wrapper, router);
  141. // Open dropdown and select both projects
  142. wrapper.find('MultipleProjectSelector HeaderItem').simulate('click');
  143. wrapper.find('MultipleProjectSelector CheckboxFancy').at(0).simulate('click');
  144. wrapper.find('MultipleProjectSelector CheckboxFancy').at(1).simulate('click');
  145. wrapper.find('MultipleProjectSelector HeaderItem').simulate('click');
  146. await tick();
  147. wrapper.update();
  148. expect(wrapper.find('MultipleProjectSelector Content').text()).toBe(
  149. 'project-2, project-3'
  150. );
  151. // Select environment
  152. wrapper.find('MultipleEnvironmentSelector HeaderItem').simulate('click');
  153. wrapper.find('MultipleEnvironmentSelector CheckboxFancy').at(1).simulate('click');
  154. wrapper.find('MultipleEnvironmentSelector HeaderItem').simulate('click');
  155. await tick();
  156. expect(wrapper.find('MultipleEnvironmentSelector Content').text()).toBe('staging');
  157. expect(GlobalSelectionStore.get().selection).toEqual({
  158. datetime: {
  159. period: '14d',
  160. utc: null,
  161. start: null,
  162. end: null,
  163. },
  164. environments: ['staging'],
  165. projects: [2, 3],
  166. });
  167. const query = wrapper.prop('location').query;
  168. expect(query).toEqual({
  169. environment: 'staging',
  170. project: ['2', '3'],
  171. });
  172. // Now change projects, first project has no environments
  173. wrapper.find('MultipleProjectSelector HeaderItem').simulate('click');
  174. wrapper.find('MultipleProjectSelector CheckboxFancy').at(1).simulate('click');
  175. wrapper.find('MultipleProjectSelector HeaderItem').simulate('click');
  176. await tick();
  177. wrapper.update();
  178. // Store should not have any environments selected
  179. expect(GlobalSelectionStore.get().selection).toEqual({
  180. datetime: {
  181. period: '14d',
  182. utc: null,
  183. start: null,
  184. end: null,
  185. },
  186. environments: [],
  187. projects: [2],
  188. });
  189. expect(wrapper.find('MultipleEnvironmentSelector Content').text()).toBe(
  190. 'All Environments'
  191. );
  192. });
  193. it('shows environments for non-member projects', async function () {
  194. const initialData = initializeOrg({
  195. organization: {features: ['global-views']},
  196. projects: [
  197. {id: 1, slug: 'staging-project', environments: ['staging'], isMember: false},
  198. {id: 2, slug: 'prod-project', environments: ['prod']},
  199. ],
  200. router: {
  201. location: {query: {project: [1]}},
  202. params: {orgId: 'org-slug'},
  203. },
  204. });
  205. jest.spyOn(ProjectsStore, 'getState').mockImplementation(() => ({
  206. projects: initialData.projects,
  207. loading: false,
  208. }));
  209. wrapper = mountWithTheme(
  210. <GlobalSelectionHeader
  211. router={initialData.router}
  212. organization={initialData.organization}
  213. projects={initialData.projects}
  214. />,
  215. changeQuery(initialData.routerContext, {project: 1})
  216. );
  217. await tick();
  218. wrapper.update();
  219. // Open environment picker
  220. wrapper.find('MultipleEnvironmentSelector HeaderItem').simulate('click');
  221. const checkboxes = wrapper.find('MultipleEnvironmentSelector AutoCompleteItem');
  222. expect(checkboxes).toHaveLength(1);
  223. expect(checkboxes.text()).toBe('staging');
  224. });
  225. it('updates GlobalSelection store with default period', async function () {
  226. wrapper = mountWithTheme(
  227. <GlobalSelectionHeader organization={organization} />,
  228. changeQuery(routerContext, {
  229. environment: 'prod',
  230. })
  231. );
  232. await tick();
  233. expect(GlobalSelectionStore.get()).toEqual({
  234. isReady: true,
  235. selection: {
  236. datetime: {
  237. period: '14d',
  238. utc: null,
  239. start: null,
  240. end: null,
  241. },
  242. environments: ['prod'],
  243. projects: [],
  244. },
  245. });
  246. // Not called because of the default date
  247. expect(router.replace).not.toHaveBeenCalled();
  248. });
  249. it('updates GlobalSelection store with empty dates in URL', async function () {
  250. wrapper = mountWithTheme(
  251. <GlobalSelectionHeader organization={organization} />,
  252. changeQuery(routerContext, {
  253. statsPeriod: null,
  254. })
  255. );
  256. await tick();
  257. expect(GlobalSelectionStore.get()).toEqual({
  258. isReady: true,
  259. selection: {
  260. datetime: {
  261. period: '14d',
  262. utc: null,
  263. start: null,
  264. end: null,
  265. },
  266. environments: [],
  267. projects: [],
  268. },
  269. });
  270. });
  271. it('resets start&end if showAbsolute prop is false', async function () {
  272. wrapper = mountWithTheme(
  273. <GlobalSelectionHeader organization={organization} showAbsolute={false} />,
  274. changeQuery(routerContext, {
  275. start: '2020-05-05T07:26:53.000',
  276. end: '2020-05-05T09:19:12.000',
  277. })
  278. );
  279. await tick();
  280. expect(GlobalSelectionStore.get()).toEqual({
  281. isReady: true,
  282. selection: {
  283. datetime: {
  284. period: '14d',
  285. utc: null,
  286. start: null,
  287. end: null,
  288. },
  289. environments: [],
  290. projects: [],
  291. },
  292. });
  293. });
  294. /**
  295. * I don't think this test is really applicable anymore
  296. */
  297. it('does not update store if url params have not changed', async function () {
  298. wrapper = mountWithTheme(
  299. <GlobalSelectionHeader organization={organization} />,
  300. changeQuery(routerContext, {
  301. statsPeriod: '7d',
  302. })
  303. );
  304. [
  305. globalActions.updateDateTime,
  306. globalActions.updateProjects,
  307. globalActions.updateEnvironments,
  308. ].forEach(mock => mock.mockClear());
  309. wrapper.setContext(
  310. changeQuery(routerContext, {
  311. statsPeriod: '7d',
  312. }).context
  313. );
  314. await tick();
  315. wrapper.update();
  316. expect(globalActions.updateDateTime).not.toHaveBeenCalled();
  317. expect(globalActions.updateProjects).not.toHaveBeenCalled();
  318. expect(globalActions.updateEnvironments).not.toHaveBeenCalled();
  319. expect(GlobalSelectionStore.get()).toEqual({
  320. isReady: true,
  321. selection: {
  322. datetime: {
  323. period: '7d',
  324. utc: null,
  325. start: null,
  326. end: null,
  327. },
  328. environments: [],
  329. projects: [],
  330. },
  331. });
  332. });
  333. it('loads from local storage when no URL parameters', async function () {
  334. getItem.mockImplementation(() =>
  335. JSON.stringify({projects: [3], environments: ['staging']})
  336. );
  337. const initializationObj = initializeOrg({
  338. organization: {
  339. features: ['global-views'],
  340. },
  341. router: {
  342. params: {orgId: 'org-slug'}, // we need this to be set to make sure org in context is same as current org in URL
  343. },
  344. });
  345. wrapper = mountWithTheme(
  346. <GlobalSelectionHeader organization={initializationObj.organization} />,
  347. initializationObj.routerContext
  348. );
  349. await tick(); // reflux tick
  350. expect(GlobalSelectionStore.get().selection.projects).toEqual([3]);
  351. // Since these are coming from URL, there should be no changes and
  352. // router does not need to be called
  353. expect(initializationObj.router.replace).toHaveBeenLastCalledWith(
  354. expect.objectContaining({
  355. query: {
  356. environment: ['staging'],
  357. project: [3],
  358. },
  359. })
  360. );
  361. });
  362. it('does not load from local storage when there are URL params', async function () {
  363. getItem.mockImplementation(() =>
  364. JSON.stringify({projects: [3], environments: ['staging']})
  365. );
  366. const initializationObj = initializeOrg({
  367. organization: {
  368. features: ['global-views'],
  369. },
  370. router: {
  371. params: {orgId: 'org-slug'}, // we need this to be set to make sure org in context is same as current org in URL
  372. location: {query: {project: [1, 2]}},
  373. },
  374. });
  375. wrapper = mountWithTheme(
  376. <GlobalSelectionHeader organization={initializationObj.organization} />,
  377. initializationObj.routerContext
  378. );
  379. await tick(); // reflux tick
  380. expect(GlobalSelectionStore.get().selection.projects).toEqual([1, 2]);
  381. // Since these are coming from URL, there should be no changes and
  382. // router does not need to be called
  383. expect(initializationObj.router.replace).not.toHaveBeenCalled();
  384. });
  385. it('updates store when there are query params in URL', async function () {
  386. const initializationObj = initializeOrg({
  387. organization: {
  388. features: ['global-views'],
  389. },
  390. router: {
  391. params: {orgId: 'org-slug'}, // we need this to be set to make sure org in context is same as current org in URL
  392. location: {query: {project: [1, 2]}},
  393. },
  394. });
  395. wrapper = mountWithTheme(
  396. <GlobalSelectionHeader organization={initializationObj.organization} />,
  397. initializationObj.routerContext
  398. );
  399. await tick(); // reflux tick
  400. expect(GlobalSelectionStore.get().selection.projects).toEqual([1, 2]);
  401. // Since these are coming from URL, there should be no changes and
  402. // router does not need to be called
  403. expect(initializationObj.router.replace).not.toHaveBeenCalled();
  404. });
  405. it('updates store with default values when there are no query params in URL', async function () {
  406. const initializationObj = initializeOrg({
  407. organization: {
  408. features: ['global-views'],
  409. },
  410. router: {
  411. params: {orgId: 'org-slug'}, // we need this to be set to make sure org in context is same as current org in URL
  412. location: {query: {}},
  413. },
  414. });
  415. wrapper = mountWithTheme(
  416. <GlobalSelectionHeader organization={initializationObj.organization} />,
  417. initializationObj.routerContext
  418. );
  419. // Router does not update because params have not changed
  420. expect(initializationObj.router.replace).not.toHaveBeenCalled();
  421. });
  422. /**
  423. * GSH: (no global-views)
  424. * - mounts with no state from router
  425. * - params org id === org.slug
  426. * - updateProjects should not be called (enforceSingleProject should not be called)
  427. * - componentDidUpdate with loadingProjects === true, and pass in list of projects (via projects store)
  428. * - enforceProject should be called and updateProjects() called with the new project
  429. * - variation:
  430. * - params.orgId !== org.slug (e.g. just switched orgs)
  431. *
  432. * When switching orgs when not in Issues view, the issues view gets rendered
  433. * with params.orgId !== org.slug
  434. * Global selection header gets unmounted and mounted, and in this case nothing should be done
  435. * until it gets updated and params.orgId === org.slug
  436. *
  437. * Separate issue:
  438. * IssuesList ("child view") renders before a single project is enforced, will require refactoring
  439. * views so that they depend on GSH enforcing a single project first IF they don't have required feature
  440. * (and no project id in URL).
  441. */
  442. describe('Single project selection mode', function () {
  443. it('does not do anything while organization is switching in single project', async function () {
  444. const initialData = initializeOrg({
  445. organization: {slug: 'old-org-slug'},
  446. router: {
  447. params: {orgId: 'org-slug'}, // we need this to be set to make sure org in context is same as current org in URL
  448. location: {query: {project: [1]}},
  449. },
  450. });
  451. ProjectsStore.getState.mockRestore();
  452. ProjectsStore.getAll.mockRestore();
  453. MockApiClient.addMockResponse({
  454. url: '/organizations/old-org-slug/projects/',
  455. body: [],
  456. });
  457. // This can happen when you switch organization so params.orgId !== the current org in context
  458. // In this case params.orgId = 'org-slug'
  459. wrapper = mountWithTheme(
  460. <GlobalSelectionHeader organization={initialData.organization} />,
  461. initialData.routerContext
  462. );
  463. expect(globalActions.updateProjects).not.toHaveBeenCalled();
  464. const updatedOrganization = {
  465. ...organization,
  466. slug: 'org-slug',
  467. features: [],
  468. projects: [TestStubs.Project({id: '123', slug: 'org-slug-project1'})],
  469. };
  470. MockApiClient.addMockResponse({
  471. url: '/organizations/org-slug/',
  472. body: updatedOrganization,
  473. });
  474. // Eventually OrganizationContext will fetch org details for `org-slug` and update `organization` prop
  475. // emulate fetchOrganizationDetails
  476. OrganizationActions.update(updatedOrganization);
  477. wrapper.setContext({
  478. organization: updatedOrganization,
  479. location: {query: {}},
  480. router: {
  481. ...initialData.router,
  482. location: {query: {}},
  483. },
  484. });
  485. wrapper.setProps({organization: updatedOrganization});
  486. ProjectsStore.loadInitialData(updatedOrganization.projects);
  487. expect(initialData.router.replace).toHaveBeenLastCalledWith(
  488. expect.objectContaining({
  489. query: {environment: [], project: [123]},
  490. })
  491. );
  492. });
  493. it('selects first project if more than one is requested', function () {
  494. const initializationObj = initializeOrg({
  495. router: {
  496. params: {orgId: 'org-slug'}, // we need this to be set to make sure org in context is same as current org in URL
  497. location: {query: {project: [1, 2]}},
  498. },
  499. });
  500. wrapper = mountWithTheme(
  501. <GlobalSelectionHeader organization={initializationObj.organization} />,
  502. initializationObj.routerContext
  503. );
  504. expect(initializationObj.router.replace).toHaveBeenCalledWith(
  505. expect.objectContaining({
  506. query: {environment: [], project: [1]},
  507. })
  508. );
  509. });
  510. it('selects first project if none (i.e. all) is requested', async function () {
  511. const project = TestStubs.Project({id: '3'});
  512. const org = TestStubs.Organization({projects: [project]});
  513. jest
  514. .spyOn(ProjectsStore, 'getState')
  515. .mockImplementation(() => ({projects: org.projects, loading: false}));
  516. const initializationObj = initializeOrg({
  517. organization: org,
  518. router: {
  519. params: {orgId: 'org-slug'},
  520. location: {query: {}},
  521. },
  522. });
  523. wrapper = mountWithTheme(
  524. <GlobalSelectionHeader organization={initializationObj.organization} />,
  525. initializationObj.routerContext
  526. );
  527. expect(initializationObj.router.replace).toHaveBeenCalledWith(
  528. expect.objectContaining({
  529. query: {environment: [], project: [3]},
  530. })
  531. );
  532. });
  533. });
  534. describe('forceProject selection mode', function () {
  535. beforeEach(function () {
  536. MockApiClient.addMockResponse({
  537. url: '/organizations/org-slug/projects/',
  538. body: [],
  539. });
  540. const initialData = initializeOrg({
  541. organization: {features: ['global-views']},
  542. projects: [
  543. {id: 1, slug: 'staging-project', environments: ['staging']},
  544. {id: 2, slug: 'prod-project', environments: ['prod']},
  545. ],
  546. router: {
  547. location: {query: {}},
  548. },
  549. });
  550. jest.spyOn(ProjectsStore, 'getState').mockImplementation(() => ({
  551. projects: initialData.organization.projects,
  552. loadingProjects: false,
  553. }));
  554. wrapper = mountWithTheme(
  555. <GlobalSelectionHeader
  556. organization={initialData.organization}
  557. shouldForceProject
  558. forceProject={initialData.organization.projects[0]}
  559. showIssueStreamLink
  560. />,
  561. initialData.routerContext
  562. );
  563. });
  564. it('renders a back button to the forced project', function () {
  565. const back = wrapper.find('BackButtonWrapper');
  566. expect(back).toHaveLength(1);
  567. });
  568. it('renders only environments from the forced project', async function () {
  569. await wrapper.find('MultipleEnvironmentSelector HeaderItem').simulate('click');
  570. await wrapper.update();
  571. const items = wrapper.find('MultipleEnvironmentSelector EnvironmentSelectorItem');
  572. expect(items.length).toEqual(1);
  573. expect(items.at(0).text()).toBe('staging');
  574. });
  575. });
  576. describe('without global-views (multi-project feature)', function () {
  577. describe('without existing URL params', function () {
  578. const initialData = initializeOrg({
  579. projects: [
  580. {id: 0, slug: 'random project', isMember: true},
  581. {id: 1, slug: 'staging-project', environments: ['staging']},
  582. {id: 2, slug: 'prod-project', environments: ['prod']},
  583. ],
  584. router: {
  585. location: {query: {}},
  586. params: {orgId: 'org-slug'},
  587. },
  588. });
  589. const createWrapper = props => {
  590. wrapper = mountWithTheme(
  591. <GlobalSelectionHeader
  592. params={{orgId: initialData.organization.slug}}
  593. organization={initialData.organization}
  594. {...props}
  595. />,
  596. initialData.routerContext
  597. );
  598. return wrapper;
  599. };
  600. beforeEach(function () {
  601. jest.spyOn(ProjectsStore, 'getState').mockImplementation(() => ({
  602. projects: initialData.organization.projects,
  603. loading: false,
  604. }));
  605. initialData.router.push.mockClear();
  606. initialData.router.replace.mockClear();
  607. });
  608. it('uses first project in org projects when mounting', async function () {
  609. createWrapper();
  610. await tick();
  611. wrapper.update();
  612. expect(initialData.router.replace).toHaveBeenLastCalledWith({
  613. pathname: undefined,
  614. query: {environment: [], project: [0]},
  615. });
  616. });
  617. it('appends projectId to URL when `forceProject` becomes available (async)', async function () {
  618. const mockProjectsStoreState = {
  619. projects: [],
  620. loading: true,
  621. };
  622. jest
  623. .spyOn(ProjectsStore, 'getState')
  624. .mockImplementation(() => mockProjectsStoreState);
  625. // forceProject generally starts undefined
  626. createWrapper({shouldForceProject: true});
  627. // load the projects
  628. mockProjectsStoreState.projects = initialData.organization.projects;
  629. mockProjectsStoreState.loading = false;
  630. wrapper.setProps({
  631. forceProject: initialData.organization.projects[1],
  632. });
  633. wrapper.update();
  634. expect(initialData.router.replace).toHaveBeenLastCalledWith({
  635. pathname: undefined,
  636. query: {environment: [], project: [1]},
  637. });
  638. expect(initialData.router.replace).toHaveBeenCalledTimes(1);
  639. });
  640. it('does not append projectId to URL when `forceProject` becomes available but project id already exists in URL', async function () {
  641. // forceProject generally starts undefined
  642. createWrapper({shouldForceProject: true});
  643. wrapper.setContext({
  644. router: {
  645. ...initialData.router,
  646. location: {
  647. ...initialData.router.location,
  648. query: {
  649. project: 321,
  650. },
  651. },
  652. },
  653. });
  654. wrapper.setProps({
  655. forceProject: initialData.organization.projects[1],
  656. });
  657. wrapper.update();
  658. expect(initialData.router.replace).not.toHaveBeenCalled();
  659. });
  660. it('appends projectId to URL when mounted with `forceProject`', async function () {
  661. // forceProject generally starts undefined
  662. createWrapper({
  663. shouldForceProject: true,
  664. forceProject: initialData.organization.projects[1],
  665. });
  666. wrapper.update();
  667. expect(initialData.router.replace).toHaveBeenLastCalledWith({
  668. pathname: undefined,
  669. query: {environment: [], project: [1]},
  670. });
  671. });
  672. });
  673. describe('with existing URL params', function () {
  674. const initialData = initializeOrg({
  675. projects: [
  676. {id: 0, slug: 'random project', isMember: true},
  677. {id: 1, slug: 'staging-project', environments: ['staging']},
  678. {id: 2, slug: 'prod-project', environments: ['prod']},
  679. ],
  680. router: {
  681. location: {query: {statsPeriod: '90d'}},
  682. params: {orgId: 'org-slug'},
  683. },
  684. });
  685. jest
  686. .spyOn(ProjectsStore, 'getAll')
  687. .mockImplementation(() => initialData.organization.projects);
  688. const createWrapper = props => {
  689. wrapper = mountWithTheme(
  690. <GlobalSelectionHeader
  691. params={{orgId: initialData.organization.slug}}
  692. organization={initialData.organization}
  693. {...props}
  694. />,
  695. initialData.routerContext
  696. );
  697. return wrapper;
  698. };
  699. beforeEach(function () {
  700. initialData.router.push.mockClear();
  701. initialData.router.replace.mockClear();
  702. });
  703. it('appends projectId to URL when mounted with `forceProject`', async function () {
  704. // forceProject generally starts undefined
  705. createWrapper({
  706. shouldForceProject: true,
  707. forceProject: initialData.organization.projects[1],
  708. });
  709. wrapper.update();
  710. expect(initialData.router.replace).toHaveBeenLastCalledWith({
  711. pathname: undefined,
  712. query: {environment: [], project: [1], statsPeriod: '90d'},
  713. });
  714. });
  715. });
  716. });
  717. describe('with global-views (multi-project feature)', function () {
  718. describe('without existing URL params', function () {
  719. const initialData = initializeOrg({
  720. organization: {features: ['global-views']},
  721. projects: [
  722. {id: 0, slug: 'random project', isMember: true},
  723. {id: 1, slug: 'staging-project', environments: ['staging']},
  724. {id: 2, slug: 'prod-project', environments: ['prod']},
  725. ],
  726. router: {
  727. location: {query: {}},
  728. params: {orgId: 'org-slug'},
  729. },
  730. });
  731. const createWrapper = (props, ctx) => {
  732. wrapper = mountWithTheme(
  733. <GlobalSelectionHeader
  734. params={{orgId: initialData.organization.slug}}
  735. organization={initialData.organization}
  736. {...props}
  737. />,
  738. {
  739. ...initialData.routerContext,
  740. ...ctx,
  741. }
  742. );
  743. return wrapper;
  744. };
  745. beforeEach(function () {
  746. jest.spyOn(ProjectsStore, 'getState').mockImplementation(() => ({
  747. projects: initialData.organization.projects,
  748. loading: false,
  749. }));
  750. initialData.router.push.mockClear();
  751. initialData.router.replace.mockClear();
  752. });
  753. it('does not use first project in org projects when mounting (and without localStorage data)', async function () {
  754. createWrapper();
  755. await tick();
  756. wrapper.update();
  757. expect(initialData.router.replace).not.toHaveBeenCalled();
  758. });
  759. it('does not append projectId to URL when `loadingProjects` changes and finishes loading', async function () {
  760. const mockProjectsStoreState = {
  761. projects: [],
  762. loading: true,
  763. };
  764. jest
  765. .spyOn(ProjectsStore, 'getState')
  766. .mockImplementation(() => mockProjectsStoreState);
  767. createWrapper();
  768. // load the projects
  769. mockProjectsStoreState.projects = initialData.organization.projects;
  770. mockProjectsStoreState.loading = false;
  771. wrapper.update();
  772. expect(initialData.router.replace).not.toHaveBeenCalled();
  773. });
  774. it('appends projectId to URL when `forceProject` becomes available (async)', async function () {
  775. const mockProjectsStoreState = {
  776. projects: [],
  777. loading: true,
  778. };
  779. jest
  780. .spyOn(ProjectsStore, 'getState')
  781. .mockImplementation(() => mockProjectsStoreState);
  782. // forceProject generally starts undefined
  783. createWrapper({shouldForceProject: true});
  784. await tick();
  785. // load the projects
  786. mockProjectsStoreState.projects = initialData.organization.projects;
  787. mockProjectsStoreState.loading = false;
  788. wrapper.setProps({
  789. forceProject: initialData.organization.projects[1],
  790. });
  791. wrapper.update();
  792. expect(initialData.router.replace).toHaveBeenLastCalledWith({
  793. pathname: undefined,
  794. query: {environment: [], project: [1]},
  795. });
  796. expect(initialData.router.replace).toHaveBeenCalledTimes(1);
  797. });
  798. it('does not append projectId to URL when `forceProject` becomes available but project id already exists in URL', async function () {
  799. // forceProject generally starts undefined
  800. createWrapper(
  801. {shouldForceProject: true},
  802. changeQuery(initialData.routerContext, {project: 321})
  803. );
  804. await tick();
  805. wrapper.setProps({
  806. forceProject: initialData.organization.projects[1],
  807. });
  808. wrapper.update();
  809. expect(initialData.router.replace).not.toHaveBeenCalled();
  810. });
  811. });
  812. });
  813. describe('projects list', function () {
  814. let memberProject, nonMemberProject, initialData;
  815. beforeEach(function () {
  816. memberProject = TestStubs.Project({id: '3', isMember: true});
  817. nonMemberProject = TestStubs.Project({id: '4', isMember: false});
  818. initialData = initializeOrg({
  819. organization: {
  820. projects: [memberProject, nonMemberProject],
  821. },
  822. router: {
  823. location: {query: {}},
  824. params: {
  825. orgId: 'org-slug',
  826. },
  827. },
  828. });
  829. jest.spyOn(ProjectsStore, 'getState').mockImplementation(() => ({
  830. projects: initialData.organization.projects,
  831. loading: false,
  832. }));
  833. wrapper = mountWithTheme(
  834. <GlobalSelectionHeader organization={initialData.organization} />,
  835. initialData.routerContext
  836. );
  837. });
  838. it('gets member projects', function () {
  839. expect(wrapper.find('MultipleProjectSelector').prop('projects')).toEqual([
  840. memberProject,
  841. ]);
  842. });
  843. it('gets all projects if superuser', function () {
  844. ConfigStore.config = {
  845. user: {
  846. isSuperuser: true,
  847. },
  848. };
  849. wrapper = mountWithTheme(
  850. <GlobalSelectionHeader organization={initialData.organization} />,
  851. initialData.routerContext
  852. );
  853. expect(wrapper.find('MultipleProjectSelector').prop('projects')).toEqual([
  854. memberProject,
  855. ]);
  856. expect(wrapper.find('MultipleProjectSelector').prop('nonMemberProjects')).toEqual([
  857. nonMemberProject,
  858. ]);
  859. });
  860. it('shows "My Projects" button', async function () {
  861. initialData.organization.features.push('global-views');
  862. wrapper = mountWithTheme(
  863. <GlobalSelectionHeader
  864. organization={initialData.organization}
  865. projects={initialData.organization.projects}
  866. />,
  867. initialData.routerContext
  868. );
  869. await tick();
  870. wrapper.update();
  871. // open the project menu.
  872. wrapper.find('MultipleProjectSelector HeaderItem').simulate('click');
  873. const projectSelector = wrapper.find('MultipleProjectSelector');
  874. // Two projects
  875. expect(projectSelector.find('AutoCompleteItem')).toHaveLength(2);
  876. // My projects in the footer
  877. expect(
  878. projectSelector.find('SelectorFooterControls Button').first().text()
  879. ).toEqual('View My Projects');
  880. });
  881. it('shows "All Projects" button based on features', async function () {
  882. initialData.organization.features.push('global-views');
  883. initialData.organization.features.push('open-membership');
  884. wrapper = mountWithTheme(
  885. <GlobalSelectionHeader
  886. organization={initialData.organization}
  887. projects={initialData.organization.projects}
  888. />,
  889. initialData.routerContext
  890. );
  891. await tick();
  892. wrapper.update();
  893. // open the project menu.
  894. wrapper.find('MultipleProjectSelector HeaderItem').simulate('click');
  895. const projectSelector = wrapper.find('MultipleProjectSelector');
  896. // Two projects
  897. expect(projectSelector.find('AutoCompleteItem')).toHaveLength(2);
  898. // All projects in the footer
  899. expect(
  900. projectSelector.find('SelectorFooterControls Button').first().text()
  901. ).toEqual('View All Projects');
  902. });
  903. it('shows "All Projects" button based on role', async function () {
  904. initialData.organization.features.push('global-views');
  905. initialData.organization.role = 'owner';
  906. wrapper = mountWithTheme(
  907. <GlobalSelectionHeader
  908. organization={initialData.organization}
  909. projects={initialData.organization.projects}
  910. />,
  911. initialData.routerContext
  912. );
  913. await tick();
  914. wrapper.update();
  915. // open the project menu.
  916. wrapper.find('MultipleProjectSelector HeaderItem').simulate('click');
  917. const projectSelector = wrapper.find('MultipleProjectSelector');
  918. // Two projects
  919. expect(projectSelector.find('AutoCompleteItem')).toHaveLength(2);
  920. // All projects in the footer
  921. expect(
  922. projectSelector.find('SelectorFooterControls Button').first().text()
  923. ).toEqual('View All Projects');
  924. });
  925. it('shows "My Projects" when "all projects" is selected', async function () {
  926. initialData.organization.features.push('global-views');
  927. initialData.organization.role = 'owner';
  928. wrapper = mountWithTheme(
  929. <GlobalSelectionHeader
  930. organization={initialData.organization}
  931. projects={initialData.organization.projects}
  932. />,
  933. changeQuery(initialData.routerContext, {project: -1})
  934. );
  935. await tick();
  936. wrapper.update();
  937. // open the project menu.
  938. wrapper.find('MultipleProjectSelector HeaderItem').simulate('click');
  939. const projectSelector = wrapper.find('MultipleProjectSelector');
  940. // My projects in the footer
  941. expect(
  942. projectSelector.find('SelectorFooterControls Button').first().text()
  943. ).toEqual('View My Projects');
  944. });
  945. });
  946. describe('project icons', function () {
  947. const initialData = initializeOrg({
  948. organization: {features: ['global-views']},
  949. projects: [
  950. {id: 0, slug: 'go', platform: 'go'},
  951. {id: 1, slug: 'javascript', platform: 'javascript'},
  952. {id: 2, slug: 'other', platform: 'other'},
  953. {id: 3, slug: 'php', platform: 'php'},
  954. {id: 4, slug: 'python', platform: 'python'},
  955. {id: 5, slug: 'rust', platform: 'rust'},
  956. {id: 6, slug: 'swift', platform: 'swift'},
  957. ],
  958. });
  959. beforeEach(function () {
  960. jest.spyOn(ProjectsStore, 'getState').mockImplementation(() => ({
  961. projects: initialData.organization.projects,
  962. loading: false,
  963. }));
  964. });
  965. it('shows IconProject when no projects are selected', async function () {
  966. wrapper = mountWithTheme(
  967. <GlobalSelectionHeader
  968. organization={initialData.organization}
  969. projects={initialData.organization.projects}
  970. />,
  971. changeQuery(initialData.routerContext, {project: -1})
  972. );
  973. await tick();
  974. wrapper.update();
  975. const projectSelector = wrapper.find('MultipleProjectSelector');
  976. expect(projectSelector.find('IconContainer svg').exists()).toBeTruthy();
  977. expect(projectSelector.find('PlatformIcon').exists()).toBeFalsy();
  978. expect(projectSelector.find('Content').text()).toEqual('All Projects');
  979. });
  980. it('shows PlatformIcon when one project is selected', async function () {
  981. wrapper = mountWithTheme(
  982. <GlobalSelectionHeader
  983. organization={initialData.organization}
  984. projects={initialData.organization.projects}
  985. />,
  986. changeQuery(initialData.routerContext, {project: 1})
  987. );
  988. await tick();
  989. wrapper.update();
  990. const projectSelector = wrapper.find('MultipleProjectSelector');
  991. expect(projectSelector.find('StyledPlatformIcon').props().platform).toEqual(
  992. 'javascript'
  993. );
  994. expect(projectSelector.find('Content').text()).toEqual('javascript');
  995. });
  996. it('shows multiple PlatformIcons when multiple projects are selected, no more than 5', async function () {
  997. wrapper = mountWithTheme(
  998. <GlobalSelectionHeader
  999. organization={initialData.organization}
  1000. projects={initialData.organization.projects}
  1001. />,
  1002. initialData.routerContext
  1003. );
  1004. await tick();
  1005. wrapper.update();
  1006. // select 6 projects
  1007. const headerItem = wrapper.find('MultipleProjectSelector HeaderItem');
  1008. headerItem.simulate('click');
  1009. wrapper
  1010. .find('MultipleProjectSelector CheckboxFancy')
  1011. .forEach(project => project.simulate('click'));
  1012. headerItem.simulate('click');
  1013. await tick();
  1014. wrapper.update();
  1015. // assert title and icons
  1016. const title = wrapper.find('MultipleProjectSelector Content');
  1017. const icons = wrapper.find('MultipleProjectSelector StyledPlatformIcon');
  1018. expect(title.text()).toBe('javascript, other, php, python, rust, swift');
  1019. expect(icons.length).toBe(5);
  1020. expect(icons.at(3).props().platform).toBe('rust');
  1021. expect(icons.at(4).props().platform).toBe('swift');
  1022. });
  1023. });
  1024. });