widgetBuilderDataset.spec.tsx 42 KB

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