widgetBuilderDataset.spec.tsx 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252
  1. import selectEvent from 'react-select-event';
  2. import {urlEncode} from '@sentry/utils';
  3. import {initializeOrg} from 'sentry-test/initializeOrg';
  4. import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
  5. import TagStore from 'sentry/stores/tagStore';
  6. import {
  7. DashboardDetails,
  8. DashboardWidgetSource,
  9. DisplayType,
  10. Widget,
  11. WidgetType,
  12. } from 'sentry/views/dashboardsV2/types';
  13. import WidgetBuilder, {WidgetBuilderProps} from 'sentry/views/dashboardsV2/widgetBuilder';
  14. const defaultOrgFeatures = [
  15. 'performance-view',
  16. 'new-widget-builder-experience-design',
  17. 'dashboards-edit',
  18. 'global-views',
  19. 'dashboards-mep',
  20. ];
  21. // Mocking worldMapChart to avoid act warnings
  22. jest.mock('sentry/components/charts/worldMapChart');
  23. function mockDashboard(dashboard: Partial<DashboardDetails>): DashboardDetails {
  24. return {
  25. id: '1',
  26. title: 'Dashboard',
  27. createdBy: undefined,
  28. dateCreated: '2020-01-01T00:00:00.000Z',
  29. widgets: [],
  30. projects: [],
  31. filters: {},
  32. ...dashboard,
  33. };
  34. }
  35. function renderTestComponent({
  36. dashboard,
  37. query,
  38. orgFeatures,
  39. onSave,
  40. params,
  41. }: {
  42. dashboard?: WidgetBuilderProps['dashboard'];
  43. onSave?: WidgetBuilderProps['onSave'];
  44. orgFeatures?: string[];
  45. params?: Partial<WidgetBuilderProps['params']>;
  46. query?: Record<string, any>;
  47. } = {}) {
  48. const {organization, router, routerContext} = initializeOrg({
  49. ...initializeOrg(),
  50. organization: {
  51. features: orgFeatures ?? defaultOrgFeatures,
  52. },
  53. router: {
  54. location: {
  55. query: {
  56. source: DashboardWidgetSource.DASHBOARDS,
  57. ...query,
  58. },
  59. },
  60. },
  61. });
  62. render(
  63. <WidgetBuilder
  64. route={{}}
  65. router={router}
  66. routes={router.routes}
  67. routeParams={router.params}
  68. location={router.location}
  69. dashboard={{
  70. id: 'new',
  71. title: 'Dashboard',
  72. createdBy: undefined,
  73. dateCreated: '2020-01-01T00:00:00.000Z',
  74. widgets: [],
  75. projects: [],
  76. filters: {},
  77. ...dashboard,
  78. }}
  79. onSave={onSave ?? jest.fn()}
  80. params={{
  81. orgId: organization.slug,
  82. dashboardId: dashboard?.id ?? 'new',
  83. ...params,
  84. }}
  85. />,
  86. {
  87. context: routerContext,
  88. organization,
  89. }
  90. );
  91. return {router};
  92. }
  93. describe('WidgetBuilder', function () {
  94. const untitledDashboard: DashboardDetails = {
  95. id: '1',
  96. title: 'Untitled Dashboard',
  97. createdBy: undefined,
  98. dateCreated: '2020-01-01T00:00:00.000Z',
  99. widgets: [],
  100. projects: [],
  101. filters: {},
  102. };
  103. const testDashboard: DashboardDetails = {
  104. id: '2',
  105. title: 'Test Dashboard',
  106. createdBy: undefined,
  107. dateCreated: '2020-01-01T00:00:00.000Z',
  108. widgets: [],
  109. projects: [],
  110. filters: {},
  111. };
  112. let eventsMock: jest.Mock | undefined;
  113. let sessionsDataMock: jest.Mock | undefined;
  114. let metricsDataMock: jest.Mock | undefined;
  115. let measurementsMetaMock: jest.Mock | undefined;
  116. beforeEach(function () {
  117. MockApiClient.addMockResponse({
  118. url: '/organizations/org-slug/dashboards/',
  119. body: [
  120. {...untitledDashboard, widgetDisplay: [DisplayType.TABLE]},
  121. {...testDashboard, widgetDisplay: [DisplayType.AREA]},
  122. ],
  123. });
  124. MockApiClient.addMockResponse({
  125. url: '/organizations/org-slug/dashboards/widgets/',
  126. method: 'POST',
  127. statusCode: 200,
  128. body: [],
  129. });
  130. MockApiClient.addMockResponse({
  131. url: '/organizations/org-slug/eventsv2/',
  132. method: 'GET',
  133. statusCode: 200,
  134. body: {
  135. meta: {},
  136. data: [],
  137. },
  138. });
  139. eventsMock = MockApiClient.addMockResponse({
  140. url: '/organizations/org-slug/events/',
  141. method: 'GET',
  142. statusCode: 200,
  143. body: {
  144. meta: {fields: {}},
  145. data: [],
  146. },
  147. });
  148. MockApiClient.addMockResponse({
  149. url: '/organizations/org-slug/projects/',
  150. method: 'GET',
  151. body: [],
  152. });
  153. MockApiClient.addMockResponse({
  154. url: '/organizations/org-slug/recent-searches/',
  155. method: 'GET',
  156. body: [],
  157. });
  158. MockApiClient.addMockResponse({
  159. url: '/organizations/org-slug/recent-searches/',
  160. method: 'POST',
  161. body: [],
  162. });
  163. MockApiClient.addMockResponse({
  164. url: '/organizations/org-slug/issues/',
  165. method: 'GET',
  166. body: [],
  167. });
  168. MockApiClient.addMockResponse({
  169. url: '/organizations/org-slug/events-stats/',
  170. body: [],
  171. });
  172. MockApiClient.addMockResponse({
  173. url: '/organizations/org-slug/tags/event.type/values/',
  174. body: [{count: 2, name: 'Nvidia 1080ti'}],
  175. });
  176. MockApiClient.addMockResponse({
  177. url: '/organizations/org-slug/events-geo/',
  178. body: {data: [], meta: {}},
  179. });
  180. MockApiClient.addMockResponse({
  181. url: '/organizations/org-slug/users/',
  182. body: [],
  183. });
  184. sessionsDataMock = MockApiClient.addMockResponse({
  185. method: 'GET',
  186. url: '/organizations/org-slug/sessions/',
  187. body: TestStubs.SessionsField({
  188. field: `sum(session)`,
  189. }),
  190. });
  191. metricsDataMock = MockApiClient.addMockResponse({
  192. method: 'GET',
  193. url: '/organizations/org-slug/metrics/data/',
  194. body: TestStubs.MetricsField({
  195. field: 'sum(sentry.sessions.session)',
  196. }),
  197. });
  198. MockApiClient.addMockResponse({
  199. url: '/organizations/org-slug/tags/',
  200. method: 'GET',
  201. body: TestStubs.Tags(),
  202. });
  203. measurementsMetaMock = MockApiClient.addMockResponse({
  204. url: '/organizations/org-slug/measurements-meta/',
  205. method: 'GET',
  206. body: {},
  207. });
  208. MockApiClient.addMockResponse({
  209. url: '/organizations/org-slug/tags/is/values/',
  210. method: 'GET',
  211. body: [],
  212. });
  213. TagStore.reset();
  214. });
  215. afterEach(function () {
  216. MockApiClient.clearMockResponses();
  217. jest.clearAllMocks();
  218. jest.useRealTimers();
  219. });
  220. describe('Release Widgets', function () {
  221. const releaseHealthFeatureFlags = [
  222. ...defaultOrgFeatures,
  223. 'new-widget-builder-experience-design',
  224. 'dashboards-releases',
  225. ];
  226. it('does not show the Release Health dataset if there is no dashboards-releases flag', async function () {
  227. renderTestComponent({
  228. orgFeatures: [...defaultOrgFeatures, 'new-widget-builder-experience-design'],
  229. });
  230. expect(await screen.findByText('Errors and Transactions')).toBeInTheDocument();
  231. expect(
  232. screen.queryByText('Releases (Sessions, Crash rates)')
  233. ).not.toBeInTheDocument();
  234. });
  235. it('shows the Release Health dataset if there is the dashboards-releases flag', async function () {
  236. renderTestComponent({
  237. orgFeatures: releaseHealthFeatureFlags,
  238. });
  239. expect(await screen.findByText('Errors and Transactions')).toBeInTheDocument();
  240. expect(screen.getByText('Releases (Sessions, Crash rates)')).toBeInTheDocument();
  241. });
  242. it('maintains the selected dataset when display type is changed', async function () {
  243. renderTestComponent({
  244. orgFeatures: releaseHealthFeatureFlags,
  245. });
  246. expect(
  247. await screen.findByText('Releases (Sessions, Crash rates)')
  248. ).toBeInTheDocument();
  249. expect(screen.getByLabelText(/releases/i)).not.toBeChecked();
  250. userEvent.click(screen.getByLabelText(/releases/i));
  251. await waitFor(() => expect(screen.getByLabelText(/releases/i)).toBeChecked());
  252. userEvent.click(screen.getByText('Table'));
  253. userEvent.click(screen.getByText('Line Chart'));
  254. await waitFor(() => expect(screen.getByLabelText(/releases/i)).toBeChecked());
  255. });
  256. it('displays releases tags', async function () {
  257. renderTestComponent({
  258. orgFeatures: releaseHealthFeatureFlags,
  259. });
  260. expect(
  261. await screen.findByText('Releases (Sessions, Crash rates)')
  262. ).toBeInTheDocument();
  263. userEvent.click(screen.getByLabelText(/releases/i));
  264. expect(screen.getByText('crash_free_rate(…)')).toBeInTheDocument();
  265. expect(screen.getByText('session')).toBeInTheDocument();
  266. userEvent.click(screen.getByText('crash_free_rate(…)'));
  267. expect(screen.getByText('count_unique(…)')).toBeInTheDocument();
  268. expect(screen.getByText('release')).toBeInTheDocument();
  269. expect(screen.getByText('environment')).toBeInTheDocument();
  270. expect(screen.getByText('session.status')).toBeInTheDocument();
  271. userEvent.click(screen.getByText('count_unique(…)'));
  272. expect(screen.getByText('user')).toBeInTheDocument();
  273. });
  274. it('does not display tags as params', async function () {
  275. renderTestComponent({
  276. orgFeatures: releaseHealthFeatureFlags,
  277. });
  278. expect(
  279. await screen.findByText('Releases (Sessions, Crash rates)')
  280. ).toBeInTheDocument();
  281. userEvent.click(screen.getByLabelText(/releases/i));
  282. expect(screen.getByText('crash_free_rate(…)')).toBeInTheDocument();
  283. await selectEvent.select(screen.getByText('crash_free_rate(…)'), 'count_unique(…)');
  284. userEvent.click(screen.getByText('user'));
  285. expect(screen.queryByText('release')).not.toBeInTheDocument();
  286. expect(screen.queryByText('environment')).not.toBeInTheDocument();
  287. expect(screen.queryByText('session.status')).not.toBeInTheDocument();
  288. });
  289. it('does not allow sort by when session.status is selected', async function () {
  290. renderTestComponent({
  291. orgFeatures: releaseHealthFeatureFlags,
  292. });
  293. expect(
  294. await screen.findByText('Releases (Sessions, Crash rates)')
  295. ).toBeInTheDocument();
  296. userEvent.click(screen.getByLabelText(/releases/i));
  297. expect(screen.getByText('High to low')).toBeEnabled();
  298. expect(screen.getByText('crash_free_rate(session)')).toBeInTheDocument();
  299. userEvent.click(screen.getByLabelText('Add a Column'));
  300. await selectEvent.select(screen.getByText('(Required)'), 'session.status');
  301. expect(screen.getByRole('textbox', {name: 'Sort direction'})).toBeDisabled();
  302. expect(screen.getByRole('textbox', {name: 'Sort by'})).toBeDisabled();
  303. });
  304. it('does not allow sort on tags except release', async function () {
  305. jest.useFakeTimers().setSystemTime(new Date('2022-08-02'));
  306. renderTestComponent({
  307. orgFeatures: releaseHealthFeatureFlags,
  308. });
  309. expect(
  310. await screen.findByText('Releases (Sessions, Crash rates)')
  311. ).toBeInTheDocument();
  312. userEvent.click(screen.getByLabelText(/releases/i));
  313. expect(screen.getByText('High to low')).toBeEnabled();
  314. expect(screen.getByText('crash_free_rate(session)')).toBeInTheDocument();
  315. userEvent.click(screen.getByLabelText('Add a Column'));
  316. await selectEvent.select(screen.getByText('(Required)'), 'release');
  317. userEvent.click(screen.getByLabelText('Add a Column'));
  318. await selectEvent.select(screen.getByText('(Required)'), 'environment');
  319. expect(await screen.findByText('Sort by a column')).toBeInTheDocument();
  320. // Selector "sortDirection"
  321. expect(screen.getByText('High to low')).toBeInTheDocument();
  322. // Selector "sortBy"
  323. userEvent.click(screen.getAllByText('crash_free_rate(session)')[1]);
  324. // release exists in sort by selector
  325. expect(screen.getAllByText('release')).toHaveLength(3);
  326. // environment does not exist in sort by selector
  327. expect(screen.getAllByText('environment')).toHaveLength(2);
  328. });
  329. it('makes the appropriate sessions call', async function () {
  330. jest.useFakeTimers().setSystemTime(new Date('2022-08-02'));
  331. renderTestComponent({
  332. orgFeatures: releaseHealthFeatureFlags,
  333. });
  334. expect(
  335. await screen.findByText('Releases (Sessions, Crash rates)')
  336. ).toBeInTheDocument();
  337. userEvent.click(screen.getByLabelText(/releases/i));
  338. userEvent.click(screen.getByText('Table'));
  339. userEvent.click(screen.getByText('Line Chart'));
  340. await waitFor(() =>
  341. expect(metricsDataMock).toHaveBeenLastCalledWith(
  342. `/organizations/org-slug/metrics/data/`,
  343. expect.objectContaining({
  344. query: expect.objectContaining({
  345. environment: [],
  346. field: [`session.crash_free_rate`],
  347. groupBy: [],
  348. interval: '5m',
  349. project: [],
  350. statsPeriod: '24h',
  351. }),
  352. })
  353. )
  354. );
  355. });
  356. it('calls the session endpoint with the right limit', async function () {
  357. jest.useFakeTimers().setSystemTime(new Date('2022-08-02'));
  358. renderTestComponent({
  359. orgFeatures: releaseHealthFeatureFlags,
  360. });
  361. expect(
  362. await screen.findByText('Releases (Sessions, Crash rates)')
  363. ).toBeInTheDocument();
  364. userEvent.click(screen.getByLabelText(/releases/i));
  365. userEvent.click(screen.getByText('Table'));
  366. userEvent.click(screen.getByText('Line Chart'));
  367. await selectEvent.select(await screen.findByText('Select group'), 'project');
  368. expect(screen.getByText('Limit to 5 results')).toBeInTheDocument();
  369. await waitFor(() =>
  370. expect(metricsDataMock).toHaveBeenLastCalledWith(
  371. `/organizations/org-slug/metrics/data/`,
  372. expect.objectContaining({
  373. query: expect.objectContaining({
  374. environment: [],
  375. field: ['session.crash_free_rate'],
  376. groupBy: ['project_id'],
  377. interval: '5m',
  378. orderBy: '-session.crash_free_rate',
  379. per_page: 5,
  380. project: [],
  381. statsPeriod: '24h',
  382. }),
  383. })
  384. )
  385. );
  386. });
  387. it('calls sessions api when session.status is selected as a groupby', async function () {
  388. jest.useFakeTimers().setSystemTime(new Date('2022-08-02'));
  389. renderTestComponent({
  390. orgFeatures: releaseHealthFeatureFlags,
  391. });
  392. expect(
  393. await screen.findByText('Releases (Sessions, Crash rates)')
  394. ).toBeInTheDocument();
  395. userEvent.click(screen.getByLabelText(/releases/i));
  396. userEvent.click(screen.getByText('Table'));
  397. userEvent.click(screen.getByText('Line Chart'));
  398. await selectEvent.select(await screen.findByText('Select group'), 'session.status');
  399. expect(screen.getByText('Limit to 5 results')).toBeInTheDocument();
  400. await waitFor(() =>
  401. expect(sessionsDataMock).toHaveBeenLastCalledWith(
  402. `/organizations/org-slug/sessions/`,
  403. expect.objectContaining({
  404. query: expect.objectContaining({
  405. environment: [],
  406. field: ['crash_free_rate(session)'],
  407. groupBy: ['session.status'],
  408. interval: '5m',
  409. project: [],
  410. statsPeriod: '24h',
  411. }),
  412. })
  413. )
  414. );
  415. });
  416. it('displays the correct options for area chart', async function () {
  417. renderTestComponent({
  418. orgFeatures: releaseHealthFeatureFlags,
  419. });
  420. expect(
  421. await screen.findByText('Releases (Sessions, Crash rates)')
  422. ).toBeInTheDocument();
  423. // change dataset to releases
  424. userEvent.click(screen.getByLabelText(/releases/i));
  425. userEvent.click(screen.getByText('Table'));
  426. userEvent.click(screen.getByText('Line Chart'));
  427. expect(screen.getByText('crash_free_rate(…)')).toBeInTheDocument();
  428. expect(screen.getByText(`session`)).toBeInTheDocument();
  429. userEvent.click(screen.getByText('crash_free_rate(…)'));
  430. expect(screen.getByText('count_unique(…)')).toBeInTheDocument();
  431. userEvent.click(screen.getByText('count_unique(…)'));
  432. expect(screen.getByText('user')).toBeInTheDocument();
  433. });
  434. it('sets widgetType to release', async function () {
  435. jest.useFakeTimers().setSystemTime(new Date('2022-08-02'));
  436. renderTestComponent({
  437. orgFeatures: releaseHealthFeatureFlags,
  438. });
  439. userEvent.click(await screen.findByText('Releases (Sessions, Crash rates)'));
  440. expect(metricsDataMock).toHaveBeenCalled();
  441. expect(screen.getByLabelText(/Releases/i)).toBeChecked();
  442. });
  443. it('does not display "add an equation" button', async function () {
  444. const widget: Widget = {
  445. title: 'Release Widget',
  446. displayType: DisplayType.TABLE,
  447. widgetType: WidgetType.RELEASE,
  448. queries: [
  449. {
  450. name: 'errors',
  451. conditions: 'event.type:error',
  452. fields: ['sdk.name', 'count()'],
  453. columns: ['sdk.name'],
  454. aggregates: ['count()'],
  455. orderby: '-sdk.name',
  456. },
  457. ],
  458. interval: '1d',
  459. id: '1',
  460. };
  461. const dashboard = mockDashboard({widgets: [widget]});
  462. renderTestComponent({
  463. orgFeatures: releaseHealthFeatureFlags,
  464. dashboard,
  465. params: {
  466. widgetIndex: '0',
  467. },
  468. });
  469. // Select line chart display
  470. userEvent.click(await screen.findByText('Table'));
  471. userEvent.click(screen.getByText('Line Chart'));
  472. await waitFor(() =>
  473. expect(screen.queryByLabelText('Add an Equation')).not.toBeInTheDocument()
  474. );
  475. });
  476. it('render release dataset disabled when the display type is world map', async function () {
  477. renderTestComponent({
  478. query: {
  479. source: DashboardWidgetSource.DISCOVERV2,
  480. },
  481. orgFeatures: releaseHealthFeatureFlags,
  482. });
  483. userEvent.click(await screen.findByText('Table'));
  484. userEvent.click(screen.getByText('World Map'));
  485. await waitFor(() => expect(screen.getByLabelText(/Releases/)).toBeDisabled());
  486. expect(
  487. screen.getByRole('radio', {
  488. name: 'Select Errors and Transactions',
  489. })
  490. ).toBeEnabled();
  491. expect(
  492. screen.getByRole('radio', {
  493. name: 'Select Issues (States, Assignment, Time, etc.)',
  494. })
  495. ).toBeDisabled();
  496. });
  497. it('renders with a release search bar', async function () {
  498. renderTestComponent({
  499. orgFeatures: releaseHealthFeatureFlags,
  500. });
  501. userEvent.type(
  502. await screen.findByPlaceholderText('Search for events, users, tags, and more'),
  503. 'session.status:'
  504. );
  505. await waitFor(() => {
  506. expect(screen.getByText("isn't supported here.")).toBeInTheDocument();
  507. });
  508. userEvent.click(screen.getByText('Releases (Sessions, Crash rates)'));
  509. userEvent.click(
  510. screen.getByPlaceholderText(
  511. 'Search for release version, session status, and more'
  512. )
  513. );
  514. expect(await screen.findByText('environment')).toBeInTheDocument();
  515. expect(screen.getByText('project')).toBeInTheDocument();
  516. expect(screen.getByText('release')).toBeInTheDocument();
  517. });
  518. it('adds a function when the only column chosen in a table is a tag', async function () {
  519. jest.useFakeTimers().setSystemTime(new Date('2022-08-02'));
  520. renderTestComponent({
  521. orgFeatures: releaseHealthFeatureFlags,
  522. });
  523. userEvent.click(await screen.findByText('Releases (Sessions, Crash rates)'));
  524. await selectEvent.select(screen.getByText('crash_free_rate(…)'), 'environment');
  525. // 1 in the table header, 1 in the column selector, and 1 in the sort by
  526. expect(screen.getAllByText(/crash_free_rate/)).toHaveLength(3);
  527. expect(screen.getAllByText('environment')).toHaveLength(2);
  528. });
  529. });
  530. describe('Issue Widgets', function () {
  531. it('sets widgetType to issues', async function () {
  532. const handleSave = jest.fn();
  533. renderTestComponent({onSave: handleSave});
  534. userEvent.click(await screen.findByText('Issues (States, Assignment, Time, etc.)'));
  535. userEvent.click(screen.getByLabelText('Add Widget'));
  536. await waitFor(() => {
  537. expect(handleSave).toHaveBeenCalledWith([
  538. expect.objectContaining({
  539. title: 'Custom Widget',
  540. displayType: DisplayType.TABLE,
  541. interval: '5m',
  542. widgetType: WidgetType.ISSUE,
  543. queries: [
  544. {
  545. conditions: '',
  546. fields: ['issue', 'assignee', 'title'],
  547. columns: ['issue', 'assignee', 'title'],
  548. aggregates: [],
  549. fieldAliases: [],
  550. name: '',
  551. orderby: 'date',
  552. },
  553. ],
  554. }),
  555. ]);
  556. });
  557. expect(handleSave).toHaveBeenCalledTimes(1);
  558. });
  559. it('render issues dataset disabled when the display type is not set to table', async function () {
  560. renderTestComponent({
  561. query: {
  562. source: DashboardWidgetSource.DISCOVERV2,
  563. },
  564. });
  565. userEvent.click(await screen.findByText('Table'));
  566. userEvent.click(screen.getByText('Line Chart'));
  567. expect(
  568. screen.getByRole('radio', {
  569. name: 'Select Errors and Transactions',
  570. })
  571. ).toBeEnabled();
  572. expect(
  573. screen.getByRole('radio', {
  574. name: 'Select Issues (States, Assignment, Time, etc.)',
  575. })
  576. ).toBeDisabled();
  577. });
  578. it('disables moving and deleting issue column', async function () {
  579. renderTestComponent();
  580. userEvent.click(await screen.findByText('Issues (States, Assignment, Time, etc.)'));
  581. expect(screen.getByText('issue')).toBeInTheDocument();
  582. expect(screen.getByText('assignee')).toBeInTheDocument();
  583. expect(screen.getByText('title')).toBeInTheDocument();
  584. expect(screen.getAllByLabelText('Remove column')).toHaveLength(2);
  585. expect(screen.getAllByLabelText('Drag to reorder')).toHaveLength(3);
  586. userEvent.click(screen.getAllByLabelText('Remove column')[1]);
  587. userEvent.click(screen.getAllByLabelText('Remove column')[0]);
  588. expect(screen.getByText('issue')).toBeInTheDocument();
  589. expect(screen.queryByText('assignee')).not.toBeInTheDocument();
  590. expect(screen.queryByText('title')).not.toBeInTheDocument();
  591. expect(screen.queryByLabelText('Remove column')).not.toBeInTheDocument();
  592. expect(screen.queryByLabelText('Drag to reorder')).not.toBeInTheDocument();
  593. });
  594. it('issue query does not work on default search bar', async function () {
  595. renderTestComponent();
  596. const input = (await screen.findByPlaceholderText(
  597. 'Search for events, users, tags, and more'
  598. )) as HTMLTextAreaElement;
  599. userEvent.paste(input, 'bookmarks', {
  600. clipboardData: {getData: () => ''},
  601. } as unknown as React.ClipboardEvent<HTMLTextAreaElement>);
  602. input.setSelectionRange(9, 9);
  603. expect(await screen.findByText('No items found')).toBeInTheDocument();
  604. });
  605. it('renders with an issues search bar when selected in dataset selection', async function () {
  606. renderTestComponent();
  607. userEvent.click(await screen.findByText('Issues (States, Assignment, Time, etc.)'));
  608. const input = (await screen.findByPlaceholderText(
  609. 'Search for issues, status, assigned, and more'
  610. )) as HTMLTextAreaElement;
  611. userEvent.paste(input, 'is:', {
  612. clipboardData: {getData: () => ''},
  613. } as unknown as React.ClipboardEvent<HTMLTextAreaElement>);
  614. input.setSelectionRange(3, 3);
  615. expect(await screen.findByText('resolved')).toBeInTheDocument();
  616. });
  617. it('Update table header values (field alias)', async function () {
  618. const handleSave = jest.fn();
  619. renderTestComponent({
  620. onSave: handleSave,
  621. orgFeatures: [...defaultOrgFeatures, 'new-widget-builder-experience-design'],
  622. });
  623. await screen.findByText('Table');
  624. userEvent.click(screen.getByText('Issues (States, Assignment, Time, etc.)'));
  625. userEvent.paste(screen.getAllByPlaceholderText('Alias')[0], 'First Alias');
  626. userEvent.click(screen.getByText('Add Widget'));
  627. await waitFor(() => {
  628. expect(handleSave).toHaveBeenCalledWith([
  629. expect.objectContaining({
  630. queries: [
  631. expect.objectContaining({
  632. fieldAliases: ['First Alias', '', ''],
  633. }),
  634. ],
  635. }),
  636. ]);
  637. });
  638. });
  639. });
  640. describe('Events Widgets', function () {
  641. describe('Custom Performance Metrics', function () {
  642. it('can choose a custom measurement', async function () {
  643. measurementsMetaMock = MockApiClient.addMockResponse({
  644. url: '/organizations/org-slug/measurements-meta/',
  645. method: 'GET',
  646. body: {'measurements.custom.measurement': {functions: ['p99']}},
  647. });
  648. eventsMock = MockApiClient.addMockResponse({
  649. url: '/organizations/org-slug/events/',
  650. method: 'GET',
  651. statusCode: 200,
  652. body: {
  653. meta: {
  654. fields: {'p99(measurements.total.db.calls)': 'duration'},
  655. isMetricsData: true,
  656. },
  657. data: [{'p99(measurements.total.db.calls)': 10}],
  658. },
  659. });
  660. const {router} = renderTestComponent({
  661. query: {source: DashboardWidgetSource.DISCOVERV2},
  662. dashboard: testDashboard,
  663. orgFeatures: [...defaultOrgFeatures, 'discover-frontend-use-events-endpoint'],
  664. });
  665. expect(await screen.findAllByText('Custom Widget')).toHaveLength(2);
  666. // 1 in the table header, 1 in the column selector, 1 in the sort field
  667. const countFields = screen.getAllByText('count()');
  668. expect(countFields).toHaveLength(3);
  669. await selectEvent.select(countFields[1], ['p99(…)']);
  670. await selectEvent.select(screen.getByText('transaction.duration'), [
  671. 'measurements.custom.measurement',
  672. ]);
  673. userEvent.click(screen.getByText('Add Widget'));
  674. await waitFor(() => {
  675. expect(router.push).toHaveBeenCalledWith(
  676. expect.objectContaining({
  677. pathname: '/organizations/org-slug/dashboard/2/',
  678. query: {
  679. displayType: 'table',
  680. interval: '5m',
  681. title: 'Custom Widget',
  682. queryNames: [''],
  683. queryConditions: [''],
  684. queryFields: ['p99(measurements.custom.measurement)'],
  685. queryOrderby: '-p99(measurements.custom.measurement)',
  686. start: null,
  687. end: null,
  688. statsPeriod: '24h',
  689. utc: null,
  690. project: [],
  691. environment: [],
  692. },
  693. })
  694. );
  695. });
  696. });
  697. it('raises an alert banner and disables saving widget if widget result is not metrics data and widget is using custom measurements', async function () {
  698. eventsMock = MockApiClient.addMockResponse({
  699. url: '/organizations/org-slug/events/',
  700. method: 'GET',
  701. statusCode: 200,
  702. body: {
  703. meta: {
  704. fields: {'p99(measurements.custom.measurement)': 'duration'},
  705. isMetricsData: false,
  706. },
  707. data: [{'p99(measurements.custom.measurement)': 10}],
  708. },
  709. });
  710. const defaultWidgetQuery = {
  711. name: '',
  712. fields: ['p99(measurements.custom.measurement)'],
  713. columns: [],
  714. aggregates: ['p99(measurements.custom.measurement)'],
  715. conditions: 'user:test.user@sentry.io',
  716. orderby: '',
  717. };
  718. const defaultTableColumns = ['p99(measurements.custom.measurement)'];
  719. renderTestComponent({
  720. query: {
  721. source: DashboardWidgetSource.DISCOVERV2,
  722. defaultWidgetQuery: urlEncode(defaultWidgetQuery),
  723. displayType: DisplayType.TABLE,
  724. defaultTableColumns,
  725. },
  726. orgFeatures: [
  727. ...defaultOrgFeatures,
  728. 'discover-frontend-use-events-endpoint',
  729. 'dashboards-mep',
  730. ],
  731. });
  732. await waitFor(() => {
  733. expect(measurementsMetaMock).toHaveBeenCalled();
  734. });
  735. await waitFor(() => {
  736. expect(eventsMock).toHaveBeenCalled();
  737. });
  738. screen.getByText('You have inputs that are incompatible with');
  739. expect(screen.getByText('Add Widget').closest('button')).toBeDisabled();
  740. });
  741. it('raises an alert banner if widget result is not metrics data', async function () {
  742. eventsMock = MockApiClient.addMockResponse({
  743. url: '/organizations/org-slug/events/',
  744. method: 'GET',
  745. statusCode: 200,
  746. body: {
  747. meta: {
  748. fields: {'p99(measurements.lcp)': 'duration'},
  749. isMetricsData: false,
  750. },
  751. data: [{'p99(measurements.lcp)': 10}],
  752. },
  753. });
  754. const defaultWidgetQuery = {
  755. name: '',
  756. fields: ['p99(measurements.lcp)'],
  757. columns: [],
  758. aggregates: ['p99(measurements.lcp)'],
  759. conditions: 'user:test.user@sentry.io',
  760. orderby: '',
  761. };
  762. const defaultTableColumns = ['p99(measurements.lcp)'];
  763. renderTestComponent({
  764. query: {
  765. source: DashboardWidgetSource.DISCOVERV2,
  766. defaultWidgetQuery: urlEncode(defaultWidgetQuery),
  767. displayType: DisplayType.TABLE,
  768. defaultTableColumns,
  769. },
  770. orgFeatures: [
  771. ...defaultOrgFeatures,
  772. 'discover-frontend-use-events-endpoint',
  773. 'dashboards-mep',
  774. ],
  775. });
  776. await waitFor(() => {
  777. expect(measurementsMetaMock).toHaveBeenCalled();
  778. });
  779. await waitFor(() => {
  780. expect(eventsMock).toHaveBeenCalled();
  781. });
  782. screen.getByText('Your selection is only applicable to');
  783. });
  784. it('does not raise an alert banner if widget result is not metrics data but widget contains error fields', async function () {
  785. eventsMock = MockApiClient.addMockResponse({
  786. url: '/organizations/org-slug/events/',
  787. method: 'GET',
  788. statusCode: 200,
  789. body: {
  790. meta: {
  791. fields: {'p99(measurements.lcp)': 'duration'},
  792. isMetricsData: false,
  793. },
  794. data: [{'p99(measurements.lcp)': 10}],
  795. },
  796. });
  797. const defaultWidgetQuery = {
  798. name: '',
  799. fields: ['p99(measurements.lcp)'],
  800. columns: ['error.handled'],
  801. aggregates: ['p99(measurements.lcp)'],
  802. conditions: 'user:test.user@sentry.io',
  803. orderby: '',
  804. };
  805. const defaultTableColumns = ['p99(measurements.lcp)'];
  806. renderTestComponent({
  807. query: {
  808. source: DashboardWidgetSource.DISCOVERV2,
  809. defaultWidgetQuery: urlEncode(defaultWidgetQuery),
  810. displayType: DisplayType.TABLE,
  811. defaultTableColumns,
  812. },
  813. orgFeatures: [
  814. ...defaultOrgFeatures,
  815. 'discover-frontend-use-events-endpoint',
  816. 'dashboards-mep',
  817. ],
  818. });
  819. await waitFor(() => {
  820. expect(measurementsMetaMock).toHaveBeenCalled();
  821. });
  822. await waitFor(() => {
  823. expect(eventsMock).toHaveBeenCalled();
  824. });
  825. expect(
  826. screen.queryByText('Your selection is only applicable to')
  827. ).not.toBeInTheDocument();
  828. });
  829. it('only displays custom measurements in supported functions', async function () {
  830. measurementsMetaMock = MockApiClient.addMockResponse({
  831. url: '/organizations/org-slug/measurements-meta/',
  832. method: 'GET',
  833. body: {
  834. 'measurements.custom.measurement': {functions: ['p99']},
  835. 'measurements.another.custom.measurement': {functions: ['p95']},
  836. },
  837. });
  838. renderTestComponent({
  839. query: {source: DashboardWidgetSource.DISCOVERV2},
  840. dashboard: testDashboard,
  841. orgFeatures: [...defaultOrgFeatures, 'discover-frontend-use-events-endpoint'],
  842. });
  843. expect(await screen.findAllByText('Custom Widget')).toHaveLength(2);
  844. await selectEvent.select(screen.getAllByText('count()')[1], ['p99(…)']);
  845. userEvent.click(screen.getByText('transaction.duration'));
  846. screen.getByText('measurements.custom.measurement');
  847. expect(
  848. screen.queryByText('measurements.another.custom.measurement')
  849. ).not.toBeInTheDocument();
  850. await selectEvent.select(screen.getAllByText('p99(…)')[0], ['p95(…)']);
  851. userEvent.click(screen.getByText('transaction.duration'));
  852. screen.getByText('measurements.another.custom.measurement');
  853. expect(
  854. screen.queryByText('measurements.custom.measurement')
  855. ).not.toBeInTheDocument();
  856. });
  857. it('renders custom performance metric using duration units from events meta', async function () {
  858. eventsMock = MockApiClient.addMockResponse({
  859. url: '/organizations/org-slug/events/',
  860. method: 'GET',
  861. statusCode: 200,
  862. body: {
  863. meta: {
  864. fields: {'p99(measurements.custom.measurement)': 'duration'},
  865. isMetricsData: true,
  866. units: {'p99(measurements.custom.measurement)': 'hour'},
  867. },
  868. data: [{'p99(measurements.custom.measurement)': 12}],
  869. },
  870. });
  871. renderTestComponent({
  872. query: {source: DashboardWidgetSource.DISCOVERV2},
  873. dashboard: {
  874. ...testDashboard,
  875. widgets: [
  876. {
  877. title: 'Custom Measurement Widget',
  878. interval: '1d',
  879. id: '1',
  880. widgetType: WidgetType.DISCOVER,
  881. displayType: DisplayType.TABLE,
  882. queries: [
  883. {
  884. conditions: '',
  885. name: '',
  886. fields: ['p99(measurements.custom.measurement)'],
  887. columns: [],
  888. aggregates: ['p99(measurements.custom.measurement)'],
  889. orderby: '-p99(measurements.custom.measurement)',
  890. },
  891. ],
  892. },
  893. ],
  894. },
  895. params: {
  896. widgetIndex: '0',
  897. },
  898. orgFeatures: [...defaultOrgFeatures, 'discover-frontend-use-events-endpoint'],
  899. });
  900. await screen.findByText('12.00hr');
  901. });
  902. it('renders custom performance metric using size units from events meta', async function () {
  903. eventsMock = MockApiClient.addMockResponse({
  904. url: '/organizations/org-slug/events/',
  905. method: 'GET',
  906. statusCode: 200,
  907. body: {
  908. meta: {
  909. fields: {'p99(measurements.custom.measurement)': 'size'},
  910. isMetricsData: true,
  911. units: {'p99(measurements.custom.measurement)': 'kibibyte'},
  912. },
  913. data: [{'p99(measurements.custom.measurement)': 12}],
  914. },
  915. });
  916. renderTestComponent({
  917. query: {source: DashboardWidgetSource.DISCOVERV2},
  918. dashboard: {
  919. ...testDashboard,
  920. widgets: [
  921. {
  922. title: 'Custom Measurement Widget',
  923. interval: '1d',
  924. id: '1',
  925. widgetType: WidgetType.DISCOVER,
  926. displayType: DisplayType.TABLE,
  927. queries: [
  928. {
  929. conditions: '',
  930. name: '',
  931. fields: ['p99(measurements.custom.measurement)'],
  932. columns: [],
  933. aggregates: ['p99(measurements.custom.measurement)'],
  934. orderby: '-p99(measurements.custom.measurement)',
  935. },
  936. ],
  937. },
  938. ],
  939. },
  940. params: {
  941. widgetIndex: '0',
  942. },
  943. orgFeatures: [...defaultOrgFeatures, 'discover-frontend-use-events-endpoint'],
  944. });
  945. await screen.findByText('12.0 KiB');
  946. });
  947. it('renders custom performance metric using abyte format size units from events meta', async function () {
  948. eventsMock = MockApiClient.addMockResponse({
  949. url: '/organizations/org-slug/events/',
  950. method: 'GET',
  951. statusCode: 200,
  952. body: {
  953. meta: {
  954. fields: {'p99(measurements.custom.measurement)': 'size'},
  955. isMetricsData: true,
  956. units: {'p99(measurements.custom.measurement)': 'kilobyte'},
  957. },
  958. data: [{'p99(measurements.custom.measurement)': 12000}],
  959. },
  960. });
  961. renderTestComponent({
  962. query: {source: DashboardWidgetSource.DISCOVERV2},
  963. dashboard: {
  964. ...testDashboard,
  965. widgets: [
  966. {
  967. title: 'Custom Measurement Widget',
  968. interval: '1d',
  969. id: '1',
  970. widgetType: WidgetType.DISCOVER,
  971. displayType: DisplayType.TABLE,
  972. queries: [
  973. {
  974. conditions: '',
  975. name: '',
  976. fields: ['p99(measurements.custom.measurement)'],
  977. columns: [],
  978. aggregates: ['p99(measurements.custom.measurement)'],
  979. orderby: '-p99(measurements.custom.measurement)',
  980. },
  981. ],
  982. },
  983. ],
  984. },
  985. params: {
  986. widgetIndex: '0',
  987. },
  988. orgFeatures: [...defaultOrgFeatures, 'discover-frontend-use-events-endpoint'],
  989. });
  990. await screen.findByText('12 MB');
  991. });
  992. it('displays custom performance metric in column select', async function () {
  993. renderTestComponent({
  994. query: {source: DashboardWidgetSource.DISCOVERV2},
  995. dashboard: {
  996. ...testDashboard,
  997. widgets: [
  998. {
  999. title: 'Custom Measurement Widget',
  1000. interval: '1d',
  1001. id: '1',
  1002. widgetType: WidgetType.DISCOVER,
  1003. displayType: DisplayType.TABLE,
  1004. queries: [
  1005. {
  1006. conditions: '',
  1007. name: '',
  1008. fields: ['p99(measurements.custom.measurement)'],
  1009. columns: [],
  1010. aggregates: ['p99(measurements.custom.measurement)'],
  1011. orderby: '-p99(measurements.custom.measurement)',
  1012. },
  1013. ],
  1014. },
  1015. ],
  1016. },
  1017. params: {
  1018. widgetIndex: '0',
  1019. },
  1020. orgFeatures: [...defaultOrgFeatures, 'discover-frontend-use-events-endpoint'],
  1021. });
  1022. await screen.findByText('measurements.custom.measurement');
  1023. });
  1024. it('does not default to sorting by transaction when columns change', async function () {
  1025. renderTestComponent({
  1026. query: {source: DashboardWidgetSource.DISCOVERV2},
  1027. dashboard: {
  1028. ...testDashboard,
  1029. widgets: [
  1030. {
  1031. title: 'Custom Measurement Widget',
  1032. interval: '1d',
  1033. id: '1',
  1034. widgetType: WidgetType.DISCOVER,
  1035. displayType: DisplayType.TABLE,
  1036. queries: [
  1037. {
  1038. conditions: '',
  1039. name: '',
  1040. fields: [
  1041. 'p99(measurements.custom.measurement)',
  1042. 'transaction',
  1043. 'count()',
  1044. ],
  1045. columns: ['transaction'],
  1046. aggregates: ['p99(measurements.custom.measurement)', 'count()'],
  1047. orderby: '-p99(measurements.custom.measurement)',
  1048. },
  1049. ],
  1050. },
  1051. ],
  1052. },
  1053. params: {
  1054. widgetIndex: '0',
  1055. },
  1056. orgFeatures: [...defaultOrgFeatures, 'discover-frontend-use-events-endpoint'],
  1057. });
  1058. expect(
  1059. await screen.findByText('p99(measurements.custom.measurement)')
  1060. ).toBeInTheDocument();
  1061. // Delete p99(measurements.custom.measurement) column
  1062. userEvent.click(screen.getAllByLabelText('Remove column')[0]);
  1063. expect(
  1064. screen.queryByText('p99(measurements.custom.measurement)')
  1065. ).not.toBeInTheDocument();
  1066. expect(screen.getAllByText('transaction').length).toEqual(1);
  1067. expect(screen.getAllByText('count()').length).toEqual(2);
  1068. });
  1069. });
  1070. });
  1071. });