widgetBuilderDataset.spec.tsx 41 KB

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