widgetCard.spec.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612
  1. import {initializeOrg} from 'sentry-test/initializeOrg';
  2. import {mountGlobalModal} from 'sentry-test/modal';
  3. import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
  4. import * as modal from 'sentry/actionCreators/modal';
  5. import {Client} from 'sentry/api';
  6. import SimpleTableChart from 'sentry/components/charts/simpleTableChart';
  7. import {DisplayType, Widget, WidgetType} from 'sentry/views/dashboardsV2/types';
  8. import WidgetCard from 'sentry/views/dashboardsV2/widgetCard';
  9. import MetricsWidgetQueries from 'sentry/views/dashboardsV2/widgetCard/metricsWidgetQueries';
  10. jest.mock('sentry/components/charts/simpleTableChart');
  11. jest.mock('sentry/views/dashboardsV2/widgetCard/metricsWidgetQueries');
  12. describe('Dashboards > WidgetCard', function () {
  13. const {router, organization, routerContext} = initializeOrg({
  14. organization: TestStubs.Organization({
  15. features: ['dashboards-edit', 'discover-basic'],
  16. projects: [TestStubs.Project()],
  17. }),
  18. router: {orgId: 'orgId'},
  19. } as Parameters<typeof initializeOrg>[0]);
  20. const multipleQueryWidget: Widget = {
  21. title: 'Errors',
  22. interval: '5m',
  23. displayType: DisplayType.LINE,
  24. widgetType: WidgetType.DISCOVER,
  25. queries: [
  26. {
  27. conditions: 'event.type:error',
  28. fields: ['count()', 'failure_count()'],
  29. aggregates: ['count()', 'failure_count()'],
  30. columns: [],
  31. name: 'errors',
  32. orderby: '',
  33. },
  34. {
  35. conditions: 'event.type:default',
  36. fields: ['count()', 'failure_count()'],
  37. aggregates: ['count()', 'failure_count()'],
  38. columns: [],
  39. name: 'default',
  40. orderby: '',
  41. },
  42. ],
  43. };
  44. const selection = {
  45. projects: [1],
  46. environments: ['prod'],
  47. datetime: {
  48. period: '14d',
  49. start: null,
  50. end: null,
  51. utc: false,
  52. },
  53. };
  54. const api = new Client();
  55. let eventsMock;
  56. beforeEach(function () {
  57. MockApiClient.addMockResponse({
  58. url: '/organizations/org-slug/events-stats/',
  59. body: [],
  60. });
  61. MockApiClient.addMockResponse({
  62. url: '/organizations/org-slug/events-geo/',
  63. body: [],
  64. });
  65. eventsMock = MockApiClient.addMockResponse({
  66. url: '/organizations/org-slug/eventsv2/',
  67. body: {
  68. meta: {title: 'string'},
  69. data: [{title: 'title'}],
  70. },
  71. });
  72. });
  73. afterEach(function () {
  74. MockApiClient.clearMockResponses();
  75. });
  76. it('renders with Open in Discover button and opens the Query Selector Modal when clicked', async function () {
  77. const spy = jest.spyOn(modal, 'openDashboardWidgetQuerySelectorModal');
  78. render(
  79. <WidgetCard
  80. api={api}
  81. organization={organization}
  82. widget={multipleQueryWidget}
  83. selection={selection}
  84. isEditing={false}
  85. onDelete={() => undefined}
  86. onEdit={() => undefined}
  87. onDuplicate={() => undefined}
  88. renderErrorMessage={() => undefined}
  89. isSorting={false}
  90. currentWidgetDragging={false}
  91. showContextMenu
  92. widgetLimitReached={false}
  93. />
  94. );
  95. userEvent.click(await screen.findByLabelText('Widget actions'));
  96. expect(screen.getByText('Open in Discover')).toBeInTheDocument();
  97. userEvent.click(screen.getByText('Open in Discover'));
  98. expect(spy).toHaveBeenCalledWith({
  99. organization,
  100. widget: multipleQueryWidget,
  101. });
  102. });
  103. it('renders with Open in Discover button and opens in Discover when clicked', async function () {
  104. render(
  105. <WidgetCard
  106. api={api}
  107. organization={organization}
  108. widget={{...multipleQueryWidget, queries: [multipleQueryWidget.queries[0]]}}
  109. selection={selection}
  110. isEditing={false}
  111. onDelete={() => undefined}
  112. onEdit={() => undefined}
  113. onDuplicate={() => undefined}
  114. renderErrorMessage={() => undefined}
  115. isSorting={false}
  116. currentWidgetDragging={false}
  117. showContextMenu
  118. widgetLimitReached={false}
  119. />,
  120. {context: routerContext}
  121. );
  122. userEvent.click(await screen.findByLabelText('Widget actions'));
  123. expect(screen.getByText('Open in Discover')).toBeInTheDocument();
  124. userEvent.click(screen.getByText('Open in Discover'));
  125. expect(router.push).toHaveBeenCalledWith(
  126. '/organizations/org-slug/discover/results/?environment=prod&field=count%28%29&field=failure_count%28%29&name=Errors&project=1&query=event.type%3Aerror&statsPeriod=14d&yAxis=count%28%29&yAxis=failure_count%28%29'
  127. );
  128. });
  129. it('Opens in Discover with World Map', async function () {
  130. render(
  131. <WidgetCard
  132. api={api}
  133. organization={organization}
  134. widget={{
  135. ...multipleQueryWidget,
  136. displayType: DisplayType.WORLD_MAP,
  137. queries: [
  138. {
  139. ...multipleQueryWidget.queries[0],
  140. fields: ['count()'],
  141. aggregates: ['count()'],
  142. columns: [],
  143. },
  144. ],
  145. }}
  146. selection={selection}
  147. isEditing={false}
  148. onDelete={() => undefined}
  149. onEdit={() => undefined}
  150. onDuplicate={() => undefined}
  151. renderErrorMessage={() => undefined}
  152. isSorting={false}
  153. currentWidgetDragging={false}
  154. showContextMenu
  155. widgetLimitReached={false}
  156. />,
  157. {context: routerContext}
  158. );
  159. userEvent.click(await screen.findByLabelText('Widget actions'));
  160. expect(screen.getByText('Open in Discover')).toBeInTheDocument();
  161. userEvent.click(screen.getByText('Open in Discover'));
  162. expect(router.push).toHaveBeenCalledWith(
  163. '/organizations/org-slug/discover/results/?display=worldmap&environment=prod&field=geo.country_code&field=count%28%29&name=Errors&project=1&query=event.type%3Aerror%20has%3Ageo.country_code&statsPeriod=14d&yAxis=count%28%29'
  164. );
  165. });
  166. it('Opens in Discover with prepended fields pulled from equations', async function () {
  167. render(
  168. <WidgetCard
  169. api={api}
  170. organization={organization}
  171. widget={{
  172. ...multipleQueryWidget,
  173. queries: [
  174. {
  175. ...multipleQueryWidget.queries[0],
  176. fields: [
  177. 'equation|(count() + failure_count()) / count_if(transaction.duration,equals,300)',
  178. ],
  179. columns: [],
  180. aggregates: [
  181. 'equation|(count() + failure_count()) / count_if(transaction.duration,equals,300)',
  182. ],
  183. },
  184. ],
  185. }}
  186. selection={selection}
  187. isEditing={false}
  188. onDelete={() => undefined}
  189. onEdit={() => undefined}
  190. onDuplicate={() => undefined}
  191. renderErrorMessage={() => undefined}
  192. isSorting={false}
  193. currentWidgetDragging={false}
  194. showContextMenu
  195. widgetLimitReached={false}
  196. />,
  197. {context: routerContext}
  198. );
  199. userEvent.click(await screen.findByLabelText('Widget actions'));
  200. expect(screen.getByText('Open in Discover')).toBeInTheDocument();
  201. userEvent.click(screen.getByText('Open in Discover'));
  202. expect(router.push).toHaveBeenCalledWith(
  203. '/organizations/org-slug/discover/results/?environment=prod&field=count_if%28transaction.duration%2Cequals%2C300%29&field=failure_count%28%29&field=count%28%29&field=equation%7C%28count%28%29%20%2B%20failure_count%28%29%29%20%2F%20count_if%28transaction.duration%2Cequals%2C300%29&name=Errors&project=1&query=event.type%3Aerror&statsPeriod=14d&yAxis=equation%7C%28count%28%29%20%2B%20failure_count%28%29%29%20%2F%20count_if%28transaction.duration%2Cequals%2C300%29'
  204. );
  205. });
  206. it('Opens in Discover with Top N', async function () {
  207. render(
  208. <WidgetCard
  209. api={api}
  210. organization={organization}
  211. widget={{
  212. ...multipleQueryWidget,
  213. displayType: DisplayType.TOP_N,
  214. queries: [
  215. {
  216. ...multipleQueryWidget.queries[0],
  217. fields: ['transaction', 'count()'],
  218. columns: ['transaction'],
  219. aggregates: ['count()'],
  220. },
  221. ],
  222. }}
  223. selection={selection}
  224. isEditing={false}
  225. onDelete={() => undefined}
  226. onEdit={() => undefined}
  227. onDuplicate={() => undefined}
  228. renderErrorMessage={() => undefined}
  229. isSorting={false}
  230. currentWidgetDragging={false}
  231. showContextMenu
  232. widgetLimitReached={false}
  233. />,
  234. {context: routerContext}
  235. );
  236. userEvent.click(await screen.findByLabelText('Widget actions'));
  237. expect(screen.getByText('Open in Discover')).toBeInTheDocument();
  238. userEvent.click(screen.getByText('Open in Discover'));
  239. expect(router.push).toHaveBeenCalledWith(
  240. '/organizations/org-slug/discover/results/?display=top5&environment=prod&field=transaction&field=count%28%29&name=Errors&project=1&query=event.type%3Aerror&statsPeriod=14d&yAxis=count%28%29'
  241. );
  242. });
  243. it('calls onDuplicate when Duplicate Widget is clicked', async function () {
  244. const mock = jest.fn();
  245. render(
  246. <WidgetCard
  247. api={api}
  248. organization={organization}
  249. widget={{
  250. ...multipleQueryWidget,
  251. displayType: DisplayType.WORLD_MAP,
  252. queries: [{...multipleQueryWidget.queries[0], fields: ['count()']}],
  253. }}
  254. selection={selection}
  255. isEditing={false}
  256. onDelete={() => undefined}
  257. onEdit={() => undefined}
  258. onDuplicate={mock}
  259. renderErrorMessage={() => undefined}
  260. isSorting={false}
  261. currentWidgetDragging={false}
  262. showContextMenu
  263. widgetLimitReached={false}
  264. />
  265. );
  266. userEvent.click(await screen.findByLabelText('Widget actions'));
  267. expect(screen.getByText('Duplicate Widget')).toBeInTheDocument();
  268. userEvent.click(screen.getByText('Duplicate Widget'));
  269. expect(mock).toHaveBeenCalledTimes(1);
  270. });
  271. it('does not add duplicate widgets if max widget is reached', async function () {
  272. const mock = jest.fn();
  273. render(
  274. <WidgetCard
  275. api={api}
  276. organization={organization}
  277. widget={{
  278. ...multipleQueryWidget,
  279. displayType: DisplayType.WORLD_MAP,
  280. queries: [{...multipleQueryWidget.queries[0], fields: ['count()']}],
  281. }}
  282. selection={selection}
  283. isEditing={false}
  284. onDelete={() => undefined}
  285. onEdit={() => undefined}
  286. onDuplicate={mock}
  287. renderErrorMessage={() => undefined}
  288. isSorting={false}
  289. currentWidgetDragging={false}
  290. showContextMenu
  291. widgetLimitReached
  292. />
  293. );
  294. userEvent.click(await screen.findByLabelText('Widget actions'));
  295. expect(screen.getByText('Duplicate Widget')).toBeInTheDocument();
  296. userEvent.click(screen.getByText('Duplicate Widget'));
  297. expect(mock).toHaveBeenCalledTimes(0);
  298. });
  299. it('calls onEdit when Edit Widget is clicked', async function () {
  300. const mock = jest.fn();
  301. render(
  302. <WidgetCard
  303. api={api}
  304. organization={organization}
  305. widget={{
  306. ...multipleQueryWidget,
  307. displayType: DisplayType.WORLD_MAP,
  308. queries: [{...multipleQueryWidget.queries[0], fields: ['count()']}],
  309. }}
  310. selection={selection}
  311. isEditing={false}
  312. onDelete={() => undefined}
  313. onEdit={mock}
  314. onDuplicate={() => undefined}
  315. renderErrorMessage={() => undefined}
  316. isSorting={false}
  317. currentWidgetDragging={false}
  318. showContextMenu
  319. widgetLimitReached={false}
  320. />
  321. );
  322. userEvent.click(await screen.findByLabelText('Widget actions'));
  323. expect(screen.getByText('Edit Widget')).toBeInTheDocument();
  324. userEvent.click(screen.getByText('Edit Widget'));
  325. expect(mock).toHaveBeenCalledTimes(1);
  326. });
  327. it('renders delete widget option', async function () {
  328. const mock = jest.fn();
  329. render(
  330. <WidgetCard
  331. api={api}
  332. organization={organization}
  333. widget={{
  334. ...multipleQueryWidget,
  335. displayType: DisplayType.WORLD_MAP,
  336. queries: [{...multipleQueryWidget.queries[0], fields: ['count()']}],
  337. }}
  338. selection={selection}
  339. isEditing={false}
  340. onDelete={mock}
  341. onEdit={() => undefined}
  342. onDuplicate={() => undefined}
  343. renderErrorMessage={() => undefined}
  344. isSorting={false}
  345. currentWidgetDragging={false}
  346. showContextMenu
  347. widgetLimitReached={false}
  348. />
  349. );
  350. userEvent.click(await screen.findByLabelText('Widget actions'));
  351. expect(screen.getByText('Delete Widget')).toBeInTheDocument();
  352. userEvent.click(screen.getByText('Delete Widget'));
  353. // Confirm Modal
  354. await mountGlobalModal();
  355. await screen.findByRole('dialog');
  356. userEvent.click(screen.getByTestId('confirm-button'));
  357. expect(mock).toHaveBeenCalled();
  358. });
  359. it('calls eventsV2 with a limit of 20 items', async function () {
  360. const mock = jest.fn();
  361. render(
  362. <WidgetCard
  363. api={api}
  364. organization={organization}
  365. widget={{
  366. ...multipleQueryWidget,
  367. displayType: DisplayType.TABLE,
  368. queries: [{...multipleQueryWidget.queries[0], fields: ['count()']}],
  369. }}
  370. selection={selection}
  371. isEditing={false}
  372. onDelete={mock}
  373. onEdit={() => undefined}
  374. onDuplicate={() => undefined}
  375. renderErrorMessage={() => undefined}
  376. isSorting={false}
  377. currentWidgetDragging={false}
  378. showContextMenu
  379. widgetLimitReached={false}
  380. tableItemLimit={20}
  381. />
  382. );
  383. await tick();
  384. expect(eventsMock).toHaveBeenCalledWith(
  385. '/organizations/org-slug/eventsv2/',
  386. expect.objectContaining({
  387. query: expect.objectContaining({
  388. per_page: 20,
  389. }),
  390. })
  391. );
  392. });
  393. it('calls eventsV2 with a default limit of 5 items', async function () {
  394. const mock = jest.fn();
  395. render(
  396. <WidgetCard
  397. api={api}
  398. organization={organization}
  399. widget={{
  400. ...multipleQueryWidget,
  401. displayType: DisplayType.TABLE,
  402. queries: [{...multipleQueryWidget.queries[0], fields: ['count()']}],
  403. }}
  404. selection={selection}
  405. isEditing={false}
  406. onDelete={mock}
  407. onEdit={() => undefined}
  408. onDuplicate={() => undefined}
  409. renderErrorMessage={() => undefined}
  410. isSorting={false}
  411. currentWidgetDragging={false}
  412. showContextMenu
  413. widgetLimitReached={false}
  414. />
  415. );
  416. await tick();
  417. expect(eventsMock).toHaveBeenCalledWith(
  418. '/organizations/org-slug/eventsv2/',
  419. expect.objectContaining({
  420. query: expect.objectContaining({
  421. per_page: 5,
  422. }),
  423. })
  424. );
  425. });
  426. it('has sticky table headers', async function () {
  427. const tableWidget: Widget = {
  428. title: 'Table Widget',
  429. interval: '5m',
  430. displayType: DisplayType.TABLE,
  431. widgetType: WidgetType.DISCOVER,
  432. queries: [
  433. {
  434. conditions: '',
  435. fields: ['transaction', 'count()'],
  436. columns: ['transaction'],
  437. aggregates: ['count()'],
  438. name: 'Table',
  439. orderby: '',
  440. },
  441. ],
  442. };
  443. render(
  444. <WidgetCard
  445. api={api}
  446. organization={organization}
  447. widget={tableWidget}
  448. selection={selection}
  449. isEditing={false}
  450. onDelete={() => undefined}
  451. onEdit={() => undefined}
  452. onDuplicate={() => undefined}
  453. renderErrorMessage={() => undefined}
  454. isSorting={false}
  455. currentWidgetDragging={false}
  456. showContextMenu
  457. widgetLimitReached={false}
  458. tableItemLimit={20}
  459. />
  460. );
  461. await tick();
  462. expect(SimpleTableChart).toHaveBeenCalledWith(
  463. expect.objectContaining({stickyHeaders: true}),
  464. expect.anything()
  465. );
  466. });
  467. it('calls metrics queries', function () {
  468. const widget: Widget = {
  469. title: 'Metrics Widget',
  470. interval: '5m',
  471. displayType: DisplayType.LINE,
  472. widgetType: WidgetType.METRICS,
  473. queries: [],
  474. };
  475. render(
  476. <WidgetCard
  477. api={api}
  478. organization={organization}
  479. widget={widget}
  480. selection={selection}
  481. isEditing={false}
  482. onDelete={() => undefined}
  483. onEdit={() => undefined}
  484. onDuplicate={() => undefined}
  485. renderErrorMessage={() => undefined}
  486. isSorting={false}
  487. currentWidgetDragging={false}
  488. showContextMenu
  489. widgetLimitReached={false}
  490. tableItemLimit={20}
  491. />
  492. );
  493. expect(MetricsWidgetQueries).toHaveBeenCalledTimes(1);
  494. });
  495. it('opens the widget viewer modal when a widget has no id', async () => {
  496. const widget: Widget = {
  497. title: 'Widget',
  498. interval: '5m',
  499. displayType: DisplayType.LINE,
  500. widgetType: WidgetType.DISCOVER,
  501. queries: [],
  502. };
  503. render(
  504. <WidgetCard
  505. api={api}
  506. organization={organization}
  507. widget={widget}
  508. selection={selection}
  509. isEditing={false}
  510. onDelete={() => undefined}
  511. onEdit={() => undefined}
  512. onDuplicate={() => undefined}
  513. renderErrorMessage={() => undefined}
  514. isSorting={false}
  515. currentWidgetDragging={false}
  516. showContextMenu
  517. widgetLimitReached={false}
  518. showWidgetViewerButton
  519. index="10"
  520. isPreview
  521. />,
  522. {context: routerContext}
  523. );
  524. userEvent.click(await screen.findByLabelText('Open Widget Viewer'));
  525. expect(router.push).toHaveBeenCalledWith(
  526. expect.objectContaining({pathname: '/mock-pathname/widget/10/'})
  527. );
  528. });
  529. it('renders stored data disclaimer', async function () {
  530. MockApiClient.addMockResponse({
  531. url: '/organizations/org-slug/eventsv2/',
  532. body: {
  533. meta: {title: 'string', isMetricsData: false},
  534. data: [{title: 'title'}],
  535. },
  536. });
  537. render(
  538. <WidgetCard
  539. api={api}
  540. organization={{
  541. ...organization,
  542. features: [...organization.features, 'dashboards-mep'],
  543. }}
  544. widget={{
  545. ...multipleQueryWidget,
  546. displayType: DisplayType.TABLE,
  547. queries: [{...multipleQueryWidget.queries[0]}],
  548. }}
  549. selection={selection}
  550. isEditing={false}
  551. onDelete={() => undefined}
  552. onEdit={() => undefined}
  553. onDuplicate={() => undefined}
  554. renderErrorMessage={() => undefined}
  555. isSorting={false}
  556. currentWidgetDragging={false}
  557. showContextMenu
  558. widgetLimitReached={false}
  559. showStoredAlert
  560. />,
  561. {context: routerContext}
  562. );
  563. await waitFor(() => {
  564. // Badge in the widget header
  565. expect(screen.getByText('Stored')).toBeInTheDocument();
  566. });
  567. await waitFor(() => {
  568. expect(
  569. // Alert below the widget
  570. screen.getByText(/we've automatically adjusted your results/i)
  571. ).toBeInTheDocument();
  572. });
  573. });
  574. });