widgetViewerModal.spec.tsx 55 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579
  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 {AggregationOutputType} from 'sentry/utils/discover/fields';
  12. import {
  13. DisplayType,
  14. Widget,
  15. WidgetQuery,
  16. WidgetType,
  17. } from 'sentry/views/dashboardsV2/types';
  18. jest.mock('echarts-for-react/lib/core', () => {
  19. return jest.fn(({style}) => {
  20. return <div style={{...style, background: 'green'}}>echarts mock</div>;
  21. });
  22. });
  23. jest.mock('sentry/components/tooltip', () => {
  24. return jest.fn(props => {
  25. return <div>{props.children}</div>;
  26. });
  27. });
  28. const stubEl = (props: {children?: React.ReactNode}) => <div>{props.children}</div>;
  29. let eventsMetaMock;
  30. const waitForMetaToHaveBeenCalled = async () => {
  31. await waitFor(() => {
  32. expect(eventsMetaMock).toHaveBeenCalled();
  33. });
  34. };
  35. async function renderModal({
  36. initialData: {organization, routerContext},
  37. widget,
  38. seriesData,
  39. tableData,
  40. pageLinks,
  41. seriesResultsType,
  42. }: {
  43. initialData: any;
  44. widget: any;
  45. pageLinks?: string;
  46. seriesData?: Series[];
  47. seriesResultsType?: Record<string, AggregationOutputType>;
  48. tableData?: TableDataWithTitle[];
  49. }) {
  50. const rendered = render(
  51. <div style={{padding: space(4)}}>
  52. <WidgetViewerModal
  53. Header={stubEl}
  54. Footer={stubEl as ModalRenderProps['Footer']}
  55. Body={stubEl as ModalRenderProps['Body']}
  56. CloseButton={stubEl}
  57. closeModal={() => undefined}
  58. organization={organization}
  59. widget={widget}
  60. onEdit={() => undefined}
  61. seriesData={seriesData}
  62. tableData={tableData}
  63. pageLinks={pageLinks}
  64. seriesResultsType={seriesResultsType}
  65. />
  66. </div>,
  67. {
  68. context: routerContext,
  69. organization,
  70. }
  71. );
  72. // Need to wait since WidgetViewerModal will make a request to events-meta
  73. // for total events count on mount
  74. if (widget.widgetType === WidgetType.DISCOVER) {
  75. await waitForMetaToHaveBeenCalled();
  76. }
  77. return rendered;
  78. }
  79. describe('Modals -> WidgetViewerModal', function () {
  80. let initialData, initialDataWithFlag;
  81. beforeEach(() => {
  82. initialData = initializeOrg({
  83. organization: {
  84. features: ['discover-query', 'widget-viewer-modal'],
  85. apdexThreshold: 400,
  86. },
  87. router: {
  88. location: {query: {}},
  89. },
  90. project: 1,
  91. projects: [],
  92. });
  93. initialDataWithFlag = {
  94. ...initialData,
  95. organization: {
  96. ...initialData.organization,
  97. features: [
  98. ...initialData.organization.features,
  99. 'discover-frontend-use-events-endpoint',
  100. ],
  101. },
  102. };
  103. MockApiClient.addMockResponse({
  104. url: '/organizations/org-slug/projects/',
  105. body: [],
  106. });
  107. MockApiClient.addMockResponse({
  108. url: '/organizations/org-slug/releases/',
  109. body: [],
  110. });
  111. eventsMetaMock = MockApiClient.addMockResponse({
  112. url: '/organizations/org-slug/events-meta/',
  113. body: {count: 33323612},
  114. });
  115. PageFiltersStore.init();
  116. PageFiltersStore.onInitializeUrlState(
  117. {
  118. projects: [1, 2],
  119. environments: ['prod', 'dev'],
  120. datetime: {start: null, end: null, period: '24h', utc: null},
  121. },
  122. new Set()
  123. );
  124. });
  125. afterEach(() => {
  126. MockApiClient.clearMockResponses();
  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: {'count()': 'duration', 'count_unique()': '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. it('renders widget chart with default number y axis formatter when seriesResultType has multiple different types', async function () {
  491. mockEvents();
  492. await renderModal({
  493. initialData: initialDataWithFlag,
  494. widget: mockWidget,
  495. seriesData: [],
  496. seriesResultsType: {'count()': 'duration', 'count_unique()': 'size'},
  497. });
  498. const calls = (ReactEchartsCore as jest.Mock).mock.calls;
  499. const yAxisFormatter =
  500. calls[calls.length - 1][0].option.yAxis.axisLabel.formatter;
  501. expect(yAxisFormatter(123)).toEqual('123');
  502. });
  503. it('does not allow sorting by transaction name when widget is using metrics', async function () {
  504. const eventsMock = MockApiClient.addMockResponse({
  505. url: '/organizations/org-slug/events/',
  506. body: {
  507. data: [
  508. {
  509. title: '/organizations/:orgId/dashboards/',
  510. id: '1',
  511. count: 1,
  512. },
  513. ],
  514. meta: {
  515. fields: {
  516. title: 'string',
  517. id: 'string',
  518. count: 1,
  519. },
  520. isMetricsData: true,
  521. },
  522. },
  523. });
  524. await renderModal({
  525. initialData: initialDataWithFlag,
  526. widget: mockWidget,
  527. seriesData: [],
  528. seriesResultsType: {'count()': 'duration'},
  529. });
  530. expect(eventsMock).toHaveBeenCalledTimes(1);
  531. expect(screen.getByText('title')).toBeInTheDocument();
  532. userEvent.click(screen.getByText('title'));
  533. expect(initialData.router.push).not.toHaveBeenCalledWith({
  534. query: {sort: ['-title']},
  535. });
  536. });
  537. });
  538. });
  539. describe('TopN Chart Widget', function () {
  540. let mockQuery, mockWidget;
  541. function mockEventsStats() {
  542. return MockApiClient.addMockResponse({
  543. url: '/organizations/org-slug/events-stats/',
  544. body: {
  545. data: [
  546. [[1646100000], [{count: 1}]],
  547. [[1646120000], [{count: 1}]],
  548. ],
  549. start: 1646100000,
  550. end: 1646120000,
  551. isMetricsData: false,
  552. },
  553. });
  554. }
  555. const eventsMockData = [
  556. {
  557. 'error.type': ['Test Error 1a', 'Test Error 1b', 'Test Error 1c'],
  558. count: 10,
  559. },
  560. {
  561. 'error.type': ['Test Error 2'],
  562. count: 6,
  563. },
  564. {
  565. 'error.type': ['Test Error 3'],
  566. count: 5,
  567. },
  568. {
  569. 'error.type': ['Test Error 4'],
  570. count: 4,
  571. },
  572. {
  573. 'error.type': ['Test Error 5'],
  574. count: 3,
  575. },
  576. {
  577. 'error.type': ['Test Error 6'],
  578. count: 2,
  579. },
  580. ];
  581. function mockEventsv2() {
  582. return MockApiClient.addMockResponse({
  583. url: '/organizations/org-slug/eventsv2/',
  584. match: [MockApiClient.matchQuery({cursor: undefined})],
  585. headers: {
  586. Link:
  587. '<http://localhost/api/0/organizations/org-slug/eventsv2/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1",' +
  588. '<http://localhost/api/0/organizations/org-slug/eventsv2/?cursor=0:10:0>; rel="next"; results="true"; cursor="0:10:0"',
  589. },
  590. body: {
  591. data: eventsMockData,
  592. meta: {
  593. 'error.type': 'array',
  594. count: 'integer',
  595. },
  596. },
  597. });
  598. }
  599. function mockEvents() {
  600. return MockApiClient.addMockResponse({
  601. url: '/organizations/org-slug/events/',
  602. match: [MockApiClient.matchQuery({cursor: undefined})],
  603. headers: {
  604. Link:
  605. '<http://localhost/api/0/organizations/org-slug/eventsv2/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1",' +
  606. '<http://localhost/api/0/organizations/org-slug/eventsv2/?cursor=0:10:0>; rel="next"; results="true"; cursor="0:10:0"',
  607. },
  608. body: {
  609. data: eventsMockData,
  610. meta: {
  611. fields: {
  612. 'error.type': 'array',
  613. count: 'integer',
  614. },
  615. },
  616. },
  617. });
  618. }
  619. beforeEach(function () {
  620. mockQuery = {
  621. conditions: 'title:/organizations/:orgId/performance/summary/',
  622. fields: ['error.type', 'count()'],
  623. aggregates: ['count()'],
  624. columns: ['error.type'],
  625. id: '1',
  626. name: 'Query Name',
  627. orderby: '',
  628. };
  629. mockWidget = {
  630. title: 'Test Widget',
  631. displayType: DisplayType.TOP_N,
  632. interval: '5m',
  633. queries: [mockQuery],
  634. widgetType: WidgetType.DISCOVER,
  635. };
  636. MockApiClient.addMockResponse({
  637. url: '/organizations/org-slug/eventsv2/',
  638. match: [MockApiClient.matchQuery({cursor: '0:10:0'})],
  639. headers: {
  640. Link:
  641. '<http://localhost/api/0/organizations/org-slug/eventsv2/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1",' +
  642. '<http://localhost/api/0/organizations/org-slug/eventsv2/?cursor=0:20:0>; rel="next"; results="true"; cursor="0:20:0"',
  643. },
  644. body: {
  645. data: [
  646. {
  647. 'error.type': ['Next Page Test Error'],
  648. count: 1,
  649. },
  650. ],
  651. meta: {
  652. 'error.type': 'array',
  653. count: 'integer',
  654. },
  655. },
  656. });
  657. });
  658. describe('with eventsv2', function () {
  659. it('renders Discover topn chart widget viewer', async function () {
  660. mockEventsStats();
  661. mockEventsv2();
  662. const {container} = await renderModal({initialData, widget: mockWidget});
  663. expect(container).toSnapshot();
  664. });
  665. it('sorts table when a sortable column header is clicked', async function () {
  666. const eventsStatsMock = mockEventsStats();
  667. const eventsv2Mock = mockEventsv2();
  668. const {rerender} = await renderModal({initialData, widget: mockWidget});
  669. userEvent.click(screen.getByText('count()'));
  670. expect(initialData.router.push).toHaveBeenCalledWith({
  671. query: {sort: ['-count()']},
  672. });
  673. // Need to manually set the new router location and rerender to simulate the sortable column click
  674. initialData.router.location.query = {sort: ['-count()']};
  675. rerender(
  676. <WidgetViewerModal
  677. Header={stubEl}
  678. Footer={stubEl as ModalRenderProps['Footer']}
  679. Body={stubEl as ModalRenderProps['Body']}
  680. CloseButton={stubEl}
  681. closeModal={() => undefined}
  682. organization={initialData.organization}
  683. widget={mockWidget}
  684. onEdit={() => undefined}
  685. />
  686. );
  687. await waitForMetaToHaveBeenCalled();
  688. expect(eventsv2Mock).toHaveBeenCalledWith(
  689. '/organizations/org-slug/eventsv2/',
  690. expect.objectContaining({
  691. query: expect.objectContaining({sort: ['-count()']}),
  692. })
  693. );
  694. expect(eventsStatsMock).toHaveBeenCalledWith(
  695. '/organizations/org-slug/events-stats/',
  696. expect.objectContaining({
  697. query: expect.objectContaining({orderby: '-count()'}),
  698. })
  699. );
  700. });
  701. it('renders pagination buttons', async function () {
  702. mockEventsStats();
  703. mockEventsv2();
  704. await renderModal({initialData, widget: mockWidget});
  705. expect(screen.getByRole('button', {name: 'Previous'})).toBeInTheDocument();
  706. expect(screen.getByRole('button', {name: 'Next'})).toBeInTheDocument();
  707. });
  708. it('does not render pagination buttons', async function () {
  709. mockEventsStats();
  710. mockEventsv2();
  711. MockApiClient.addMockResponse({
  712. url: '/organizations/org-slug/eventsv2/',
  713. headers: {
  714. Link:
  715. '<http://localhost/api/0/organizations/org-slug/eventsv2/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1",' +
  716. '<http://localhost/api/0/organizations/org-slug/eventsv2/?cursor=0:20:0>; rel="next"; results="false"; cursor="0:20:0"',
  717. },
  718. body: {
  719. data: [
  720. {
  721. 'error.type': ['No Pagination'],
  722. count: 1,
  723. },
  724. ],
  725. meta: {
  726. 'error.type': 'array',
  727. count: 'integer',
  728. },
  729. },
  730. });
  731. await renderModal({initialData, widget: mockWidget});
  732. expect(
  733. screen.queryByRole('button', {name: 'Previous'})
  734. ).not.toBeInTheDocument();
  735. expect(screen.queryByRole('button', {name: 'Next'})).not.toBeInTheDocument();
  736. });
  737. it('paginates to the next page', async function () {
  738. mockEventsStats();
  739. mockEventsv2();
  740. const {rerender} = await renderModal({initialData, widget: mockWidget});
  741. expect(screen.getByText('Test Error 1c')).toBeInTheDocument();
  742. userEvent.click(screen.getByRole('button', {name: 'Next'}));
  743. expect(initialData.router.replace).toHaveBeenCalledWith(
  744. expect.objectContaining({
  745. query: {cursor: '0:10:0'},
  746. })
  747. );
  748. // Need to manually set the new router location and rerender to simulate the next page click
  749. initialData.router.location.query = {cursor: ['0:10:0']};
  750. rerender(
  751. <WidgetViewerModal
  752. Header={stubEl}
  753. Footer={stubEl as ModalRenderProps['Footer']}
  754. Body={stubEl as ModalRenderProps['Body']}
  755. CloseButton={stubEl}
  756. closeModal={() => undefined}
  757. organization={initialData.organization}
  758. widget={mockWidget}
  759. onEdit={() => undefined}
  760. />
  761. );
  762. await waitForMetaToHaveBeenCalled();
  763. expect(await screen.findByText('Next Page Test Error')).toBeInTheDocument();
  764. });
  765. it('uses provided seriesData and does not make an events-stats requests', async function () {
  766. const eventsStatsMock = mockEventsStats();
  767. mockEventsv2();
  768. await renderModal({initialData, widget: mockWidget, seriesData: []});
  769. expect(eventsStatsMock).not.toHaveBeenCalled();
  770. });
  771. it('makes events-stats requests when table is sorted', async function () {
  772. const eventsStatsMock = mockEventsStats();
  773. mockEventsv2();
  774. await renderModal({
  775. initialData,
  776. widget: mockWidget,
  777. seriesData: [],
  778. });
  779. expect(eventsStatsMock).not.toHaveBeenCalled();
  780. userEvent.click(screen.getByText('count()'));
  781. await waitForMetaToHaveBeenCalled();
  782. expect(eventsStatsMock).toHaveBeenCalledTimes(1);
  783. });
  784. it('renders widget chart minimap', async function () {
  785. mockEventsStats();
  786. mockEventsv2();
  787. initialData.organization.features.push('widget-viewer-modal-minimap');
  788. await renderModal({initialData, widget: mockWidget});
  789. expect(ReactEchartsCore).toHaveBeenLastCalledWith(
  790. expect.objectContaining({
  791. option: expect.objectContaining({
  792. dataZoom: expect.arrayContaining([
  793. expect.objectContaining({
  794. realtime: false,
  795. showDetail: false,
  796. end: 100,
  797. start: 0,
  798. }),
  799. ]),
  800. }),
  801. }),
  802. {}
  803. );
  804. });
  805. it('zooming on minimap updates location query and updates echart start and end values', async function () {
  806. mockEventsStats();
  807. mockEventsv2();
  808. initialData.organization.features.push('widget-viewer-modal-minimap');
  809. await renderModal({initialData, widget: mockWidget});
  810. const calls = (ReactEchartsCore as jest.Mock).mock.calls;
  811. act(() => {
  812. // Simulate dataZoom event on chart
  813. calls[calls.length - 1][0].onEvents.datazoom(
  814. {seriesStart: 1646100000000, seriesEnd: 1646120000000},
  815. {
  816. getModel: () => {
  817. return {
  818. _payload: {start: 30, end: 70},
  819. };
  820. },
  821. }
  822. );
  823. });
  824. expect(initialData.router.push).toHaveBeenCalledWith(
  825. expect.objectContaining({
  826. query: {
  827. viewerEnd: '2022-03-01T05:53:20',
  828. viewerStart: '2022-03-01T03:40:00',
  829. },
  830. })
  831. );
  832. await waitFor(() => {
  833. expect(ReactEchartsCore).toHaveBeenLastCalledWith(
  834. expect.objectContaining({
  835. option: expect.objectContaining({
  836. dataZoom: expect.arrayContaining([
  837. expect.objectContaining({
  838. realtime: false,
  839. showDetail: false,
  840. endValue: 1646114000000,
  841. startValue: 1646106000000,
  842. }),
  843. ]),
  844. }),
  845. }),
  846. {}
  847. );
  848. });
  849. });
  850. });
  851. describe('with events', function () {
  852. it('sorts table when a sortable column header is clicked', async function () {
  853. const eventsStatsMock = mockEventsStats();
  854. const eventsMock = mockEvents();
  855. const {rerender} = await renderModal({
  856. initialData: initialDataWithFlag,
  857. widget: mockWidget,
  858. });
  859. userEvent.click(screen.getByText('count()'));
  860. expect(initialDataWithFlag.router.push).toHaveBeenCalledWith({
  861. query: {sort: ['-count()']},
  862. });
  863. // Need to manually set the new router location and rerender to simulate the sortable column click
  864. initialDataWithFlag.router.location.query = {sort: ['-count()']};
  865. rerender(
  866. <WidgetViewerModal
  867. Header={stubEl}
  868. Footer={stubEl as ModalRenderProps['Footer']}
  869. Body={stubEl as ModalRenderProps['Body']}
  870. CloseButton={stubEl}
  871. closeModal={() => undefined}
  872. organization={initialDataWithFlag.organization}
  873. widget={mockWidget}
  874. onEdit={() => undefined}
  875. />
  876. );
  877. await waitForMetaToHaveBeenCalled();
  878. expect(eventsMock).toHaveBeenCalledWith(
  879. '/organizations/org-slug/events/',
  880. expect.objectContaining({
  881. query: expect.objectContaining({sort: ['-count()']}),
  882. })
  883. );
  884. expect(eventsStatsMock).toHaveBeenCalledWith(
  885. '/organizations/org-slug/events-stats/',
  886. expect.objectContaining({
  887. query: expect.objectContaining({orderby: '-count()'}),
  888. })
  889. );
  890. });
  891. });
  892. });
  893. describe('World Map Chart Widget', function () {
  894. let mockQuery, mockWidget;
  895. const eventsMockData = [
  896. {
  897. 'geo.country_code': 'ES',
  898. p75_measurements_lcp: 2000,
  899. },
  900. {
  901. 'geo.country_code': 'SK',
  902. p75_measurements_lcp: 3000,
  903. },
  904. {
  905. 'geo.country_code': 'CO',
  906. p75_measurements_lcp: 4000,
  907. },
  908. ];
  909. function mockEventsGeo() {
  910. return MockApiClient.addMockResponse({
  911. url: '/organizations/org-slug/events-geo/',
  912. body: {
  913. data: eventsMockData,
  914. meta: {
  915. 'geo.country_code': 'string',
  916. p75_measurements_lcp: 'duration',
  917. },
  918. },
  919. });
  920. }
  921. function mockEventsv2() {
  922. return MockApiClient.addMockResponse({
  923. url: '/organizations/org-slug/eventsv2/',
  924. body: {
  925. data: eventsMockData,
  926. meta: {
  927. 'geo.country_code': 'string',
  928. p75_measurements_lcp: 'duration',
  929. },
  930. },
  931. });
  932. }
  933. function mockEvents() {
  934. return MockApiClient.addMockResponse({
  935. url: '/organizations/org-slug/events/',
  936. body: {
  937. data: eventsMockData,
  938. meta: {
  939. fields: {
  940. 'geo.country_code': 'string',
  941. p75_measurements_lcp: 'duration',
  942. },
  943. },
  944. },
  945. });
  946. }
  947. beforeEach(function () {
  948. mockQuery = {
  949. conditions: 'title:/organizations/:orgId/performance/summary/',
  950. fields: ['p75(measurements.lcp)'],
  951. aggregates: ['p75(measurements.lcp)'],
  952. columns: [],
  953. id: '1',
  954. name: 'Query Name',
  955. orderby: '',
  956. };
  957. mockWidget = {
  958. title: 'Test Widget',
  959. displayType: DisplayType.WORLD_MAP,
  960. interval: '5m',
  961. queries: [mockQuery],
  962. widgetType: WidgetType.DISCOVER,
  963. };
  964. });
  965. describe('with eventsv2', function () {
  966. it('always queries geo.country_code in the table chart', async function () {
  967. const eventsv2Mock = mockEventsv2();
  968. mockEventsGeo();
  969. // Getting the following console.error from worldMapChart
  970. // "Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function."
  971. // Ignoring it
  972. jest.spyOn(console, 'error').mockImplementation();
  973. await act(async () => {
  974. renderModal({initialData, widget: mockWidget});
  975. await tick();
  976. });
  977. expect(eventsv2Mock).toHaveBeenCalledWith(
  978. '/organizations/org-slug/eventsv2/',
  979. expect.objectContaining({
  980. query: expect.objectContaining({
  981. field: ['geo.country_code', 'p75(measurements.lcp)'],
  982. }),
  983. })
  984. );
  985. expect(await screen.findByText('geo.country_code')).toBeInTheDocument();
  986. });
  987. it('renders Discover topn chart widget viewer', async function () {
  988. mockEventsv2();
  989. mockEventsGeo();
  990. const {container} = await renderModal({initialData, widget: mockWidget});
  991. expect(container).toSnapshot();
  992. });
  993. it('uses provided tableData and does not make an eventsv2 requests', async function () {
  994. const eventsGeoMock = mockEventsGeo();
  995. mockEventsv2();
  996. await renderModal({initialData, widget: mockWidget, tableData: []});
  997. expect(eventsGeoMock).not.toHaveBeenCalled();
  998. });
  999. });
  1000. describe('with events', function () {
  1001. it('always queries geo.country_code in the table chart', async function () {
  1002. const eventsMock = mockEvents();
  1003. mockEventsGeo();
  1004. await renderModal({initialData: initialDataWithFlag, widget: mockWidget});
  1005. expect(eventsMock).toHaveBeenCalledWith(
  1006. '/organizations/org-slug/events/',
  1007. expect.objectContaining({
  1008. query: expect.objectContaining({
  1009. field: ['geo.country_code', 'p75(measurements.lcp)'],
  1010. }),
  1011. })
  1012. );
  1013. expect(await screen.findByText('geo.country_code')).toBeInTheDocument();
  1014. });
  1015. });
  1016. });
  1017. describe('Table Widget', function () {
  1018. const mockQuery = {
  1019. conditions: 'title:/organizations/:orgId/performance/summary/',
  1020. fields: ['title', 'count()'],
  1021. aggregates: ['count()'],
  1022. columns: ['title'],
  1023. id: '1',
  1024. name: 'Query Name',
  1025. orderby: '',
  1026. };
  1027. const mockWidget = {
  1028. title: 'Test Widget',
  1029. displayType: DisplayType.TABLE,
  1030. interval: '5m',
  1031. queries: [mockQuery],
  1032. widgetType: WidgetType.DISCOVER,
  1033. };
  1034. function mockEventsv2() {
  1035. return MockApiClient.addMockResponse({
  1036. url: '/organizations/org-slug/eventsv2/',
  1037. body: {
  1038. data: [
  1039. {
  1040. title: '/organizations/:orgId/dashboards/',
  1041. id: '1',
  1042. count: 1,
  1043. },
  1044. ],
  1045. meta: {
  1046. title: 'string',
  1047. id: 'string',
  1048. count: 1,
  1049. isMetricsData: false,
  1050. },
  1051. },
  1052. });
  1053. }
  1054. function mockEvents() {
  1055. return MockApiClient.addMockResponse({
  1056. url: '/organizations/org-slug/events/',
  1057. body: {
  1058. data: [
  1059. {
  1060. title: '/organizations/:orgId/dashboards/',
  1061. id: '1',
  1062. count: 1,
  1063. },
  1064. ],
  1065. meta: {
  1066. fields: {
  1067. title: 'string',
  1068. id: 'string',
  1069. count: 1,
  1070. },
  1071. isMetricsData: false,
  1072. },
  1073. },
  1074. });
  1075. }
  1076. describe('with eventsv2', function () {
  1077. it('makes eventsv2 requests when table is paginated', async function () {
  1078. const eventsv2Mock = mockEventsv2();
  1079. await renderModal({
  1080. initialData,
  1081. widget: mockWidget,
  1082. tableData: [],
  1083. pageLinks:
  1084. '<https://sentry.io>; rel="previous"; results="false"; cursor="0:0:1", <https://sentry.io>; rel="next"; results="true"; cursor="0:20:0"',
  1085. });
  1086. expect(eventsv2Mock).not.toHaveBeenCalled();
  1087. userEvent.click(screen.getByLabelText('Next'));
  1088. await waitFor(() => {
  1089. expect(eventsv2Mock).toHaveBeenCalled();
  1090. });
  1091. });
  1092. });
  1093. describe('with events', function () {
  1094. it('makes events requests when table is paginated', async function () {
  1095. const eventsMock = mockEvents();
  1096. await renderModal({
  1097. initialData: initialDataWithFlag,
  1098. widget: mockWidget,
  1099. tableData: [],
  1100. pageLinks:
  1101. '<https://sentry.io>; rel="previous"; results="false"; cursor="0:0:1", <https://sentry.io>; rel="next"; results="true"; cursor="0:20:0"',
  1102. });
  1103. expect(eventsMock).not.toHaveBeenCalled();
  1104. userEvent.click(screen.getByLabelText('Next'));
  1105. await waitFor(() => {
  1106. expect(eventsMock).toHaveBeenCalled();
  1107. });
  1108. });
  1109. it('displays table data with units correctly', async function () {
  1110. const eventsMock = MockApiClient.addMockResponse({
  1111. url: '/organizations/org-slug/events/',
  1112. match: [MockApiClient.matchQuery({cursor: undefined})],
  1113. headers: {
  1114. Link:
  1115. '<http://localhost/api/0/organizations/org-slug/events/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1",' +
  1116. '<http://localhost/api/0/organizations/org-slug/events/?cursor=0:10:0>; rel="next"; results="true"; cursor="0:10:0"',
  1117. },
  1118. body: {
  1119. data: [
  1120. {
  1121. 'p75(measurements.custom.minute)': 94.87035966318831,
  1122. 'p95(measurements.custom.ratio)': 0.9881980140455187,
  1123. 'p75(measurements.custom.kibibyte)': 217.87035966318834,
  1124. },
  1125. ],
  1126. meta: {
  1127. fields: {
  1128. 'p75(measurements.custom.minute)': 'duration',
  1129. 'p95(measurements.custom.ratio)': 'percentage',
  1130. 'p75(measurements.custom.kibibyte)': 'size',
  1131. },
  1132. units: {
  1133. 'p75(measurements.custom.minute)': 'minute',
  1134. 'p95(measurements.custom.ratio)': null,
  1135. 'p75(measurements.custom.kibibyte)': 'kibibyte',
  1136. },
  1137. isMetricsData: true,
  1138. tips: {},
  1139. },
  1140. },
  1141. });
  1142. await renderModal({
  1143. initialData: initialDataWithFlag,
  1144. widget: {
  1145. title: 'Custom Widget',
  1146. displayType: 'table',
  1147. queries: [
  1148. {
  1149. fields: [
  1150. 'p75(measurements.custom.kibibyte)',
  1151. 'p75(measurements.custom.minute)',
  1152. 'p95(measurements.custom.ratio)',
  1153. ],
  1154. aggregates: [
  1155. 'p75(measurements.custom.kibibyte)',
  1156. 'p75(measurements.custom.minute)',
  1157. 'p95(measurements.custom.ratio)',
  1158. ],
  1159. columns: [],
  1160. orderby: '-p75(measurements.custom.kibibyte)',
  1161. },
  1162. ],
  1163. widgetType: 'discover',
  1164. },
  1165. });
  1166. await waitFor(() => {
  1167. expect(eventsMock).toHaveBeenCalled();
  1168. });
  1169. expect(screen.getByText('217.9 KiB')).toBeInTheDocument();
  1170. expect(screen.getByText('1.58hr')).toBeInTheDocument();
  1171. expect(screen.getByText('98.82%')).toBeInTheDocument();
  1172. });
  1173. });
  1174. });
  1175. });
  1176. describe('Issue Table Widget', function () {
  1177. let issuesMock;
  1178. const mockQuery = {
  1179. conditions: 'is:unresolved',
  1180. fields: ['events', 'status', 'title'],
  1181. columns: ['events', 'status', 'title'],
  1182. aggregates: [],
  1183. id: '1',
  1184. name: 'Query Name',
  1185. orderby: '',
  1186. };
  1187. const mockWidget = {
  1188. id: '1',
  1189. title: 'Issue Widget',
  1190. displayType: DisplayType.TABLE,
  1191. interval: '5m',
  1192. queries: [mockQuery],
  1193. widgetType: WidgetType.ISSUE,
  1194. };
  1195. beforeEach(function () {
  1196. MemberListStore.loadInitialData([]);
  1197. MockApiClient.addMockResponse({
  1198. url: '/organizations/org-slug/issues/',
  1199. method: 'GET',
  1200. match: [
  1201. MockApiClient.matchData({
  1202. cursor: '0:10:0',
  1203. }),
  1204. ],
  1205. headers: {
  1206. Link:
  1207. '<http://localhost/api/0/organizations/org-slug/issues/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1",' +
  1208. '<http://localhost/api/0/organizations/org-slug/issues/?cursor=0:20:0>; rel="next"; results="true"; cursor="0:20:0"',
  1209. },
  1210. body: [
  1211. {
  1212. id: '2',
  1213. title: 'Another Error: Failed',
  1214. project: {
  1215. id: '3',
  1216. },
  1217. status: 'unresolved',
  1218. lifetime: {count: 5},
  1219. count: 3,
  1220. userCount: 1,
  1221. },
  1222. ],
  1223. });
  1224. issuesMock = MockApiClient.addMockResponse({
  1225. url: '/organizations/org-slug/issues/',
  1226. method: 'GET',
  1227. match: [
  1228. MockApiClient.matchData({
  1229. cursor: undefined,
  1230. }),
  1231. ],
  1232. headers: {
  1233. Link:
  1234. '<http://localhost/api/0/organizations/org-slug/issues/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1",' +
  1235. '<http://localhost/api/0/organizations/org-slug/issues/?cursor=0:10:0>; rel="next"; results="true"; cursor="0:10:0"',
  1236. },
  1237. body: [
  1238. {
  1239. id: '1',
  1240. title: 'Error: Failed',
  1241. project: {
  1242. id: '3',
  1243. },
  1244. status: 'unresolved',
  1245. lifetime: {count: 10},
  1246. count: 6,
  1247. userCount: 3,
  1248. },
  1249. ],
  1250. });
  1251. });
  1252. it('renders widget title', async function () {
  1253. await renderModal({initialData, widget: mockWidget});
  1254. expect(screen.getByText('Issue Widget')).toBeInTheDocument();
  1255. });
  1256. it('renders Edit and Open buttons', async function () {
  1257. await renderModal({initialData, widget: mockWidget});
  1258. expect(screen.getByText('Edit Widget')).toBeInTheDocument();
  1259. expect(screen.getByText('Open in Issues')).toBeInTheDocument();
  1260. });
  1261. it('renders events, status, and title table columns', async function () {
  1262. await renderModal({initialData, widget: mockWidget});
  1263. expect(screen.getByText('title')).toBeInTheDocument();
  1264. expect(await screen.findByText('Error: Failed')).toBeInTheDocument();
  1265. expect(screen.getByText('events')).toBeInTheDocument();
  1266. expect(screen.getByText('6')).toBeInTheDocument();
  1267. expect(screen.getByText('status')).toBeInTheDocument();
  1268. expect(screen.getByText('unresolved')).toBeInTheDocument();
  1269. });
  1270. it('renders Issue table widget viewer', async function () {
  1271. const {container} = await renderModal({initialData, widget: mockWidget});
  1272. await screen.findByText('Error: Failed');
  1273. expect(container).toSnapshot();
  1274. });
  1275. it('redirects user to Issues when clicking Open in Issues', async function () {
  1276. await renderModal({initialData, widget: mockWidget});
  1277. userEvent.click(screen.getByText('Open in Issues'));
  1278. expect(initialData.router.push).toHaveBeenCalledWith(
  1279. '/organizations/org-slug/issues/?environment=prod&environment=dev&project=1&project=2&query=is%3Aunresolved&sort=&statsPeriod=24h'
  1280. );
  1281. });
  1282. it('sorts table when a sortable column header is clicked', async function () {
  1283. const {rerender} = await renderModal({initialData, widget: mockWidget});
  1284. userEvent.click(screen.getByText('events'));
  1285. expect(initialData.router.push).toHaveBeenCalledWith({
  1286. query: {sort: 'freq'},
  1287. });
  1288. // Need to manually set the new router location and rerender to simulate the sortable column click
  1289. initialData.router.location.query = {sort: ['freq']};
  1290. rerender(
  1291. <WidgetViewerModal
  1292. Header={stubEl}
  1293. Footer={stubEl as ModalRenderProps['Footer']}
  1294. Body={stubEl as ModalRenderProps['Body']}
  1295. CloseButton={stubEl}
  1296. closeModal={() => undefined}
  1297. organization={initialData.organization}
  1298. widget={mockWidget}
  1299. onEdit={() => undefined}
  1300. />
  1301. );
  1302. expect(issuesMock).toHaveBeenCalledWith(
  1303. '/organizations/org-slug/issues/',
  1304. expect.objectContaining({
  1305. data: {
  1306. cursor: undefined,
  1307. environment: ['prod', 'dev'],
  1308. expand: ['owners'],
  1309. limit: 20,
  1310. project: [1, 2],
  1311. query: 'is:unresolved',
  1312. sort: 'date',
  1313. statsPeriod: '24h',
  1314. },
  1315. })
  1316. );
  1317. });
  1318. it('renders pagination buttons', async function () {
  1319. await renderModal({initialData, widget: mockWidget});
  1320. expect(await screen.findByRole('button', {name: 'Previous'})).toBeInTheDocument();
  1321. expect(screen.getByRole('button', {name: 'Next'})).toBeInTheDocument();
  1322. });
  1323. it('paginates to the next page', async function () {
  1324. const {rerender} = await renderModal({initialData, widget: mockWidget});
  1325. expect(await screen.findByText('Error: Failed')).toBeInTheDocument();
  1326. userEvent.click(screen.getByRole('button', {name: 'Next'}));
  1327. expect(issuesMock).toHaveBeenCalledTimes(1);
  1328. expect(initialData.router.replace).toHaveBeenCalledWith(
  1329. expect.objectContaining({
  1330. query: {cursor: '0:10:0', page: 1},
  1331. })
  1332. );
  1333. // Need to manually set the new router location and rerender to simulate the next page click
  1334. initialData.router.location.query = {cursor: ['0:10:0']};
  1335. rerender(
  1336. <WidgetViewerModal
  1337. Header={stubEl}
  1338. Footer={stubEl as ModalRenderProps['Footer']}
  1339. Body={stubEl as ModalRenderProps['Body']}
  1340. CloseButton={stubEl}
  1341. closeModal={() => undefined}
  1342. organization={initialData.organization}
  1343. widget={mockWidget}
  1344. onEdit={() => undefined}
  1345. />
  1346. );
  1347. expect(await screen.findByText('Another Error: Failed')).toBeInTheDocument();
  1348. });
  1349. it('displays with correct table column widths', async function () {
  1350. initialData.router.location.query = {width: ['-1', '-1', '575']};
  1351. await renderModal({initialData, widget: mockWidget});
  1352. expect(screen.getByTestId('grid-editable')).toHaveStyle({
  1353. 'grid-template-columns':
  1354. ' minmax(90px, auto) minmax(90px, auto) minmax(575px, auto)',
  1355. });
  1356. });
  1357. it('uses provided tableData and does not make an issues requests', async function () {
  1358. await renderModal({initialData, widget: mockWidget, tableData: []});
  1359. expect(issuesMock).not.toHaveBeenCalled();
  1360. });
  1361. it('makes issues requests when table is sorted', async function () {
  1362. await renderModal({
  1363. initialData,
  1364. widget: mockWidget,
  1365. tableData: [],
  1366. });
  1367. expect(issuesMock).not.toHaveBeenCalled();
  1368. userEvent.click(screen.getByText('events'));
  1369. await waitFor(() => {
  1370. expect(issuesMock).toHaveBeenCalled();
  1371. });
  1372. });
  1373. });
  1374. describe('Release Health Widgets', function () {
  1375. let metricsMock;
  1376. const mockQuery = {
  1377. conditions: '',
  1378. fields: [`sum(session)`],
  1379. columns: [],
  1380. aggregates: [],
  1381. id: '1',
  1382. name: 'Query Name',
  1383. orderby: '',
  1384. };
  1385. const mockWidget = {
  1386. id: '1',
  1387. title: 'Release Widget',
  1388. displayType: DisplayType.LINE,
  1389. interval: '5m',
  1390. queries: [mockQuery],
  1391. widgetType: WidgetType.RELEASE,
  1392. };
  1393. beforeEach(function () {
  1394. metricsMock = MockApiClient.addMockResponse({
  1395. url: '/organizations/org-slug/metrics/data/',
  1396. body: TestStubs.MetricsTotalCountByReleaseIn24h(),
  1397. headers: {
  1398. link:
  1399. '<http://localhost/api/0/organizations/org-slug/metrics/data/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1",' +
  1400. '<http://localhost/api/0/organizations/org-slug/metrics/data/?cursor=0:10:0>; rel="next"; results="true"; cursor="0:10:0"',
  1401. },
  1402. });
  1403. });
  1404. it('does a sessions query', async function () {
  1405. jest.useFakeTimers().setSystemTime(new Date('2022-08-02'));
  1406. await renderModal({initialData, widget: mockWidget});
  1407. expect(metricsMock).toHaveBeenCalled();
  1408. });
  1409. it('renders widget title', async function () {
  1410. await renderModal({initialData, widget: mockWidget});
  1411. expect(screen.getByText('Release Widget')).toBeInTheDocument();
  1412. });
  1413. it('renders Edit and Open in Releases buttons', async function () {
  1414. await renderModal({initialData, widget: mockWidget});
  1415. expect(screen.getByText('Edit Widget')).toBeInTheDocument();
  1416. expect(screen.getByText('Open in Releases')).toBeInTheDocument();
  1417. });
  1418. it('Open in Releases button redirects browser', async function () {
  1419. await renderModal({initialData, widget: mockWidget});
  1420. userEvent.click(screen.getByText('Open in Releases'));
  1421. expect(initialData.router.push).toHaveBeenCalledWith(
  1422. '/organizations/org-slug/releases/?environment=prod&environment=dev&project=1&project=2&statsPeriod=24h'
  1423. );
  1424. });
  1425. it('renders table header and body', async function () {
  1426. await renderModal({initialData, widget: mockWidget});
  1427. expect(screen.getByText('release')).toBeInTheDocument();
  1428. expect(await screen.findByText('e102abb2c46e')).toBeInTheDocument();
  1429. expect(screen.getByText('sum(session)')).toBeInTheDocument();
  1430. expect(screen.getByText('6.3k')).toBeInTheDocument();
  1431. });
  1432. it('renders Release widget viewer', async function () {
  1433. const {container} = await renderModal({initialData, widget: mockWidget});
  1434. expect(await screen.findByText('e102abb2c46e')).toBeInTheDocument();
  1435. expect(container).toSnapshot();
  1436. });
  1437. it('renders pagination buttons', async function () {
  1438. await renderModal({
  1439. initialData,
  1440. widget: mockWidget,
  1441. });
  1442. expect(await screen.findByRole('button', {name: 'Previous'})).toBeInTheDocument();
  1443. expect(screen.getByRole('button', {name: 'Next'})).toBeInTheDocument();
  1444. });
  1445. it('does not render pagination buttons when sorting by release', async function () {
  1446. await renderModal({
  1447. initialData,
  1448. widget: {...mockWidget, queries: [{...mockQuery, orderby: 'release'}]},
  1449. });
  1450. expect(screen.queryByRole('button', {name: 'Previous'})).not.toBeInTheDocument();
  1451. expect(screen.queryByRole('button', {name: 'Next'})).not.toBeInTheDocument();
  1452. });
  1453. it('makes a new sessions request after sorting by a table column', async function () {
  1454. const {rerender} = await renderModal({
  1455. initialData,
  1456. widget: mockWidget,
  1457. tableData: [],
  1458. seriesData: [],
  1459. });
  1460. expect(metricsMock).toHaveBeenCalledTimes(1);
  1461. userEvent.click(screen.getByText(`sum(session)`));
  1462. expect(initialData.router.push).toHaveBeenCalledWith({
  1463. query: {sort: '-sum(session)'},
  1464. });
  1465. // Need to manually set the new router location and rerender to simulate the sortable column click
  1466. initialData.router.location.query = {sort: '-sum(session)'};
  1467. rerender(
  1468. <WidgetViewerModal
  1469. Header={stubEl}
  1470. Footer={stubEl as ModalRenderProps['Footer']}
  1471. Body={stubEl as ModalRenderProps['Body']}
  1472. CloseButton={stubEl}
  1473. closeModal={() => undefined}
  1474. organization={initialData.organization}
  1475. widget={mockWidget}
  1476. onEdit={() => undefined}
  1477. seriesData={[]}
  1478. tableData={[]}
  1479. />
  1480. );
  1481. await waitFor(() => {
  1482. expect(metricsMock).toHaveBeenCalledTimes(2);
  1483. });
  1484. });
  1485. });
  1486. });