widgetBuilderDataset.spec.tsx 41 KB

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