widgetBuilderDataset.spec.tsx 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272
  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/dashboardsV2/types';
  13. import WidgetBuilder, {WidgetBuilderProps} from 'sentry/views/dashboardsV2/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. TagStore.reset();
  234. });
  235. afterEach(function () {
  236. MockApiClient.clearMockResponses();
  237. jest.clearAllMocks();
  238. jest.useRealTimers();
  239. });
  240. describe('Release Widgets', function () {
  241. it('shows the Release Health dataset', async function () {
  242. renderTestComponent();
  243. expect(await screen.findByText('Errors and Transactions')).toBeInTheDocument();
  244. expect(screen.getByText('Releases (Sessions, Crash rates)')).toBeInTheDocument();
  245. });
  246. it('maintains the selected dataset when display type is changed', async function () {
  247. renderTestComponent();
  248. expect(
  249. await screen.findByText('Releases (Sessions, Crash rates)')
  250. ).toBeInTheDocument();
  251. expect(screen.getByLabelText(/releases/i)).not.toBeChecked();
  252. userEvent.click(screen.getByLabelText(/releases/i));
  253. await waitFor(() => expect(screen.getByLabelText(/releases/i)).toBeChecked());
  254. userEvent.click(screen.getByText('Table'));
  255. userEvent.click(screen.getByText('Line Chart'));
  256. await waitFor(() => expect(screen.getByLabelText(/releases/i)).toBeChecked());
  257. });
  258. it('displays releases tags', async function () {
  259. renderTestComponent();
  260. expect(
  261. await screen.findByText('Releases (Sessions, Crash rates)')
  262. ).toBeInTheDocument();
  263. userEvent.click(screen.getByLabelText(/releases/i));
  264. expect(screen.getByText('crash_free_rate(…)')).toBeInTheDocument();
  265. expect(screen.getByText('session')).toBeInTheDocument();
  266. userEvent.click(screen.getByText('crash_free_rate(…)'));
  267. expect(screen.getByText('count_unique(…)')).toBeInTheDocument();
  268. expect(screen.getByText('release')).toBeInTheDocument();
  269. expect(screen.getByText('environment')).toBeInTheDocument();
  270. expect(screen.getByText('session.status')).toBeInTheDocument();
  271. userEvent.click(screen.getByText('count_unique(…)'));
  272. expect(screen.getByText('user')).toBeInTheDocument();
  273. });
  274. it('does not display tags as params', async function () {
  275. renderTestComponent();
  276. expect(
  277. await screen.findByText('Releases (Sessions, Crash rates)')
  278. ).toBeInTheDocument();
  279. userEvent.click(screen.getByLabelText(/releases/i));
  280. expect(screen.getByText('crash_free_rate(…)')).toBeInTheDocument();
  281. await selectEvent.select(screen.getByText('crash_free_rate(…)'), 'count_unique(…)');
  282. userEvent.click(screen.getByText('user'));
  283. expect(screen.queryByText('release')).not.toBeInTheDocument();
  284. expect(screen.queryByText('environment')).not.toBeInTheDocument();
  285. expect(screen.queryByText('session.status')).not.toBeInTheDocument();
  286. });
  287. it('does not allow sort by when session.status is selected', async function () {
  288. renderTestComponent();
  289. expect(
  290. await screen.findByText('Releases (Sessions, Crash rates)')
  291. ).toBeInTheDocument();
  292. userEvent.click(screen.getByLabelText(/releases/i));
  293. expect(screen.getByText('High to low')).toBeEnabled();
  294. expect(screen.getByText('crash_free_rate(session)')).toBeInTheDocument();
  295. userEvent.click(screen.getByLabelText('Add a Column'));
  296. await selectEvent.select(screen.getByText('(Required)'), 'session.status');
  297. expect(screen.getByRole('textbox', {name: 'Sort direction'})).toBeDisabled();
  298. expect(screen.getByRole('textbox', {name: 'Sort by'})).toBeDisabled();
  299. });
  300. it('does not allow sort on tags except release', async function () {
  301. jest.useFakeTimers().setSystemTime(new Date('2022-08-02'));
  302. renderTestComponent();
  303. expect(
  304. await screen.findByText('Releases (Sessions, Crash rates)')
  305. ).toBeInTheDocument();
  306. userEvent.click(screen.getByLabelText(/releases/i));
  307. expect(screen.getByText('High to low')).toBeEnabled();
  308. expect(screen.getByText('crash_free_rate(session)')).toBeInTheDocument();
  309. userEvent.click(screen.getByLabelText('Add a Column'));
  310. await selectEvent.select(screen.getByText('(Required)'), 'release');
  311. userEvent.click(screen.getByLabelText('Add a Column'));
  312. await selectEvent.select(screen.getByText('(Required)'), 'environment');
  313. expect(await screen.findByText('Sort by a column')).toBeInTheDocument();
  314. // Selector "sortDirection"
  315. expect(screen.getByText('High to low')).toBeInTheDocument();
  316. // Selector "sortBy"
  317. userEvent.click(screen.getAllByText('crash_free_rate(session)')[1]);
  318. // release exists in sort by selector
  319. expect(screen.getAllByText('release')).toHaveLength(3);
  320. // environment does not exist in sort by selector
  321. expect(screen.getAllByText('environment')).toHaveLength(2);
  322. });
  323. it('makes the appropriate sessions call', async function () {
  324. jest.useFakeTimers().setSystemTime(new Date('2022-08-02'));
  325. renderTestComponent();
  326. expect(
  327. await screen.findByText('Releases (Sessions, Crash rates)')
  328. ).toBeInTheDocument();
  329. userEvent.click(screen.getByLabelText(/releases/i));
  330. userEvent.click(screen.getByText('Table'));
  331. userEvent.click(screen.getByText('Line Chart'));
  332. await waitFor(() =>
  333. expect(metricsDataMock).toHaveBeenLastCalledWith(
  334. `/organizations/org-slug/metrics/data/`,
  335. expect.objectContaining({
  336. query: expect.objectContaining({
  337. environment: [],
  338. field: [`session.crash_free_rate`],
  339. groupBy: [],
  340. interval: '5m',
  341. project: [],
  342. statsPeriod: '24h',
  343. }),
  344. })
  345. )
  346. );
  347. });
  348. it('calls the session endpoint with the right limit', async function () {
  349. jest.useFakeTimers().setSystemTime(new Date('2022-08-02'));
  350. renderTestComponent();
  351. expect(
  352. await screen.findByText('Releases (Sessions, Crash rates)')
  353. ).toBeInTheDocument();
  354. userEvent.click(screen.getByLabelText(/releases/i));
  355. userEvent.click(screen.getByText('Table'));
  356. userEvent.click(screen.getByText('Line Chart'));
  357. await selectEvent.select(await screen.findByText('Select group'), 'project');
  358. expect(screen.getByText('Limit to 5 results')).toBeInTheDocument();
  359. await waitFor(() =>
  360. expect(metricsDataMock).toHaveBeenLastCalledWith(
  361. `/organizations/org-slug/metrics/data/`,
  362. expect.objectContaining({
  363. query: expect.objectContaining({
  364. environment: [],
  365. field: ['session.crash_free_rate'],
  366. groupBy: ['project_id'],
  367. interval: '5m',
  368. orderBy: '-session.crash_free_rate',
  369. per_page: 5,
  370. project: [],
  371. statsPeriod: '24h',
  372. }),
  373. })
  374. )
  375. );
  376. });
  377. it('calls sessions api when session.status is selected as a groupby', async function () {
  378. jest.useFakeTimers().setSystemTime(new Date('2022-08-02'));
  379. renderTestComponent();
  380. expect(
  381. await screen.findByText('Releases (Sessions, Crash rates)')
  382. ).toBeInTheDocument();
  383. userEvent.click(screen.getByLabelText(/releases/i));
  384. userEvent.click(screen.getByText('Table'));
  385. userEvent.click(screen.getByText('Line Chart'));
  386. await selectEvent.select(await screen.findByText('Select group'), 'session.status');
  387. expect(screen.getByText('Limit to 5 results')).toBeInTheDocument();
  388. await waitFor(() =>
  389. expect(sessionsDataMock).toHaveBeenLastCalledWith(
  390. `/organizations/org-slug/sessions/`,
  391. expect.objectContaining({
  392. query: expect.objectContaining({
  393. environment: [],
  394. field: ['crash_free_rate(session)'],
  395. groupBy: ['session.status'],
  396. interval: '5m',
  397. project: [],
  398. statsPeriod: '24h',
  399. }),
  400. })
  401. )
  402. );
  403. });
  404. it('displays the correct options for area chart', async function () {
  405. renderTestComponent();
  406. expect(
  407. await screen.findByText('Releases (Sessions, Crash rates)')
  408. ).toBeInTheDocument();
  409. // change dataset to releases
  410. userEvent.click(screen.getByLabelText(/releases/i));
  411. userEvent.click(screen.getByText('Table'));
  412. userEvent.click(screen.getByText('Line Chart'));
  413. expect(screen.getByText('crash_free_rate(…)')).toBeInTheDocument();
  414. expect(screen.getByText(`session`)).toBeInTheDocument();
  415. userEvent.click(screen.getByText('crash_free_rate(…)'));
  416. expect(screen.getByText('count_unique(…)')).toBeInTheDocument();
  417. userEvent.click(screen.getByText('count_unique(…)'));
  418. expect(screen.getByText('user')).toBeInTheDocument();
  419. });
  420. it('sets widgetType to release', async function () {
  421. jest.useFakeTimers().setSystemTime(new Date('2022-08-02'));
  422. renderTestComponent();
  423. userEvent.click(await screen.findByText('Releases (Sessions, Crash rates)'));
  424. expect(metricsDataMock).toHaveBeenCalled();
  425. expect(screen.getByLabelText(/Releases/i)).toBeChecked();
  426. });
  427. it('does not display "add an equation" button', async function () {
  428. const widget: Widget = {
  429. title: 'Release Widget',
  430. displayType: DisplayType.TABLE,
  431. widgetType: WidgetType.RELEASE,
  432. queries: [
  433. {
  434. name: 'errors',
  435. conditions: 'event.type:error',
  436. fields: ['sdk.name', 'count()'],
  437. columns: ['sdk.name'],
  438. aggregates: ['count()'],
  439. orderby: '-sdk.name',
  440. },
  441. ],
  442. interval: '1d',
  443. id: '1',
  444. };
  445. const dashboard = mockDashboard({widgets: [widget]});
  446. renderTestComponent({
  447. dashboard,
  448. params: {
  449. widgetIndex: '0',
  450. },
  451. });
  452. // Select line chart display
  453. userEvent.click(await screen.findByText('Table'));
  454. userEvent.click(screen.getByText('Line Chart'));
  455. await waitFor(() =>
  456. expect(screen.queryByLabelText('Add an Equation')).not.toBeInTheDocument()
  457. );
  458. });
  459. it('render release dataset disabled when the display type is world map', async function () {
  460. renderTestComponent({
  461. query: {
  462. source: DashboardWidgetSource.DISCOVERV2,
  463. },
  464. });
  465. userEvent.click(await screen.findByText('Table'));
  466. userEvent.click(screen.getByText('World Map'));
  467. await waitFor(() => expect(screen.getByLabelText(/Releases/)).toBeDisabled());
  468. expect(
  469. screen.getByRole('radio', {
  470. name: 'Errors and Transactions',
  471. })
  472. ).toBeEnabled();
  473. expect(
  474. screen.getByRole('radio', {
  475. name: 'Issues (States, Assignment, Time, etc.)',
  476. })
  477. ).toBeDisabled();
  478. });
  479. it('renders with a release search bar', async function () {
  480. renderTestComponent();
  481. userEvent.type(
  482. await screen.findByPlaceholderText('Search for events, users, tags, and more'),
  483. 'session.status:'
  484. );
  485. await waitFor(() => {
  486. expect(screen.getByText("isn't supported here.")).toBeInTheDocument();
  487. });
  488. userEvent.click(screen.getByText('Releases (Sessions, Crash rates)'));
  489. userEvent.click(
  490. screen.getByPlaceholderText(
  491. 'Search for release version, session status, and more'
  492. )
  493. );
  494. expect(await screen.findByText('environment')).toBeInTheDocument();
  495. expect(screen.getByText('project')).toBeInTheDocument();
  496. expect(screen.getByText('release')).toBeInTheDocument();
  497. });
  498. it('adds a function when the only column chosen in a table is a tag', async function () {
  499. jest.useFakeTimers().setSystemTime(new Date('2022-08-02'));
  500. renderTestComponent();
  501. userEvent.click(await screen.findByText('Releases (Sessions, Crash rates)'));
  502. await selectEvent.select(screen.getByText('crash_free_rate(…)'), 'environment');
  503. // 1 in the table header, 1 in the column selector, and 1 in the sort by
  504. expect(screen.getAllByText(/crash_free_rate/)).toHaveLength(3);
  505. expect(screen.getAllByText('environment')).toHaveLength(2);
  506. });
  507. });
  508. describe('Issue Widgets', function () {
  509. it('sets widgetType to issues', async function () {
  510. const handleSave = jest.fn();
  511. renderTestComponent({onSave: handleSave});
  512. userEvent.click(await screen.findByText('Issues (States, Assignment, Time, etc.)'));
  513. userEvent.click(screen.getByLabelText('Add Widget'));
  514. await waitFor(() => {
  515. expect(handleSave).toHaveBeenCalledWith([
  516. expect.objectContaining({
  517. title: 'Custom Widget',
  518. displayType: DisplayType.TABLE,
  519. interval: '5m',
  520. widgetType: WidgetType.ISSUE,
  521. queries: [
  522. {
  523. conditions: '',
  524. fields: ['issue', 'assignee', 'title'],
  525. columns: ['issue', 'assignee', 'title'],
  526. aggregates: [],
  527. fieldAliases: [],
  528. name: '',
  529. orderby: 'date',
  530. },
  531. ],
  532. }),
  533. ]);
  534. });
  535. expect(handleSave).toHaveBeenCalledTimes(1);
  536. });
  537. it('render issues dataset disabled when the display type is not set to table', async function () {
  538. renderTestComponent({
  539. query: {
  540. source: DashboardWidgetSource.DISCOVERV2,
  541. },
  542. });
  543. userEvent.click(await screen.findByText('Table'));
  544. userEvent.click(screen.getByText('Line Chart'));
  545. expect(
  546. screen.getByRole('radio', {
  547. name: 'Errors and Transactions',
  548. })
  549. ).toBeEnabled();
  550. expect(
  551. screen.getByRole('radio', {
  552. name: 'Issues (States, Assignment, Time, etc.)',
  553. })
  554. ).toBeDisabled();
  555. });
  556. it('disables moving and deleting issue column', async function () {
  557. renderTestComponent();
  558. userEvent.click(await screen.findByText('Issues (States, Assignment, Time, etc.)'));
  559. expect(screen.getByText('issue')).toBeInTheDocument();
  560. expect(screen.getByText('assignee')).toBeInTheDocument();
  561. expect(screen.getByText('title')).toBeInTheDocument();
  562. expect(screen.getAllByLabelText('Remove column')).toHaveLength(2);
  563. expect(screen.getAllByLabelText('Drag to reorder')).toHaveLength(3);
  564. userEvent.click(screen.getAllByLabelText('Remove column')[1]);
  565. userEvent.click(screen.getAllByLabelText('Remove column')[0]);
  566. expect(screen.getByText('issue')).toBeInTheDocument();
  567. expect(screen.queryByText('assignee')).not.toBeInTheDocument();
  568. expect(screen.queryByText('title')).not.toBeInTheDocument();
  569. expect(screen.queryByLabelText('Remove column')).not.toBeInTheDocument();
  570. expect(screen.queryByLabelText('Drag to reorder')).not.toBeInTheDocument();
  571. });
  572. it('issue query does not work on default search bar', async function () {
  573. renderTestComponent();
  574. const input = (await screen.findByPlaceholderText(
  575. 'Search for events, users, tags, and more'
  576. )) as HTMLTextAreaElement;
  577. userEvent.paste(input, 'bookmarks', {
  578. clipboardData: {getData: () => ''},
  579. } as unknown as React.ClipboardEvent<HTMLTextAreaElement>);
  580. input.setSelectionRange(9, 9);
  581. expect(await screen.findByText('No items found')).toBeInTheDocument();
  582. });
  583. it('renders with an issues search bar when selected in dataset selection', async function () {
  584. renderTestComponent();
  585. userEvent.click(await screen.findByText('Issues (States, Assignment, Time, etc.)'));
  586. const input = (await screen.findByPlaceholderText(
  587. 'Search for issues, status, assigned, and more'
  588. )) as HTMLTextAreaElement;
  589. userEvent.paste(input, 'is:', {
  590. clipboardData: {getData: () => ''},
  591. } as unknown as React.ClipboardEvent<HTMLTextAreaElement>);
  592. input.setSelectionRange(3, 3);
  593. expect(await screen.findByText('resolved')).toBeInTheDocument();
  594. });
  595. it('Update table header values (field alias)', async function () {
  596. const handleSave = jest.fn();
  597. renderTestComponent({
  598. onSave: handleSave,
  599. });
  600. await screen.findByText('Table');
  601. userEvent.click(screen.getByText('Issues (States, Assignment, Time, etc.)'));
  602. userEvent.paste(screen.getAllByPlaceholderText('Alias')[0], 'First Alias');
  603. userEvent.click(screen.getByText('Add Widget'));
  604. await waitFor(() => {
  605. expect(handleSave).toHaveBeenCalledWith([
  606. expect.objectContaining({
  607. queries: [
  608. expect.objectContaining({
  609. fieldAliases: ['First Alias', '', ''],
  610. }),
  611. ],
  612. }),
  613. ]);
  614. });
  615. });
  616. });
  617. describe('Events Widgets', function () {
  618. describe('Custom Performance Metrics', function () {
  619. it('can choose a custom measurement', async function () {
  620. measurementsMetaMock = MockApiClient.addMockResponse({
  621. url: '/organizations/org-slug/measurements-meta/',
  622. method: 'GET',
  623. body: {'measurements.custom.measurement': {functions: ['p99']}},
  624. });
  625. eventsMock = MockApiClient.addMockResponse({
  626. url: '/organizations/org-slug/events/',
  627. method: 'GET',
  628. statusCode: 200,
  629. body: {
  630. meta: {
  631. fields: {'p99(measurements.total.db.calls)': 'duration'},
  632. isMetricsData: true,
  633. },
  634. data: [{'p99(measurements.total.db.calls)': 10}],
  635. },
  636. });
  637. const {router} = renderTestComponent({
  638. query: {source: DashboardWidgetSource.DISCOVERV2},
  639. dashboard: testDashboard,
  640. orgFeatures: [...defaultOrgFeatures, 'discover-frontend-use-events-endpoint'],
  641. });
  642. expect(await screen.findAllByText('Custom Widget')).toHaveLength(2);
  643. // 1 in the table header, 1 in the column selector, 1 in the sort field
  644. const countFields = screen.getAllByText('count()');
  645. expect(countFields).toHaveLength(3);
  646. await selectEvent.select(countFields[1], ['p99(…)']);
  647. await selectEvent.select(screen.getByText('transaction.duration'), [
  648. 'measurements.custom.measurement',
  649. ]);
  650. userEvent.click(screen.getByText('Add Widget'));
  651. await waitFor(() => {
  652. expect(router.push).toHaveBeenCalledWith(
  653. expect.objectContaining({
  654. pathname: '/organizations/org-slug/dashboard/2/',
  655. query: {
  656. displayType: 'table',
  657. interval: '5m',
  658. title: 'Custom Widget',
  659. queryNames: [''],
  660. queryConditions: [''],
  661. queryFields: ['p99(measurements.custom.measurement)'],
  662. queryOrderby: '-p99(measurements.custom.measurement)',
  663. start: null,
  664. end: null,
  665. statsPeriod: '24h',
  666. utc: null,
  667. project: [],
  668. environment: [],
  669. },
  670. })
  671. );
  672. });
  673. });
  674. it('raises an alert banner but allows saving widget if widget result is not metrics data and widget is using custom measurements', async function () {
  675. eventsMock = MockApiClient.addMockResponse({
  676. url: '/organizations/org-slug/events/',
  677. method: 'GET',
  678. statusCode: 200,
  679. body: {
  680. meta: {
  681. fields: {'p99(measurements.custom.measurement)': 'duration'},
  682. isMetricsData: false,
  683. },
  684. data: [{'p99(measurements.custom.measurement)': 10}],
  685. },
  686. });
  687. const defaultWidgetQuery = {
  688. name: '',
  689. fields: ['p99(measurements.custom.measurement)'],
  690. columns: [],
  691. aggregates: ['p99(measurements.custom.measurement)'],
  692. conditions: 'user:test.user@sentry.io',
  693. orderby: '',
  694. };
  695. const defaultTableColumns = ['p99(measurements.custom.measurement)'];
  696. renderTestComponent({
  697. query: {
  698. source: DashboardWidgetSource.DISCOVERV2,
  699. defaultWidgetQuery: urlEncode(defaultWidgetQuery),
  700. displayType: DisplayType.TABLE,
  701. defaultTableColumns,
  702. },
  703. orgFeatures: [
  704. ...defaultOrgFeatures,
  705. 'discover-frontend-use-events-endpoint',
  706. 'dashboards-mep',
  707. 'server-side-sampling',
  708. 'mep-rollout-flag',
  709. ],
  710. });
  711. await waitFor(() => {
  712. expect(measurementsMetaMock).toHaveBeenCalled();
  713. });
  714. await waitFor(() => {
  715. expect(eventsMock).toHaveBeenCalled();
  716. });
  717. screen.getByText('Your selection is only applicable to');
  718. expect(screen.getByText('Add Widget').closest('button')).toBeEnabled();
  719. });
  720. it('raises an alert banner if widget result is not metrics data', async function () {
  721. eventsMock = MockApiClient.addMockResponse({
  722. url: '/organizations/org-slug/events/',
  723. method: 'GET',
  724. statusCode: 200,
  725. body: {
  726. meta: {
  727. fields: {'p99(measurements.lcp)': 'duration'},
  728. isMetricsData: false,
  729. },
  730. data: [{'p99(measurements.lcp)': 10}],
  731. },
  732. });
  733. const defaultWidgetQuery = {
  734. name: '',
  735. fields: ['p99(measurements.lcp)'],
  736. columns: [],
  737. aggregates: ['p99(measurements.lcp)'],
  738. conditions: 'user:test.user@sentry.io',
  739. orderby: '',
  740. };
  741. const defaultTableColumns = ['p99(measurements.lcp)'];
  742. renderTestComponent({
  743. query: {
  744. source: DashboardWidgetSource.DISCOVERV2,
  745. defaultWidgetQuery: urlEncode(defaultWidgetQuery),
  746. displayType: DisplayType.TABLE,
  747. defaultTableColumns,
  748. },
  749. orgFeatures: [
  750. ...defaultOrgFeatures,
  751. 'discover-frontend-use-events-endpoint',
  752. 'dashboards-mep',
  753. 'server-side-sampling',
  754. 'mep-rollout-flag',
  755. ],
  756. });
  757. await waitFor(() => {
  758. expect(measurementsMetaMock).toHaveBeenCalled();
  759. });
  760. await waitFor(() => {
  761. expect(eventsMock).toHaveBeenCalled();
  762. });
  763. screen.getByText('Your selection is only applicable to');
  764. });
  765. it('does not raise an alert banner if widget result is not metrics data but widget contains error fields', async function () {
  766. eventsMock = MockApiClient.addMockResponse({
  767. url: '/organizations/org-slug/events/',
  768. method: 'GET',
  769. statusCode: 200,
  770. body: {
  771. meta: {
  772. fields: {'p99(measurements.lcp)': 'duration'},
  773. isMetricsData: false,
  774. },
  775. data: [{'p99(measurements.lcp)': 10}],
  776. },
  777. });
  778. const defaultWidgetQuery = {
  779. name: '',
  780. fields: ['p99(measurements.lcp)'],
  781. columns: ['error.handled'],
  782. aggregates: ['p99(measurements.lcp)'],
  783. conditions: 'user:test.user@sentry.io',
  784. orderby: '',
  785. };
  786. const defaultTableColumns = ['p99(measurements.lcp)'];
  787. renderTestComponent({
  788. query: {
  789. source: DashboardWidgetSource.DISCOVERV2,
  790. defaultWidgetQuery: urlEncode(defaultWidgetQuery),
  791. displayType: DisplayType.TABLE,
  792. defaultTableColumns,
  793. },
  794. orgFeatures: [
  795. ...defaultOrgFeatures,
  796. 'discover-frontend-use-events-endpoint',
  797. 'dashboards-mep',
  798. ],
  799. });
  800. await waitFor(() => {
  801. expect(measurementsMetaMock).toHaveBeenCalled();
  802. });
  803. await waitFor(() => {
  804. expect(eventsMock).toHaveBeenCalled();
  805. });
  806. expect(
  807. screen.queryByText('Your selection is only applicable to')
  808. ).not.toBeInTheDocument();
  809. });
  810. it('only displays custom measurements in supported functions', async function () {
  811. measurementsMetaMock = MockApiClient.addMockResponse({
  812. url: '/organizations/org-slug/measurements-meta/',
  813. method: 'GET',
  814. body: {
  815. 'measurements.custom.measurement': {functions: ['p99']},
  816. 'measurements.another.custom.measurement': {functions: ['p95']},
  817. },
  818. });
  819. renderTestComponent({
  820. query: {source: DashboardWidgetSource.DISCOVERV2},
  821. dashboard: testDashboard,
  822. orgFeatures: [...defaultOrgFeatures, 'discover-frontend-use-events-endpoint'],
  823. });
  824. expect(await screen.findAllByText('Custom Widget')).toHaveLength(2);
  825. await selectEvent.select(screen.getAllByText('count()')[1], ['p99(…)']);
  826. userEvent.click(screen.getByText('transaction.duration'));
  827. screen.getByText('measurements.custom.measurement');
  828. expect(
  829. screen.queryByText('measurements.another.custom.measurement')
  830. ).not.toBeInTheDocument();
  831. await selectEvent.select(screen.getAllByText('p99(…)')[0], ['p95(…)']);
  832. userEvent.click(screen.getByText('transaction.duration'));
  833. screen.getByText('measurements.another.custom.measurement');
  834. expect(
  835. screen.queryByText('measurements.custom.measurement')
  836. ).not.toBeInTheDocument();
  837. });
  838. it('renders custom performance metric using duration units from events meta', async function () {
  839. eventsMock = MockApiClient.addMockResponse({
  840. url: '/organizations/org-slug/events/',
  841. method: 'GET',
  842. statusCode: 200,
  843. body: {
  844. meta: {
  845. fields: {'p99(measurements.custom.measurement)': 'duration'},
  846. isMetricsData: true,
  847. units: {'p99(measurements.custom.measurement)': 'hour'},
  848. },
  849. data: [{'p99(measurements.custom.measurement)': 12}],
  850. },
  851. });
  852. renderTestComponent({
  853. query: {source: DashboardWidgetSource.DISCOVERV2},
  854. dashboard: {
  855. ...testDashboard,
  856. widgets: [
  857. {
  858. title: 'Custom Measurement Widget',
  859. interval: '1d',
  860. id: '1',
  861. widgetType: WidgetType.DISCOVER,
  862. displayType: DisplayType.TABLE,
  863. queries: [
  864. {
  865. conditions: '',
  866. name: '',
  867. fields: ['p99(measurements.custom.measurement)'],
  868. columns: [],
  869. aggregates: ['p99(measurements.custom.measurement)'],
  870. orderby: '-p99(measurements.custom.measurement)',
  871. },
  872. ],
  873. },
  874. ],
  875. },
  876. params: {
  877. widgetIndex: '0',
  878. },
  879. orgFeatures: [...defaultOrgFeatures, 'discover-frontend-use-events-endpoint'],
  880. });
  881. await screen.findByText('12.00hr');
  882. });
  883. it('renders custom performance metric using size units from events meta', async function () {
  884. eventsMock = MockApiClient.addMockResponse({
  885. url: '/organizations/org-slug/events/',
  886. method: 'GET',
  887. statusCode: 200,
  888. body: {
  889. meta: {
  890. fields: {'p99(measurements.custom.measurement)': 'size'},
  891. isMetricsData: true,
  892. units: {'p99(measurements.custom.measurement)': 'kibibyte'},
  893. },
  894. data: [{'p99(measurements.custom.measurement)': 12}],
  895. },
  896. });
  897. renderTestComponent({
  898. query: {source: DashboardWidgetSource.DISCOVERV2},
  899. dashboard: {
  900. ...testDashboard,
  901. widgets: [
  902. {
  903. title: 'Custom Measurement Widget',
  904. interval: '1d',
  905. id: '1',
  906. widgetType: WidgetType.DISCOVER,
  907. displayType: DisplayType.TABLE,
  908. queries: [
  909. {
  910. conditions: '',
  911. name: '',
  912. fields: ['p99(measurements.custom.measurement)'],
  913. columns: [],
  914. aggregates: ['p99(measurements.custom.measurement)'],
  915. orderby: '-p99(measurements.custom.measurement)',
  916. },
  917. ],
  918. },
  919. ],
  920. },
  921. params: {
  922. widgetIndex: '0',
  923. },
  924. orgFeatures: [...defaultOrgFeatures, 'discover-frontend-use-events-endpoint'],
  925. });
  926. await screen.findByText('12.0 KiB');
  927. });
  928. it('renders custom performance metric using abyte format size units from events meta', async function () {
  929. eventsMock = MockApiClient.addMockResponse({
  930. url: '/organizations/org-slug/events/',
  931. method: 'GET',
  932. statusCode: 200,
  933. body: {
  934. meta: {
  935. fields: {'p99(measurements.custom.measurement)': 'size'},
  936. isMetricsData: true,
  937. units: {'p99(measurements.custom.measurement)': 'kilobyte'},
  938. },
  939. data: [{'p99(measurements.custom.measurement)': 12000}],
  940. },
  941. });
  942. renderTestComponent({
  943. query: {source: DashboardWidgetSource.DISCOVERV2},
  944. dashboard: {
  945. ...testDashboard,
  946. widgets: [
  947. {
  948. title: 'Custom Measurement Widget',
  949. interval: '1d',
  950. id: '1',
  951. widgetType: WidgetType.DISCOVER,
  952. displayType: DisplayType.TABLE,
  953. queries: [
  954. {
  955. conditions: '',
  956. name: '',
  957. fields: ['p99(measurements.custom.measurement)'],
  958. columns: [],
  959. aggregates: ['p99(measurements.custom.measurement)'],
  960. orderby: '-p99(measurements.custom.measurement)',
  961. },
  962. ],
  963. },
  964. ],
  965. },
  966. params: {
  967. widgetIndex: '0',
  968. },
  969. orgFeatures: [...defaultOrgFeatures, 'discover-frontend-use-events-endpoint'],
  970. });
  971. await screen.findByText('12 MB');
  972. });
  973. it('displays saved custom performance metric in column select', async function () {
  974. renderTestComponent({
  975. query: {source: DashboardWidgetSource.DISCOVERV2},
  976. dashboard: {
  977. ...testDashboard,
  978. widgets: [
  979. {
  980. title: 'Custom Measurement Widget',
  981. interval: '1d',
  982. id: '1',
  983. widgetType: WidgetType.DISCOVER,
  984. displayType: DisplayType.TABLE,
  985. queries: [
  986. {
  987. conditions: '',
  988. name: '',
  989. fields: ['p99(measurements.custom.measurement)'],
  990. columns: [],
  991. aggregates: ['p99(measurements.custom.measurement)'],
  992. orderby: '-p99(measurements.custom.measurement)',
  993. },
  994. ],
  995. },
  996. ],
  997. },
  998. params: {
  999. widgetIndex: '0',
  1000. },
  1001. orgFeatures: [...defaultOrgFeatures, 'discover-frontend-use-events-endpoint'],
  1002. });
  1003. await screen.findByText('measurements.custom.measurement');
  1004. });
  1005. it('displays custom performance metric in column select dropdown', async function () {
  1006. measurementsMetaMock = MockApiClient.addMockResponse({
  1007. url: '/organizations/org-slug/measurements-meta/',
  1008. method: 'GET',
  1009. body: {'measurements.custom.measurement': {functions: ['p99']}},
  1010. });
  1011. renderTestComponent({
  1012. query: {source: DashboardWidgetSource.DISCOVERV2},
  1013. dashboard: {
  1014. ...testDashboard,
  1015. widgets: [
  1016. {
  1017. title: 'Custom Measurement Widget',
  1018. interval: '1d',
  1019. id: '1',
  1020. widgetType: WidgetType.DISCOVER,
  1021. displayType: DisplayType.TABLE,
  1022. queries: [
  1023. {
  1024. conditions: '',
  1025. name: '',
  1026. fields: ['transaction', 'count()'],
  1027. columns: ['transaction'],
  1028. aggregates: ['count()'],
  1029. orderby: '-count()',
  1030. },
  1031. ],
  1032. },
  1033. ],
  1034. },
  1035. params: {
  1036. widgetIndex: '0',
  1037. },
  1038. orgFeatures: [...defaultOrgFeatures, 'discover-frontend-use-events-endpoint'],
  1039. });
  1040. await screen.findByText('transaction');
  1041. userEvent.click(screen.getAllByText('count()')[1]);
  1042. expect(screen.getByText('measurements.custom.measurement')).toBeInTheDocument();
  1043. });
  1044. it('does not default to sorting by transaction when columns change', async function () {
  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: [
  1061. 'p99(measurements.custom.measurement)',
  1062. 'transaction',
  1063. 'count()',
  1064. ],
  1065. columns: ['transaction'],
  1066. aggregates: ['p99(measurements.custom.measurement)', 'count()'],
  1067. orderby: '-p99(measurements.custom.measurement)',
  1068. },
  1069. ],
  1070. },
  1071. ],
  1072. },
  1073. params: {
  1074. widgetIndex: '0',
  1075. },
  1076. orgFeatures: [...defaultOrgFeatures, 'discover-frontend-use-events-endpoint'],
  1077. });
  1078. expect(
  1079. await screen.findByText('p99(measurements.custom.measurement)')
  1080. ).toBeInTheDocument();
  1081. // Delete p99(measurements.custom.measurement) column
  1082. userEvent.click(screen.getAllByLabelText('Remove column')[0]);
  1083. expect(
  1084. screen.queryByText('p99(measurements.custom.measurement)')
  1085. ).not.toBeInTheDocument();
  1086. expect(screen.getAllByText('transaction').length).toEqual(1);
  1087. expect(screen.getAllByText('count()').length).toEqual(2);
  1088. });
  1089. });
  1090. });
  1091. });