widgetBuilderDataset.spec.tsx 41 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300
  1. import {urlEncode} from '@sentry/utils';
  2. import {MetricsFieldFixture} from 'sentry-fixture/metrics';
  3. import {SessionsFieldFixture} from 'sentry-fixture/sessions';
  4. import {TagsFixture} from 'sentry-fixture/tags';
  5. import {initializeOrg} from 'sentry-test/initializeOrg';
  6. import {
  7. render,
  8. screen,
  9. userEvent,
  10. waitFor,
  11. within,
  12. } from 'sentry-test/reactTestingLibrary';
  13. import selectEvent from 'sentry-test/selectEvent';
  14. import {resetMockDate, setMockDate} from 'sentry-test/utils';
  15. import ProjectsStore from 'sentry/stores/projectsStore';
  16. import TagStore from 'sentry/stores/tagStore';
  17. import type {DashboardDetails, Widget} from 'sentry/views/dashboards/types';
  18. import {
  19. DashboardWidgetSource,
  20. DisplayType,
  21. WidgetType,
  22. } from 'sentry/views/dashboards/types';
  23. import type {WidgetBuilderProps} from 'sentry/views/dashboards/widgetBuilder';
  24. import WidgetBuilder from 'sentry/views/dashboards/widgetBuilder';
  25. const defaultOrgFeatures = [
  26. 'performance-view',
  27. 'dashboards-edit',
  28. 'global-views',
  29. 'dashboards-mep',
  30. 'dashboards-rh-widget',
  31. ];
  32. function mockDashboard(dashboard: Partial<DashboardDetails>): DashboardDetails {
  33. return {
  34. id: '1',
  35. title: 'Dashboard',
  36. createdBy: undefined,
  37. dateCreated: '2020-01-01T00:00:00.000Z',
  38. widgets: [],
  39. projects: [],
  40. filters: {},
  41. ...dashboard,
  42. };
  43. }
  44. function renderTestComponent({
  45. dashboard,
  46. query,
  47. orgFeatures,
  48. onSave,
  49. params,
  50. }: {
  51. dashboard?: WidgetBuilderProps['dashboard'];
  52. onSave?: WidgetBuilderProps['onSave'];
  53. orgFeatures?: string[];
  54. params?: Partial<WidgetBuilderProps['params']>;
  55. query?: Record<string, any>;
  56. } = {}) {
  57. const {organization, router, routerContext} = initializeOrg({
  58. organization: {
  59. features: orgFeatures ?? defaultOrgFeatures,
  60. },
  61. router: {
  62. location: {
  63. query: {
  64. source: DashboardWidgetSource.DASHBOARDS,
  65. ...query,
  66. },
  67. },
  68. },
  69. });
  70. ProjectsStore.loadInitialData(organization.projects);
  71. render(
  72. <WidgetBuilder
  73. route={{}}
  74. router={router}
  75. routes={router.routes}
  76. routeParams={router.params}
  77. location={router.location}
  78. dashboard={{
  79. id: 'new',
  80. title: 'Dashboard',
  81. createdBy: undefined,
  82. dateCreated: '2020-01-01T00:00:00.000Z',
  83. widgets: [],
  84. projects: [],
  85. filters: {},
  86. ...dashboard,
  87. }}
  88. onSave={onSave ?? jest.fn()}
  89. params={{
  90. orgId: organization.slug,
  91. dashboardId: dashboard?.id ?? 'new',
  92. ...params,
  93. }}
  94. />,
  95. {
  96. context: routerContext,
  97. organization,
  98. }
  99. );
  100. return {router};
  101. }
  102. describe('WidgetBuilder', function () {
  103. const untitledDashboard: DashboardDetails = {
  104. id: '1',
  105. title: 'Untitled Dashboard',
  106. createdBy: undefined,
  107. dateCreated: '2020-01-01T00:00:00.000Z',
  108. widgets: [],
  109. projects: [],
  110. filters: {},
  111. };
  112. const testDashboard: DashboardDetails = {
  113. id: '2',
  114. title: 'Test Dashboard',
  115. createdBy: undefined,
  116. dateCreated: '2020-01-01T00:00:00.000Z',
  117. widgets: [],
  118. projects: [],
  119. filters: {},
  120. };
  121. let eventsMock: jest.Mock | undefined;
  122. let sessionsDataMock: jest.Mock | undefined;
  123. let metricsDataMock: jest.Mock | undefined;
  124. let measurementsMetaMock: jest.Mock | undefined;
  125. beforeEach(function () {
  126. MockApiClient.addMockResponse({
  127. url: '/organizations/org-slug/dashboards/',
  128. body: [
  129. {...untitledDashboard, widgetDisplay: [DisplayType.TABLE]},
  130. {...testDashboard, widgetDisplay: [DisplayType.AREA]},
  131. ],
  132. });
  133. MockApiClient.addMockResponse({
  134. url: '/organizations/org-slug/dashboards/widgets/',
  135. method: 'POST',
  136. statusCode: 200,
  137. body: [],
  138. });
  139. MockApiClient.addMockResponse({
  140. url: '/organizations/org-slug/eventsv2/',
  141. method: 'GET',
  142. statusCode: 200,
  143. body: {
  144. meta: {},
  145. data: [],
  146. },
  147. });
  148. eventsMock = MockApiClient.addMockResponse({
  149. url: '/organizations/org-slug/events/',
  150. method: 'GET',
  151. statusCode: 200,
  152. body: {
  153. meta: {fields: {}},
  154. data: [],
  155. },
  156. });
  157. MockApiClient.addMockResponse({
  158. url: '/organizations/org-slug/projects/',
  159. method: 'GET',
  160. body: [],
  161. });
  162. MockApiClient.addMockResponse({
  163. url: '/organizations/org-slug/recent-searches/',
  164. method: 'GET',
  165. body: [],
  166. });
  167. MockApiClient.addMockResponse({
  168. url: '/organizations/org-slug/recent-searches/',
  169. method: 'POST',
  170. body: [],
  171. });
  172. MockApiClient.addMockResponse({
  173. url: '/organizations/org-slug/issues/',
  174. method: 'GET',
  175. body: [],
  176. });
  177. MockApiClient.addMockResponse({
  178. url: '/organizations/org-slug/events-stats/',
  179. body: [],
  180. });
  181. MockApiClient.addMockResponse({
  182. url: '/organizations/org-slug/tags/event.type/values/',
  183. body: [{count: 2, name: 'Nvidia 1080ti'}],
  184. });
  185. MockApiClient.addMockResponse({
  186. url: '/organizations/org-slug/users/',
  187. body: [],
  188. });
  189. sessionsDataMock = MockApiClient.addMockResponse({
  190. method: 'GET',
  191. url: '/organizations/org-slug/sessions/',
  192. body: SessionsFieldFixture(`sum(session)`),
  193. });
  194. metricsDataMock = MockApiClient.addMockResponse({
  195. method: 'GET',
  196. url: '/organizations/org-slug/metrics/data/',
  197. body: MetricsFieldFixture('session.all'),
  198. });
  199. MockApiClient.addMockResponse({
  200. url: '/organizations/org-slug/tags/',
  201. method: 'GET',
  202. body: TagsFixture(),
  203. });
  204. measurementsMetaMock = MockApiClient.addMockResponse({
  205. url: '/organizations/org-slug/measurements-meta/',
  206. method: 'GET',
  207. body: {},
  208. });
  209. MockApiClient.addMockResponse({
  210. url: '/organizations/org-slug/tags/is/values/',
  211. method: 'GET',
  212. body: [],
  213. });
  214. MockApiClient.addMockResponse({
  215. url: '/organizations/org-slug/metrics-compatibility/',
  216. method: 'GET',
  217. body: {
  218. incompatible_projects: [],
  219. compatible_projects: [1],
  220. },
  221. });
  222. MockApiClient.addMockResponse({
  223. url: '/organizations/org-slug/metrics-compatibility-sums/',
  224. method: 'GET',
  225. body: {
  226. sum: {
  227. metrics: 988803,
  228. metrics_null: 0,
  229. metrics_unparam: 132,
  230. },
  231. },
  232. });
  233. MockApiClient.addMockResponse({
  234. url: '/organizations/org-slug/releases/',
  235. body: [],
  236. });
  237. TagStore.reset();
  238. });
  239. afterEach(function () {
  240. MockApiClient.clearMockResponses();
  241. jest.clearAllMocks();
  242. resetMockDate();
  243. });
  244. describe('Release Widgets', function () {
  245. it('shows the Release Health dataset', async function () {
  246. renderTestComponent();
  247. expect(await screen.findByText('Errors and Transactions')).toBeInTheDocument();
  248. expect(screen.getByText('Releases (Sessions, Crash rates)')).toBeInTheDocument();
  249. });
  250. it('maintains the selected dataset when display type is changed', async function () {
  251. renderTestComponent();
  252. expect(
  253. await screen.findByText('Releases (Sessions, Crash rates)')
  254. ).toBeInTheDocument();
  255. expect(screen.getByRole('radio', {name: /Releases/i})).not.toBeChecked();
  256. await userEvent.click(screen.getByRole('radio', {name: /Releases/i}));
  257. await waitFor(() =>
  258. expect(screen.getByRole('radio', {name: /Releases/i})).toBeChecked()
  259. );
  260. await userEvent.click(screen.getByText('Table'));
  261. await userEvent.click(screen.getByText('Line Chart'));
  262. await waitFor(() =>
  263. expect(screen.getByRole('radio', {name: /Releases/i})).toBeChecked()
  264. );
  265. });
  266. it('displays releases tags', async function () {
  267. renderTestComponent();
  268. expect(
  269. await screen.findByText('Releases (Sessions, Crash rates)')
  270. ).toBeInTheDocument();
  271. await userEvent.click(screen.getByRole('radio', {name: /Releases/i}));
  272. expect(screen.getByText('crash_free_rate(…)')).toBeInTheDocument();
  273. expect(screen.getByText('session')).toBeInTheDocument();
  274. await userEvent.click(screen.getByText('crash_free_rate(…)'));
  275. expect(screen.getByText('count_unique(…)')).toBeInTheDocument();
  276. expect(screen.getByText('release')).toBeInTheDocument();
  277. expect(screen.getByText('environment')).toBeInTheDocument();
  278. expect(screen.getByText('session.status')).toBeInTheDocument();
  279. await userEvent.click(screen.getByText('count_unique(…)'));
  280. expect(screen.getByText('user')).toBeInTheDocument();
  281. });
  282. it('does not display tags as params', async function () {
  283. renderTestComponent();
  284. expect(
  285. await screen.findByText('Releases (Sessions, Crash rates)')
  286. ).toBeInTheDocument();
  287. await userEvent.click(screen.getByRole('radio', {name: /Releases/i}));
  288. expect(screen.getByText('crash_free_rate(…)')).toBeInTheDocument();
  289. await selectEvent.select(screen.getByText('crash_free_rate(…)'), 'count_unique(…)');
  290. await userEvent.click(screen.getByText('user'));
  291. expect(screen.queryByText('release')).not.toBeInTheDocument();
  292. expect(screen.queryByText('environment')).not.toBeInTheDocument();
  293. expect(screen.queryByText('session.status')).not.toBeInTheDocument();
  294. });
  295. it('does not allow sort by when session.status is selected', async function () {
  296. renderTestComponent();
  297. expect(
  298. await screen.findByText('Releases (Sessions, Crash rates)')
  299. ).toBeInTheDocument();
  300. await userEvent.click(screen.getByRole('radio', {name: /Releases/i}));
  301. expect(screen.getByText('High to low')).toBeEnabled();
  302. expect(screen.getByText('crash_free_rate(session)')).toBeInTheDocument();
  303. await userEvent.click(screen.getByLabelText('Add a Column'));
  304. await selectEvent.select(screen.getByText('(Required)'), 'session.status');
  305. expect(screen.getByRole('textbox', {name: 'Sort direction'})).toBeDisabled();
  306. expect(screen.getByRole('textbox', {name: 'Sort by'})).toBeDisabled();
  307. });
  308. it('does not allow sort on tags except release', async function () {
  309. setMockDate(new Date('2022-08-02'));
  310. renderTestComponent();
  311. expect(
  312. await screen.findByText('Releases (Sessions, Crash rates)')
  313. ).toBeInTheDocument();
  314. await userEvent.click(screen.getByRole('radio', {name: /Releases/i}), {
  315. delay: null,
  316. });
  317. expect(
  318. within(screen.getByTestId('sort-by-step')).getByText('High to low')
  319. ).toBeEnabled();
  320. expect(
  321. within(screen.getByTestId('sort-by-step')).getByText('crash_free_rate(session)')
  322. ).toBeInTheDocument();
  323. await userEvent.click(screen.getByLabelText('Add a Column'), {delay: null});
  324. await selectEvent.select(screen.getByText('(Required)'), 'release');
  325. await userEvent.click(screen.getByLabelText('Add a Column'), {delay: null});
  326. await selectEvent.select(screen.getByText('(Required)'), 'environment');
  327. expect(await screen.findByText('Sort by a column')).toBeInTheDocument();
  328. // Selector "sortDirection"
  329. expect(screen.getByText('High to low')).toBeInTheDocument();
  330. // Selector "sortBy"
  331. await userEvent.click(screen.getAllByText('crash_free_rate(session)')[1], {
  332. delay: null,
  333. });
  334. // release exists in sort by selector
  335. expect(screen.getAllByText('release')).toHaveLength(3);
  336. // environment does not exist in sort by selector
  337. expect(screen.getAllByText('environment')).toHaveLength(2);
  338. });
  339. it('makes the appropriate sessions call', async function () {
  340. setMockDate(new Date('2022-08-02'));
  341. renderTestComponent();
  342. expect(
  343. await screen.findByText('Releases (Sessions, Crash rates)')
  344. ).toBeInTheDocument();
  345. await userEvent.click(screen.getByRole('radio', {name: /Releases/i}), {
  346. delay: null,
  347. });
  348. await userEvent.click(screen.getByText('Table'), {delay: null});
  349. await userEvent.click(screen.getByText('Line Chart'), {delay: null});
  350. await waitFor(() =>
  351. expect(metricsDataMock).toHaveBeenLastCalledWith(
  352. `/organizations/org-slug/metrics/data/`,
  353. expect.objectContaining({
  354. query: expect.objectContaining({
  355. environment: [],
  356. field: [`session.crash_free_rate`],
  357. groupBy: [],
  358. interval: '5m',
  359. project: [],
  360. statsPeriod: '24h',
  361. }),
  362. })
  363. )
  364. );
  365. });
  366. it('calls the session endpoint with the right limit', async function () {
  367. setMockDate(new Date('2022-08-02'));
  368. renderTestComponent();
  369. expect(
  370. await screen.findByText('Releases (Sessions, Crash rates)')
  371. ).toBeInTheDocument();
  372. await userEvent.click(screen.getByRole('radio', {name: /Releases/i}), {
  373. delay: null,
  374. });
  375. await userEvent.click(screen.getByText('Table'), {delay: null});
  376. await userEvent.click(screen.getByText('Line Chart'), {delay: null});
  377. await selectEvent.select(await screen.findByText('Select group'), 'project');
  378. expect(screen.getByText('Limit to 5 results')).toBeInTheDocument();
  379. await waitFor(() =>
  380. expect(metricsDataMock).toHaveBeenLastCalledWith(
  381. `/organizations/org-slug/metrics/data/`,
  382. expect.objectContaining({
  383. query: expect.objectContaining({
  384. environment: [],
  385. field: ['session.crash_free_rate'],
  386. groupBy: ['project_id'],
  387. interval: '5m',
  388. orderBy: '-session.crash_free_rate',
  389. per_page: 5,
  390. project: [],
  391. statsPeriod: '24h',
  392. }),
  393. })
  394. )
  395. );
  396. });
  397. it('calls sessions api when session.status is selected as a groupby', async function () {
  398. setMockDate(new Date('2022-08-02'));
  399. renderTestComponent();
  400. expect(
  401. await screen.findByText('Releases (Sessions, Crash rates)')
  402. ).toBeInTheDocument();
  403. await userEvent.click(screen.getByRole('radio', {name: /Releases/i}), {
  404. delay: null,
  405. });
  406. await userEvent.click(screen.getByText('Table'), {delay: null});
  407. await userEvent.click(screen.getByText('Line Chart'), {delay: null});
  408. await selectEvent.select(await screen.findByText('Select group'), 'session.status');
  409. expect(screen.getByText('Limit to 5 results')).toBeInTheDocument();
  410. await waitFor(() =>
  411. expect(sessionsDataMock).toHaveBeenLastCalledWith(
  412. `/organizations/org-slug/sessions/`,
  413. expect.objectContaining({
  414. query: expect.objectContaining({
  415. environment: [],
  416. field: ['crash_free_rate(session)'],
  417. groupBy: ['session.status'],
  418. interval: '5m',
  419. project: [],
  420. statsPeriod: '24h',
  421. }),
  422. })
  423. )
  424. );
  425. });
  426. it('displays the correct options for area chart', async function () {
  427. renderTestComponent();
  428. expect(
  429. await screen.findByText('Releases (Sessions, Crash rates)')
  430. ).toBeInTheDocument();
  431. // change dataset to releases
  432. await userEvent.click(screen.getByRole('radio', {name: /Releases/i}));
  433. await userEvent.click(screen.getByText('Table'));
  434. await userEvent.click(screen.getByText('Line Chart'));
  435. expect(screen.getByText('crash_free_rate(…)')).toBeInTheDocument();
  436. expect(screen.getByText(`session`)).toBeInTheDocument();
  437. await userEvent.click(screen.getByText('crash_free_rate(…)'));
  438. expect(screen.getByText('count_unique(…)')).toBeInTheDocument();
  439. await userEvent.click(screen.getByText('count_unique(…)'));
  440. expect(screen.getByText('user')).toBeInTheDocument();
  441. });
  442. it('sets widgetType to release', async function () {
  443. setMockDate(new Date('2022-08-02'));
  444. renderTestComponent();
  445. await userEvent.click(await screen.findByText('Releases (Sessions, Crash rates)'), {
  446. delay: null,
  447. });
  448. expect(metricsDataMock).toHaveBeenCalled();
  449. expect(screen.getByRole('radio', {name: /Releases/i})).toBeChecked();
  450. });
  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. setMockDate(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. });