widgetViewerModal.spec.tsx 55 KB

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