widgetBuilder.spec.tsx 59 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032
  1. import selectEvent from 'react-select-event';
  2. import {urlEncode} from '@sentry/utils';
  3. import {initializeOrg} from 'sentry-test/initializeOrg';
  4. import {
  5. act,
  6. fireEvent,
  7. render,
  8. renderGlobalModal,
  9. screen,
  10. userEvent,
  11. waitFor,
  12. } from 'sentry-test/reactTestingLibrary';
  13. import * as modals from 'sentry/actionCreators/modal';
  14. import TagStore from 'sentry/stores/tagStore';
  15. import {TOP_N} from 'sentry/utils/discover/types';
  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. ];
  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. /**
  100. * This test suite contains tests that test the generic interactions
  101. * between most components in the WidgetBuilder. Tests for the
  102. * SortBy step can be found in (and should be added to)
  103. * ./widgetBuilderSortBy.spec.tsx and tests for specific dataset
  104. * behaviour can be found (and should be added to) ./widgetBuilderDataset.spec.tsx.
  105. * The test files are broken up to allow better parallelization
  106. * in CI (we currently parallelize files).
  107. */
  108. describe('WidgetBuilder', function () {
  109. const untitledDashboard: DashboardDetails = {
  110. id: '1',
  111. title: 'Untitled Dashboard',
  112. createdBy: undefined,
  113. dateCreated: '2020-01-01T00:00:00.000Z',
  114. widgets: [],
  115. projects: [],
  116. filters: {},
  117. };
  118. const testDashboard: DashboardDetails = {
  119. id: '2',
  120. title: 'Test Dashboard',
  121. createdBy: undefined,
  122. dateCreated: '2020-01-01T00:00:00.000Z',
  123. widgets: [],
  124. projects: [],
  125. filters: {},
  126. };
  127. let eventsStatsMock: jest.Mock | undefined;
  128. let eventsMock: jest.Mock | undefined;
  129. let tagsMock: jest.Mock | undefined;
  130. beforeEach(function () {
  131. MockApiClient.addMockResponse({
  132. url: '/organizations/org-slug/dashboards/',
  133. body: [
  134. {...untitledDashboard, widgetDisplay: [DisplayType.TABLE]},
  135. {...testDashboard, widgetDisplay: [DisplayType.AREA]},
  136. ],
  137. });
  138. MockApiClient.addMockResponse({
  139. url: '/organizations/org-slug/dashboards/widgets/',
  140. method: 'POST',
  141. statusCode: 200,
  142. body: [],
  143. });
  144. eventsMock = MockApiClient.addMockResponse({
  145. url: '/organizations/org-slug/events/',
  146. method: 'GET',
  147. statusCode: 200,
  148. body: {
  149. meta: {fields: {}},
  150. data: [],
  151. },
  152. });
  153. MockApiClient.addMockResponse({
  154. url: '/organizations/org-slug/projects/',
  155. method: 'GET',
  156. body: [],
  157. });
  158. MockApiClient.addMockResponse({
  159. url: '/organizations/org-slug/recent-searches/',
  160. method: 'GET',
  161. body: [],
  162. });
  163. MockApiClient.addMockResponse({
  164. url: '/organizations/org-slug/recent-searches/',
  165. method: 'POST',
  166. body: [],
  167. });
  168. MockApiClient.addMockResponse({
  169. url: '/organizations/org-slug/issues/',
  170. method: 'GET',
  171. body: [],
  172. });
  173. eventsStatsMock = MockApiClient.addMockResponse({
  174. url: '/organizations/org-slug/events-stats/',
  175. body: [],
  176. });
  177. MockApiClient.addMockResponse({
  178. url: '/organizations/org-slug/tags/event.type/values/',
  179. body: [{count: 2, name: 'Nvidia 1080ti'}],
  180. });
  181. MockApiClient.addMockResponse({
  182. url: '/organizations/org-slug/users/',
  183. body: [],
  184. });
  185. MockApiClient.addMockResponse({
  186. method: 'GET',
  187. url: '/organizations/org-slug/sessions/',
  188. body: TestStubs.SessionsField({
  189. field: `sum(session)`,
  190. }),
  191. });
  192. MockApiClient.addMockResponse({
  193. method: 'GET',
  194. url: '/organizations/org-slug/metrics/data/',
  195. body: TestStubs.MetricsField({
  196. field: 'sum(sentry.sessions.session)',
  197. }),
  198. });
  199. tagsMock = MockApiClient.addMockResponse({
  200. url: '/organizations/org-slug/tags/',
  201. method: 'GET',
  202. body: TestStubs.Tags(),
  203. });
  204. MockApiClient.addMockResponse({
  205. url: '/organizations/org-slug/measurements-meta/',
  206. method: 'GET',
  207. body: {},
  208. });
  209. MockApiClient.addMockResponse({
  210. url: '/organizations/org-slug/tags/is/values/',
  211. method: 'GET',
  212. body: [],
  213. });
  214. MockApiClient.addMockResponse({
  215. url: '/organizations/org-slug/tags/transaction.duration/values/',
  216. method: 'GET',
  217. body: [],
  218. });
  219. MockApiClient.addMockResponse({
  220. url: '/organizations/org-slug/releases/',
  221. body: [],
  222. });
  223. TagStore.reset();
  224. });
  225. afterEach(function () {
  226. MockApiClient.clearMockResponses();
  227. jest.clearAllMocks();
  228. jest.useRealTimers();
  229. });
  230. it('no feature access', function () {
  231. renderTestComponent({orgFeatures: []});
  232. expect(screen.getByText("You don't have access to this feature")).toBeInTheDocument();
  233. });
  234. it('widget not found', function () {
  235. const widget: Widget = {
  236. displayType: DisplayType.AREA,
  237. interval: '1d',
  238. queries: [
  239. {
  240. name: 'Known Users',
  241. fields: [],
  242. columns: [],
  243. aggregates: [],
  244. conditions: '',
  245. orderby: '-time',
  246. },
  247. {
  248. name: 'Anonymous Users',
  249. fields: [],
  250. columns: [],
  251. aggregates: [],
  252. conditions: '',
  253. orderby: '-time',
  254. },
  255. ],
  256. title: 'Transactions',
  257. id: '1',
  258. };
  259. const dashboard = mockDashboard({widgets: [widget]});
  260. renderTestComponent({
  261. dashboard,
  262. orgFeatures: ['dashboards-edit'],
  263. params: {
  264. widgetIndex: '2', // Out of bounds, only one widget
  265. },
  266. });
  267. expect(
  268. screen.getByText('The widget you want to edit was not found.')
  269. ).toBeInTheDocument();
  270. });
  271. it('renders a widget not found message if the widget index url is not an integer', function () {
  272. const widget: Widget = {
  273. displayType: DisplayType.AREA,
  274. interval: '1d',
  275. queries: [
  276. {
  277. name: 'Known Users',
  278. fields: [],
  279. columns: [],
  280. aggregates: [],
  281. conditions: '',
  282. orderby: '-time',
  283. },
  284. ],
  285. title: 'Transactions',
  286. id: '1',
  287. };
  288. const dashboard = mockDashboard({widgets: [widget]});
  289. renderTestComponent({
  290. dashboard,
  291. orgFeatures: ['dashboards-edit'],
  292. params: {
  293. widgetIndex: '0.5', // Invalid index
  294. },
  295. });
  296. expect(
  297. screen.getByText('The widget you want to edit was not found.')
  298. ).toBeInTheDocument();
  299. });
  300. it('renders', async function () {
  301. renderTestComponent();
  302. // Header - Breadcrumbs
  303. expect(await screen.findByRole('link', {name: 'Dashboards'})).toHaveAttribute(
  304. 'href',
  305. '/organizations/org-slug/dashboards/'
  306. );
  307. expect(screen.getByRole('link', {name: 'Dashboard'})).toHaveAttribute(
  308. 'href',
  309. '/organizations/org-slug/dashboards/new/'
  310. );
  311. expect(screen.getByText('Widget Builder')).toBeInTheDocument();
  312. // Header - Widget Title
  313. expect(screen.getByText('Custom Widget')).toBeInTheDocument();
  314. // Footer - Actions
  315. expect(screen.getByLabelText('Cancel')).toBeInTheDocument();
  316. expect(screen.getByLabelText('Add Widget')).toBeInTheDocument();
  317. // Content - Step 1
  318. expect(
  319. screen.getByRole('heading', {name: 'Choose your dataset'})
  320. ).toBeInTheDocument();
  321. expect(screen.getByLabelText('Errors and Transactions')).toBeChecked();
  322. // Content - Step 2
  323. expect(
  324. screen.getByRole('heading', {name: 'Choose your visualization'})
  325. ).toBeInTheDocument();
  326. // Content - Step 3
  327. expect(
  328. screen.getByRole('heading', {name: 'Choose your columns'})
  329. ).toBeInTheDocument();
  330. // Content - Step 4
  331. expect(
  332. screen.getByRole('heading', {name: 'Filter your results'})
  333. ).toBeInTheDocument();
  334. // Content - Step 5
  335. expect(screen.getByRole('heading', {name: 'Sort by a column'})).toBeInTheDocument();
  336. });
  337. it('has links back to the new dashboard if creating', async function () {
  338. // Dashboard has undefined dashboardId when creating from a new dashboard
  339. // because of route setup
  340. renderTestComponent({params: {dashboardId: undefined}});
  341. expect(await screen.findByLabelText('Cancel')).toHaveAttribute(
  342. 'href',
  343. '/organizations/org-slug/dashboards/new/'
  344. );
  345. });
  346. it('renders new design', async function () {
  347. renderTestComponent({
  348. orgFeatures: [...defaultOrgFeatures],
  349. });
  350. // Switch to line chart for time series
  351. await userEvent.click(screen.getByText('Table'));
  352. await userEvent.click(screen.getByText('Line Chart'));
  353. // Header - Breadcrumbs
  354. expect(await screen.findByRole('link', {name: 'Dashboards'})).toHaveAttribute(
  355. 'href',
  356. '/organizations/org-slug/dashboards/'
  357. );
  358. expect(screen.getByRole('link', {name: 'Dashboard'})).toHaveAttribute(
  359. 'href',
  360. '/organizations/org-slug/dashboards/new/'
  361. );
  362. expect(screen.getByText('Widget Builder')).toBeInTheDocument();
  363. // Header - Widget Title
  364. expect(screen.getByText('Custom Widget')).toBeInTheDocument();
  365. // Footer - Actions
  366. expect(screen.getByLabelText('Cancel')).toBeInTheDocument();
  367. expect(screen.getByLabelText('Add Widget')).toBeInTheDocument();
  368. // Content - Step 1
  369. expect(
  370. screen.getByRole('heading', {name: 'Choose your dataset'})
  371. ).toBeInTheDocument();
  372. expect(screen.getByLabelText('Errors and Transactions')).toBeChecked();
  373. // Content - Step 2
  374. expect(
  375. screen.getByRole('heading', {name: 'Choose your visualization'})
  376. ).toBeInTheDocument();
  377. // Content - Step 3
  378. expect(
  379. screen.getByRole('heading', {name: 'Choose what to plot in the y-axis'})
  380. ).toBeInTheDocument();
  381. // Content - Step 4
  382. expect(
  383. screen.getByRole('heading', {name: 'Filter your results'})
  384. ).toBeInTheDocument();
  385. // Content - Step 5
  386. expect(screen.getByRole('heading', {name: 'Group your results'})).toBeInTheDocument();
  387. });
  388. it('can update the title', async function () {
  389. renderTestComponent({
  390. query: {source: DashboardWidgetSource.DISCOVERV2},
  391. });
  392. const customWidgetLabels = await screen.findByText('Custom Widget');
  393. // EditableText and chart title
  394. expect(customWidgetLabels).toBeInTheDocument();
  395. await userEvent.clear(screen.getByRole('textbox', {name: 'Widget title'}));
  396. await userEvent.click(screen.getByRole('textbox', {name: 'Widget title'}));
  397. await userEvent.paste('Unique Users');
  398. await userEvent.keyboard('{enter}');
  399. expect(screen.queryByText('Custom Widget')).not.toBeInTheDocument();
  400. expect(screen.getByText('Unique Users')).toBeInTheDocument();
  401. });
  402. it('can add query conditions', async function () {
  403. const {router} = renderTestComponent({
  404. query: {source: DashboardWidgetSource.DISCOVERV2},
  405. dashboard: testDashboard,
  406. });
  407. const search = await screen.findByTestId(/smart-search-input/);
  408. await userEvent.click(search);
  409. // Use fireEvent for performance reasons as this test suite is slow
  410. fireEvent.paste(search, {
  411. target: {value: 'color:blue'},
  412. clipboardData: {getData: () => 'color:blue'},
  413. });
  414. await userEvent.keyboard('{enter}');
  415. await userEvent.click(screen.getByText('Add Widget'));
  416. await waitFor(() => {
  417. expect(router.push).toHaveBeenCalledWith(
  418. expect.objectContaining({
  419. pathname: '/organizations/org-slug/dashboard/2/',
  420. query: {
  421. displayType: 'table',
  422. interval: '5m',
  423. title: 'Custom Widget',
  424. queryNames: [''],
  425. queryConditions: ['color:blue'],
  426. queryFields: ['count()'],
  427. queryOrderby: '-count()',
  428. start: null,
  429. end: null,
  430. statsPeriod: '24h',
  431. utc: null,
  432. project: [],
  433. environment: [],
  434. },
  435. })
  436. );
  437. });
  438. });
  439. it('can choose a field', async function () {
  440. const {router} = renderTestComponent({
  441. query: {source: DashboardWidgetSource.DISCOVERV2},
  442. dashboard: testDashboard,
  443. });
  444. expect(await screen.findByText('Custom Widget')).toBeInTheDocument();
  445. // No delete button as there is only one query.
  446. expect(screen.queryByLabelText('Remove query')).not.toBeInTheDocument();
  447. // 1 in the table header, 1 in the column selector, 1 in the sort field
  448. const countFields = screen.getAllByText('count()');
  449. expect(countFields).toHaveLength(3);
  450. await selectEvent.select(countFields[1], ['last_seen()']);
  451. await userEvent.click(screen.getByText('Add Widget'));
  452. await waitFor(() => {
  453. expect(router.push).toHaveBeenCalledWith(
  454. expect.objectContaining({
  455. pathname: '/organizations/org-slug/dashboard/2/',
  456. query: {
  457. displayType: 'table',
  458. interval: '5m',
  459. title: 'Custom Widget',
  460. queryNames: [''],
  461. queryConditions: [''],
  462. queryFields: ['last_seen()'],
  463. queryOrderby: '-last_seen()',
  464. start: null,
  465. end: null,
  466. statsPeriod: '24h',
  467. utc: null,
  468. project: [],
  469. environment: [],
  470. },
  471. })
  472. );
  473. });
  474. });
  475. it('can add additional fields', async function () {
  476. const handleSave = jest.fn();
  477. renderTestComponent({onSave: handleSave});
  478. await userEvent.click(await screen.findByText('Table'));
  479. // Select line chart display
  480. await userEvent.click(screen.getByText('Line Chart'));
  481. // Click the add overlay button
  482. await userEvent.click(screen.getByLabelText('Add Overlay'));
  483. await selectEvent.select(screen.getByText('(Required)'), ['count_unique(…)']);
  484. await userEvent.click(screen.getByLabelText('Add Widget'));
  485. await waitFor(() => {
  486. expect(handleSave).toHaveBeenCalledWith([
  487. expect.objectContaining({
  488. title: 'Custom Widget',
  489. displayType: DisplayType.LINE,
  490. interval: '5m',
  491. widgetType: WidgetType.DISCOVER,
  492. queries: [
  493. {
  494. conditions: '',
  495. fields: ['count()', 'count_unique(user)'],
  496. aggregates: ['count()', 'count_unique(user)'],
  497. fieldAliases: [],
  498. columns: [],
  499. orderby: '',
  500. name: '',
  501. },
  502. ],
  503. }),
  504. ]);
  505. });
  506. expect(handleSave).toHaveBeenCalledTimes(1);
  507. });
  508. it('can add equation fields', async function () {
  509. const handleSave = jest.fn();
  510. renderTestComponent({onSave: handleSave});
  511. await userEvent.click(await screen.findByText('Table'));
  512. // Select line chart display
  513. await userEvent.click(screen.getByText('Line Chart'));
  514. // Click the add an equation button
  515. await userEvent.click(screen.getByLabelText('Add an Equation'));
  516. expect(screen.getByPlaceholderText('Equation')).toBeInTheDocument();
  517. await userEvent.click(screen.getByPlaceholderText('Equation'));
  518. await userEvent.paste('count() + 100');
  519. await userEvent.click(screen.getByLabelText('Add Widget'));
  520. await waitFor(() => {
  521. expect(handleSave).toHaveBeenCalledWith([
  522. expect.objectContaining({
  523. title: 'Custom Widget',
  524. displayType: DisplayType.LINE,
  525. interval: '5m',
  526. widgetType: WidgetType.DISCOVER,
  527. queries: [
  528. {
  529. name: '',
  530. fields: ['count()', 'equation|count() + 100'],
  531. aggregates: ['count()', 'equation|count() + 100'],
  532. columns: [],
  533. fieldAliases: [],
  534. conditions: '',
  535. orderby: '',
  536. },
  537. ],
  538. }),
  539. ]);
  540. });
  541. expect(handleSave).toHaveBeenCalledTimes(1);
  542. });
  543. it('can respond to validation feedback', async function () {
  544. MockApiClient.addMockResponse({
  545. url: '/organizations/org-slug/dashboards/widgets/',
  546. method: 'POST',
  547. statusCode: 400,
  548. body: {
  549. title: ['This field may not be blank.'],
  550. },
  551. });
  552. renderTestComponent();
  553. await userEvent.click(await screen.findByText('Table'));
  554. const customWidgetLabels = await screen.findByText('Custom Widget');
  555. // EditableText and chart title
  556. expect(customWidgetLabels).toBeInTheDocument();
  557. await userEvent.clear(screen.getByRole('textbox', {name: 'Widget title'}));
  558. await userEvent.click(screen.getByText('Add Widget'));
  559. await screen.findByText('This field may not be blank.');
  560. });
  561. it('sets up widget data in edit correctly', async function () {
  562. const widget: Widget = {
  563. id: '1',
  564. title: 'Errors over time',
  565. interval: '5m',
  566. displayType: DisplayType.LINE,
  567. queries: [
  568. {
  569. name: 'errors',
  570. conditions: 'event.type:error',
  571. fields: ['count()', 'count_unique(id)'],
  572. aggregates: ['count()', 'count_unique(id)'],
  573. columns: [],
  574. orderby: '',
  575. },
  576. {
  577. name: 'csp',
  578. conditions: 'event.type:csp',
  579. fields: ['count()', 'count_unique(id)'],
  580. aggregates: ['count()', 'count_unique(id)'],
  581. columns: [],
  582. orderby: '',
  583. },
  584. ],
  585. };
  586. const dashboard = mockDashboard({widgets: [widget]});
  587. renderTestComponent({dashboard, params: {widgetIndex: '0'}});
  588. await screen.findByText('Line Chart');
  589. // Should be in edit 'mode'
  590. expect(await screen.findByText('Update Widget')).toBeInTheDocument();
  591. // Should set widget data up.
  592. expect(screen.getByText('Update Widget')).toBeInTheDocument();
  593. // Filters
  594. expect(
  595. screen.getAllByPlaceholderText('Search for events, users, tags, and more')
  596. ).toHaveLength(2);
  597. expect(screen.getByText('event.type:csp')).toBeInTheDocument();
  598. expect(screen.getByText('event.type:error')).toBeInTheDocument();
  599. // Y-axis
  600. expect(screen.getAllByRole('button', {name: 'Remove query'})).toHaveLength(2);
  601. expect(screen.getByText('count()')).toBeInTheDocument();
  602. expect(screen.getByText('count_unique(…)')).toBeInTheDocument();
  603. expect(screen.getByText('id')).toBeInTheDocument();
  604. // Expect events-stats endpoint to be called for each search conditions with
  605. // the same y-axis parameters
  606. expect(eventsStatsMock).toHaveBeenNthCalledWith(
  607. 1,
  608. '/organizations/org-slug/events-stats/',
  609. expect.objectContaining({
  610. query: expect.objectContaining({
  611. query: 'event.type:error',
  612. yAxis: ['count()', 'count_unique(id)'],
  613. }),
  614. })
  615. );
  616. expect(eventsStatsMock).toHaveBeenNthCalledWith(
  617. 2,
  618. '/organizations/org-slug/events-stats/',
  619. expect.objectContaining({
  620. query: expect.objectContaining({
  621. query: 'event.type:csp',
  622. yAxis: ['count()', 'count_unique(id)'],
  623. }),
  624. })
  625. );
  626. });
  627. it('can edit a widget', async function () {
  628. const widget: Widget = {
  629. id: '1',
  630. title: 'Errors over time',
  631. interval: '5m',
  632. displayType: DisplayType.LINE,
  633. queries: [
  634. {
  635. name: 'errors',
  636. conditions: 'event.type:error',
  637. fields: ['count()', 'count_unique(id)'],
  638. aggregates: ['count()', 'count_unique(id)'],
  639. columns: [],
  640. orderby: '',
  641. },
  642. {
  643. name: 'csp',
  644. conditions: 'event.type:csp',
  645. fields: ['count()', 'count_unique(id)'],
  646. aggregates: ['count()', 'count_unique(id)'],
  647. columns: [],
  648. orderby: '',
  649. },
  650. ],
  651. };
  652. const dashboard = mockDashboard({widgets: [widget]});
  653. const handleSave = jest.fn();
  654. renderTestComponent({onSave: handleSave, dashboard, params: {widgetIndex: '0'}});
  655. await screen.findByText('Line Chart');
  656. // Should be in edit 'mode'
  657. expect(screen.getByText('Update Widget')).toBeInTheDocument();
  658. const customWidgetLabels = screen.getByText(widget.title);
  659. // EditableText and chart title
  660. expect(customWidgetLabels).toBeInTheDocument();
  661. await userEvent.clear(screen.getByRole('textbox', {name: 'Widget title'}));
  662. await userEvent.click(screen.getByRole('textbox', {name: 'Widget title'}));
  663. await userEvent.paste('New Title');
  664. await userEvent.click(screen.getByRole('button', {name: 'Update Widget'}));
  665. await waitFor(() => {
  666. expect(handleSave).toHaveBeenCalledWith([
  667. expect.objectContaining({
  668. ...widget,
  669. title: 'New Title',
  670. }),
  671. ]);
  672. });
  673. expect(handleSave).toHaveBeenCalledTimes(1);
  674. });
  675. it('renders column inputs for table widgets', async function () {
  676. const widget: Widget = {
  677. id: '0',
  678. title: 'sdk usage',
  679. interval: '5m',
  680. displayType: DisplayType.TABLE,
  681. queries: [
  682. {
  683. name: 'errors',
  684. conditions: 'event.type:error',
  685. fields: ['sdk.name', 'count()'],
  686. columns: ['sdk.name'],
  687. aggregates: ['count()'],
  688. orderby: '',
  689. },
  690. ],
  691. };
  692. const dashboard = mockDashboard({widgets: [widget]});
  693. renderTestComponent({dashboard, params: {widgetIndex: '0'}});
  694. // Should be in edit 'mode'
  695. expect(await screen.findByText('Update Widget')).toBeInTheDocument();
  696. // Should set widget data up.
  697. expect(screen.getByText(widget.title)).toBeInTheDocument();
  698. expect(screen.getByText('Table')).toBeInTheDocument();
  699. expect(screen.getByLabelText('Search events')).toBeInTheDocument();
  700. // Should have an orderby select
  701. expect(screen.getByText('Sort by a column')).toBeInTheDocument();
  702. // Add a column, and choose a value,
  703. expect(screen.getByLabelText('Add a Column')).toBeInTheDocument();
  704. });
  705. it('can save table widgets', async function () {
  706. const widget: Widget = {
  707. id: '0',
  708. title: 'sdk usage',
  709. interval: '5m',
  710. displayType: DisplayType.TABLE,
  711. queries: [
  712. {
  713. name: 'errors',
  714. conditions: 'event.type:error',
  715. fields: ['sdk.name', 'count()'],
  716. columns: ['sdk.name'],
  717. aggregates: ['count()'],
  718. orderby: '-count()',
  719. },
  720. ],
  721. };
  722. const dashboard = mockDashboard({widgets: [widget]});
  723. const handleSave = jest.fn();
  724. renderTestComponent({dashboard, onSave: handleSave, params: {widgetIndex: '0'}});
  725. // Should be in edit 'mode'
  726. expect(await screen.findByText('Update Widget')).toBeInTheDocument();
  727. // Add a column, and choose a value,
  728. await userEvent.click(screen.getByLabelText('Add a Column'));
  729. await selectEvent.select(screen.getByText('(Required)'), 'trace');
  730. // Save widget
  731. await userEvent.click(screen.getByLabelText('Update Widget'));
  732. await waitFor(() => {
  733. expect(handleSave).toHaveBeenCalledWith([
  734. expect.objectContaining({
  735. id: '0',
  736. title: 'sdk usage',
  737. displayType: DisplayType.TABLE,
  738. interval: '5m',
  739. queries: [
  740. {
  741. name: 'errors',
  742. conditions: 'event.type:error',
  743. fields: ['sdk.name', 'count()', 'trace'],
  744. aggregates: ['count()'],
  745. columns: ['sdk.name', 'trace'],
  746. orderby: '-count()',
  747. fieldAliases: ['', '', ''],
  748. },
  749. ],
  750. widgetType: WidgetType.DISCOVER,
  751. }),
  752. ]);
  753. });
  754. expect(handleSave).toHaveBeenCalledTimes(1);
  755. });
  756. it('should properly query for table fields', async function () {
  757. const defaultWidgetQuery = {
  758. name: '',
  759. fields: ['title', 'count()'],
  760. columns: ['title'],
  761. aggregates: ['count()'],
  762. conditions: '',
  763. orderby: '',
  764. };
  765. const defaultTableColumns = ['title', 'count()', 'count_unique(user)', 'epm()'];
  766. renderTestComponent({
  767. query: {
  768. source: DashboardWidgetSource.DISCOVERV2,
  769. defaultWidgetQuery: urlEncode(defaultWidgetQuery),
  770. displayType: DisplayType.LINE,
  771. defaultTableColumns,
  772. },
  773. });
  774. expect(await screen.findByText('Line Chart')).toBeInTheDocument();
  775. await userEvent.click(screen.getByText('Line Chart'));
  776. await userEvent.click(screen.getByText('Table'));
  777. await waitFor(() => {
  778. expect(eventsMock).toHaveBeenLastCalledWith(
  779. '/organizations/org-slug/events/',
  780. expect.objectContaining({
  781. query: expect.objectContaining({
  782. field: defaultTableColumns,
  783. }),
  784. })
  785. );
  786. });
  787. });
  788. it('should use defaultWidgetQuery Y-Axis and Conditions if given a defaultWidgetQuery', async function () {
  789. const defaultWidgetQuery = {
  790. name: '',
  791. fields: ['count()', 'failure_count()', 'count_unique(user)'],
  792. columns: [],
  793. aggregates: ['count()', 'failure_count()', 'count_unique(user)'],
  794. conditions: 'tag:value',
  795. orderby: '',
  796. };
  797. renderTestComponent({
  798. query: {
  799. source: DashboardWidgetSource.DISCOVERV2,
  800. defaultWidgetQuery: urlEncode(defaultWidgetQuery),
  801. },
  802. });
  803. expect(await screen.findByText('tag:value')).toBeInTheDocument();
  804. // Table display, column, and sort field
  805. expect(screen.getAllByText('count()')).toHaveLength(3);
  806. // Table display and column
  807. expect(screen.getAllByText('failure_count()')).toHaveLength(2);
  808. // Table display
  809. expect(screen.getByText('count_unique(user)')).toBeInTheDocument();
  810. // Column
  811. expect(screen.getByText('count_unique(…)')).toBeInTheDocument();
  812. // Column
  813. expect(screen.getByText('user')).toBeInTheDocument();
  814. });
  815. it('uses displayType if given a displayType', async function () {
  816. renderTestComponent({
  817. query: {
  818. displayType: DisplayType.BAR,
  819. },
  820. });
  821. expect(await screen.findByText('Bar Chart')).toBeInTheDocument();
  822. });
  823. it('deletes the widget when the modal is confirmed', async () => {
  824. const handleSave = jest.fn();
  825. const widget: Widget = {
  826. id: '1',
  827. title: 'Errors over time',
  828. interval: '5m',
  829. displayType: DisplayType.LINE,
  830. queries: [
  831. {
  832. name: 'errors',
  833. conditions: 'event.type:error',
  834. fields: ['count()', 'count_unique(id)'],
  835. aggregates: ['count()', 'count_unique(id)'],
  836. columns: [],
  837. orderby: '',
  838. },
  839. {
  840. name: 'csp',
  841. conditions: 'event.type:csp',
  842. fields: ['count()', 'count_unique(id)'],
  843. aggregates: ['count()', 'count_unique(id)'],
  844. columns: [],
  845. orderby: '',
  846. },
  847. ],
  848. };
  849. const dashboard = mockDashboard({widgets: [widget]});
  850. renderTestComponent({onSave: handleSave, dashboard, params: {widgetIndex: '0'}});
  851. await userEvent.click(await screen.findByText('Delete'));
  852. renderGlobalModal();
  853. await userEvent.click(await screen.findByText('Confirm'));
  854. await waitFor(() => {
  855. // The only widget was deleted
  856. expect(handleSave).toHaveBeenCalledWith([]);
  857. });
  858. expect(handleSave).toHaveBeenCalledTimes(1);
  859. });
  860. it('persists the page filter period when updating a widget', async () => {
  861. const widget: Widget = {
  862. id: '1',
  863. title: 'Errors over time',
  864. interval: '5m',
  865. displayType: DisplayType.LINE,
  866. queries: [
  867. {
  868. name: 'errors',
  869. conditions: 'event.type:error',
  870. fields: ['count()', 'count_unique(id)'],
  871. aggregates: ['count()', 'count_unique(id)'],
  872. columns: [],
  873. orderby: '',
  874. },
  875. ],
  876. };
  877. const dashboard = mockDashboard({widgets: [widget]});
  878. const {router} = renderTestComponent({
  879. dashboard,
  880. params: {orgId: 'org-slug', widgetIndex: '0'},
  881. query: {statsPeriod: '90d'},
  882. });
  883. await userEvent.click(screen.getByText('Update Widget'));
  884. await waitFor(() => {
  885. expect(router.push).toHaveBeenLastCalledWith(
  886. expect.objectContaining({
  887. pathname: '/organizations/org-slug/dashboard/1/',
  888. query: expect.objectContaining({
  889. statsPeriod: '90d',
  890. }),
  891. })
  892. );
  893. });
  894. });
  895. it('renders page filters in the filter step', async () => {
  896. const mockReleases = MockApiClient.addMockResponse({
  897. url: '/organizations/org-slug/releases/',
  898. body: [TestStubs.Release()],
  899. });
  900. renderTestComponent({
  901. params: {orgId: 'org-slug'},
  902. query: {statsPeriod: '90d'},
  903. orgFeatures: defaultOrgFeatures,
  904. });
  905. expect(await screen.findByTestId('page-filter-timerange-selector')).toBeDisabled();
  906. expect(screen.getByTestId('page-filter-environment-selector')).toBeDisabled();
  907. expect(screen.getByTestId('page-filter-project-selector-loading')).toBeDisabled();
  908. expect(mockReleases).toHaveBeenCalled();
  909. expect(screen.getByRole('button', {name: /all releases/i})).toBeDisabled();
  910. });
  911. it('appends dashboard filters to widget builder fetch data request', async () => {
  912. MockApiClient.addMockResponse({
  913. url: '/organizations/org-slug/releases/',
  914. body: [TestStubs.Release()],
  915. });
  916. const mock = MockApiClient.addMockResponse({
  917. url: '/organizations/org-slug/events/',
  918. body: [],
  919. });
  920. renderTestComponent({
  921. dashboard: {
  922. id: 'new',
  923. title: 'Dashboard',
  924. createdBy: undefined,
  925. dateCreated: '2020-01-01T00:00:00.000Z',
  926. widgets: [],
  927. projects: [],
  928. filters: {release: ['abc@1.2.0']},
  929. },
  930. params: {orgId: 'org-slug'},
  931. query: {statsPeriod: '90d'},
  932. orgFeatures: defaultOrgFeatures,
  933. });
  934. await waitFor(() => {
  935. expect(mock).toHaveBeenCalledWith(
  936. '/organizations/org-slug/events/',
  937. expect.objectContaining({
  938. query: expect.objectContaining({
  939. query: ' release:abc@1.2.0 ',
  940. }),
  941. })
  942. );
  943. });
  944. });
  945. it('does not error when query conditions field is blurred', async function () {
  946. jest.useFakeTimers();
  947. const widget: Widget = {
  948. id: '0',
  949. title: 'sdk usage',
  950. interval: '5m',
  951. displayType: DisplayType.BAR,
  952. queries: [
  953. {
  954. name: 'filled in',
  955. conditions: 'event.type:error',
  956. fields: ['count()', 'count_unique(id)'],
  957. aggregates: ['count()', 'count_unique(id)'],
  958. columns: [],
  959. orderby: '-count()',
  960. },
  961. ],
  962. };
  963. const dashboard = mockDashboard({widgets: [widget]});
  964. const handleSave = jest.fn();
  965. renderTestComponent({dashboard, onSave: handleSave, params: {widgetIndex: '0'}});
  966. await userEvent.click(await screen.findByLabelText('Add Query'), {delay: null});
  967. // Triggering the onBlur of the new field should not error
  968. await userEvent.click(
  969. screen.getAllByPlaceholderText('Search for events, users, tags, and more')[1],
  970. {delay: null}
  971. );
  972. await userEvent.keyboard('{Escape}', {delay: null});
  973. // Run all timers because the handleBlur contains a setTimeout
  974. jest.runAllTimers();
  975. });
  976. it('does not wipe column changes when filters are modified', async function () {
  977. jest.useFakeTimers();
  978. // widgetIndex: undefined means creating a new widget
  979. renderTestComponent({params: {widgetIndex: undefined}});
  980. await userEvent.click(await screen.findByLabelText('Add a Column'), {delay: null});
  981. await selectEvent.select(screen.getByText('(Required)'), /project/);
  982. // Triggering the onBlur of the filter should not error
  983. await userEvent.click(
  984. screen.getByPlaceholderText('Search for events, users, tags, and more'),
  985. {delay: null}
  986. );
  987. await userEvent.keyboard('{enter}', {delay: null});
  988. expect(await screen.findAllByText('project')).toHaveLength(2);
  989. });
  990. it('renders fields with commas properly', async () => {
  991. const defaultWidgetQuery = {
  992. conditions: '',
  993. fields: ['equation|count_if(transaction.duration,equals,300)*2'],
  994. aggregates: ['equation|count_if(transaction.duration,equals,300)*2'],
  995. columns: [],
  996. orderby: '',
  997. name: '',
  998. };
  999. const defaultTableColumns = [
  1000. 'count_if(transaction.duration,equals,300)',
  1001. 'equation|count_if(transaction.duration,equals,300)*2',
  1002. ];
  1003. renderTestComponent({
  1004. query: {
  1005. source: DashboardWidgetSource.DISCOVERV2,
  1006. defaultWidgetQuery: urlEncode(defaultWidgetQuery),
  1007. defaultTableColumns,
  1008. yAxis: ['equation|count_if(transaction.duration,equals,300)*2'],
  1009. },
  1010. });
  1011. expect(
  1012. await screen.findByText('count_if(transaction.duration,equals,300)*2')
  1013. ).toBeInTheDocument();
  1014. });
  1015. it('sets the correct fields for a top n widget', async () => {
  1016. renderTestComponent({
  1017. orgFeatures: [...defaultOrgFeatures, 'performance-view'],
  1018. query: {
  1019. displayType: DisplayType.TOP_N,
  1020. },
  1021. });
  1022. // Top N now opens as Area Chart
  1023. expect(await screen.findByText('Area Chart')).toBeInTheDocument();
  1024. // Add a group by
  1025. await userEvent.click(screen.getByText('Add Overlay'));
  1026. await selectEvent.select(screen.getByText('Select group'), /project/);
  1027. // Change the y-axis
  1028. await selectEvent.select(screen.getAllByText('count()')[0], 'eps()');
  1029. await waitFor(() => {
  1030. expect(eventsStatsMock).toHaveBeenLastCalledWith(
  1031. '/organizations/org-slug/events-stats/',
  1032. expect.objectContaining({
  1033. query: expect.objectContaining({
  1034. query: '',
  1035. yAxis: ['eps()'],
  1036. field: ['project', 'eps()'],
  1037. topEvents: TOP_N,
  1038. orderby: '-eps()',
  1039. }),
  1040. })
  1041. );
  1042. });
  1043. });
  1044. it('fetches tags when tag store is empty', async function () {
  1045. renderTestComponent();
  1046. await waitFor(() => {
  1047. expect(tagsMock).toHaveBeenCalled();
  1048. });
  1049. });
  1050. it('does not fetch tags when tag store is not empty', async function () {
  1051. await act(async () => {
  1052. TagStore.loadTagsSuccess(TestStubs.Tags());
  1053. renderTestComponent();
  1054. await tick();
  1055. });
  1056. expect(tagsMock).not.toHaveBeenCalled();
  1057. });
  1058. it('excludes the Other series when grouping and using multiple y-axes', async function () {
  1059. renderTestComponent({
  1060. orgFeatures: [...defaultOrgFeatures],
  1061. query: {
  1062. displayType: DisplayType.LINE,
  1063. },
  1064. });
  1065. await selectEvent.select(await screen.findByText('Select group'), 'project');
  1066. await userEvent.click(screen.getByText('Add Overlay'));
  1067. await selectEvent.select(screen.getByText('(Required)'), /count_unique/);
  1068. await waitFor(() => {
  1069. expect(eventsStatsMock).toHaveBeenCalledWith(
  1070. '/organizations/org-slug/events-stats/',
  1071. expect.objectContaining({
  1072. query: expect.objectContaining({excludeOther: '1'}),
  1073. })
  1074. );
  1075. });
  1076. });
  1077. it('excludes the Other series when grouping and using multiple queries', async function () {
  1078. renderTestComponent({
  1079. orgFeatures: [...defaultOrgFeatures],
  1080. query: {
  1081. displayType: DisplayType.LINE,
  1082. },
  1083. });
  1084. await selectEvent.select(await screen.findByText('Select group'), 'project');
  1085. await userEvent.click(screen.getByText('Add Query'));
  1086. await waitFor(() => {
  1087. expect(eventsStatsMock).toHaveBeenCalledWith(
  1088. '/organizations/org-slug/events-stats/',
  1089. expect.objectContaining({
  1090. query: expect.objectContaining({excludeOther: '1'}),
  1091. })
  1092. );
  1093. });
  1094. });
  1095. it('includes Other series when there is only one query and one y-axis', async function () {
  1096. renderTestComponent({
  1097. orgFeatures: [...defaultOrgFeatures],
  1098. query: {
  1099. displayType: DisplayType.LINE,
  1100. },
  1101. });
  1102. await selectEvent.select(await screen.findByText('Select group'), 'project');
  1103. await waitFor(() => {
  1104. expect(eventsStatsMock).toHaveBeenCalledWith(
  1105. '/organizations/org-slug/events-stats/',
  1106. expect.objectContaining({
  1107. query: expect.not.objectContaining({excludeOther: '1'}),
  1108. })
  1109. );
  1110. });
  1111. });
  1112. it('decreases the limit when more y-axes and queries are added', async function () {
  1113. renderTestComponent({
  1114. orgFeatures: [...defaultOrgFeatures],
  1115. query: {
  1116. displayType: DisplayType.LINE,
  1117. },
  1118. });
  1119. await selectEvent.select(await screen.findByText('Select group'), 'project');
  1120. screen.getByText('Limit to 5 results');
  1121. await userEvent.click(screen.getByText('Add Query'));
  1122. await userEvent.click(screen.getByText('Add Overlay'));
  1123. expect(screen.getByText('Limit to 2 results')).toBeInTheDocument();
  1124. });
  1125. it('alerts the user if there are unsaved title changes', async function () {
  1126. const {router} = renderTestComponent();
  1127. const alertMock = jest.fn();
  1128. const setRouteLeaveHookMock = jest.spyOn(router, 'setRouteLeaveHook');
  1129. setRouteLeaveHookMock.mockImplementationOnce((_route, _callback) => {
  1130. alertMock();
  1131. });
  1132. const customWidgetLabels = await screen.findByText('Custom Widget');
  1133. // EditableText and chart title
  1134. expect(customWidgetLabels).toBeInTheDocument();
  1135. // Change title text
  1136. await userEvent.clear(screen.getByRole('textbox', {name: 'Widget title'}));
  1137. await userEvent.click(screen.getByRole('textbox', {name: 'Widget title'}));
  1138. await userEvent.paste('Unique Users');
  1139. await userEvent.keyboard('{Enter}');
  1140. // Click Cancel
  1141. await userEvent.click(screen.getByText('Cancel'));
  1142. // Assert an alert was triggered
  1143. expect(alertMock).toHaveBeenCalled();
  1144. });
  1145. it('alerts the user if there are unsaved description changes', async function () {
  1146. const {router} = renderTestComponent();
  1147. const alertMock = jest.fn();
  1148. const setRouteLeaveHookMock = jest.spyOn(router, 'setRouteLeaveHook');
  1149. setRouteLeaveHookMock.mockImplementationOnce((_route, _callback) => {
  1150. alertMock();
  1151. });
  1152. const descriptionTextArea = await screen.findByRole('textbox', {
  1153. name: 'Widget Description',
  1154. });
  1155. expect(descriptionTextArea).toBeInTheDocument();
  1156. expect(descriptionTextArea).toHaveAttribute(
  1157. 'placeholder',
  1158. 'Enter description (Optional)'
  1159. );
  1160. // Change description text
  1161. await userEvent.clear(descriptionTextArea);
  1162. await userEvent.click(descriptionTextArea);
  1163. await userEvent.paste('This is a description');
  1164. await userEvent.keyboard('{Enter}');
  1165. // Click Cancel
  1166. await userEvent.click(screen.getByText('Cancel'));
  1167. // Assert an alert was triggered
  1168. expect(alertMock).toHaveBeenCalled();
  1169. });
  1170. it('does not trigger alert dialog if no changes', async function () {
  1171. const {router} = renderTestComponent();
  1172. const alertMock = jest.fn();
  1173. const setRouteLeaveHookMock = jest.spyOn(router, 'setRouteLeaveHook');
  1174. setRouteLeaveHookMock.mockImplementationOnce((_route, _callback) => {
  1175. alertMock();
  1176. });
  1177. // Click Cancel
  1178. await userEvent.click(await screen.findByText('Cancel'));
  1179. // Assert an alert was triggered
  1180. expect(alertMock).not.toHaveBeenCalled();
  1181. });
  1182. describe('Widget creation coming from other verticals', function () {
  1183. it('redirects correctly when creating a new dashboard', async function () {
  1184. const {router} = renderTestComponent({
  1185. query: {source: DashboardWidgetSource.DISCOVERV2},
  1186. });
  1187. await userEvent.click(await screen.findByText('Add Widget'));
  1188. await waitFor(() => {
  1189. expect(router.push).toHaveBeenCalledWith(
  1190. expect.objectContaining({
  1191. pathname: '/organizations/org-slug/dashboards/new/',
  1192. query: {
  1193. displayType: 'table',
  1194. interval: '5m',
  1195. title: 'Custom Widget',
  1196. queryNames: [''],
  1197. queryConditions: [''],
  1198. queryFields: ['count()'],
  1199. queryOrderby: '-count()',
  1200. start: null,
  1201. end: null,
  1202. statsPeriod: '24h',
  1203. utc: null,
  1204. project: [],
  1205. environment: [],
  1206. },
  1207. })
  1208. );
  1209. });
  1210. });
  1211. it('redirects correctly when choosing an existing dashboard', async function () {
  1212. const {router} = renderTestComponent({
  1213. query: {source: DashboardWidgetSource.DISCOVERV2},
  1214. dashboard: testDashboard,
  1215. });
  1216. await userEvent.click(await screen.findByText('Add Widget'));
  1217. await waitFor(() => {
  1218. expect(router.push).toHaveBeenCalledWith(
  1219. expect.objectContaining({
  1220. pathname: '/organizations/org-slug/dashboard/2/',
  1221. query: {
  1222. displayType: 'table',
  1223. interval: '5m',
  1224. title: 'Custom Widget',
  1225. queryNames: [''],
  1226. queryConditions: [''],
  1227. queryFields: ['count()'],
  1228. queryOrderby: '-count()',
  1229. start: null,
  1230. end: null,
  1231. statsPeriod: '24h',
  1232. utc: null,
  1233. project: [],
  1234. environment: [],
  1235. },
  1236. })
  1237. );
  1238. });
  1239. });
  1240. it('shows the correct orderby when switching from a line chart to table', async function () {
  1241. const defaultWidgetQuery = {
  1242. name: '',
  1243. fields: ['count_unique(user)'],
  1244. columns: [],
  1245. aggregates: ['count_unique(user)'],
  1246. conditions: '',
  1247. orderby: 'count_unique(user)',
  1248. };
  1249. const defaultTableColumns = ['title', 'count_unique(user)'];
  1250. renderTestComponent({
  1251. orgFeatures: [...defaultOrgFeatures],
  1252. query: {
  1253. source: DashboardWidgetSource.DISCOVERV2,
  1254. defaultWidgetQuery: urlEncode(defaultWidgetQuery),
  1255. displayType: DisplayType.LINE,
  1256. defaultTableColumns,
  1257. },
  1258. });
  1259. await userEvent.click(await screen.findByText('Line Chart'));
  1260. await userEvent.click(screen.getByText('Table'));
  1261. expect(screen.getAllByText('count_unique(user)')[0]).toBeInTheDocument();
  1262. await waitFor(() => {
  1263. expect(eventsMock).toHaveBeenLastCalledWith(
  1264. '/organizations/org-slug/events/',
  1265. expect.objectContaining({
  1266. query: expect.objectContaining({
  1267. field: defaultTableColumns,
  1268. sort: ['count_unique(user)'],
  1269. }),
  1270. })
  1271. );
  1272. });
  1273. });
  1274. it('does not send request with orderby if a timeseries chart without grouping', async function () {
  1275. const defaultWidgetQuery = {
  1276. name: '',
  1277. fields: ['count_unique(user)'],
  1278. columns: [],
  1279. aggregates: ['count_unique(user)'],
  1280. conditions: '',
  1281. orderby: 'count_unique(user)',
  1282. };
  1283. const defaultTableColumns = ['title', 'count_unique(user)'];
  1284. renderTestComponent({
  1285. orgFeatures: [...defaultOrgFeatures],
  1286. query: {
  1287. source: DashboardWidgetSource.DISCOVERV2,
  1288. defaultWidgetQuery: urlEncode(defaultWidgetQuery),
  1289. displayType: DisplayType.LINE,
  1290. defaultTableColumns,
  1291. },
  1292. });
  1293. await waitFor(() => {
  1294. expect(eventsStatsMock).toHaveBeenLastCalledWith(
  1295. '/organizations/org-slug/events-stats/',
  1296. expect.objectContaining({
  1297. query: expect.objectContaining({
  1298. orderby: '',
  1299. }),
  1300. })
  1301. );
  1302. });
  1303. });
  1304. });
  1305. it('opens top-N widgets as area display', async function () {
  1306. const widget: Widget = {
  1307. id: '1',
  1308. title: 'Errors over time',
  1309. interval: '5m',
  1310. displayType: DisplayType.TOP_N,
  1311. queries: [
  1312. {
  1313. name: '',
  1314. conditions: '',
  1315. fields: ['count()', 'count_unique(id)'],
  1316. aggregates: ['count()', 'count_unique(id)'],
  1317. columns: [],
  1318. orderby: '-count()',
  1319. },
  1320. ],
  1321. };
  1322. const dashboard = mockDashboard({widgets: [widget]});
  1323. renderTestComponent({
  1324. orgFeatures: [...defaultOrgFeatures],
  1325. dashboard,
  1326. params: {
  1327. widgetIndex: '0',
  1328. },
  1329. });
  1330. expect(await screen.findByText('Area Chart')).toBeInTheDocument();
  1331. });
  1332. it('Update table header values (field alias)', async function () {
  1333. const handleSave = jest.fn();
  1334. renderTestComponent({
  1335. onSave: handleSave,
  1336. orgFeatures: [...defaultOrgFeatures],
  1337. });
  1338. await userEvent.click(screen.getByPlaceholderText('Alias'));
  1339. await userEvent.paste('First Alias');
  1340. await userEvent.click(screen.getByLabelText('Add a Column'));
  1341. await userEvent.click(screen.getAllByPlaceholderText('Alias')[1]);
  1342. await userEvent.paste('Second Alias');
  1343. await userEvent.click(screen.getByText('Add Widget'));
  1344. await waitFor(() => {
  1345. expect(handleSave).toHaveBeenCalledWith([
  1346. expect.objectContaining({
  1347. queries: [
  1348. expect.objectContaining({fieldAliases: ['First Alias', 'Second Alias']}),
  1349. ],
  1350. }),
  1351. ]);
  1352. });
  1353. });
  1354. it('does not wipe equation aliases when a column alias is updated', async function () {
  1355. renderTestComponent({
  1356. orgFeatures: [...defaultOrgFeatures],
  1357. });
  1358. await userEvent.click(screen.getByText('Add an Equation'));
  1359. await userEvent.click(screen.getAllByPlaceholderText('Alias')[1]);
  1360. await userEvent.paste('This should persist');
  1361. await userEvent.type(screen.getAllByPlaceholderText('Alias')[0], 'A');
  1362. expect(await screen.findByText('This should persist')).toBeInTheDocument();
  1363. });
  1364. it('does not wipe equation aliases when a column selection is made', async function () {
  1365. renderTestComponent({
  1366. orgFeatures: [...defaultOrgFeatures],
  1367. });
  1368. await userEvent.click(screen.getByText('Add an Equation'));
  1369. await userEvent.click(screen.getAllByPlaceholderText('Alias')[1]);
  1370. await userEvent.paste('This should persist');
  1371. // 1 for the table, 1 for the the column selector, 1 for the sort
  1372. await waitFor(() => expect(screen.getAllByText('count()')).toHaveLength(3));
  1373. await selectEvent.select(screen.getAllByText('count()')[1], /count_unique/);
  1374. expect(screen.getByText('This should persist')).toBeInTheDocument();
  1375. });
  1376. it('copies over the orderby from the previous query if adding another', async function () {
  1377. renderTestComponent({
  1378. orgFeatures: [...defaultOrgFeatures],
  1379. });
  1380. await userEvent.click(await screen.findByText('Table'));
  1381. await userEvent.click(screen.getByText('Line Chart'));
  1382. await selectEvent.select(screen.getByText('Select group'), 'project');
  1383. await selectEvent.select(screen.getAllByText('count()')[1], 'count_unique(…)');
  1384. MockApiClient.clearMockResponses();
  1385. eventsStatsMock = MockApiClient.addMockResponse({
  1386. url: '/organizations/org-slug/events-stats/',
  1387. body: [],
  1388. });
  1389. await userEvent.click(screen.getByText('Add Query'));
  1390. // Assert on two calls, one for each query
  1391. const expectedArgs = expect.objectContaining({
  1392. query: expect.objectContaining({
  1393. orderby: '-count_unique(user)',
  1394. }),
  1395. });
  1396. expect(eventsStatsMock).toHaveBeenNthCalledWith(
  1397. 1,
  1398. '/organizations/org-slug/events-stats/',
  1399. expectedArgs
  1400. );
  1401. expect(eventsStatsMock).toHaveBeenNthCalledWith(
  1402. 2,
  1403. '/organizations/org-slug/events-stats/',
  1404. expectedArgs
  1405. );
  1406. });
  1407. it('disables add widget button and prevents widget previewing from firing widget query if widget query condition is invalid', async function () {
  1408. renderTestComponent({
  1409. orgFeatures: [...defaultOrgFeatures],
  1410. });
  1411. await userEvent.click(await screen.findByText('Table'));
  1412. await userEvent.click(screen.getByText('Line Chart'));
  1413. expect(eventsStatsMock).toHaveBeenCalledTimes(1);
  1414. await userEvent.type(
  1415. screen.getByTestId('smart-search-input'),
  1416. 'transaction.duration:123a'
  1417. );
  1418. // Unfocus input
  1419. await userEvent.click(screen.getByText('Filter your results'));
  1420. expect(screen.getByText('Add Widget').closest('button')).toBeDisabled();
  1421. expect(screen.getByText('Widget query condition is invalid.')).toBeInTheDocument();
  1422. expect(eventsStatsMock).toHaveBeenCalledTimes(1);
  1423. });
  1424. describe('Widget Library', function () {
  1425. it('only opens the modal when the query data is changed', async function () {
  1426. const mockModal = jest.spyOn(modals, 'openWidgetBuilderOverwriteModal');
  1427. renderTestComponent();
  1428. await screen.findByText('Widget Library');
  1429. await userEvent.click(screen.getByText('Duration Distribution'));
  1430. // Widget Library, Builder title, and Chart title
  1431. expect(screen.getAllByText('Duration Distribution')).toHaveLength(2);
  1432. // Confirm modal doesn't open because no changes were made
  1433. expect(mockModal).not.toHaveBeenCalled();
  1434. await userEvent.click(screen.getAllByLabelText('Remove this Y-Axis')[0]);
  1435. await userEvent.click(screen.getByText('High Throughput Transactions'));
  1436. // Should not have overwritten widget data, and confirm modal should open
  1437. expect(screen.getAllByText('Duration Distribution')).toHaveLength(2);
  1438. expect(mockModal).toHaveBeenCalled();
  1439. });
  1440. });
  1441. describe('group by field', function () {
  1442. it('does not contain functions as options', async function () {
  1443. renderTestComponent({
  1444. query: {displayType: 'line'},
  1445. orgFeatures: [...defaultOrgFeatures],
  1446. });
  1447. expect(await screen.findByText('Select group')).toBeInTheDocument();
  1448. await userEvent.click(screen.getByText('Select group'));
  1449. // Only one f(x) field set in the y-axis selector
  1450. expect(screen.getByText('f(x)')).toBeInTheDocument();
  1451. });
  1452. it('adds more fields when Add Group is clicked', async function () {
  1453. renderTestComponent({
  1454. query: {displayType: 'line'},
  1455. orgFeatures: [...defaultOrgFeatures],
  1456. });
  1457. await userEvent.click(await screen.findByText('Add Group'));
  1458. expect(await screen.findAllByText('Select group')).toHaveLength(2);
  1459. });
  1460. it("doesn't reset group by when changing y-axis", async function () {
  1461. renderTestComponent({
  1462. query: {displayType: 'line'},
  1463. orgFeatures: [...defaultOrgFeatures],
  1464. });
  1465. await selectEvent.select(await screen.findByText('Select group'), 'project');
  1466. await userEvent.click(screen.getAllByText('count()')[0], {
  1467. skipHover: true,
  1468. });
  1469. await userEvent.click(screen.getByText(/count_unique/), {
  1470. skipHover: true,
  1471. });
  1472. expect(await screen.findByText('project')).toBeInTheDocument();
  1473. });
  1474. it("doesn't erase the selection when switching to another time series", async function () {
  1475. renderTestComponent({
  1476. query: {displayType: 'line'},
  1477. orgFeatures: [...defaultOrgFeatures],
  1478. });
  1479. await selectEvent.select(await screen.findByText('Select group'), 'project');
  1480. await userEvent.click(screen.getByText('Line Chart'));
  1481. await userEvent.click(screen.getByText('Area Chart'));
  1482. expect(await screen.findByText('project')).toBeInTheDocument();
  1483. });
  1484. it('sends a top N request when a grouping is selected', async function () {
  1485. renderTestComponent({
  1486. query: {displayType: 'line'},
  1487. orgFeatures: [...defaultOrgFeatures],
  1488. });
  1489. await userEvent.click(await screen.findByText('Group your results'));
  1490. await userEvent.type(screen.getByText('Select group'), 'project{enter}');
  1491. await waitFor(() =>
  1492. expect(eventsStatsMock).toHaveBeenNthCalledWith(
  1493. 2,
  1494. '/organizations/org-slug/events-stats/',
  1495. expect.objectContaining({
  1496. query: expect.objectContaining({
  1497. query: '',
  1498. yAxis: ['count()'],
  1499. field: ['project', 'count()'],
  1500. topEvents: TOP_N,
  1501. orderby: '-count()',
  1502. }),
  1503. })
  1504. )
  1505. );
  1506. });
  1507. it('allows deleting groups until there is one left', async function () {
  1508. renderTestComponent({
  1509. query: {displayType: 'line'},
  1510. orgFeatures: [...defaultOrgFeatures],
  1511. });
  1512. await userEvent.click(await screen.findByText('Add Group'));
  1513. expect(screen.getAllByLabelText('Remove group')).toHaveLength(2);
  1514. await userEvent.click(screen.getAllByLabelText('Remove group')[1]);
  1515. await waitFor(() =>
  1516. expect(screen.queryByLabelText('Remove group')).not.toBeInTheDocument()
  1517. );
  1518. });
  1519. it("display 'remove' and 'drag to reorder' buttons", async function () {
  1520. renderTestComponent({
  1521. query: {displayType: 'line'},
  1522. orgFeatures: [...defaultOrgFeatures],
  1523. });
  1524. expect(screen.queryByLabelText('Remove group')).not.toBeInTheDocument();
  1525. await selectEvent.select(screen.getByText('Select group'), 'project');
  1526. expect(screen.getByLabelText('Remove group')).toBeInTheDocument();
  1527. expect(screen.queryByLabelText('Drag to reorder')).not.toBeInTheDocument();
  1528. await userEvent.click(screen.getByText('Add Group'));
  1529. expect(screen.getAllByLabelText('Remove group')).toHaveLength(2);
  1530. expect(screen.getAllByLabelText('Drag to reorder')).toHaveLength(2);
  1531. });
  1532. it.todo(
  1533. 'Since simulate drag and drop with RTL is not recommended because of browser layout, remember to create acceptance test for this'
  1534. );
  1535. });
  1536. describe('limit field', function () {
  1537. it('renders if groupBy value is present', async function () {
  1538. const handleSave = jest.fn();
  1539. renderTestComponent({
  1540. query: {displayType: 'line'},
  1541. orgFeatures: [...defaultOrgFeatures],
  1542. onSave: handleSave,
  1543. });
  1544. await selectEvent.select(await screen.findByText('Select group'), 'project');
  1545. expect(screen.getByText('Limit to 5 results')).toBeInTheDocument();
  1546. await userEvent.click(screen.getByText('Add Widget'));
  1547. await waitFor(() =>
  1548. expect(handleSave).toHaveBeenCalledWith([
  1549. expect.objectContaining({
  1550. limit: 5,
  1551. }),
  1552. ])
  1553. );
  1554. });
  1555. it('update value', async function () {
  1556. renderTestComponent({
  1557. query: {displayType: 'line'},
  1558. orgFeatures: [...defaultOrgFeatures],
  1559. });
  1560. await selectEvent.select(await screen.findByText('Select group'), 'project');
  1561. await userEvent.click(screen.getByText('Limit to 5 results'));
  1562. await userEvent.click(screen.getByText('Limit to 2 results'));
  1563. await waitFor(() =>
  1564. expect(eventsStatsMock).toHaveBeenCalledWith(
  1565. '/organizations/org-slug/events-stats/',
  1566. expect.objectContaining({
  1567. query: expect.objectContaining({
  1568. query: '',
  1569. yAxis: ['count()'],
  1570. field: ['project', 'count()'],
  1571. topEvents: 2,
  1572. orderby: '-count()',
  1573. }),
  1574. })
  1575. )
  1576. );
  1577. });
  1578. it('gets removed if no groupBy value', async function () {
  1579. renderTestComponent({
  1580. query: {displayType: 'line'},
  1581. orgFeatures: [...defaultOrgFeatures],
  1582. });
  1583. await selectEvent.select(await screen.findByText('Select group'), 'project');
  1584. expect(screen.getByText('Limit to 5 results')).toBeInTheDocument();
  1585. await userEvent.click(screen.getByLabelText('Remove group'));
  1586. await waitFor(() =>
  1587. expect(screen.queryByText('Limit to 5 results')).not.toBeInTheDocument()
  1588. );
  1589. });
  1590. it('applies a limit when switching from a table to timeseries chart with grouping', async function () {
  1591. const widget: Widget = {
  1592. displayType: DisplayType.TABLE,
  1593. interval: '1d',
  1594. queries: [
  1595. {
  1596. name: 'Test Widget',
  1597. fields: ['count()', 'count_unique(user)', 'epm()', 'project'],
  1598. columns: ['project'],
  1599. aggregates: ['count()', 'count_unique(user)', 'epm()'],
  1600. conditions: '',
  1601. orderby: '',
  1602. },
  1603. ],
  1604. title: 'Transactions',
  1605. id: '1',
  1606. };
  1607. const dashboard = mockDashboard({widgets: [widget]});
  1608. renderTestComponent({
  1609. dashboard,
  1610. orgFeatures: [...defaultOrgFeatures],
  1611. params: {
  1612. widgetIndex: '0',
  1613. },
  1614. });
  1615. await userEvent.click(await screen.findByText('Table'));
  1616. await userEvent.click(screen.getByText('Line Chart'));
  1617. expect(screen.getByText('Limit to 3 results')).toBeInTheDocument();
  1618. expect(eventsStatsMock).toHaveBeenCalledWith(
  1619. '/organizations/org-slug/events-stats/',
  1620. expect.objectContaining({
  1621. query: expect.objectContaining({
  1622. topEvents: 3,
  1623. }),
  1624. })
  1625. );
  1626. });
  1627. it('persists the limit when switching between timeseries charts', async function () {
  1628. const widget: Widget = {
  1629. displayType: DisplayType.AREA,
  1630. interval: '1d',
  1631. queries: [
  1632. {
  1633. name: 'Test Widget',
  1634. fields: ['count()', 'count_unique(user)', 'epm()', 'project'],
  1635. columns: ['project'],
  1636. aggregates: ['count()', 'count_unique(user)', 'epm()'],
  1637. conditions: '',
  1638. orderby: '',
  1639. },
  1640. ],
  1641. title: 'Transactions',
  1642. id: '1',
  1643. limit: 1,
  1644. };
  1645. const dashboard = mockDashboard({widgets: [widget]});
  1646. renderTestComponent({
  1647. dashboard,
  1648. orgFeatures: [...defaultOrgFeatures],
  1649. params: {
  1650. widgetIndex: '0',
  1651. },
  1652. });
  1653. await userEvent.click(await screen.findByText('Area Chart'));
  1654. await userEvent.click(screen.getByText('Line Chart'));
  1655. expect(screen.getByText('Limit to 1 result')).toBeInTheDocument();
  1656. expect(eventsStatsMock).toHaveBeenCalledWith(
  1657. '/organizations/org-slug/events-stats/',
  1658. expect.objectContaining({
  1659. query: expect.objectContaining({
  1660. topEvents: 1,
  1661. }),
  1662. })
  1663. );
  1664. });
  1665. it('unsets the limit when going from timeseries to table', async function () {
  1666. const widget: Widget = {
  1667. displayType: DisplayType.AREA,
  1668. interval: '1d',
  1669. queries: [
  1670. {
  1671. name: 'Test Widget',
  1672. fields: ['count()', 'count_unique(user)', 'epm()', 'project'],
  1673. columns: ['project'],
  1674. aggregates: ['count()', 'count_unique(user)', 'epm()'],
  1675. conditions: '',
  1676. orderby: '',
  1677. },
  1678. ],
  1679. title: 'Transactions',
  1680. id: '1',
  1681. limit: 1,
  1682. };
  1683. const dashboard = mockDashboard({widgets: [widget]});
  1684. renderTestComponent({
  1685. dashboard,
  1686. orgFeatures: [...defaultOrgFeatures],
  1687. params: {
  1688. widgetIndex: '0',
  1689. },
  1690. });
  1691. await userEvent.click(await screen.findByText('Area Chart'));
  1692. await userEvent.click(screen.getByText('Table'));
  1693. expect(screen.queryByText('Limit to 1 result')).not.toBeInTheDocument();
  1694. expect(eventsMock).toHaveBeenCalledWith(
  1695. '/organizations/org-slug/events/',
  1696. expect.objectContaining({
  1697. query: expect.objectContaining({
  1698. topEvents: undefined,
  1699. }),
  1700. })
  1701. );
  1702. });
  1703. });
  1704. });