widgetBuilderDataset.spec.tsx 40 KB

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