widgetBuilderDataset.spec.tsx 40 KB

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