widgetBuilderDataset.spec.tsx 40 KB

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