widgetBuilderDataset.spec.tsx 41 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307
  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('renders with a release search bar', async function () {
  490. renderTestComponent();
  491. await 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. await userEvent.click(screen.getByText('Releases (Sessions, Crash rates)'));
  499. await 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. await userEvent.click(await screen.findByText('Releases (Sessions, Crash rates)'), {
  512. delay: null,
  513. });
  514. await selectEvent.select(screen.getByText('crash_free_rate(…)'), 'environment');
  515. // 1 in the table header, 1 in the column selector, and 1 in the sort by
  516. expect(screen.getAllByText(/crash_free_rate/)).toHaveLength(3);
  517. expect(screen.getAllByText('environment')).toHaveLength(2);
  518. });
  519. });
  520. describe('Issue Widgets', function () {
  521. it('sets widgetType to issues', async function () {
  522. const handleSave = jest.fn();
  523. renderTestComponent({onSave: handleSave});
  524. await userEvent.click(
  525. await screen.findByText('Issues (States, Assignment, Time, etc.)')
  526. );
  527. await userEvent.click(screen.getByLabelText('Add Widget'));
  528. await waitFor(() => {
  529. expect(handleSave).toHaveBeenCalledWith([
  530. expect.objectContaining({
  531. title: 'Custom Widget',
  532. displayType: DisplayType.TABLE,
  533. interval: '5m',
  534. widgetType: WidgetType.ISSUE,
  535. queries: [
  536. {
  537. conditions: '',
  538. fields: ['issue', 'assignee', 'title'],
  539. columns: ['issue', 'assignee', 'title'],
  540. aggregates: [],
  541. fieldAliases: [],
  542. name: '',
  543. orderby: 'date',
  544. },
  545. ],
  546. }),
  547. ]);
  548. });
  549. expect(handleSave).toHaveBeenCalledTimes(1);
  550. });
  551. it('render issues dataset disabled when the display type is not set to table', async function () {
  552. renderTestComponent({
  553. query: {
  554. source: DashboardWidgetSource.DISCOVERV2,
  555. },
  556. });
  557. await userEvent.click(await screen.findByText('Table'));
  558. await userEvent.click(screen.getByText('Line Chart'));
  559. expect(
  560. screen.getByRole('radio', {
  561. name: 'Errors and Transactions',
  562. })
  563. ).toBeEnabled();
  564. expect(
  565. screen.getByRole('radio', {
  566. name: 'Issues (States, Assignment, Time, etc.)',
  567. })
  568. ).toBeDisabled();
  569. });
  570. it('disables moving and deleting issue column', async function () {
  571. renderTestComponent();
  572. await userEvent.click(
  573. await screen.findByText('Issues (States, Assignment, Time, etc.)')
  574. );
  575. expect(
  576. within(screen.getByTestId('choose-column-step')).getByText('issue')
  577. ).toBeInTheDocument();
  578. expect(
  579. within(screen.getByTestId('choose-column-step')).getByText('assignee')
  580. ).toBeInTheDocument();
  581. expect(
  582. within(screen.getByTestId('choose-column-step')).getByText('title')
  583. ).toBeInTheDocument();
  584. expect(
  585. within(screen.getByTestId('choose-column-step')).getAllByLabelText(
  586. 'Remove column'
  587. )
  588. ).toHaveLength(2);
  589. expect(
  590. within(screen.getByTestId('choose-column-step')).getAllByLabelText(
  591. 'Drag to reorder'
  592. )
  593. ).toHaveLength(3);
  594. await userEvent.click(screen.getAllByLabelText('Remove column')[1]);
  595. await userEvent.click(screen.getAllByLabelText('Remove column')[0]);
  596. expect(
  597. within(screen.getByTestId('choose-column-step')).getByText('issue')
  598. ).toBeInTheDocument();
  599. expect(
  600. within(screen.getByTestId('choose-column-step')).queryByText('assignee')
  601. ).not.toBeInTheDocument();
  602. expect(
  603. within(screen.getByTestId('choose-column-step')).queryByText('title')
  604. ).not.toBeInTheDocument();
  605. expect(
  606. within(screen.getByTestId('choose-column-step')).queryByLabelText('Remove column')
  607. ).not.toBeInTheDocument();
  608. expect(
  609. within(screen.getByTestId('choose-column-step')).queryByLabelText(
  610. 'Drag to reorder'
  611. )
  612. ).not.toBeInTheDocument();
  613. });
  614. it('issue query does not work on default search bar', async function () {
  615. renderTestComponent();
  616. const input = (await screen.findByPlaceholderText(
  617. 'Search for events, users, tags, and more'
  618. )) as HTMLTextAreaElement;
  619. await userEvent.type(input, 'bookmarks');
  620. input.setSelectionRange(9, 9);
  621. expect(await screen.findByText('No items found')).toBeInTheDocument();
  622. });
  623. it('renders with an issues search bar when selected in dataset selection', async function () {
  624. renderTestComponent();
  625. await userEvent.click(
  626. await screen.findByText('Issues (States, Assignment, Time, etc.)')
  627. );
  628. const input = (await screen.findByPlaceholderText(
  629. 'Search for issues, status, assigned, and more'
  630. )) as HTMLTextAreaElement;
  631. await userEvent.type(input, 'is:');
  632. input.setSelectionRange(3, 3);
  633. expect(await screen.findByText('resolved')).toBeInTheDocument();
  634. });
  635. it('Update table header values (field alias)', async function () {
  636. const handleSave = jest.fn();
  637. renderTestComponent({
  638. onSave: handleSave,
  639. });
  640. await screen.findByText('Table');
  641. await userEvent.click(screen.getByText('Issues (States, Assignment, Time, etc.)'));
  642. await userEvent.type(screen.getAllByPlaceholderText('Alias')[0], 'First Alias');
  643. await userEvent.click(screen.getByText('Add Widget'));
  644. await waitFor(() => {
  645. expect(handleSave).toHaveBeenCalledWith([
  646. expect.objectContaining({
  647. queries: [
  648. expect.objectContaining({
  649. fieldAliases: ['First Alias', '', ''],
  650. }),
  651. ],
  652. }),
  653. ]);
  654. });
  655. });
  656. });
  657. describe('Events Widgets', function () {
  658. describe('Custom Performance Metrics', function () {
  659. it('can choose a custom measurement', async function () {
  660. measurementsMetaMock = MockApiClient.addMockResponse({
  661. url: '/organizations/org-slug/measurements-meta/',
  662. method: 'GET',
  663. body: {'measurements.custom.measurement': {functions: ['p99']}},
  664. });
  665. eventsMock = MockApiClient.addMockResponse({
  666. url: '/organizations/org-slug/events/',
  667. method: 'GET',
  668. statusCode: 200,
  669. body: {
  670. meta: {
  671. fields: {'p99(measurements.total.db.calls)': 'duration'},
  672. isMetricsData: true,
  673. },
  674. data: [{'p99(measurements.total.db.calls)': 10}],
  675. },
  676. });
  677. const {router} = renderTestComponent({
  678. query: {source: DashboardWidgetSource.DISCOVERV2},
  679. dashboard: testDashboard,
  680. orgFeatures: [...defaultOrgFeatures],
  681. });
  682. expect(await screen.findByText('Custom Widget')).toBeInTheDocument();
  683. // 1 in the table header, 1 in the column selector, 1 in the sort field
  684. const countFields = screen.getAllByText('count()');
  685. expect(countFields).toHaveLength(3);
  686. await selectEvent.select(countFields[1], ['p99(…)']);
  687. await selectEvent.select(screen.getByText('transaction.duration'), [
  688. 'measurements.custom.measurement',
  689. ]);
  690. await userEvent.click(screen.getByText('Add Widget'));
  691. await waitFor(() => {
  692. expect(router.push).toHaveBeenCalledWith(
  693. expect.objectContaining({
  694. pathname: '/organizations/org-slug/dashboard/2/',
  695. query: {
  696. displayType: 'table',
  697. interval: '5m',
  698. title: 'Custom Widget',
  699. queryNames: [''],
  700. queryConditions: [''],
  701. queryFields: ['p99(measurements.custom.measurement)'],
  702. queryOrderby: '-p99(measurements.custom.measurement)',
  703. start: null,
  704. end: null,
  705. statsPeriod: '24h',
  706. utc: null,
  707. project: [],
  708. environment: [],
  709. },
  710. })
  711. );
  712. });
  713. });
  714. it('raises an alert banner but allows saving widget if widget result is not metrics data and widget is using custom measurements', async function () {
  715. eventsMock = MockApiClient.addMockResponse({
  716. url: '/organizations/org-slug/events/',
  717. method: 'GET',
  718. statusCode: 200,
  719. body: {
  720. meta: {
  721. fields: {'p99(measurements.custom.measurement)': 'duration'},
  722. isMetricsData: false,
  723. },
  724. data: [{'p99(measurements.custom.measurement)': 10}],
  725. },
  726. });
  727. const defaultWidgetQuery = {
  728. name: '',
  729. fields: ['p99(measurements.custom.measurement)'],
  730. columns: [],
  731. aggregates: ['p99(measurements.custom.measurement)'],
  732. conditions: 'user:test.user@sentry.io',
  733. orderby: '',
  734. };
  735. const defaultTableColumns = ['p99(measurements.custom.measurement)'];
  736. renderTestComponent({
  737. query: {
  738. source: DashboardWidgetSource.DISCOVERV2,
  739. defaultWidgetQuery: urlEncode(defaultWidgetQuery),
  740. displayType: DisplayType.TABLE,
  741. defaultTableColumns,
  742. },
  743. orgFeatures: [
  744. ...defaultOrgFeatures,
  745. 'dashboards-mep',
  746. 'dynamic-sampling',
  747. 'mep-rollout-flag',
  748. ],
  749. });
  750. await waitFor(() => {
  751. expect(measurementsMetaMock).toHaveBeenCalled();
  752. });
  753. await waitFor(() => {
  754. expect(eventsMock).toHaveBeenCalled();
  755. });
  756. screen.getByText('Your selection is only applicable to', {exact: false});
  757. expect(screen.getByText('Add Widget').closest('button')).toBeEnabled();
  758. });
  759. it('raises an alert banner if widget result is not metrics data', async function () {
  760. eventsMock = MockApiClient.addMockResponse({
  761. url: '/organizations/org-slug/events/',
  762. method: 'GET',
  763. statusCode: 200,
  764. body: {
  765. meta: {
  766. fields: {'p99(measurements.lcp)': 'duration'},
  767. isMetricsData: false,
  768. },
  769. data: [{'p99(measurements.lcp)': 10}],
  770. },
  771. });
  772. const defaultWidgetQuery = {
  773. name: '',
  774. fields: ['p99(measurements.lcp)'],
  775. columns: [],
  776. aggregates: ['p99(measurements.lcp)'],
  777. conditions: 'user:test.user@sentry.io',
  778. orderby: '',
  779. };
  780. const defaultTableColumns = ['p99(measurements.lcp)'];
  781. renderTestComponent({
  782. query: {
  783. source: DashboardWidgetSource.DISCOVERV2,
  784. defaultWidgetQuery: urlEncode(defaultWidgetQuery),
  785. displayType: DisplayType.TABLE,
  786. defaultTableColumns,
  787. },
  788. orgFeatures: [
  789. ...defaultOrgFeatures,
  790. 'dashboards-mep',
  791. 'dynamic-sampling',
  792. 'mep-rollout-flag',
  793. ],
  794. });
  795. await waitFor(() => {
  796. expect(measurementsMetaMock).toHaveBeenCalled();
  797. });
  798. await waitFor(() => {
  799. expect(eventsMock).toHaveBeenCalled();
  800. });
  801. screen.getByText('Your selection is only applicable to', {exact: false});
  802. });
  803. it('does not raise an alert banner if widget result is not metrics data but widget contains error fields', async function () {
  804. eventsMock = MockApiClient.addMockResponse({
  805. url: '/organizations/org-slug/events/',
  806. method: 'GET',
  807. statusCode: 200,
  808. body: {
  809. meta: {
  810. fields: {'p99(measurements.lcp)': 'duration'},
  811. isMetricsData: false,
  812. },
  813. data: [{'p99(measurements.lcp)': 10}],
  814. },
  815. });
  816. const defaultWidgetQuery = {
  817. name: '',
  818. fields: ['p99(measurements.lcp)'],
  819. columns: ['error.handled'],
  820. aggregates: ['p99(measurements.lcp)'],
  821. conditions: 'user:test.user@sentry.io',
  822. orderby: '',
  823. };
  824. const defaultTableColumns = ['p99(measurements.lcp)'];
  825. renderTestComponent({
  826. query: {
  827. source: DashboardWidgetSource.DISCOVERV2,
  828. defaultWidgetQuery: urlEncode(defaultWidgetQuery),
  829. displayType: DisplayType.TABLE,
  830. defaultTableColumns,
  831. },
  832. orgFeatures: [...defaultOrgFeatures, 'dashboards-mep'],
  833. });
  834. await waitFor(() => {
  835. expect(measurementsMetaMock).toHaveBeenCalled();
  836. });
  837. await waitFor(() => {
  838. expect(eventsMock).toHaveBeenCalled();
  839. });
  840. expect(
  841. screen.queryByText('Your selection is only applicable to', {exact: false})
  842. ).not.toBeInTheDocument();
  843. });
  844. it('only displays custom measurements in supported functions', async function () {
  845. measurementsMetaMock = MockApiClient.addMockResponse({
  846. url: '/organizations/org-slug/measurements-meta/',
  847. method: 'GET',
  848. body: {
  849. 'measurements.custom.measurement': {functions: ['p99']},
  850. 'measurements.another.custom.measurement': {functions: ['p95']},
  851. },
  852. });
  853. renderTestComponent({
  854. query: {source: DashboardWidgetSource.DISCOVERV2},
  855. dashboard: testDashboard,
  856. orgFeatures: [...defaultOrgFeatures],
  857. });
  858. expect(await screen.findByText('Custom Widget')).toBeInTheDocument();
  859. await selectEvent.select(screen.getAllByText('count()')[1], ['p99(…)']);
  860. await userEvent.click(screen.getByText('transaction.duration'));
  861. screen.getByText('measurements.custom.measurement');
  862. expect(
  863. screen.queryByText('measurements.another.custom.measurement')
  864. ).not.toBeInTheDocument();
  865. await selectEvent.select(screen.getAllByText('p99(…)')[0], ['p95(…)']);
  866. await userEvent.click(screen.getByText('transaction.duration'));
  867. screen.getByText('measurements.another.custom.measurement');
  868. expect(
  869. screen.queryByText('measurements.custom.measurement')
  870. ).not.toBeInTheDocument();
  871. });
  872. it('renders custom performance metric using duration units from events meta', async function () {
  873. eventsMock = MockApiClient.addMockResponse({
  874. url: '/organizations/org-slug/events/',
  875. method: 'GET',
  876. statusCode: 200,
  877. body: {
  878. meta: {
  879. fields: {'p99(measurements.custom.measurement)': 'duration'},
  880. isMetricsData: true,
  881. units: {'p99(measurements.custom.measurement)': 'hour'},
  882. },
  883. data: [{'p99(measurements.custom.measurement)': 12}],
  884. },
  885. });
  886. renderTestComponent({
  887. query: {source: DashboardWidgetSource.DISCOVERV2},
  888. dashboard: {
  889. ...testDashboard,
  890. widgets: [
  891. {
  892. title: 'Custom Measurement Widget',
  893. interval: '1d',
  894. id: '1',
  895. widgetType: WidgetType.DISCOVER,
  896. displayType: DisplayType.TABLE,
  897. queries: [
  898. {
  899. conditions: '',
  900. name: '',
  901. fields: ['p99(measurements.custom.measurement)'],
  902. columns: [],
  903. aggregates: ['p99(measurements.custom.measurement)'],
  904. orderby: '-p99(measurements.custom.measurement)',
  905. },
  906. ],
  907. },
  908. ],
  909. },
  910. params: {
  911. widgetIndex: '0',
  912. },
  913. orgFeatures: [...defaultOrgFeatures],
  914. });
  915. await screen.findByText('12.00hr');
  916. });
  917. it('renders custom performance metric using size units from events meta', async function () {
  918. eventsMock = MockApiClient.addMockResponse({
  919. url: '/organizations/org-slug/events/',
  920. method: 'GET',
  921. statusCode: 200,
  922. body: {
  923. meta: {
  924. fields: {'p99(measurements.custom.measurement)': 'size'},
  925. isMetricsData: true,
  926. units: {'p99(measurements.custom.measurement)': 'kibibyte'},
  927. },
  928. data: [{'p99(measurements.custom.measurement)': 12}],
  929. },
  930. });
  931. renderTestComponent({
  932. query: {source: DashboardWidgetSource.DISCOVERV2},
  933. dashboard: {
  934. ...testDashboard,
  935. widgets: [
  936. {
  937. title: 'Custom Measurement Widget',
  938. interval: '1d',
  939. id: '1',
  940. widgetType: WidgetType.DISCOVER,
  941. displayType: DisplayType.TABLE,
  942. queries: [
  943. {
  944. conditions: '',
  945. name: '',
  946. fields: ['p99(measurements.custom.measurement)'],
  947. columns: [],
  948. aggregates: ['p99(measurements.custom.measurement)'],
  949. orderby: '-p99(measurements.custom.measurement)',
  950. },
  951. ],
  952. },
  953. ],
  954. },
  955. params: {
  956. widgetIndex: '0',
  957. },
  958. orgFeatures: [...defaultOrgFeatures],
  959. });
  960. await screen.findByText('12.0 KiB');
  961. });
  962. it('renders custom performance metric using abyte format size units from events meta', async function () {
  963. eventsMock = MockApiClient.addMockResponse({
  964. url: '/organizations/org-slug/events/',
  965. method: 'GET',
  966. statusCode: 200,
  967. body: {
  968. meta: {
  969. fields: {'p99(measurements.custom.measurement)': 'size'},
  970. isMetricsData: true,
  971. units: {'p99(measurements.custom.measurement)': 'kilobyte'},
  972. },
  973. data: [{'p99(measurements.custom.measurement)': 12000}],
  974. },
  975. });
  976. renderTestComponent({
  977. query: {source: DashboardWidgetSource.DISCOVERV2},
  978. dashboard: {
  979. ...testDashboard,
  980. widgets: [
  981. {
  982. title: 'Custom Measurement Widget',
  983. interval: '1d',
  984. id: '1',
  985. widgetType: WidgetType.DISCOVER,
  986. displayType: DisplayType.TABLE,
  987. queries: [
  988. {
  989. conditions: '',
  990. name: '',
  991. fields: ['p99(measurements.custom.measurement)'],
  992. columns: [],
  993. aggregates: ['p99(measurements.custom.measurement)'],
  994. orderby: '-p99(measurements.custom.measurement)',
  995. },
  996. ],
  997. },
  998. ],
  999. },
  1000. params: {
  1001. widgetIndex: '0',
  1002. },
  1003. orgFeatures: [...defaultOrgFeatures],
  1004. });
  1005. await screen.findByText('12 MB');
  1006. });
  1007. it('displays saved custom performance metric in column select', async function () {
  1008. renderTestComponent({
  1009. query: {source: DashboardWidgetSource.DISCOVERV2},
  1010. dashboard: {
  1011. ...testDashboard,
  1012. widgets: [
  1013. {
  1014. title: 'Custom Measurement Widget',
  1015. interval: '1d',
  1016. id: '1',
  1017. widgetType: WidgetType.DISCOVER,
  1018. displayType: DisplayType.TABLE,
  1019. queries: [
  1020. {
  1021. conditions: '',
  1022. name: '',
  1023. fields: ['p99(measurements.custom.measurement)'],
  1024. columns: [],
  1025. aggregates: ['p99(measurements.custom.measurement)'],
  1026. orderby: '-p99(measurements.custom.measurement)',
  1027. },
  1028. ],
  1029. },
  1030. ],
  1031. },
  1032. params: {
  1033. widgetIndex: '0',
  1034. },
  1035. orgFeatures: [...defaultOrgFeatures],
  1036. });
  1037. await screen.findByText('measurements.custom.measurement');
  1038. });
  1039. it('displays custom performance metric in column select dropdown', async function () {
  1040. measurementsMetaMock = MockApiClient.addMockResponse({
  1041. url: '/organizations/org-slug/measurements-meta/',
  1042. method: 'GET',
  1043. body: {'measurements.custom.measurement': {functions: ['p99']}},
  1044. });
  1045. renderTestComponent({
  1046. query: {source: DashboardWidgetSource.DISCOVERV2},
  1047. dashboard: {
  1048. ...testDashboard,
  1049. widgets: [
  1050. {
  1051. title: 'Custom Measurement Widget',
  1052. interval: '1d',
  1053. id: '1',
  1054. widgetType: WidgetType.DISCOVER,
  1055. displayType: DisplayType.TABLE,
  1056. queries: [
  1057. {
  1058. conditions: '',
  1059. name: '',
  1060. fields: ['transaction', 'count()'],
  1061. columns: ['transaction'],
  1062. aggregates: ['count()'],
  1063. orderby: '-count()',
  1064. },
  1065. ],
  1066. },
  1067. ],
  1068. },
  1069. params: {
  1070. widgetIndex: '0',
  1071. },
  1072. orgFeatures: [...defaultOrgFeatures],
  1073. });
  1074. await screen.findByText('transaction');
  1075. await userEvent.click(screen.getAllByText('count()')[1]);
  1076. expect(screen.getByText('measurements.custom.measurement')).toBeInTheDocument();
  1077. });
  1078. it('does not default to sorting by transaction when columns change', async function () {
  1079. renderTestComponent({
  1080. query: {source: DashboardWidgetSource.DISCOVERV2},
  1081. dashboard: {
  1082. ...testDashboard,
  1083. widgets: [
  1084. {
  1085. title: 'Custom Measurement Widget',
  1086. interval: '1d',
  1087. id: '1',
  1088. widgetType: WidgetType.DISCOVER,
  1089. displayType: DisplayType.TABLE,
  1090. queries: [
  1091. {
  1092. conditions: '',
  1093. name: '',
  1094. fields: [
  1095. 'p99(measurements.custom.measurement)',
  1096. 'transaction',
  1097. 'count()',
  1098. ],
  1099. columns: ['transaction'],
  1100. aggregates: ['p99(measurements.custom.measurement)', 'count()'],
  1101. orderby: '-p99(measurements.custom.measurement)',
  1102. },
  1103. ],
  1104. },
  1105. ],
  1106. },
  1107. params: {
  1108. widgetIndex: '0',
  1109. },
  1110. orgFeatures: [...defaultOrgFeatures],
  1111. });
  1112. expect(
  1113. await screen.findByText('p99(measurements.custom.measurement)')
  1114. ).toBeInTheDocument();
  1115. // Delete p99(measurements.custom.measurement) column
  1116. await userEvent.click(screen.getAllByLabelText('Remove column')[0]);
  1117. expect(
  1118. screen.queryByText('p99(measurements.custom.measurement)')
  1119. ).not.toBeInTheDocument();
  1120. expect(
  1121. within(screen.getByTestId('sort-by-step')).queryByText('transaction')
  1122. ).not.toBeInTheDocument();
  1123. expect(
  1124. within(screen.getByTestId('sort-by-step')).getByText('count()')
  1125. ).toBeInTheDocument();
  1126. });
  1127. });
  1128. });
  1129. });