widgetBuilderDataset.spec.tsx 41 KB

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