widgetViewerModal.spec.tsx 53 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549
  1. import ReactEchartsCore from 'echarts-for-react/lib/core';
  2. import {initializeOrg} from 'sentry-test/initializeOrg';
  3. import {act, render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
  4. import {ModalRenderProps} from 'sentry/actionCreators/modal';
  5. import WidgetViewerModal from 'sentry/components/modals/widgetViewerModal';
  6. import MemberListStore from 'sentry/stores/memberListStore';
  7. import PageFiltersStore from 'sentry/stores/pageFiltersStore';
  8. import space from 'sentry/styles/space';
  9. import {Series} from 'sentry/types/echarts';
  10. import {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
  11. import {
  12. DisplayType,
  13. Widget,
  14. WidgetQuery,
  15. WidgetType,
  16. } from 'sentry/views/dashboardsV2/types';
  17. jest.mock('echarts-for-react/lib/core', () => {
  18. return jest.fn(({style}) => {
  19. return <div style={{...style, background: 'green'}}>echarts mock</div>;
  20. });
  21. });
  22. jest.mock('sentry/components/tooltip', () => {
  23. return jest.fn(props => {
  24. return <div>{props.children}</div>;
  25. });
  26. });
  27. const stubEl = (props: {children?: React.ReactNode}) => <div>{props.children}</div>;
  28. let eventsMetaMock;
  29. const waitForMetaToHaveBeenCalled = async () => {
  30. await waitFor(() => {
  31. expect(eventsMetaMock).toHaveBeenCalled();
  32. });
  33. };
  34. async function renderModal({
  35. initialData: {organization, routerContext},
  36. widget,
  37. seriesData,
  38. tableData,
  39. pageLinks,
  40. seriesResultsType,
  41. }: {
  42. initialData: any;
  43. widget: any;
  44. pageLinks?: string;
  45. seriesData?: Series[];
  46. seriesResultsType?: string;
  47. tableData?: TableDataWithTitle[];
  48. }) {
  49. const rendered = render(
  50. <div style={{padding: space(4)}}>
  51. <WidgetViewerModal
  52. Header={stubEl}
  53. Footer={stubEl as ModalRenderProps['Footer']}
  54. Body={stubEl as ModalRenderProps['Body']}
  55. CloseButton={stubEl}
  56. closeModal={() => undefined}
  57. organization={organization}
  58. widget={widget}
  59. onEdit={() => undefined}
  60. seriesData={seriesData}
  61. tableData={tableData}
  62. pageLinks={pageLinks}
  63. seriesResultsType={seriesResultsType}
  64. />
  65. </div>,
  66. {
  67. context: routerContext,
  68. organization,
  69. }
  70. );
  71. // Need to wait since WidgetViewerModal will make a request to events-meta
  72. // for total events count on mount
  73. if (widget.widgetType === WidgetType.DISCOVER) {
  74. await waitForMetaToHaveBeenCalled();
  75. }
  76. return rendered;
  77. }
  78. describe('Modals -> WidgetViewerModal', function () {
  79. let initialData, initialDataWithFlag;
  80. beforeEach(() => {
  81. initialData = initializeOrg({
  82. organization: {
  83. features: ['discover-query', 'widget-viewer-modal'],
  84. apdexThreshold: 400,
  85. },
  86. router: {
  87. location: {query: {}},
  88. },
  89. project: 1,
  90. projects: [],
  91. });
  92. initialDataWithFlag = {
  93. ...initialData,
  94. organization: {
  95. ...initialData.organization,
  96. features: [
  97. ...initialData.organization.features,
  98. 'discover-frontend-use-events-endpoint',
  99. ],
  100. },
  101. };
  102. MockApiClient.addMockResponse({
  103. url: '/organizations/org-slug/projects/',
  104. body: [],
  105. });
  106. MockApiClient.addMockResponse({
  107. url: '/organizations/org-slug/releases/',
  108. body: [],
  109. });
  110. eventsMetaMock = MockApiClient.addMockResponse({
  111. url: '/organizations/org-slug/events-meta/',
  112. body: {count: 33323612},
  113. });
  114. PageFiltersStore.init();
  115. PageFiltersStore.onInitializeUrlState(
  116. {
  117. projects: [1, 2],
  118. environments: ['prod', 'dev'],
  119. datetime: {start: null, end: null, period: '24h', utc: null},
  120. },
  121. new Set()
  122. );
  123. });
  124. afterEach(() => {
  125. MockApiClient.clearMockResponses();
  126. PageFiltersStore.teardown();
  127. });
  128. describe('Discover Widgets', function () {
  129. describe('Area Chart Widget', function () {
  130. let mockQuery: WidgetQuery;
  131. let additionalMockQuery: WidgetQuery;
  132. let mockWidget: Widget;
  133. function mockEventsv2() {
  134. return MockApiClient.addMockResponse({
  135. url: '/organizations/org-slug/eventsv2/',
  136. body: {
  137. data: [
  138. {
  139. title: '/organizations/:orgId/dashboards/',
  140. id: '1',
  141. count: 1,
  142. },
  143. ],
  144. meta: {
  145. title: 'string',
  146. id: 'string',
  147. count: 1,
  148. isMetricsData: false,
  149. },
  150. },
  151. });
  152. }
  153. function mockEvents() {
  154. return MockApiClient.addMockResponse({
  155. url: '/organizations/org-slug/events/',
  156. body: {
  157. data: [
  158. {
  159. title: '/organizations/:orgId/dashboards/',
  160. id: '1',
  161. count: 1,
  162. },
  163. ],
  164. meta: {
  165. fields: {
  166. title: 'string',
  167. id: 'string',
  168. count: 1,
  169. },
  170. isMetricsData: false,
  171. },
  172. },
  173. });
  174. }
  175. beforeEach(function () {
  176. mockQuery = {
  177. conditions: 'title:/organizations/:orgId/performance/summary/',
  178. fields: ['count()'],
  179. aggregates: ['count()'],
  180. columns: [],
  181. name: 'Query Name',
  182. orderby: '',
  183. };
  184. additionalMockQuery = {
  185. conditions: '',
  186. fields: ['count()'],
  187. aggregates: ['count()'],
  188. columns: [],
  189. name: 'Another Query Name',
  190. orderby: '',
  191. };
  192. mockWidget = {
  193. id: '1',
  194. title: 'Test Widget',
  195. displayType: DisplayType.AREA,
  196. interval: '5m',
  197. queries: [mockQuery, additionalMockQuery],
  198. widgetType: WidgetType.DISCOVER,
  199. };
  200. (ReactEchartsCore as jest.Mock).mockClear();
  201. MockApiClient.addMockResponse({
  202. url: '/organizations/org-slug/events-stats/',
  203. body: {
  204. data: [
  205. [[1646100000], [{count: 1}]],
  206. [[1646120000], [{count: 1}]],
  207. ],
  208. start: 1646100000,
  209. end: 1646120000,
  210. isMetricsData: false,
  211. },
  212. });
  213. });
  214. describe('with eventsv2', function () {
  215. it('renders Edit and Open buttons', async function () {
  216. mockEventsv2();
  217. await renderModal({initialData, widget: mockWidget});
  218. expect(screen.getByText('Edit Widget')).toBeInTheDocument();
  219. expect(screen.getByText('Open in Discover')).toBeInTheDocument();
  220. });
  221. it('renders updated table columns and orderby', async function () {
  222. const eventsv2Mock = mockEventsv2();
  223. await renderModal({initialData, widget: mockWidget});
  224. expect(screen.getByText('title')).toBeInTheDocument();
  225. expect(
  226. screen.getByText('/organizations/:orgId/dashboards/')
  227. ).toBeInTheDocument();
  228. expect(eventsv2Mock).toHaveBeenCalledWith(
  229. '/organizations/org-slug/eventsv2/',
  230. expect.objectContaining({
  231. query: expect.objectContaining({sort: ['-count()']}),
  232. })
  233. );
  234. });
  235. it('renders area chart', async function () {
  236. mockEventsv2();
  237. await renderModal({initialData, widget: mockWidget});
  238. expect(screen.getByText('echarts mock')).toBeInTheDocument();
  239. });
  240. it('renders Discover area chart widget viewer', async function () {
  241. mockEventsv2();
  242. const {container} = await renderModal({initialData, widget: mockWidget});
  243. expect(container).toSnapshot();
  244. });
  245. it('redirects user to Discover when clicking Open in Discover', async function () {
  246. mockEventsv2();
  247. await renderModal({initialData, widget: mockWidget});
  248. userEvent.click(screen.getByText('Open in Discover'));
  249. expect(initialData.router.push).toHaveBeenCalledWith(
  250. '/organizations/org-slug/discover/results/?environment=prod&environment=dev&field=count%28%29&name=Test%20Widget&project=1&project=2&query=title%3A%2Forganizations%2F%3AorgId%2Fperformance%2Fsummary%2F&statsPeriod=24h&yAxis=count%28%29'
  251. );
  252. });
  253. it('zooms into the selected time range', async function () {
  254. mockEventsv2();
  255. await renderModal({initialData, widget: mockWidget});
  256. act(() => {
  257. // Simulate dataZoom event on chart
  258. (ReactEchartsCore as jest.Mock).mock.calls[0][0].onEvents.datazoom(
  259. undefined,
  260. {
  261. getModel: () => {
  262. return {
  263. _payload: {
  264. batch: [{startValue: 1646100000000, endValue: 1646120000000}],
  265. },
  266. };
  267. },
  268. }
  269. );
  270. });
  271. expect(initialData.router.push).toHaveBeenCalledWith(
  272. expect.objectContaining({
  273. query: {
  274. viewerEnd: '2022-03-01T07:33:20',
  275. viewerStart: '2022-03-01T02:00:00',
  276. },
  277. })
  278. );
  279. });
  280. it('renders multiquery label and selector', async function () {
  281. mockEventsv2();
  282. await renderModal({initialData, widget: mockWidget});
  283. expect(
  284. screen.getByText(
  285. 'This widget was built with multiple queries. Table data can only be displayed for one query at a time. To edit any of the queries, edit the widget.'
  286. )
  287. ).toBeInTheDocument();
  288. expect(screen.getByText('Query Name')).toBeInTheDocument();
  289. });
  290. it('updates selected query when selected in the query dropdown', async function () {
  291. mockEventsv2();
  292. const {rerender} = await renderModal({initialData, widget: mockWidget});
  293. userEvent.click(screen.getByText('Query Name'));
  294. userEvent.click(screen.getByText('Another Query Name'));
  295. expect(initialData.router.replace).toHaveBeenCalledWith({
  296. query: {query: 1},
  297. });
  298. // Need to manually set the new router location and rerender to simulate the dropdown selection click
  299. initialData.router.location.query = {query: ['1']};
  300. rerender(
  301. <WidgetViewerModal
  302. Header={stubEl}
  303. Footer={stubEl as ModalRenderProps['Footer']}
  304. Body={stubEl as ModalRenderProps['Body']}
  305. CloseButton={stubEl}
  306. closeModal={() => undefined}
  307. organization={initialData.organization}
  308. widget={mockWidget}
  309. onEdit={() => undefined}
  310. />
  311. );
  312. await waitForMetaToHaveBeenCalled();
  313. expect(screen.getByText('Another Query Name')).toBeInTheDocument();
  314. });
  315. it('renders the correct discover query link when there are multiple queries in a widget', async function () {
  316. mockEventsv2();
  317. initialData.router.location.query = {query: ['1']};
  318. await renderModal({initialData, widget: mockWidget});
  319. expect(screen.getByRole('button', {name: 'Open in Discover'})).toHaveAttribute(
  320. 'href',
  321. '/organizations/org-slug/discover/results/?environment=prod&environment=dev&field=count%28%29&name=Test%20Widget&project=1&project=2&query=&statsPeriod=24h&yAxis=count%28%29'
  322. );
  323. });
  324. it('renders with first legend disabled by default', async function () {
  325. mockEventsv2();
  326. // Rerender with first legend disabled
  327. initialData.router.location.query = {legend: ['Query Name']};
  328. await renderModal({initialData, widget: mockWidget});
  329. expect(ReactEchartsCore).toHaveBeenLastCalledWith(
  330. expect.objectContaining({
  331. option: expect.objectContaining({
  332. legend: expect.objectContaining({
  333. selected: {'Query Name': false},
  334. }),
  335. }),
  336. }),
  337. {}
  338. );
  339. });
  340. it('renders total results in footer', async function () {
  341. mockEventsv2();
  342. await renderModal({initialData, widget: mockWidget});
  343. expect(screen.getByText('33,323,612')).toBeInTheDocument();
  344. });
  345. it('renders highlighted query text and multiple queries in select dropdown', async function () {
  346. mockEventsv2();
  347. const {container} = await renderModal({
  348. initialData,
  349. widget: {
  350. ...mockWidget,
  351. queries: [{...mockQuery, name: ''}, additionalMockQuery],
  352. },
  353. });
  354. userEvent.click(screen.getByText('/organizations/:orgId/performance/summary/'));
  355. expect(container).toSnapshot();
  356. });
  357. it('renders widget chart minimap', async function () {
  358. initialData.organization.features.push('widget-viewer-modal-minimap');
  359. mockEventsv2();
  360. await renderModal({
  361. initialData,
  362. widget: {
  363. ...mockWidget,
  364. queries: [{...mockQuery, name: ''}, additionalMockQuery],
  365. },
  366. });
  367. expect(ReactEchartsCore).toHaveBeenLastCalledWith(
  368. expect.objectContaining({
  369. option: expect.objectContaining({
  370. dataZoom: expect.arrayContaining([
  371. expect.objectContaining({
  372. realtime: false,
  373. showDetail: false,
  374. end: 100,
  375. start: 0,
  376. }),
  377. ]),
  378. }),
  379. }),
  380. {}
  381. );
  382. });
  383. it('zooming on minimap updates location query and updates echart start and end values', async function () {
  384. initialData.organization.features.push('widget-viewer-modal-minimap');
  385. mockEventsv2();
  386. await renderModal({
  387. initialData,
  388. widget: {
  389. ...mockWidget,
  390. queries: [{...mockQuery, name: ''}, additionalMockQuery],
  391. },
  392. });
  393. const calls = (ReactEchartsCore as jest.Mock).mock.calls;
  394. act(() => {
  395. // Simulate dataZoom event on chart
  396. calls[calls.length - 1][0].onEvents.datazoom(
  397. {seriesStart: 1646100000000, seriesEnd: 1646120000000},
  398. {
  399. getModel: () => {
  400. return {
  401. _payload: {start: 30, end: 70},
  402. };
  403. },
  404. }
  405. );
  406. });
  407. expect(initialData.router.push).toHaveBeenCalledWith(
  408. expect.objectContaining({
  409. query: {
  410. viewerEnd: '2022-03-01T05:53:20',
  411. viewerStart: '2022-03-01T03:40:00',
  412. },
  413. })
  414. );
  415. });
  416. it('includes group by in widget viewer table', async function () {
  417. mockEventsv2();
  418. mockWidget.queries = [
  419. {
  420. conditions: 'title:/organizations/:orgId/performance/summary/',
  421. fields: ['count()'],
  422. aggregates: ['count()'],
  423. columns: ['transaction'],
  424. name: 'Query Name',
  425. orderby: '-count()',
  426. },
  427. ];
  428. await renderModal({initialData, widget: mockWidget});
  429. screen.getByText('transaction');
  430. });
  431. it('includes order by in widget viewer table if not explicitly selected', async function () {
  432. mockEventsv2();
  433. mockWidget.queries = [
  434. {
  435. conditions: 'title:/organizations/:orgId/performance/summary/',
  436. fields: ['count()'],
  437. aggregates: ['count()'],
  438. columns: ['transaction'],
  439. name: 'Query Name',
  440. orderby: 'count_unique(user)',
  441. },
  442. ];
  443. await renderModal({initialData, widget: mockWidget});
  444. screen.getByText('count_unique(user)');
  445. });
  446. it('includes a custom equation order by in widget viewer table if not explicitly selected', async function () {
  447. mockEventsv2();
  448. mockWidget.queries = [
  449. {
  450. conditions: 'title:/organizations/:orgId/performance/summary/',
  451. fields: ['count()'],
  452. aggregates: ['count()'],
  453. columns: ['transaction'],
  454. name: 'Query Name',
  455. orderby: '-equation|count_unique(user) + 1',
  456. },
  457. ];
  458. await renderModal({initialData, widget: mockWidget});
  459. screen.getByText('count_unique(user) + 1');
  460. });
  461. });
  462. describe('with events', function () {
  463. it('renders updated table columns and orderby', async function () {
  464. const eventsMock = mockEvents();
  465. await renderModal({initialData: initialDataWithFlag, widget: mockWidget});
  466. expect(screen.getByText('title')).toBeInTheDocument();
  467. expect(
  468. screen.getByText('/organizations/:orgId/dashboards/')
  469. ).toBeInTheDocument();
  470. expect(eventsMock).toHaveBeenCalledWith(
  471. '/organizations/org-slug/events/',
  472. expect.objectContaining({
  473. query: expect.objectContaining({sort: ['-count()']}),
  474. })
  475. );
  476. });
  477. it('renders widget chart with y axis formatter using provided seriesResultType', async function () {
  478. mockEvents();
  479. await renderModal({
  480. initialData: initialDataWithFlag,
  481. widget: mockWidget,
  482. seriesData: [],
  483. seriesResultsType: 'duration',
  484. });
  485. const calls = (ReactEchartsCore as jest.Mock).mock.calls;
  486. const yAxisFormatter =
  487. calls[calls.length - 1][0].option.yAxis.axisLabel.formatter;
  488. expect(yAxisFormatter(123)).toEqual('123ms');
  489. });
  490. });
  491. });
  492. describe('TopN Chart Widget', function () {
  493. let mockQuery, mockWidget;
  494. function mockEventsStats() {
  495. return MockApiClient.addMockResponse({
  496. url: '/organizations/org-slug/events-stats/',
  497. body: {
  498. data: [
  499. [[1646100000], [{count: 1}]],
  500. [[1646120000], [{count: 1}]],
  501. ],
  502. start: 1646100000,
  503. end: 1646120000,
  504. isMetricsData: false,
  505. },
  506. });
  507. }
  508. const eventsMockData = [
  509. {
  510. 'error.type': ['Test Error 1a', 'Test Error 1b', 'Test Error 1c'],
  511. count: 10,
  512. },
  513. {
  514. 'error.type': ['Test Error 2'],
  515. count: 6,
  516. },
  517. {
  518. 'error.type': ['Test Error 3'],
  519. count: 5,
  520. },
  521. {
  522. 'error.type': ['Test Error 4'],
  523. count: 4,
  524. },
  525. {
  526. 'error.type': ['Test Error 5'],
  527. count: 3,
  528. },
  529. {
  530. 'error.type': ['Test Error 6'],
  531. count: 2,
  532. },
  533. ];
  534. function mockEventsv2() {
  535. return MockApiClient.addMockResponse({
  536. url: '/organizations/org-slug/eventsv2/',
  537. match: [MockApiClient.matchQuery({cursor: undefined})],
  538. headers: {
  539. Link:
  540. '<http://localhost/api/0/organizations/org-slug/eventsv2/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1",' +
  541. '<http://localhost/api/0/organizations/org-slug/eventsv2/?cursor=0:10:0>; rel="next"; results="true"; cursor="0:10:0"',
  542. },
  543. body: {
  544. data: eventsMockData,
  545. meta: {
  546. 'error.type': 'array',
  547. count: 'integer',
  548. },
  549. },
  550. });
  551. }
  552. function mockEvents() {
  553. return MockApiClient.addMockResponse({
  554. url: '/organizations/org-slug/events/',
  555. match: [MockApiClient.matchQuery({cursor: undefined})],
  556. headers: {
  557. Link:
  558. '<http://localhost/api/0/organizations/org-slug/eventsv2/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1",' +
  559. '<http://localhost/api/0/organizations/org-slug/eventsv2/?cursor=0:10:0>; rel="next"; results="true"; cursor="0:10:0"',
  560. },
  561. body: {
  562. data: eventsMockData,
  563. meta: {
  564. fields: {
  565. 'error.type': 'array',
  566. count: 'integer',
  567. },
  568. },
  569. },
  570. });
  571. }
  572. beforeEach(function () {
  573. mockQuery = {
  574. conditions: 'title:/organizations/:orgId/performance/summary/',
  575. fields: ['error.type', 'count()'],
  576. aggregates: ['count()'],
  577. columns: ['error.type'],
  578. id: '1',
  579. name: 'Query Name',
  580. orderby: '',
  581. };
  582. mockWidget = {
  583. title: 'Test Widget',
  584. displayType: DisplayType.TOP_N,
  585. interval: '5m',
  586. queries: [mockQuery],
  587. widgetType: WidgetType.DISCOVER,
  588. };
  589. MockApiClient.addMockResponse({
  590. url: '/organizations/org-slug/eventsv2/',
  591. match: [MockApiClient.matchQuery({cursor: '0:10:0'})],
  592. headers: {
  593. Link:
  594. '<http://localhost/api/0/organizations/org-slug/eventsv2/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1",' +
  595. '<http://localhost/api/0/organizations/org-slug/eventsv2/?cursor=0:20:0>; rel="next"; results="true"; cursor="0:20:0"',
  596. },
  597. body: {
  598. data: [
  599. {
  600. 'error.type': ['Next Page Test Error'],
  601. count: 1,
  602. },
  603. ],
  604. meta: {
  605. 'error.type': 'array',
  606. count: 'integer',
  607. },
  608. },
  609. });
  610. });
  611. describe('with eventsv2', function () {
  612. it('renders Discover topn chart widget viewer', async function () {
  613. mockEventsStats();
  614. mockEventsv2();
  615. const {container} = await renderModal({initialData, widget: mockWidget});
  616. expect(container).toSnapshot();
  617. });
  618. it('sorts table when a sortable column header is clicked', async function () {
  619. const eventsStatsMock = mockEventsStats();
  620. const eventsv2Mock = mockEventsv2();
  621. const {rerender} = await renderModal({initialData, widget: mockWidget});
  622. userEvent.click(screen.getByText('count()'));
  623. expect(initialData.router.push).toHaveBeenCalledWith({
  624. query: {sort: ['-count()']},
  625. });
  626. // Need to manually set the new router location and rerender to simulate the sortable column click
  627. initialData.router.location.query = {sort: ['-count()']};
  628. rerender(
  629. <WidgetViewerModal
  630. Header={stubEl}
  631. Footer={stubEl as ModalRenderProps['Footer']}
  632. Body={stubEl as ModalRenderProps['Body']}
  633. CloseButton={stubEl}
  634. closeModal={() => undefined}
  635. organization={initialData.organization}
  636. widget={mockWidget}
  637. onEdit={() => undefined}
  638. />
  639. );
  640. await waitForMetaToHaveBeenCalled();
  641. expect(eventsv2Mock).toHaveBeenCalledWith(
  642. '/organizations/org-slug/eventsv2/',
  643. expect.objectContaining({
  644. query: expect.objectContaining({sort: ['-count()']}),
  645. })
  646. );
  647. expect(eventsStatsMock).toHaveBeenCalledWith(
  648. '/organizations/org-slug/events-stats/',
  649. expect.objectContaining({
  650. query: expect.objectContaining({orderby: '-count()'}),
  651. })
  652. );
  653. });
  654. it('renders pagination buttons', async function () {
  655. mockEventsStats();
  656. mockEventsv2();
  657. await renderModal({initialData, widget: mockWidget});
  658. expect(screen.getByRole('button', {name: 'Previous'})).toBeInTheDocument();
  659. expect(screen.getByRole('button', {name: 'Next'})).toBeInTheDocument();
  660. });
  661. it('does not render pagination buttons', async function () {
  662. mockEventsStats();
  663. mockEventsv2();
  664. MockApiClient.addMockResponse({
  665. url: '/organizations/org-slug/eventsv2/',
  666. headers: {
  667. Link:
  668. '<http://localhost/api/0/organizations/org-slug/eventsv2/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1",' +
  669. '<http://localhost/api/0/organizations/org-slug/eventsv2/?cursor=0:20:0>; rel="next"; results="false"; cursor="0:20:0"',
  670. },
  671. body: {
  672. data: [
  673. {
  674. 'error.type': ['No Pagination'],
  675. count: 1,
  676. },
  677. ],
  678. meta: {
  679. 'error.type': 'array',
  680. count: 'integer',
  681. },
  682. },
  683. });
  684. await renderModal({initialData, widget: mockWidget});
  685. expect(
  686. screen.queryByRole('button', {name: 'Previous'})
  687. ).not.toBeInTheDocument();
  688. expect(screen.queryByRole('button', {name: 'Next'})).not.toBeInTheDocument();
  689. });
  690. it('paginates to the next page', async function () {
  691. mockEventsStats();
  692. mockEventsv2();
  693. const {rerender} = await renderModal({initialData, widget: mockWidget});
  694. expect(screen.getByText('Test Error 1c')).toBeInTheDocument();
  695. userEvent.click(screen.getByRole('button', {name: 'Next'}));
  696. expect(initialData.router.replace).toHaveBeenCalledWith(
  697. expect.objectContaining({
  698. query: {cursor: '0:10:0'},
  699. })
  700. );
  701. // Need to manually set the new router location and rerender to simulate the next page click
  702. initialData.router.location.query = {cursor: ['0:10:0']};
  703. rerender(
  704. <WidgetViewerModal
  705. Header={stubEl}
  706. Footer={stubEl as ModalRenderProps['Footer']}
  707. Body={stubEl as ModalRenderProps['Body']}
  708. CloseButton={stubEl}
  709. closeModal={() => undefined}
  710. organization={initialData.organization}
  711. widget={mockWidget}
  712. onEdit={() => undefined}
  713. />
  714. );
  715. await waitForMetaToHaveBeenCalled();
  716. expect(await screen.findByText('Next Page Test Error')).toBeInTheDocument();
  717. });
  718. it('uses provided seriesData and does not make an events-stats requests', async function () {
  719. const eventsStatsMock = mockEventsStats();
  720. mockEventsv2();
  721. await renderModal({initialData, widget: mockWidget, seriesData: []});
  722. expect(eventsStatsMock).not.toHaveBeenCalled();
  723. });
  724. it('makes events-stats requests when table is sorted', async function () {
  725. const eventsStatsMock = mockEventsStats();
  726. mockEventsv2();
  727. await renderModal({
  728. initialData,
  729. widget: mockWidget,
  730. seriesData: [],
  731. });
  732. expect(eventsStatsMock).not.toHaveBeenCalled();
  733. userEvent.click(screen.getByText('count()'));
  734. await waitForMetaToHaveBeenCalled();
  735. expect(eventsStatsMock).toHaveBeenCalledTimes(1);
  736. });
  737. it('renders widget chart minimap', async function () {
  738. mockEventsStats();
  739. mockEventsv2();
  740. initialData.organization.features.push('widget-viewer-modal-minimap');
  741. await renderModal({initialData, widget: mockWidget});
  742. expect(ReactEchartsCore).toHaveBeenLastCalledWith(
  743. expect.objectContaining({
  744. option: expect.objectContaining({
  745. dataZoom: expect.arrayContaining([
  746. expect.objectContaining({
  747. realtime: false,
  748. showDetail: false,
  749. end: 100,
  750. start: 0,
  751. }),
  752. ]),
  753. }),
  754. }),
  755. {}
  756. );
  757. });
  758. it('zooming on minimap updates location query and updates echart start and end values', async function () {
  759. mockEventsStats();
  760. mockEventsv2();
  761. initialData.organization.features.push('widget-viewer-modal-minimap');
  762. await renderModal({initialData, widget: mockWidget});
  763. const calls = (ReactEchartsCore as jest.Mock).mock.calls;
  764. act(() => {
  765. // Simulate dataZoom event on chart
  766. calls[calls.length - 1][0].onEvents.datazoom(
  767. {seriesStart: 1646100000000, seriesEnd: 1646120000000},
  768. {
  769. getModel: () => {
  770. return {
  771. _payload: {start: 30, end: 70},
  772. };
  773. },
  774. }
  775. );
  776. });
  777. expect(initialData.router.push).toHaveBeenCalledWith(
  778. expect.objectContaining({
  779. query: {
  780. viewerEnd: '2022-03-01T05:53:20',
  781. viewerStart: '2022-03-01T03:40:00',
  782. },
  783. })
  784. );
  785. await waitFor(() => {
  786. expect(ReactEchartsCore).toHaveBeenLastCalledWith(
  787. expect.objectContaining({
  788. option: expect.objectContaining({
  789. dataZoom: expect.arrayContaining([
  790. expect.objectContaining({
  791. realtime: false,
  792. showDetail: false,
  793. endValue: 1646114000000,
  794. startValue: 1646106000000,
  795. }),
  796. ]),
  797. }),
  798. }),
  799. {}
  800. );
  801. });
  802. });
  803. });
  804. describe('with events', function () {
  805. it('sorts table when a sortable column header is clicked', async function () {
  806. const eventsStatsMock = mockEventsStats();
  807. const eventsMock = mockEvents();
  808. const {rerender} = await renderModal({
  809. initialData: initialDataWithFlag,
  810. widget: mockWidget,
  811. });
  812. userEvent.click(screen.getByText('count()'));
  813. expect(initialDataWithFlag.router.push).toHaveBeenCalledWith({
  814. query: {sort: ['-count()']},
  815. });
  816. // Need to manually set the new router location and rerender to simulate the sortable column click
  817. initialDataWithFlag.router.location.query = {sort: ['-count()']};
  818. rerender(
  819. <WidgetViewerModal
  820. Header={stubEl}
  821. Footer={stubEl as ModalRenderProps['Footer']}
  822. Body={stubEl as ModalRenderProps['Body']}
  823. CloseButton={stubEl}
  824. closeModal={() => undefined}
  825. organization={initialDataWithFlag.organization}
  826. widget={mockWidget}
  827. onEdit={() => undefined}
  828. />
  829. );
  830. await waitForMetaToHaveBeenCalled();
  831. expect(eventsMock).toHaveBeenCalledWith(
  832. '/organizations/org-slug/events/',
  833. expect.objectContaining({
  834. query: expect.objectContaining({sort: ['-count()']}),
  835. })
  836. );
  837. expect(eventsStatsMock).toHaveBeenCalledWith(
  838. '/organizations/org-slug/events-stats/',
  839. expect.objectContaining({
  840. query: expect.objectContaining({orderby: '-count()'}),
  841. })
  842. );
  843. });
  844. });
  845. });
  846. describe('World Map Chart Widget', function () {
  847. let mockQuery, mockWidget;
  848. const eventsMockData = [
  849. {
  850. 'geo.country_code': 'ES',
  851. p75_measurements_lcp: 2000,
  852. },
  853. {
  854. 'geo.country_code': 'SK',
  855. p75_measurements_lcp: 3000,
  856. },
  857. {
  858. 'geo.country_code': 'CO',
  859. p75_measurements_lcp: 4000,
  860. },
  861. ];
  862. function mockEventsGeo() {
  863. return MockApiClient.addMockResponse({
  864. url: '/organizations/org-slug/events-geo/',
  865. body: {
  866. data: eventsMockData,
  867. meta: {
  868. 'geo.country_code': 'string',
  869. p75_measurements_lcp: 'duration',
  870. },
  871. },
  872. });
  873. }
  874. function mockEventsv2() {
  875. return MockApiClient.addMockResponse({
  876. url: '/organizations/org-slug/eventsv2/',
  877. body: {
  878. data: eventsMockData,
  879. meta: {
  880. 'geo.country_code': 'string',
  881. p75_measurements_lcp: 'duration',
  882. },
  883. },
  884. });
  885. }
  886. function mockEvents() {
  887. return MockApiClient.addMockResponse({
  888. url: '/organizations/org-slug/events/',
  889. body: {
  890. data: eventsMockData,
  891. meta: {
  892. fields: {
  893. 'geo.country_code': 'string',
  894. p75_measurements_lcp: 'duration',
  895. },
  896. },
  897. },
  898. });
  899. }
  900. beforeEach(function () {
  901. mockQuery = {
  902. conditions: 'title:/organizations/:orgId/performance/summary/',
  903. fields: ['p75(measurements.lcp)'],
  904. aggregates: ['p75(measurements.lcp)'],
  905. columns: [],
  906. id: '1',
  907. name: 'Query Name',
  908. orderby: '',
  909. };
  910. mockWidget = {
  911. title: 'Test Widget',
  912. displayType: DisplayType.WORLD_MAP,
  913. interval: '5m',
  914. queries: [mockQuery],
  915. widgetType: WidgetType.DISCOVER,
  916. };
  917. });
  918. describe('with eventsv2', function () {
  919. it('always queries geo.country_code in the table chart', async function () {
  920. const eventsv2Mock = mockEventsv2();
  921. mockEventsGeo();
  922. await renderModal({initialData, widget: mockWidget});
  923. expect(eventsv2Mock).toHaveBeenCalledWith(
  924. '/organizations/org-slug/eventsv2/',
  925. expect.objectContaining({
  926. query: expect.objectContaining({
  927. field: ['geo.country_code', 'p75(measurements.lcp)'],
  928. }),
  929. })
  930. );
  931. expect(await screen.findByText('geo.country_code')).toBeInTheDocument();
  932. });
  933. it('renders Discover topn chart widget viewer', async function () {
  934. mockEventsv2();
  935. mockEventsGeo();
  936. const {container} = await renderModal({initialData, widget: mockWidget});
  937. expect(container).toSnapshot();
  938. });
  939. it('uses provided tableData and does not make an eventsv2 requests', async function () {
  940. const eventsGeoMock = mockEventsGeo();
  941. mockEventsv2();
  942. await renderModal({initialData, widget: mockWidget, tableData: []});
  943. expect(eventsGeoMock).not.toHaveBeenCalled();
  944. });
  945. });
  946. describe('with events', function () {
  947. it('always queries geo.country_code in the table chart', async function () {
  948. const eventsMock = mockEvents();
  949. mockEventsGeo();
  950. await renderModal({initialData: initialDataWithFlag, widget: mockWidget});
  951. expect(eventsMock).toHaveBeenCalledWith(
  952. '/organizations/org-slug/events/',
  953. expect.objectContaining({
  954. query: expect.objectContaining({
  955. field: ['geo.country_code', 'p75(measurements.lcp)'],
  956. }),
  957. })
  958. );
  959. expect(await screen.findByText('geo.country_code')).toBeInTheDocument();
  960. });
  961. });
  962. });
  963. describe('Table Widget', function () {
  964. const mockQuery = {
  965. conditions: 'title:/organizations/:orgId/performance/summary/',
  966. fields: ['title', 'count()'],
  967. aggregates: ['count()'],
  968. columns: ['title'],
  969. id: '1',
  970. name: 'Query Name',
  971. orderby: '',
  972. };
  973. const mockWidget = {
  974. title: 'Test Widget',
  975. displayType: DisplayType.TABLE,
  976. interval: '5m',
  977. queries: [mockQuery],
  978. widgetType: WidgetType.DISCOVER,
  979. };
  980. function mockEventsv2() {
  981. return MockApiClient.addMockResponse({
  982. url: '/organizations/org-slug/eventsv2/',
  983. body: {
  984. data: [
  985. {
  986. title: '/organizations/:orgId/dashboards/',
  987. id: '1',
  988. count: 1,
  989. },
  990. ],
  991. meta: {
  992. title: 'string',
  993. id: 'string',
  994. count: 1,
  995. isMetricsData: false,
  996. },
  997. },
  998. });
  999. }
  1000. function mockEvents() {
  1001. return MockApiClient.addMockResponse({
  1002. url: '/organizations/org-slug/events/',
  1003. body: {
  1004. data: [
  1005. {
  1006. title: '/organizations/:orgId/dashboards/',
  1007. id: '1',
  1008. count: 1,
  1009. },
  1010. ],
  1011. meta: {
  1012. fields: {
  1013. title: 'string',
  1014. id: 'string',
  1015. count: 1,
  1016. },
  1017. isMetricsData: false,
  1018. },
  1019. },
  1020. });
  1021. }
  1022. describe('with eventsv2', function () {
  1023. it('makes eventsv2 requests when table is paginated', async function () {
  1024. const eventsv2Mock = mockEventsv2();
  1025. await renderModal({
  1026. initialData,
  1027. widget: mockWidget,
  1028. tableData: [],
  1029. pageLinks:
  1030. '<https://sentry.io>; rel="previous"; results="false"; cursor="0:0:1", <https://sentry.io>; rel="next"; results="true"; cursor="0:20:0"',
  1031. });
  1032. expect(eventsv2Mock).not.toHaveBeenCalled();
  1033. userEvent.click(screen.getByLabelText('Next'));
  1034. await waitFor(() => {
  1035. expect(eventsv2Mock).toHaveBeenCalled();
  1036. });
  1037. });
  1038. });
  1039. describe('with events', function () {
  1040. it('makes events requests when table is paginated', async function () {
  1041. const eventsMock = mockEvents();
  1042. await renderModal({
  1043. initialData: initialDataWithFlag,
  1044. widget: mockWidget,
  1045. tableData: [],
  1046. pageLinks:
  1047. '<https://sentry.io>; rel="previous"; results="false"; cursor="0:0:1", <https://sentry.io>; rel="next"; results="true"; cursor="0:20:0"',
  1048. });
  1049. expect(eventsMock).not.toHaveBeenCalled();
  1050. userEvent.click(screen.getByLabelText('Next'));
  1051. await waitFor(() => {
  1052. expect(eventsMock).toHaveBeenCalled();
  1053. });
  1054. });
  1055. it('disables the Open in Discover button for a custom measurement widget', async function () {
  1056. const customMeasurementWidget = {
  1057. ...mockWidget,
  1058. queries: [
  1059. {
  1060. conditions: '',
  1061. fields: [],
  1062. aggregates: ['p99(measurements.custom.measurement)'],
  1063. columns: ['title'],
  1064. id: '1',
  1065. name: 'Query Name',
  1066. orderby: '',
  1067. },
  1068. ],
  1069. };
  1070. await renderModal({
  1071. initialData: initialDataWithFlag,
  1072. widget: customMeasurementWidget,
  1073. tableData: [],
  1074. pageLinks:
  1075. '<https://sentry.io>; rel="previous"; results="false"; cursor="0:0:1", <https://sentry.io>; rel="next"; results="true"; cursor="0:20:0"',
  1076. });
  1077. userEvent.click(screen.getByText('Open in Discover'));
  1078. expect(initialData.router.push).not.toHaveBeenCalled();
  1079. });
  1080. it('displays table data with units correctly', async function () {
  1081. const eventsMock = MockApiClient.addMockResponse({
  1082. url: '/organizations/org-slug/events/',
  1083. match: [MockApiClient.matchQuery({cursor: undefined})],
  1084. headers: {
  1085. Link:
  1086. '<http://localhost/api/0/organizations/org-slug/events/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1",' +
  1087. '<http://localhost/api/0/organizations/org-slug/events/?cursor=0:10:0>; rel="next"; results="true"; cursor="0:10:0"',
  1088. },
  1089. body: {
  1090. data: [
  1091. {
  1092. 'p75(measurements.custom.minute)': 94.87035966318831,
  1093. 'p95(measurements.custom.ratio)': 0.9881980140455187,
  1094. 'p75(measurements.custom.kibibyte)': 217.87035966318834,
  1095. },
  1096. ],
  1097. meta: {
  1098. fields: {
  1099. 'p75(measurements.custom.minute)': 'duration',
  1100. 'p95(measurements.custom.ratio)': 'percentage',
  1101. 'p75(measurements.custom.kibibyte)': 'size',
  1102. },
  1103. units: {
  1104. 'p75(measurements.custom.minute)': 'minute',
  1105. 'p95(measurements.custom.ratio)': null,
  1106. 'p75(measurements.custom.kibibyte)': 'kibibyte',
  1107. },
  1108. isMetricsData: true,
  1109. tips: {},
  1110. },
  1111. },
  1112. });
  1113. await renderModal({
  1114. initialData: initialDataWithFlag,
  1115. widget: {
  1116. title: 'Custom Widget',
  1117. displayType: 'table',
  1118. queries: [
  1119. {
  1120. fields: [
  1121. 'p75(measurements.custom.kibibyte)',
  1122. 'p75(measurements.custom.minute)',
  1123. 'p95(measurements.custom.ratio)',
  1124. ],
  1125. aggregates: [
  1126. 'p75(measurements.custom.kibibyte)',
  1127. 'p75(measurements.custom.minute)',
  1128. 'p95(measurements.custom.ratio)',
  1129. ],
  1130. columns: [],
  1131. orderby: '-p75(measurements.custom.kibibyte)',
  1132. },
  1133. ],
  1134. widgetType: 'discover',
  1135. },
  1136. });
  1137. await waitFor(() => {
  1138. expect(eventsMock).toHaveBeenCalled();
  1139. });
  1140. expect(screen.getByText('217.9 KiB')).toBeInTheDocument();
  1141. expect(screen.getByText('1.58hr')).toBeInTheDocument();
  1142. expect(screen.getByText('98.82%')).toBeInTheDocument();
  1143. });
  1144. });
  1145. });
  1146. });
  1147. describe('Issue Table Widget', function () {
  1148. let issuesMock;
  1149. const mockQuery = {
  1150. conditions: 'is:unresolved',
  1151. fields: ['events', 'status', 'title'],
  1152. columns: ['events', 'status', 'title'],
  1153. aggregates: [],
  1154. id: '1',
  1155. name: 'Query Name',
  1156. orderby: '',
  1157. };
  1158. const mockWidget = {
  1159. id: '1',
  1160. title: 'Issue Widget',
  1161. displayType: DisplayType.TABLE,
  1162. interval: '5m',
  1163. queries: [mockQuery],
  1164. widgetType: WidgetType.ISSUE,
  1165. };
  1166. beforeEach(function () {
  1167. MemberListStore.loadInitialData([]);
  1168. MockApiClient.addMockResponse({
  1169. url: '/organizations/org-slug/issues/',
  1170. method: 'GET',
  1171. match: [
  1172. MockApiClient.matchData({
  1173. cursor: '0:10:0',
  1174. }),
  1175. ],
  1176. headers: {
  1177. Link:
  1178. '<http://localhost/api/0/organizations/org-slug/issues/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1",' +
  1179. '<http://localhost/api/0/organizations/org-slug/issues/?cursor=0:20:0>; rel="next"; results="true"; cursor="0:20:0"',
  1180. },
  1181. body: [
  1182. {
  1183. id: '2',
  1184. title: 'Another Error: Failed',
  1185. project: {
  1186. id: '3',
  1187. },
  1188. status: 'unresolved',
  1189. lifetime: {count: 5},
  1190. count: 3,
  1191. userCount: 1,
  1192. },
  1193. ],
  1194. });
  1195. issuesMock = MockApiClient.addMockResponse({
  1196. url: '/organizations/org-slug/issues/',
  1197. method: 'GET',
  1198. match: [
  1199. MockApiClient.matchData({
  1200. cursor: undefined,
  1201. }),
  1202. ],
  1203. headers: {
  1204. Link:
  1205. '<http://localhost/api/0/organizations/org-slug/issues/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1",' +
  1206. '<http://localhost/api/0/organizations/org-slug/issues/?cursor=0:10:0>; rel="next"; results="true"; cursor="0:10:0"',
  1207. },
  1208. body: [
  1209. {
  1210. id: '1',
  1211. title: 'Error: Failed',
  1212. project: {
  1213. id: '3',
  1214. },
  1215. status: 'unresolved',
  1216. lifetime: {count: 10},
  1217. count: 6,
  1218. userCount: 3,
  1219. },
  1220. ],
  1221. });
  1222. });
  1223. it('renders widget title', async function () {
  1224. await renderModal({initialData, widget: mockWidget});
  1225. expect(screen.getByText('Issue Widget')).toBeInTheDocument();
  1226. });
  1227. it('renders Edit and Open buttons', async function () {
  1228. await renderModal({initialData, widget: mockWidget});
  1229. expect(screen.getByText('Edit Widget')).toBeInTheDocument();
  1230. expect(screen.getByText('Open in Issues')).toBeInTheDocument();
  1231. });
  1232. it('renders events, status, and title table columns', async function () {
  1233. await renderModal({initialData, widget: mockWidget});
  1234. expect(screen.getByText('title')).toBeInTheDocument();
  1235. expect(await screen.findByText('Error: Failed')).toBeInTheDocument();
  1236. expect(screen.getByText('events')).toBeInTheDocument();
  1237. expect(screen.getByText('6')).toBeInTheDocument();
  1238. expect(screen.getByText('status')).toBeInTheDocument();
  1239. expect(screen.getByText('unresolved')).toBeInTheDocument();
  1240. });
  1241. it('renders Issue table widget viewer', async function () {
  1242. const {container} = await renderModal({initialData, widget: mockWidget});
  1243. await screen.findByText('Error: Failed');
  1244. expect(container).toSnapshot();
  1245. });
  1246. it('redirects user to Issues when clicking Open in Issues', async function () {
  1247. await renderModal({initialData, widget: mockWidget});
  1248. userEvent.click(screen.getByText('Open in Issues'));
  1249. expect(initialData.router.push).toHaveBeenCalledWith(
  1250. '/organizations/org-slug/issues/?environment=prod&environment=dev&project=1&project=2&query=is%3Aunresolved&sort=&statsPeriod=24h'
  1251. );
  1252. });
  1253. it('sorts table when a sortable column header is clicked', async function () {
  1254. const {rerender} = await renderModal({initialData, widget: mockWidget});
  1255. userEvent.click(screen.getByText('events'));
  1256. expect(initialData.router.push).toHaveBeenCalledWith({
  1257. query: {sort: 'freq'},
  1258. });
  1259. // Need to manually set the new router location and rerender to simulate the sortable column click
  1260. initialData.router.location.query = {sort: ['freq']};
  1261. rerender(
  1262. <WidgetViewerModal
  1263. Header={stubEl}
  1264. Footer={stubEl as ModalRenderProps['Footer']}
  1265. Body={stubEl as ModalRenderProps['Body']}
  1266. CloseButton={stubEl}
  1267. closeModal={() => undefined}
  1268. organization={initialData.organization}
  1269. widget={mockWidget}
  1270. onEdit={() => undefined}
  1271. />
  1272. );
  1273. expect(issuesMock).toHaveBeenCalledWith(
  1274. '/organizations/org-slug/issues/',
  1275. expect.objectContaining({
  1276. data: {
  1277. cursor: undefined,
  1278. environment: ['prod', 'dev'],
  1279. expand: ['owners'],
  1280. limit: 20,
  1281. project: [1, 2],
  1282. query: 'is:unresolved',
  1283. sort: 'date',
  1284. statsPeriod: '24h',
  1285. },
  1286. })
  1287. );
  1288. });
  1289. it('renders pagination buttons', async function () {
  1290. await renderModal({initialData, widget: mockWidget});
  1291. expect(await screen.findByRole('button', {name: 'Previous'})).toBeInTheDocument();
  1292. expect(screen.getByRole('button', {name: 'Next'})).toBeInTheDocument();
  1293. });
  1294. it('paginates to the next page', async function () {
  1295. const {rerender} = await renderModal({initialData, widget: mockWidget});
  1296. expect(await screen.findByText('Error: Failed')).toBeInTheDocument();
  1297. userEvent.click(screen.getByRole('button', {name: 'Next'}));
  1298. expect(issuesMock).toHaveBeenCalledTimes(1);
  1299. expect(initialData.router.replace).toHaveBeenCalledWith(
  1300. expect.objectContaining({
  1301. query: {cursor: '0:10:0', page: 1},
  1302. })
  1303. );
  1304. // Need to manually set the new router location and rerender to simulate the next page click
  1305. initialData.router.location.query = {cursor: ['0:10:0']};
  1306. rerender(
  1307. <WidgetViewerModal
  1308. Header={stubEl}
  1309. Footer={stubEl as ModalRenderProps['Footer']}
  1310. Body={stubEl as ModalRenderProps['Body']}
  1311. CloseButton={stubEl}
  1312. closeModal={() => undefined}
  1313. organization={initialData.organization}
  1314. widget={mockWidget}
  1315. onEdit={() => undefined}
  1316. />
  1317. );
  1318. expect(await screen.findByText('Another Error: Failed')).toBeInTheDocument();
  1319. });
  1320. it('displays with correct table column widths', async function () {
  1321. initialData.router.location.query = {width: ['-1', '-1', '575']};
  1322. await renderModal({initialData, widget: mockWidget});
  1323. expect(screen.getByTestId('grid-editable')).toHaveStyle({
  1324. 'grid-template-columns':
  1325. ' minmax(90px, auto) minmax(90px, auto) minmax(575px, auto)',
  1326. });
  1327. });
  1328. it('uses provided tableData and does not make an issues requests', async function () {
  1329. await renderModal({initialData, widget: mockWidget, tableData: []});
  1330. expect(issuesMock).not.toHaveBeenCalled();
  1331. });
  1332. it('makes issues requests when table is sorted', async function () {
  1333. await renderModal({
  1334. initialData,
  1335. widget: mockWidget,
  1336. tableData: [],
  1337. });
  1338. expect(issuesMock).not.toHaveBeenCalled();
  1339. userEvent.click(screen.getByText('events'));
  1340. await waitFor(() => {
  1341. expect(issuesMock).toHaveBeenCalled();
  1342. });
  1343. });
  1344. });
  1345. describe('Release Health Widgets', function () {
  1346. let metricsMock;
  1347. const mockQuery = {
  1348. conditions: '',
  1349. fields: [`sum(session)`],
  1350. columns: [],
  1351. aggregates: [],
  1352. id: '1',
  1353. name: 'Query Name',
  1354. orderby: '',
  1355. };
  1356. const mockWidget = {
  1357. id: '1',
  1358. title: 'Release Widget',
  1359. displayType: DisplayType.LINE,
  1360. interval: '5m',
  1361. queries: [mockQuery],
  1362. widgetType: WidgetType.RELEASE,
  1363. };
  1364. beforeEach(function () {
  1365. metricsMock = MockApiClient.addMockResponse({
  1366. url: '/organizations/org-slug/metrics/data/',
  1367. body: TestStubs.MetricsTotalCountByReleaseIn24h(),
  1368. headers: {
  1369. link:
  1370. '<http://localhost/api/0/organizations/org-slug/metrics/data/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1",' +
  1371. '<http://localhost/api/0/organizations/org-slug/metrics/data/?cursor=0:10:0>; rel="next"; results="true"; cursor="0:10:0"',
  1372. },
  1373. });
  1374. });
  1375. it('does a sessions query', async function () {
  1376. jest.useFakeTimers().setSystemTime(new Date('2022-08-02'));
  1377. await renderModal({initialData, widget: mockWidget});
  1378. expect(metricsMock).toHaveBeenCalled();
  1379. });
  1380. it('renders widget title', async function () {
  1381. await renderModal({initialData, widget: mockWidget});
  1382. expect(screen.getByText('Release Widget')).toBeInTheDocument();
  1383. });
  1384. it('renders Edit and Open in Releases buttons', async function () {
  1385. await renderModal({initialData, widget: mockWidget});
  1386. expect(screen.getByText('Edit Widget')).toBeInTheDocument();
  1387. expect(screen.getByText('Open in Releases')).toBeInTheDocument();
  1388. });
  1389. it('Open in Releases button redirects browser', async function () {
  1390. await renderModal({initialData, widget: mockWidget});
  1391. userEvent.click(screen.getByText('Open in Releases'));
  1392. expect(initialData.router.push).toHaveBeenCalledWith(
  1393. '/organizations/org-slug/releases/?environment=prod&environment=dev&project=1&project=2&statsPeriod=24h'
  1394. );
  1395. });
  1396. it('renders table header and body', async function () {
  1397. await renderModal({initialData, widget: mockWidget});
  1398. expect(screen.getByText('release')).toBeInTheDocument();
  1399. expect(await screen.findByText('e102abb2c46e')).toBeInTheDocument();
  1400. expect(screen.getByText('sum(session)')).toBeInTheDocument();
  1401. expect(screen.getByText('6.3k')).toBeInTheDocument();
  1402. });
  1403. it('renders Release widget viewer', async function () {
  1404. const {container} = await renderModal({initialData, widget: mockWidget});
  1405. expect(await screen.findByText('e102abb2c46e')).toBeInTheDocument();
  1406. expect(container).toSnapshot();
  1407. });
  1408. it('renders pagination buttons', async function () {
  1409. await renderModal({
  1410. initialData,
  1411. widget: mockWidget,
  1412. });
  1413. expect(await screen.findByRole('button', {name: 'Previous'})).toBeInTheDocument();
  1414. expect(screen.getByRole('button', {name: 'Next'})).toBeInTheDocument();
  1415. });
  1416. it('does not render pagination buttons when sorting by release', async function () {
  1417. await renderModal({
  1418. initialData,
  1419. widget: {...mockWidget, queries: [{...mockQuery, orderby: 'release'}]},
  1420. });
  1421. expect(screen.queryByRole('button', {name: 'Previous'})).not.toBeInTheDocument();
  1422. expect(screen.queryByRole('button', {name: 'Next'})).not.toBeInTheDocument();
  1423. });
  1424. it('makes a new sessions request after sorting by a table column', async function () {
  1425. const {rerender} = await renderModal({
  1426. initialData,
  1427. widget: mockWidget,
  1428. tableData: [],
  1429. seriesData: [],
  1430. });
  1431. expect(metricsMock).toHaveBeenCalledTimes(1);
  1432. userEvent.click(screen.getByText(`sum(session)`));
  1433. expect(initialData.router.push).toHaveBeenCalledWith({
  1434. query: {sort: '-sum(session)'},
  1435. });
  1436. // Need to manually set the new router location and rerender to simulate the sortable column click
  1437. initialData.router.location.query = {sort: '-sum(session)'};
  1438. rerender(
  1439. <WidgetViewerModal
  1440. Header={stubEl}
  1441. Footer={stubEl as ModalRenderProps['Footer']}
  1442. Body={stubEl as ModalRenderProps['Body']}
  1443. CloseButton={stubEl}
  1444. closeModal={() => undefined}
  1445. organization={initialData.organization}
  1446. widget={mockWidget}
  1447. onEdit={() => undefined}
  1448. seriesData={[]}
  1449. tableData={[]}
  1450. />
  1451. );
  1452. await waitFor(() => {
  1453. expect(metricsMock).toHaveBeenCalledTimes(2);
  1454. });
  1455. });
  1456. });
  1457. });