widgetBuilderDataset.spec.tsx 41 KB

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