widgetBuilderDataset.spec.tsx 40 KB

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