widgetBuilder.spec.tsx 79 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599
  1. import {urlEncode} from '@sentry/utils';
  2. import {DashboardFixture} from 'sentry-fixture/dashboard';
  3. import {LocationFixture} from 'sentry-fixture/locationFixture';
  4. import {MetricsFieldFixture} from 'sentry-fixture/metrics';
  5. import {ReleaseFixture} from 'sentry-fixture/release';
  6. import {SessionsFieldFixture} from 'sentry-fixture/sessions';
  7. import {TagsFixture} from 'sentry-fixture/tags';
  8. import {WidgetFixture} from 'sentry-fixture/widget';
  9. import {initializeOrg} from 'sentry-test/initializeOrg';
  10. import {
  11. act,
  12. render,
  13. renderGlobalModal,
  14. screen,
  15. userEvent,
  16. waitFor,
  17. } from 'sentry-test/reactTestingLibrary';
  18. import selectEvent from 'sentry-test/selectEvent';
  19. import * as modals from 'sentry/actionCreators/modal';
  20. import ProjectsStore from 'sentry/stores/projectsStore';
  21. import TagStore from 'sentry/stores/tagStore';
  22. import {DatasetSource, TOP_N} from 'sentry/utils/discover/types';
  23. import type {DashboardDetails, Widget} from 'sentry/views/dashboards/types';
  24. import {
  25. DashboardWidgetSource,
  26. DisplayType,
  27. WidgetType,
  28. } from 'sentry/views/dashboards/types';
  29. import type {WidgetBuilderProps} from 'sentry/views/dashboards/widgetBuilder';
  30. import WidgetBuilder from 'sentry/views/dashboards/widgetBuilder';
  31. import WidgetLegendSelectionState from '../widgetLegendSelectionState';
  32. const defaultOrgFeatures = [
  33. 'performance-view',
  34. 'dashboards-edit',
  35. 'global-views',
  36. 'dashboards-mep',
  37. ];
  38. function mockDashboard(dashboard: Partial<DashboardDetails>): DashboardDetails {
  39. return {
  40. id: '1',
  41. title: 'Dashboard',
  42. createdBy: undefined,
  43. dateCreated: '2020-01-01T00:00:00.000Z',
  44. widgets: [],
  45. projects: [],
  46. filters: {},
  47. ...dashboard,
  48. };
  49. }
  50. function renderTestComponent({
  51. dashboard,
  52. query,
  53. orgFeatures,
  54. onSave,
  55. params,
  56. updateDashboardSplitDecision,
  57. }: {
  58. dashboard?: WidgetBuilderProps['dashboard'];
  59. onSave?: WidgetBuilderProps['onSave'];
  60. orgFeatures?: string[];
  61. params?: Partial<WidgetBuilderProps['params']>;
  62. query?: Record<string, any>;
  63. updateDashboardSplitDecision?: WidgetBuilderProps['updateDashboardSplitDecision'];
  64. } = {}) {
  65. const {organization, projects, router} = initializeOrg({
  66. organization: {
  67. features: orgFeatures ?? defaultOrgFeatures,
  68. },
  69. router: {
  70. location: {
  71. query: {
  72. source: DashboardWidgetSource.DASHBOARDS,
  73. ...query,
  74. },
  75. },
  76. },
  77. });
  78. ProjectsStore.loadInitialData(projects);
  79. const widgetLegendState = new WidgetLegendSelectionState({
  80. location: LocationFixture(),
  81. dashboard: DashboardFixture([], {id: 'new', title: 'Dashboard', ...dashboard}),
  82. organization,
  83. router,
  84. });
  85. render(
  86. <WidgetBuilder
  87. route={{}}
  88. router={router}
  89. routes={router.routes}
  90. routeParams={router.params}
  91. location={router.location}
  92. dashboard={{
  93. id: 'new',
  94. title: 'Dashboard',
  95. createdBy: undefined,
  96. dateCreated: '2020-01-01T00:00:00.000Z',
  97. widgets: [],
  98. projects: [],
  99. filters: {},
  100. ...dashboard,
  101. }}
  102. onSave={onSave ?? jest.fn()}
  103. params={{
  104. orgId: organization.slug,
  105. dashboardId: dashboard?.id ?? 'new',
  106. ...params,
  107. }}
  108. updateDashboardSplitDecision={updateDashboardSplitDecision}
  109. widgetLegendState={widgetLegendState}
  110. />,
  111. {
  112. router,
  113. organization,
  114. }
  115. );
  116. return {router};
  117. }
  118. /**
  119. * This test suite contains tests that test the generic interactions
  120. * between most components in the WidgetBuilder. Tests for the
  121. * SortBy step can be found in (and should be added to)
  122. * ./widgetBuilderSortBy.spec.tsx and tests for specific dataset
  123. * behaviour can be found (and should be added to) ./widgetBuilderDataset.spec.tsx.
  124. * The test files are broken up to allow better parallelization
  125. * in CI (we currently parallelize files).
  126. */
  127. describe('WidgetBuilder', function () {
  128. const untitledDashboard: DashboardDetails = {
  129. id: '1',
  130. title: 'Untitled Dashboard',
  131. createdBy: undefined,
  132. dateCreated: '2020-01-01T00:00:00.000Z',
  133. widgets: [],
  134. projects: [],
  135. filters: {},
  136. };
  137. const testDashboard: DashboardDetails = {
  138. id: '2',
  139. title: 'Test Dashboard',
  140. createdBy: undefined,
  141. dateCreated: '2020-01-01T00:00:00.000Z',
  142. widgets: [],
  143. projects: [],
  144. filters: {},
  145. };
  146. let eventsStatsMock: jest.Mock | undefined;
  147. let eventsMock: jest.Mock | undefined;
  148. let tagsMock: jest.Mock | undefined;
  149. beforeEach(function () {
  150. MockApiClient.addMockResponse({
  151. url: '/organizations/org-slug/dashboards/',
  152. body: [
  153. {...untitledDashboard, widgetDisplay: [DisplayType.TABLE]},
  154. {...testDashboard, widgetDisplay: [DisplayType.AREA]},
  155. ],
  156. });
  157. MockApiClient.addMockResponse({
  158. url: '/organizations/org-slug/dashboards/widgets/',
  159. method: 'POST',
  160. statusCode: 200,
  161. body: [],
  162. });
  163. eventsMock = MockApiClient.addMockResponse({
  164. url: '/organizations/org-slug/events/',
  165. method: 'GET',
  166. statusCode: 200,
  167. body: {
  168. meta: {fields: {}},
  169. data: [],
  170. },
  171. });
  172. MockApiClient.addMockResponse({
  173. url: '/organizations/org-slug/projects/',
  174. method: 'GET',
  175. body: [],
  176. });
  177. MockApiClient.addMockResponse({
  178. url: '/organizations/org-slug/recent-searches/',
  179. method: 'GET',
  180. body: [],
  181. });
  182. MockApiClient.addMockResponse({
  183. url: '/organizations/org-slug/recent-searches/',
  184. method: 'POST',
  185. body: [],
  186. });
  187. MockApiClient.addMockResponse({
  188. url: '/organizations/org-slug/issues/',
  189. method: 'GET',
  190. body: [],
  191. });
  192. eventsStatsMock = MockApiClient.addMockResponse({
  193. url: '/organizations/org-slug/events-stats/',
  194. body: [],
  195. });
  196. MockApiClient.addMockResponse({
  197. url: '/organizations/org-slug/tags/event.type/values/',
  198. body: [{count: 2, name: 'Nvidia 1080ti'}],
  199. });
  200. MockApiClient.addMockResponse({
  201. url: '/organizations/org-slug/users/',
  202. body: [],
  203. });
  204. MockApiClient.addMockResponse({
  205. method: 'GET',
  206. url: '/organizations/org-slug/sessions/',
  207. body: SessionsFieldFixture(`sum(session)`),
  208. });
  209. MockApiClient.addMockResponse({
  210. method: 'GET',
  211. url: '/organizations/org-slug/metrics/data/',
  212. body: MetricsFieldFixture('session.all'),
  213. });
  214. tagsMock = MockApiClient.addMockResponse({
  215. url: '/organizations/org-slug/tags/',
  216. method: 'GET',
  217. body: TagsFixture(),
  218. });
  219. MockApiClient.addMockResponse({
  220. url: '/organizations/org-slug/measurements-meta/',
  221. method: 'GET',
  222. body: {},
  223. });
  224. MockApiClient.addMockResponse({
  225. url: '/organizations/org-slug/tags/is/values/',
  226. method: 'GET',
  227. body: [],
  228. });
  229. MockApiClient.addMockResponse({
  230. url: '/organizations/org-slug/tags/transaction.duration/values/',
  231. method: 'GET',
  232. body: [],
  233. });
  234. MockApiClient.addMockResponse({
  235. url: '/organizations/org-slug/releases/',
  236. body: [],
  237. });
  238. TagStore.reset();
  239. });
  240. afterEach(function () {
  241. MockApiClient.clearMockResponses();
  242. jest.clearAllMocks();
  243. });
  244. it('no feature access', function () {
  245. renderTestComponent({orgFeatures: []});
  246. expect(screen.getByText("You don't have access to this feature")).toBeInTheDocument();
  247. });
  248. it('widget not found', async function () {
  249. const widget: Widget = {
  250. displayType: DisplayType.AREA,
  251. interval: '1d',
  252. queries: [
  253. {
  254. name: 'Known Users',
  255. fields: [],
  256. columns: [],
  257. aggregates: [],
  258. conditions: '',
  259. orderby: '-time',
  260. },
  261. {
  262. name: 'Anonymous Users',
  263. fields: [],
  264. columns: [],
  265. aggregates: [],
  266. conditions: '',
  267. orderby: '-time',
  268. },
  269. ],
  270. title: 'Transactions',
  271. id: '1',
  272. };
  273. const dashboard = mockDashboard({widgets: [widget]});
  274. renderTestComponent({
  275. dashboard,
  276. orgFeatures: ['dashboards-edit'],
  277. params: {
  278. widgetIndex: '2', // Out of bounds, only one widget
  279. },
  280. });
  281. expect(
  282. await screen.findByText('The widget you want to edit was not found.')
  283. ).toBeInTheDocument();
  284. });
  285. it('renders a widget not found message if the widget index url is not an integer', async function () {
  286. const widget: Widget = {
  287. displayType: DisplayType.AREA,
  288. interval: '1d',
  289. queries: [
  290. {
  291. name: 'Known Users',
  292. fields: [],
  293. columns: [],
  294. aggregates: [],
  295. conditions: '',
  296. orderby: '-time',
  297. },
  298. ],
  299. title: 'Transactions',
  300. id: '1',
  301. };
  302. const dashboard = mockDashboard({widgets: [widget]});
  303. renderTestComponent({
  304. dashboard,
  305. orgFeatures: ['dashboards-edit'],
  306. params: {
  307. widgetIndex: '0.5', // Invalid index
  308. },
  309. });
  310. expect(
  311. await screen.findByText('The widget you want to edit was not found.')
  312. ).toBeInTheDocument();
  313. });
  314. it('renders', async function () {
  315. renderTestComponent();
  316. // Header - Breadcrumbs
  317. expect(await screen.findByRole('link', {name: 'Dashboards'})).toHaveAttribute(
  318. 'href',
  319. '/organizations/org-slug/dashboards/'
  320. );
  321. expect(screen.getByRole('link', {name: 'Dashboard'})).toHaveAttribute(
  322. 'href',
  323. '/organizations/org-slug/dashboards/new/'
  324. );
  325. expect(screen.getByText('Widget Builder')).toBeInTheDocument();
  326. // Header - Widget Title
  327. expect(screen.getByText('Custom Widget')).toBeInTheDocument();
  328. // Footer - Actions
  329. expect(screen.getByLabelText('Cancel')).toBeInTheDocument();
  330. expect(screen.getByLabelText('Add Widget')).toBeInTheDocument();
  331. // Content - Step 1
  332. expect(
  333. screen.getByRole('heading', {name: 'Choose your dataset'})
  334. ).toBeInTheDocument();
  335. expect(screen.getByLabelText('Errors and Transactions')).toBeChecked();
  336. // Content - Step 2
  337. expect(
  338. screen.getByRole('heading', {name: 'Choose your visualization'})
  339. ).toBeInTheDocument();
  340. // Content - Step 3
  341. expect(
  342. screen.getByRole('heading', {name: 'Choose your columns'})
  343. ).toBeInTheDocument();
  344. // Content - Step 4
  345. expect(
  346. screen.getByRole('heading', {name: 'Filter your results'})
  347. ).toBeInTheDocument();
  348. // Content - Step 5
  349. expect(screen.getByRole('heading', {name: 'Sort by a column'})).toBeInTheDocument();
  350. });
  351. it('has links back to the new dashboard if creating', async function () {
  352. // Dashboard has undefined dashboardId when creating from a new dashboard
  353. // because of route setup
  354. renderTestComponent({params: {dashboardId: undefined}});
  355. expect(await screen.findByLabelText('Cancel')).toHaveAttribute(
  356. 'href',
  357. '/organizations/org-slug/dashboards/new/'
  358. );
  359. });
  360. it('renders new design', async function () {
  361. renderTestComponent({
  362. orgFeatures: [...defaultOrgFeatures],
  363. });
  364. // Switch to line chart for time series
  365. await userEvent.click(screen.getByText('Table'));
  366. await userEvent.click(screen.getByText('Line Chart'));
  367. // Header - Breadcrumbs
  368. expect(await screen.findByRole('link', {name: 'Dashboards'})).toHaveAttribute(
  369. 'href',
  370. '/organizations/org-slug/dashboards/'
  371. );
  372. expect(screen.getByRole('link', {name: 'Dashboard'})).toHaveAttribute(
  373. 'href',
  374. '/organizations/org-slug/dashboards/new/'
  375. );
  376. expect(screen.getByText('Widget Builder')).toBeInTheDocument();
  377. // Header - Widget Title
  378. expect(screen.getByText('Custom Widget')).toBeInTheDocument();
  379. // Footer - Actions
  380. expect(screen.getByLabelText('Cancel')).toBeInTheDocument();
  381. expect(screen.getByLabelText('Add Widget')).toBeInTheDocument();
  382. // Content - Step 1
  383. expect(
  384. screen.getByRole('heading', {name: 'Choose your dataset'})
  385. ).toBeInTheDocument();
  386. expect(screen.getByLabelText('Errors and Transactions')).toBeChecked();
  387. // Content - Step 2
  388. expect(
  389. screen.getByRole('heading', {name: 'Choose your visualization'})
  390. ).toBeInTheDocument();
  391. // Content - Step 3
  392. expect(
  393. screen.getByRole('heading', {name: 'Choose what to plot in the y-axis'})
  394. ).toBeInTheDocument();
  395. // Content - Step 4
  396. expect(
  397. screen.getByRole('heading', {name: 'Filter your results'})
  398. ).toBeInTheDocument();
  399. // Content - Step 5
  400. expect(screen.getByRole('heading', {name: 'Group your results'})).toBeInTheDocument();
  401. });
  402. it('can update the title', async function () {
  403. renderTestComponent({
  404. query: {source: DashboardWidgetSource.DISCOVERV2},
  405. });
  406. const customWidgetLabels = await screen.findByText('Custom Widget');
  407. // EditableText and chart title
  408. expect(customWidgetLabels).toBeInTheDocument();
  409. await userEvent.clear(screen.getByRole('textbox', {name: 'Widget title'}));
  410. await userEvent.click(screen.getByRole('textbox', {name: 'Widget title'}));
  411. await userEvent.paste('Unique Users');
  412. await userEvent.keyboard('{enter}');
  413. expect(screen.queryByText('Custom Widget')).not.toBeInTheDocument();
  414. expect(screen.getByText('Unique Users')).toBeInTheDocument();
  415. });
  416. it('can add query conditions', async function () {
  417. const {router} = renderTestComponent({
  418. query: {source: DashboardWidgetSource.DISCOVERV2},
  419. dashboard: testDashboard,
  420. });
  421. await userEvent.click(
  422. await screen.findByRole('combobox', {name: 'Add a search term'})
  423. );
  424. await userEvent.paste('color:blue');
  425. await userEvent.keyboard('{enter}');
  426. await userEvent.click(screen.getByText('Add Widget'));
  427. await waitFor(() => {
  428. expect(router.push).toHaveBeenCalledWith(
  429. expect.objectContaining({
  430. pathname: '/organizations/org-slug/dashboard/2/',
  431. query: {
  432. displayType: 'table',
  433. interval: '5m',
  434. title: 'Custom Widget',
  435. queryNames: [''],
  436. queryConditions: ['color:blue'],
  437. queryFields: ['count()'],
  438. queryOrderby: '-count()',
  439. start: null,
  440. end: null,
  441. statsPeriod: '24h',
  442. utc: null,
  443. project: [],
  444. environment: [],
  445. widgetType: 'discover',
  446. },
  447. })
  448. );
  449. });
  450. });
  451. it('can choose a field', async function () {
  452. const {router} = renderTestComponent({
  453. query: {source: DashboardWidgetSource.DISCOVERV2},
  454. dashboard: testDashboard,
  455. });
  456. expect(await screen.findByText('Custom Widget')).toBeInTheDocument();
  457. // No delete button as there is only one query.
  458. expect(screen.queryByLabelText('Remove query')).not.toBeInTheDocument();
  459. // 1 in the table header, 1 in the column selector, 1 in the sort field
  460. const countFields = screen.getAllByText('count()');
  461. expect(countFields).toHaveLength(3);
  462. await selectEvent.select(countFields[1], ['last_seen()']);
  463. await userEvent.click(screen.getByText('Add Widget'));
  464. await waitFor(() => {
  465. expect(router.push).toHaveBeenCalledWith(
  466. expect.objectContaining({
  467. pathname: '/organizations/org-slug/dashboard/2/',
  468. query: {
  469. displayType: 'table',
  470. interval: '5m',
  471. title: 'Custom Widget',
  472. queryNames: [''],
  473. queryConditions: [''],
  474. queryFields: ['last_seen()'],
  475. queryOrderby: '-last_seen()',
  476. start: null,
  477. end: null,
  478. statsPeriod: '24h',
  479. utc: null,
  480. project: [],
  481. environment: [],
  482. widgetType: 'discover',
  483. },
  484. })
  485. );
  486. });
  487. });
  488. it('can add additional fields', async function () {
  489. const handleSave = jest.fn();
  490. renderTestComponent({onSave: handleSave});
  491. await userEvent.click(await screen.findByText('Table'));
  492. // Select line chart display
  493. await userEvent.click(screen.getByText('Line Chart'));
  494. // Click the add overlay button
  495. await userEvent.click(screen.getByLabelText('Add Overlay'));
  496. await selectEvent.select(screen.getByText('(Required)'), ['count_unique(…)']);
  497. await userEvent.click(screen.getByLabelText('Add Widget'));
  498. await waitFor(() => {
  499. expect(handleSave).toHaveBeenCalledWith([
  500. expect.objectContaining({
  501. title: 'Custom Widget',
  502. displayType: DisplayType.LINE,
  503. interval: '5m',
  504. widgetType: WidgetType.DISCOVER,
  505. queries: [
  506. {
  507. conditions: '',
  508. fields: ['count()', 'count_unique(user)'],
  509. aggregates: ['count()', 'count_unique(user)'],
  510. fieldAliases: [],
  511. columns: [],
  512. orderby: '',
  513. name: '',
  514. },
  515. ],
  516. }),
  517. ]);
  518. });
  519. expect(handleSave).toHaveBeenCalledTimes(1);
  520. });
  521. it('can add additional fields and equation for Big Number with selection', async function () {
  522. renderTestComponent({
  523. query: {
  524. displayType: DisplayType.BIG_NUMBER,
  525. },
  526. orgFeatures: [...defaultOrgFeatures, 'dashboards-bignumber-equations'],
  527. });
  528. // Add new field
  529. await userEvent.click(screen.getByLabelText('Add Field'));
  530. expect(screen.getByText('(Required)')).toBeInTheDocument();
  531. await selectEvent.select(screen.getByText('(Required)'), ['count_unique(…)']);
  532. expect(screen.getByRole('radio', {name: 'field1'})).toBeChecked();
  533. // Add another new field
  534. await userEvent.click(screen.getByLabelText('Add Field'));
  535. expect(screen.getByText('(Required)')).toBeInTheDocument();
  536. await selectEvent.select(screen.getByText('(Required)'), ['eps()']);
  537. expect(screen.getByRole('radio', {name: 'field2'})).toBeChecked();
  538. // Add an equation
  539. await userEvent.click(screen.getByLabelText('Add an Equation'));
  540. expect(screen.getByPlaceholderText('Equation')).toBeInTheDocument();
  541. expect(screen.getByRole('radio', {name: 'field3'})).toBeChecked();
  542. await userEvent.click(screen.getByPlaceholderText('Equation'));
  543. await userEvent.paste('eps() + 100');
  544. // Check if right value is displayed from equation
  545. await userEvent.click(screen.getByPlaceholderText('Equation'));
  546. await userEvent.paste('2 * 100');
  547. expect(screen.getByText('200')).toBeInTheDocument();
  548. });
  549. it('can add equation fields', async function () {
  550. const handleSave = jest.fn();
  551. renderTestComponent({onSave: handleSave});
  552. await userEvent.click(await screen.findByText('Table'));
  553. // Select line chart display
  554. await userEvent.click(screen.getByText('Line Chart'));
  555. // Click the add an equation button
  556. await userEvent.click(screen.getByLabelText('Add an Equation'));
  557. expect(screen.getByPlaceholderText('Equation')).toBeInTheDocument();
  558. await userEvent.click(screen.getByPlaceholderText('Equation'));
  559. await userEvent.paste('count() + 100');
  560. await userEvent.click(screen.getByLabelText('Add Widget'));
  561. await waitFor(() => {
  562. expect(handleSave).toHaveBeenCalledWith([
  563. expect.objectContaining({
  564. title: 'Custom Widget',
  565. displayType: DisplayType.LINE,
  566. interval: '5m',
  567. widgetType: WidgetType.DISCOVER,
  568. queries: [
  569. {
  570. name: '',
  571. fields: ['count()', 'equation|count() + 100'],
  572. aggregates: ['count()', 'equation|count() + 100'],
  573. columns: [],
  574. fieldAliases: [],
  575. conditions: '',
  576. orderby: '',
  577. },
  578. ],
  579. }),
  580. ]);
  581. });
  582. expect(handleSave).toHaveBeenCalledTimes(1);
  583. });
  584. it('can respond to validation feedback', async function () {
  585. MockApiClient.addMockResponse({
  586. url: '/organizations/org-slug/dashboards/widgets/',
  587. method: 'POST',
  588. statusCode: 400,
  589. body: {
  590. title: ['This field may not be blank.'],
  591. },
  592. });
  593. renderTestComponent();
  594. await userEvent.click(await screen.findByText('Table'));
  595. const customWidgetLabels = await screen.findByText('Custom Widget');
  596. // EditableText and chart title
  597. expect(customWidgetLabels).toBeInTheDocument();
  598. await userEvent.clear(screen.getByRole('textbox', {name: 'Widget title'}));
  599. await userEvent.click(screen.getByText('Add Widget'));
  600. await screen.findByText('This field may not be blank.');
  601. });
  602. it('sets up widget data in edit correctly', async function () {
  603. const widget: Widget = {
  604. id: '1',
  605. title: 'Errors over time',
  606. interval: '5m',
  607. displayType: DisplayType.LINE,
  608. queries: [
  609. {
  610. name: 'errors',
  611. conditions: 'event.type:error',
  612. fields: ['count()', 'count_unique(id)'],
  613. aggregates: ['count()', 'count_unique(id)'],
  614. columns: [],
  615. orderby: '',
  616. },
  617. {
  618. name: 'csp',
  619. conditions: 'event.type:csp',
  620. fields: ['count()', 'count_unique(id)'],
  621. aggregates: ['count()', 'count_unique(id)'],
  622. columns: [],
  623. orderby: '',
  624. },
  625. ],
  626. };
  627. const dashboard = mockDashboard({widgets: [widget]});
  628. renderTestComponent({dashboard, params: {widgetIndex: '0'}});
  629. await screen.findByText('Line Chart');
  630. // Should be in edit 'mode'
  631. expect(await screen.findByText('Update Widget')).toBeInTheDocument();
  632. // Should set widget data up.
  633. expect(screen.getByText('Update Widget')).toBeInTheDocument();
  634. // Filters
  635. expect(
  636. await screen.findAllByRole('grid', {name: 'Create a search query'})
  637. ).toHaveLength(2);
  638. expect(screen.getByRole('row', {name: 'event.type:csp'})).toBeInTheDocument();
  639. expect(screen.getByRole('row', {name: 'event.type:error'})).toBeInTheDocument();
  640. // Y-axis
  641. expect(screen.getAllByRole('button', {name: 'Remove query'})).toHaveLength(2);
  642. expect(screen.getByText('count()')).toBeInTheDocument();
  643. expect(screen.getByText('count_unique(…)')).toBeInTheDocument();
  644. expect(screen.getByText('id')).toBeInTheDocument();
  645. // Expect events-stats endpoint to be called for each search conditions with
  646. // the same y-axis parameters
  647. expect(eventsStatsMock).toHaveBeenNthCalledWith(
  648. 1,
  649. '/organizations/org-slug/events-stats/',
  650. expect.objectContaining({
  651. query: expect.objectContaining({
  652. query: 'event.type:error',
  653. yAxis: ['count()', 'count_unique(id)'],
  654. }),
  655. })
  656. );
  657. expect(eventsStatsMock).toHaveBeenNthCalledWith(
  658. 2,
  659. '/organizations/org-slug/events-stats/',
  660. expect.objectContaining({
  661. query: expect.objectContaining({
  662. query: 'event.type:csp',
  663. yAxis: ['count()', 'count_unique(id)'],
  664. }),
  665. })
  666. );
  667. });
  668. it('can edit a widget', async function () {
  669. const widget: Widget = {
  670. id: '1',
  671. title: 'Errors over time',
  672. interval: '5m',
  673. displayType: DisplayType.LINE,
  674. queries: [
  675. {
  676. name: 'errors',
  677. conditions: 'event.type:error',
  678. fields: ['count()', 'count_unique(id)'],
  679. aggregates: ['count()', 'count_unique(id)'],
  680. columns: [],
  681. orderby: '',
  682. },
  683. {
  684. name: 'csp',
  685. conditions: 'event.type:csp',
  686. fields: ['count()', 'count_unique(id)'],
  687. aggregates: ['count()', 'count_unique(id)'],
  688. columns: [],
  689. orderby: '',
  690. },
  691. ],
  692. };
  693. const dashboard = mockDashboard({widgets: [widget]});
  694. const handleSave = jest.fn();
  695. renderTestComponent({onSave: handleSave, dashboard, params: {widgetIndex: '0'}});
  696. await screen.findByText('Line Chart');
  697. // Should be in edit 'mode'
  698. expect(screen.getByText('Update Widget')).toBeInTheDocument();
  699. const customWidgetLabels = screen.getByText(widget.title);
  700. // EditableText and chart title
  701. expect(customWidgetLabels).toBeInTheDocument();
  702. await userEvent.clear(screen.getByRole('textbox', {name: 'Widget title'}));
  703. await userEvent.click(screen.getByRole('textbox', {name: 'Widget title'}));
  704. await userEvent.paste('New Title');
  705. await userEvent.click(screen.getByRole('button', {name: 'Update Widget'}));
  706. await waitFor(() => {
  707. expect(handleSave).toHaveBeenCalledWith([
  708. expect.objectContaining({
  709. ...widget,
  710. title: 'New Title',
  711. }),
  712. ]);
  713. });
  714. expect(handleSave).toHaveBeenCalledTimes(1);
  715. });
  716. it('renders column inputs for table widgets', async function () {
  717. const widget: Widget = {
  718. id: '0',
  719. title: 'sdk usage',
  720. interval: '5m',
  721. displayType: DisplayType.TABLE,
  722. queries: [
  723. {
  724. name: 'errors',
  725. conditions: 'event.type:error',
  726. fields: ['sdk.name', 'count()'],
  727. columns: ['sdk.name'],
  728. aggregates: ['count()'],
  729. orderby: '',
  730. },
  731. ],
  732. };
  733. const dashboard = mockDashboard({widgets: [widget]});
  734. renderTestComponent({dashboard, params: {widgetIndex: '0'}});
  735. // Should be in edit 'mode'
  736. expect(await screen.findByText('Update Widget')).toBeInTheDocument();
  737. // Should set widget data up.
  738. expect(screen.getByText(widget.title)).toBeInTheDocument();
  739. expect(screen.getByText('Table')).toBeInTheDocument();
  740. await screen.findByRole('grid', {name: 'Create a search query'});
  741. // Should have an orderby select
  742. expect(screen.getByText('Sort by a column')).toBeInTheDocument();
  743. // Add a column, and choose a value,
  744. expect(screen.getByLabelText('Add a Column')).toBeInTheDocument();
  745. });
  746. it('can save table widgets', async function () {
  747. const widget: Widget = {
  748. id: '0',
  749. title: 'sdk usage',
  750. interval: '5m',
  751. displayType: DisplayType.TABLE,
  752. queries: [
  753. {
  754. name: 'errors',
  755. conditions: 'event.type:error',
  756. fields: ['sdk.name', 'count()'],
  757. columns: ['sdk.name'],
  758. aggregates: ['count()'],
  759. orderby: '-count()',
  760. },
  761. ],
  762. };
  763. const dashboard = mockDashboard({widgets: [widget]});
  764. const handleSave = jest.fn();
  765. renderTestComponent({dashboard, onSave: handleSave, params: {widgetIndex: '0'}});
  766. // Should be in edit 'mode'
  767. expect(await screen.findByText('Update Widget')).toBeInTheDocument();
  768. // Add a column, and choose a value,
  769. await userEvent.click(screen.getByLabelText('Add a Column'));
  770. await selectEvent.select(screen.getByText('(Required)'), 'trace');
  771. // Save widget
  772. await userEvent.click(screen.getByLabelText('Update Widget'));
  773. await waitFor(() => {
  774. expect(handleSave).toHaveBeenCalledWith([
  775. expect.objectContaining({
  776. id: '0',
  777. title: 'sdk usage',
  778. displayType: DisplayType.TABLE,
  779. interval: '5m',
  780. queries: [
  781. {
  782. name: 'errors',
  783. conditions: 'event.type:error',
  784. fields: ['sdk.name', 'count()', 'trace'],
  785. aggregates: ['count()'],
  786. columns: ['sdk.name', 'trace'],
  787. orderby: '-count()',
  788. fieldAliases: ['', '', ''],
  789. },
  790. ],
  791. widgetType: WidgetType.DISCOVER,
  792. }),
  793. ]);
  794. });
  795. expect(handleSave).toHaveBeenCalledTimes(1);
  796. });
  797. it('should properly query for table fields', async function () {
  798. const defaultWidgetQuery = {
  799. name: '',
  800. fields: ['title', 'count()'],
  801. columns: ['title'],
  802. aggregates: ['count()'],
  803. conditions: '',
  804. orderby: '',
  805. };
  806. const defaultTableColumns = ['title', 'count()', 'count_unique(user)', 'epm()'];
  807. renderTestComponent({
  808. query: {
  809. source: DashboardWidgetSource.DISCOVERV2,
  810. defaultWidgetQuery: urlEncode(defaultWidgetQuery),
  811. displayType: DisplayType.LINE,
  812. defaultTableColumns,
  813. },
  814. });
  815. expect(await screen.findByText('Line Chart')).toBeInTheDocument();
  816. await userEvent.click(screen.getByText('Line Chart'));
  817. await userEvent.click(screen.getByText('Table'));
  818. await waitFor(() => {
  819. expect(eventsMock).toHaveBeenLastCalledWith(
  820. '/organizations/org-slug/events/',
  821. expect.objectContaining({
  822. query: expect.objectContaining({
  823. field: defaultTableColumns,
  824. }),
  825. })
  826. );
  827. });
  828. });
  829. it('should use defaultWidgetQuery Y-Axis and Conditions if given a defaultWidgetQuery', async function () {
  830. const defaultWidgetQuery = {
  831. name: '',
  832. fields: ['count()', 'failure_count()', 'count_unique(user)'],
  833. columns: [],
  834. aggregates: ['count()', 'failure_count()', 'count_unique(user)'],
  835. conditions: 'tag:value',
  836. orderby: '',
  837. };
  838. renderTestComponent({
  839. query: {
  840. source: DashboardWidgetSource.DISCOVERV2,
  841. defaultWidgetQuery: urlEncode(defaultWidgetQuery),
  842. },
  843. });
  844. expect(await screen.findByRole('row', {name: 'tag:value'})).toBeInTheDocument();
  845. // Table display, column, and sort field
  846. await waitFor(() => {
  847. expect(screen.getAllByText('count()')).toHaveLength(3);
  848. });
  849. // Table display and column
  850. expect(screen.getAllByText('failure_count()')).toHaveLength(2);
  851. // Table display
  852. expect(screen.getByText('count_unique(user)')).toBeInTheDocument();
  853. // Column
  854. expect(screen.getByText('count_unique(…)')).toBeInTheDocument();
  855. // Column
  856. expect(screen.getByText('user')).toBeInTheDocument();
  857. });
  858. it('uses displayType if given a displayType', async function () {
  859. renderTestComponent({
  860. query: {
  861. displayType: DisplayType.BAR,
  862. },
  863. });
  864. expect(await screen.findByText('Bar Chart')).toBeInTheDocument();
  865. });
  866. it('deletes the widget when the modal is confirmed', async () => {
  867. const handleSave = jest.fn();
  868. const widget: Widget = {
  869. id: '1',
  870. title: 'Errors over time',
  871. interval: '5m',
  872. displayType: DisplayType.LINE,
  873. queries: [
  874. {
  875. name: 'errors',
  876. conditions: 'event.type:error',
  877. fields: ['count()', 'count_unique(id)'],
  878. aggregates: ['count()', 'count_unique(id)'],
  879. columns: [],
  880. orderby: '',
  881. },
  882. {
  883. name: 'csp',
  884. conditions: 'event.type:csp',
  885. fields: ['count()', 'count_unique(id)'],
  886. aggregates: ['count()', 'count_unique(id)'],
  887. columns: [],
  888. orderby: '',
  889. },
  890. ],
  891. };
  892. const dashboard = mockDashboard({widgets: [widget]});
  893. renderTestComponent({onSave: handleSave, dashboard, params: {widgetIndex: '0'}});
  894. await userEvent.click(await screen.findByText('Delete'));
  895. renderGlobalModal();
  896. await userEvent.click(await screen.findByText('Confirm'));
  897. await waitFor(() => {
  898. // The only widget was deleted
  899. expect(handleSave).toHaveBeenCalledWith([]);
  900. });
  901. expect(handleSave).toHaveBeenCalledTimes(1);
  902. });
  903. it('persists the page filter period when updating a widget', async () => {
  904. const widget: Widget = {
  905. id: '1',
  906. title: 'Errors over time',
  907. interval: '5m',
  908. displayType: DisplayType.LINE,
  909. queries: [
  910. {
  911. name: 'errors',
  912. conditions: 'event.type:error',
  913. fields: ['count()', 'count_unique(id)'],
  914. aggregates: ['count()', 'count_unique(id)'],
  915. columns: [],
  916. orderby: '',
  917. },
  918. ],
  919. };
  920. const dashboard = mockDashboard({widgets: [widget]});
  921. const {router} = renderTestComponent({
  922. dashboard,
  923. params: {orgId: 'org-slug', widgetIndex: '0'},
  924. query: {statsPeriod: '90d'},
  925. });
  926. await userEvent.click(screen.getByText('Update Widget'));
  927. await waitFor(() => {
  928. expect(router.push).toHaveBeenLastCalledWith(
  929. expect.objectContaining({
  930. pathname: '/organizations/org-slug/dashboard/1/',
  931. query: expect.objectContaining({
  932. statsPeriod: '90d',
  933. }),
  934. })
  935. );
  936. });
  937. });
  938. it('renders page filters in the filter step', async () => {
  939. const mockReleases = MockApiClient.addMockResponse({
  940. url: '/organizations/org-slug/releases/',
  941. body: [ReleaseFixture()],
  942. });
  943. renderTestComponent({
  944. params: {orgId: 'org-slug'},
  945. query: {statsPeriod: '90d'},
  946. orgFeatures: defaultOrgFeatures,
  947. });
  948. expect(await screen.findByTestId('page-filter-timerange-selector')).toBeDisabled();
  949. expect(screen.getByTestId('page-filter-environment-selector')).toBeDisabled();
  950. expect(screen.getByTestId('page-filter-project-selector')).toBeDisabled();
  951. expect(mockReleases).toHaveBeenCalled();
  952. expect(screen.getByRole('button', {name: /all releases/i})).toBeDisabled();
  953. });
  954. it('appends dashboard filters to widget builder fetch data request', async () => {
  955. MockApiClient.addMockResponse({
  956. url: '/organizations/org-slug/releases/',
  957. body: [ReleaseFixture()],
  958. });
  959. const mock = MockApiClient.addMockResponse({
  960. url: '/organizations/org-slug/events/',
  961. body: [],
  962. });
  963. renderTestComponent({
  964. dashboard: {
  965. id: 'new',
  966. title: 'Dashboard',
  967. createdBy: undefined,
  968. dateCreated: '2020-01-01T00:00:00.000Z',
  969. widgets: [],
  970. projects: [],
  971. filters: {release: ['abc@1.2.0']},
  972. },
  973. params: {orgId: 'org-slug'},
  974. query: {statsPeriod: '90d'},
  975. orgFeatures: defaultOrgFeatures,
  976. });
  977. await waitFor(() => {
  978. expect(mock).toHaveBeenCalledWith(
  979. '/organizations/org-slug/events/',
  980. expect.objectContaining({
  981. query: expect.objectContaining({
  982. query: ' release:"abc@1.2.0" ',
  983. }),
  984. })
  985. );
  986. });
  987. });
  988. it('does not error when query conditions field is blurred', async function () {
  989. const widget: Widget = {
  990. id: '0',
  991. title: 'sdk usage',
  992. interval: '5m',
  993. displayType: DisplayType.BAR,
  994. queries: [
  995. {
  996. name: 'filled in',
  997. conditions: 'event.type:error',
  998. fields: ['count()', 'count_unique(id)'],
  999. aggregates: ['count()', 'count_unique(id)'],
  1000. columns: [],
  1001. orderby: '-count()',
  1002. },
  1003. ],
  1004. };
  1005. const dashboard = mockDashboard({widgets: [widget]});
  1006. const handleSave = jest.fn();
  1007. renderTestComponent({dashboard, onSave: handleSave, params: {widgetIndex: '0'}});
  1008. await userEvent.click(await screen.findByLabelText('Add Query'), {delay: null});
  1009. // Triggering the onBlur of the new field should not error
  1010. await userEvent.click(
  1011. screen.getAllByPlaceholderText('Search for events, users, tags, and more')[1],
  1012. {delay: null}
  1013. );
  1014. await userEvent.keyboard('{Escape}', {delay: null});
  1015. // Run all timers because the handleBlur contains a setTimeout
  1016. await act(tick);
  1017. });
  1018. it('does not wipe column changes when filters are modified', async function () {
  1019. // widgetIndex: undefined means creating a new widget
  1020. renderTestComponent({params: {widgetIndex: undefined}});
  1021. await userEvent.click(await screen.findByLabelText('Add a Column'), {delay: null});
  1022. await selectEvent.select(screen.getByText('(Required)'), /project/);
  1023. // Triggering the onBlur of the filter should not error
  1024. await userEvent.click(
  1025. screen.getByPlaceholderText('Search for events, users, tags, and more'),
  1026. {delay: null}
  1027. );
  1028. await userEvent.keyboard('{enter}', {delay: null});
  1029. expect(await screen.findAllByText('project')).toHaveLength(2);
  1030. });
  1031. it('renders fields with commas properly', async () => {
  1032. const defaultWidgetQuery = {
  1033. conditions: '',
  1034. fields: ['equation|count_if(transaction.duration,equals,300)*2'],
  1035. aggregates: ['equation|count_if(transaction.duration,equals,300)*2'],
  1036. columns: [],
  1037. orderby: '',
  1038. name: '',
  1039. };
  1040. const defaultTableColumns = [
  1041. 'count_if(transaction.duration,equals,300)',
  1042. 'equation|count_if(transaction.duration,equals,300)*2',
  1043. ];
  1044. renderTestComponent({
  1045. query: {
  1046. source: DashboardWidgetSource.DISCOVERV2,
  1047. defaultWidgetQuery: urlEncode(defaultWidgetQuery),
  1048. defaultTableColumns,
  1049. yAxis: ['equation|count_if(transaction.duration,equals,300)*2'],
  1050. },
  1051. });
  1052. expect(
  1053. await screen.findByText('count_if(transaction.duration,equals,300)*2')
  1054. ).toBeInTheDocument();
  1055. });
  1056. it('sets the correct fields for a top n widget', async () => {
  1057. renderTestComponent({
  1058. orgFeatures: [...defaultOrgFeatures, 'performance-view'],
  1059. query: {
  1060. displayType: DisplayType.TOP_N,
  1061. },
  1062. });
  1063. // Top N now opens as Area Chart
  1064. expect(await screen.findByText('Area Chart')).toBeInTheDocument();
  1065. // Add a group by
  1066. await userEvent.click(screen.getByText('Add Overlay'));
  1067. await selectEvent.select(screen.getByText('Select group'), /project/);
  1068. // Change the y-axis
  1069. await selectEvent.select(screen.getAllByText('count()')[0], 'eps()');
  1070. await waitFor(() => {
  1071. expect(eventsStatsMock).toHaveBeenLastCalledWith(
  1072. '/organizations/org-slug/events-stats/',
  1073. expect.objectContaining({
  1074. query: expect.objectContaining({
  1075. query: '',
  1076. yAxis: ['eps()'],
  1077. field: ['project', 'eps()'],
  1078. topEvents: TOP_N,
  1079. orderby: '-eps()',
  1080. }),
  1081. })
  1082. );
  1083. });
  1084. });
  1085. it('fetches tags when tag store is empty', async function () {
  1086. renderTestComponent();
  1087. await waitFor(() => {
  1088. expect(tagsMock).toHaveBeenCalled();
  1089. });
  1090. });
  1091. it('does not fetch tags when tag store is not empty', async function () {
  1092. await act(async () => {
  1093. TagStore.loadTagsSuccess(TagsFixture());
  1094. renderTestComponent();
  1095. await tick();
  1096. });
  1097. expect(tagsMock).not.toHaveBeenCalled();
  1098. });
  1099. it('excludes the Other series when grouping and using multiple y-axes', async function () {
  1100. renderTestComponent({
  1101. orgFeatures: [...defaultOrgFeatures],
  1102. query: {
  1103. displayType: DisplayType.LINE,
  1104. },
  1105. });
  1106. await selectEvent.select(await screen.findByText('Select group'), 'project');
  1107. await userEvent.click(screen.getByText('Add Overlay'));
  1108. await selectEvent.select(screen.getByText('(Required)'), /count_unique/);
  1109. await waitFor(() => {
  1110. expect(eventsStatsMock).toHaveBeenCalledWith(
  1111. '/organizations/org-slug/events-stats/',
  1112. expect.objectContaining({
  1113. query: expect.objectContaining({excludeOther: '1'}),
  1114. })
  1115. );
  1116. });
  1117. });
  1118. it('excludes the Other series when grouping and using multiple queries', async function () {
  1119. renderTestComponent({
  1120. orgFeatures: [...defaultOrgFeatures],
  1121. query: {
  1122. displayType: DisplayType.LINE,
  1123. },
  1124. });
  1125. await selectEvent.select(await screen.findByText('Select group'), 'project');
  1126. await userEvent.click(screen.getByText('Add Query'));
  1127. await waitFor(() => {
  1128. expect(eventsStatsMock).toHaveBeenCalledWith(
  1129. '/organizations/org-slug/events-stats/',
  1130. expect.objectContaining({
  1131. query: expect.objectContaining({excludeOther: '1'}),
  1132. })
  1133. );
  1134. });
  1135. });
  1136. it('includes Other series when there is only one query and one y-axis', async function () {
  1137. renderTestComponent({
  1138. orgFeatures: [...defaultOrgFeatures],
  1139. query: {
  1140. displayType: DisplayType.LINE,
  1141. },
  1142. });
  1143. await selectEvent.select(await screen.findByText('Select group'), 'project');
  1144. await waitFor(() => {
  1145. expect(eventsStatsMock).toHaveBeenCalledWith(
  1146. '/organizations/org-slug/events-stats/',
  1147. expect.objectContaining({
  1148. query: expect.not.objectContaining({excludeOther: '1'}),
  1149. })
  1150. );
  1151. });
  1152. });
  1153. it('decreases the limit when more y-axes and queries are added', async function () {
  1154. renderTestComponent({
  1155. orgFeatures: [...defaultOrgFeatures],
  1156. query: {
  1157. displayType: DisplayType.LINE,
  1158. },
  1159. });
  1160. await selectEvent.select(await screen.findByText('Select group'), 'project');
  1161. screen.getByText('Limit to 5 results');
  1162. await userEvent.click(screen.getByText('Add Query'));
  1163. await userEvent.click(screen.getByText('Add Overlay'));
  1164. expect(screen.getByText('Limit to 2 results')).toBeInTheDocument();
  1165. });
  1166. it('alerts the user if there are unsaved title changes', async function () {
  1167. renderTestComponent();
  1168. window.confirm = jest.fn();
  1169. const customWidgetLabels = await screen.findByText('Custom Widget');
  1170. // EditableText and chart title
  1171. expect(customWidgetLabels).toBeInTheDocument();
  1172. // Change title text
  1173. await userEvent.clear(screen.getByRole('textbox', {name: 'Widget title'}));
  1174. await userEvent.click(screen.getByRole('textbox', {name: 'Widget title'}));
  1175. await userEvent.paste('Unique Users');
  1176. await userEvent.keyboard('{Enter}');
  1177. // Click Cancel
  1178. await userEvent.click(screen.getByText('Cancel'));
  1179. // Assert an alert was triggered
  1180. expect(window.confirm).toHaveBeenCalled();
  1181. });
  1182. it('alerts the user if there are unsaved description changes', async function () {
  1183. renderTestComponent();
  1184. window.confirm = jest.fn();
  1185. const descriptionTextArea = await screen.findByRole('textbox', {
  1186. name: 'Widget Description',
  1187. });
  1188. expect(descriptionTextArea).toBeInTheDocument();
  1189. expect(descriptionTextArea).toHaveAttribute(
  1190. 'placeholder',
  1191. 'Enter description (Optional)'
  1192. );
  1193. // Change description text
  1194. await userEvent.clear(descriptionTextArea);
  1195. await userEvent.click(descriptionTextArea);
  1196. await userEvent.paste('This is a description');
  1197. await userEvent.keyboard('{Enter}');
  1198. // Click Cancel
  1199. await userEvent.click(screen.getByText('Cancel'));
  1200. // Assert an alert was triggered
  1201. expect(window.confirm).toHaveBeenCalled();
  1202. });
  1203. it('does not trigger alert dialog if no changes', async function () {
  1204. renderTestComponent();
  1205. const alertMock = jest.spyOn(window, 'confirm');
  1206. await userEvent.click(await screen.findByText('Cancel'));
  1207. expect(alertMock).not.toHaveBeenCalled();
  1208. });
  1209. describe('Widget creation coming from other verticals', function () {
  1210. it('redirects correctly when creating a new dashboard', async function () {
  1211. const {router} = renderTestComponent({
  1212. query: {source: DashboardWidgetSource.DISCOVERV2},
  1213. });
  1214. await userEvent.click(await screen.findByText('Add Widget'));
  1215. await waitFor(() => {
  1216. expect(router.push).toHaveBeenCalledWith(
  1217. expect.objectContaining({
  1218. pathname: '/organizations/org-slug/dashboards/new/',
  1219. query: {
  1220. displayType: 'table',
  1221. interval: '5m',
  1222. title: 'Custom Widget',
  1223. queryNames: [''],
  1224. queryConditions: [''],
  1225. queryFields: ['count()'],
  1226. queryOrderby: '-count()',
  1227. start: null,
  1228. end: null,
  1229. statsPeriod: '24h',
  1230. utc: null,
  1231. project: [],
  1232. environment: [],
  1233. widgetType: 'discover',
  1234. },
  1235. })
  1236. );
  1237. });
  1238. });
  1239. it('redirects correctly when choosing an existing dashboard', async function () {
  1240. const {router} = renderTestComponent({
  1241. query: {source: DashboardWidgetSource.DISCOVERV2},
  1242. dashboard: testDashboard,
  1243. });
  1244. await userEvent.click(await screen.findByText('Add Widget'));
  1245. await waitFor(() => {
  1246. expect(router.push).toHaveBeenCalledWith(
  1247. expect.objectContaining({
  1248. pathname: '/organizations/org-slug/dashboard/2/',
  1249. query: {
  1250. displayType: 'table',
  1251. interval: '5m',
  1252. title: 'Custom Widget',
  1253. queryNames: [''],
  1254. queryConditions: [''],
  1255. queryFields: ['count()'],
  1256. queryOrderby: '-count()',
  1257. start: null,
  1258. end: null,
  1259. statsPeriod: '24h',
  1260. utc: null,
  1261. project: [],
  1262. environment: [],
  1263. widgetType: 'discover',
  1264. },
  1265. })
  1266. );
  1267. });
  1268. });
  1269. it('shows the correct orderby when switching from a line chart to table', async function () {
  1270. const defaultWidgetQuery = {
  1271. name: '',
  1272. fields: ['count_unique(user)'],
  1273. columns: [],
  1274. aggregates: ['count_unique(user)'],
  1275. conditions: '',
  1276. orderby: 'count_unique(user)',
  1277. };
  1278. const defaultTableColumns = ['title', 'count_unique(user)'];
  1279. renderTestComponent({
  1280. orgFeatures: [...defaultOrgFeatures],
  1281. query: {
  1282. source: DashboardWidgetSource.DISCOVERV2,
  1283. defaultWidgetQuery: urlEncode(defaultWidgetQuery),
  1284. displayType: DisplayType.LINE,
  1285. defaultTableColumns,
  1286. },
  1287. });
  1288. await userEvent.click(await screen.findByText('Line Chart'));
  1289. await userEvent.click(screen.getByText('Table'));
  1290. expect(screen.getAllByText('count_unique(user)')[0]).toBeInTheDocument();
  1291. await waitFor(() => {
  1292. expect(eventsMock).toHaveBeenLastCalledWith(
  1293. '/organizations/org-slug/events/',
  1294. expect.objectContaining({
  1295. query: expect.objectContaining({
  1296. field: defaultTableColumns,
  1297. sort: ['count_unique(user)'],
  1298. }),
  1299. })
  1300. );
  1301. });
  1302. });
  1303. it('does not send request with orderby if a timeseries chart without grouping', async function () {
  1304. const defaultWidgetQuery = {
  1305. name: '',
  1306. fields: ['count_unique(user)'],
  1307. columns: [],
  1308. aggregates: ['count_unique(user)'],
  1309. conditions: '',
  1310. orderby: 'count_unique(user)',
  1311. };
  1312. const defaultTableColumns = ['title', 'count_unique(user)'];
  1313. renderTestComponent({
  1314. orgFeatures: [...defaultOrgFeatures],
  1315. query: {
  1316. source: DashboardWidgetSource.DISCOVERV2,
  1317. defaultWidgetQuery: urlEncode(defaultWidgetQuery),
  1318. displayType: DisplayType.LINE,
  1319. defaultTableColumns,
  1320. },
  1321. });
  1322. await waitFor(() => {
  1323. expect(eventsStatsMock).toHaveBeenLastCalledWith(
  1324. '/organizations/org-slug/events-stats/',
  1325. expect.objectContaining({
  1326. query: expect.objectContaining({
  1327. orderby: '',
  1328. }),
  1329. })
  1330. );
  1331. });
  1332. });
  1333. });
  1334. it('opens top-N widgets as area display', async function () {
  1335. const widget: Widget = {
  1336. id: '1',
  1337. title: 'Errors over time',
  1338. interval: '5m',
  1339. displayType: DisplayType.TOP_N,
  1340. queries: [
  1341. {
  1342. name: '',
  1343. conditions: '',
  1344. fields: ['count()', 'count_unique(id)'],
  1345. aggregates: ['count()', 'count_unique(id)'],
  1346. columns: [],
  1347. orderby: '-count()',
  1348. },
  1349. ],
  1350. };
  1351. const dashboard = mockDashboard({widgets: [widget]});
  1352. renderTestComponent({
  1353. orgFeatures: [...defaultOrgFeatures],
  1354. dashboard,
  1355. params: {
  1356. widgetIndex: '0',
  1357. },
  1358. });
  1359. expect(await screen.findByText('Area Chart')).toBeInTheDocument();
  1360. });
  1361. it('Update table header values (field alias)', async function () {
  1362. const handleSave = jest.fn();
  1363. renderTestComponent({
  1364. onSave: handleSave,
  1365. orgFeatures: [...defaultOrgFeatures],
  1366. });
  1367. await userEvent.click(screen.getByPlaceholderText('Alias'));
  1368. await userEvent.paste('First Alias');
  1369. await userEvent.click(screen.getByLabelText('Add a Column'));
  1370. await userEvent.click(screen.getAllByPlaceholderText('Alias')[1]);
  1371. await userEvent.paste('Second Alias');
  1372. await userEvent.click(screen.getByText('Add Widget'));
  1373. await waitFor(() => {
  1374. expect(handleSave).toHaveBeenCalledWith([
  1375. expect.objectContaining({
  1376. queries: [
  1377. expect.objectContaining({fieldAliases: ['First Alias', 'Second Alias']}),
  1378. ],
  1379. }),
  1380. ]);
  1381. });
  1382. });
  1383. it('does not wipe equation aliases when a column alias is updated', async function () {
  1384. renderTestComponent({
  1385. orgFeatures: [...defaultOrgFeatures],
  1386. });
  1387. await userEvent.click(screen.getByText('Add an Equation'));
  1388. await userEvent.click(screen.getAllByPlaceholderText('Alias')[1]);
  1389. await userEvent.paste('This should persist');
  1390. await userEvent.type(screen.getAllByPlaceholderText('Alias')[0], 'A');
  1391. expect(await screen.findByText('This should persist')).toBeInTheDocument();
  1392. });
  1393. it('does not wipe equation aliases when a column selection is made', async function () {
  1394. renderTestComponent({
  1395. orgFeatures: [...defaultOrgFeatures],
  1396. });
  1397. await userEvent.click(screen.getByText('Add an Equation'));
  1398. await userEvent.click(screen.getAllByPlaceholderText('Alias')[1]);
  1399. await userEvent.paste('This should persist');
  1400. // 1 for the table, 1 for the column selector, 1 for the sort
  1401. await waitFor(() => expect(screen.getAllByText('count()')).toHaveLength(3));
  1402. await selectEvent.select(screen.getAllByText('count()')[1], /count_unique/);
  1403. expect(screen.getByText('This should persist')).toBeInTheDocument();
  1404. });
  1405. it('copies over the orderby from the previous query if adding another', async function () {
  1406. renderTestComponent({
  1407. orgFeatures: [...defaultOrgFeatures],
  1408. });
  1409. await userEvent.click(await screen.findByText('Table'));
  1410. await userEvent.click(screen.getByText('Line Chart'));
  1411. await selectEvent.select(screen.getByText('Select group'), 'project');
  1412. await selectEvent.select(screen.getAllByText('count()')[1], 'count_unique(…)');
  1413. MockApiClient.clearMockResponses();
  1414. eventsStatsMock = MockApiClient.addMockResponse({
  1415. url: '/organizations/org-slug/events-stats/',
  1416. body: [],
  1417. });
  1418. await userEvent.click(screen.getByText('Add Query'));
  1419. // Assert on two calls, one for each query
  1420. const expectedArgs = expect.objectContaining({
  1421. query: expect.objectContaining({
  1422. orderby: '-count_unique(user)',
  1423. }),
  1424. });
  1425. expect(eventsStatsMock).toHaveBeenNthCalledWith(
  1426. 1,
  1427. '/organizations/org-slug/events-stats/',
  1428. expectedArgs
  1429. );
  1430. expect(eventsStatsMock).toHaveBeenNthCalledWith(
  1431. 2,
  1432. '/organizations/org-slug/events-stats/',
  1433. expectedArgs
  1434. );
  1435. });
  1436. it('disables add widget button and prevents widget previewing from firing widget query if widget query condition is invalid', async function () {
  1437. renderTestComponent({
  1438. orgFeatures: [...defaultOrgFeatures],
  1439. });
  1440. await userEvent.click(await screen.findByText('Table'));
  1441. await userEvent.click(screen.getByText('Line Chart'));
  1442. expect(eventsStatsMock).toHaveBeenCalledTimes(1);
  1443. await userEvent.click(
  1444. await screen.findByRole('combobox', {name: 'Add a search term'})
  1445. );
  1446. await userEvent.paste('transaction.duration:123a');
  1447. // Unfocus input
  1448. await userEvent.click(screen.getByText('Filter your results'));
  1449. expect(screen.getByText('Add Widget').closest('button')).toBeDisabled();
  1450. expect(screen.getByText('Widget query condition is invalid.')).toBeInTheDocument();
  1451. expect(eventsStatsMock).toHaveBeenCalledTimes(1);
  1452. });
  1453. describe('discover dataset split', function () {
  1454. let widget, dashboard;
  1455. describe('events', function () {
  1456. beforeEach(function () {
  1457. widget = {
  1458. displayType: DisplayType.TABLE,
  1459. interval: '1d',
  1460. queries: [
  1461. {
  1462. name: 'Test Widget',
  1463. fields: ['count()', 'count_unique(user)', 'epm()', 'project'],
  1464. columns: ['project'],
  1465. aggregates: ['count()', 'count_unique(user)', 'epm()'],
  1466. conditions: '',
  1467. orderby: '',
  1468. },
  1469. ],
  1470. title: 'Transactions',
  1471. id: '1',
  1472. };
  1473. dashboard = mockDashboard({widgets: [widget]});
  1474. });
  1475. it('selects the error discover split type as the dataset when the events request completes', async function () {
  1476. eventsMock = MockApiClient.addMockResponse({
  1477. url: '/organizations/org-slug/events/',
  1478. method: 'GET',
  1479. statusCode: 200,
  1480. body: {
  1481. meta: {discoverSplitDecision: WidgetType.ERRORS},
  1482. data: [],
  1483. },
  1484. });
  1485. const mockUpdateDashboardSplitDecision = jest.fn();
  1486. renderTestComponent({
  1487. orgFeatures: [...defaultOrgFeatures, 'performance-discover-dataset-selector'],
  1488. dashboard,
  1489. params: {
  1490. widgetIndex: '0',
  1491. },
  1492. updateDashboardSplitDecision: mockUpdateDashboardSplitDecision,
  1493. });
  1494. await waitFor(() => {
  1495. expect(eventsMock).toHaveBeenCalled();
  1496. });
  1497. expect(screen.getByRole('radio', {name: /errors/i})).toBeChecked();
  1498. expect(mockUpdateDashboardSplitDecision).toHaveBeenCalledWith(
  1499. '1',
  1500. WidgetType.ERRORS
  1501. );
  1502. expect(
  1503. await screen.findByText(
  1504. "We're splitting our datasets up to make it a bit easier to digest. We defaulted this widget to Errors. Edit as you see fit."
  1505. )
  1506. ).toBeInTheDocument();
  1507. });
  1508. it('selects the transaction discover split type as the dataset when the events request completes', async function () {
  1509. eventsMock = MockApiClient.addMockResponse({
  1510. url: '/organizations/org-slug/events/',
  1511. method: 'GET',
  1512. statusCode: 200,
  1513. body: {
  1514. meta: {discoverSplitDecision: WidgetType.TRANSACTIONS},
  1515. data: [],
  1516. },
  1517. });
  1518. const mockUpdateDashboardSplitDecision = jest.fn();
  1519. renderTestComponent({
  1520. orgFeatures: [...defaultOrgFeatures, 'performance-discover-dataset-selector'],
  1521. dashboard,
  1522. params: {
  1523. widgetIndex: '0',
  1524. },
  1525. updateDashboardSplitDecision: mockUpdateDashboardSplitDecision,
  1526. });
  1527. await waitFor(() => {
  1528. expect(eventsMock).toHaveBeenCalled();
  1529. });
  1530. expect(screen.getByRole('radio', {name: /transactions/i})).toBeChecked();
  1531. expect(mockUpdateDashboardSplitDecision).toHaveBeenCalledWith(
  1532. '1',
  1533. WidgetType.TRANSACTIONS
  1534. );
  1535. expect(
  1536. await screen.findByText(
  1537. "We're splitting our datasets up to make it a bit easier to digest. We defaulted this widget to Transactions. Edit as you see fit."
  1538. )
  1539. ).toBeInTheDocument();
  1540. });
  1541. it('persists the query state for tables when switching between errors and transactions', async function () {
  1542. dashboard = mockDashboard({
  1543. widgets: [
  1544. WidgetFixture({
  1545. displayType: DisplayType.TABLE,
  1546. widgetType: WidgetType.TRANSACTIONS,
  1547. queries: [
  1548. {
  1549. name: 'Test Widget',
  1550. fields: ['p99(transaction.duration)'],
  1551. columns: [],
  1552. aggregates: ['p99(transaction.duration)'],
  1553. conditions: 'testFilter:value',
  1554. orderby: '',
  1555. },
  1556. ],
  1557. }),
  1558. ],
  1559. });
  1560. renderTestComponent({
  1561. orgFeatures: [...defaultOrgFeatures, 'performance-discover-dataset-selector'],
  1562. dashboard,
  1563. params: {
  1564. widgetIndex: '0',
  1565. },
  1566. });
  1567. expect(await screen.findByText(/p99\(…\)/i)).toBeInTheDocument();
  1568. expect(screen.getByText('transaction.duration')).toBeInTheDocument();
  1569. expect(screen.getByRole('row', {name: 'testFilter:value'})).toBeInTheDocument();
  1570. expect(screen.getByRole('radio', {name: /transactions/i})).toBeChecked();
  1571. // Switch to errors
  1572. await userEvent.click(screen.getByRole('radio', {name: /errors/i}));
  1573. expect(screen.getByRole('radio', {name: /transactions/i})).not.toBeChecked();
  1574. expect(screen.getByRole('radio', {name: /errors/i})).toBeChecked();
  1575. // The state is still the same
  1576. expect(await screen.findByText(/p99\(…\)/i)).toBeInTheDocument();
  1577. expect(screen.getByText('transaction.duration')).toBeInTheDocument();
  1578. expect(screen.getByRole('row', {name: 'testFilter:value'})).toBeInTheDocument();
  1579. });
  1580. it('sets the correct default count_if parameters for the errors dataset', async function () {
  1581. dashboard = mockDashboard({
  1582. widgets: [
  1583. WidgetFixture({
  1584. displayType: DisplayType.TABLE,
  1585. widgetType: WidgetType.ERRORS,
  1586. queries: [
  1587. {
  1588. name: 'Test Widget',
  1589. fields: ['count()'],
  1590. columns: [],
  1591. aggregates: ['count()'],
  1592. conditions: '',
  1593. orderby: '',
  1594. },
  1595. ],
  1596. }),
  1597. ],
  1598. });
  1599. renderTestComponent({
  1600. orgFeatures: [...defaultOrgFeatures, 'performance-discover-dataset-selector'],
  1601. dashboard,
  1602. params: {
  1603. widgetIndex: '0',
  1604. },
  1605. });
  1606. await userEvent.click(await screen.findByTestId('label'));
  1607. await userEvent.click(screen.getByText(/count_if/));
  1608. const fieldLabels = screen.getAllByTestId('label');
  1609. expect(fieldLabels[0]).toHaveTextContent(/count_if/);
  1610. expect(fieldLabels[1]).toHaveTextContent('event.type');
  1611. expect(screen.getByDisplayValue('error')).toBeInTheDocument();
  1612. });
  1613. it('sets the correct default count_if parameters for the transactions dataset', async function () {
  1614. dashboard = mockDashboard({
  1615. widgets: [
  1616. WidgetFixture({
  1617. displayType: DisplayType.TABLE,
  1618. widgetType: WidgetType.TRANSACTIONS,
  1619. queries: [
  1620. {
  1621. name: 'Test Widget',
  1622. fields: ['count()'],
  1623. columns: [],
  1624. aggregates: ['count()'],
  1625. conditions: '',
  1626. orderby: '',
  1627. },
  1628. ],
  1629. }),
  1630. ],
  1631. });
  1632. renderTestComponent({
  1633. orgFeatures: [...defaultOrgFeatures, 'performance-discover-dataset-selector'],
  1634. dashboard,
  1635. params: {
  1636. widgetIndex: '0',
  1637. },
  1638. });
  1639. await userEvent.click(await screen.findByTestId('label'));
  1640. await userEvent.click(screen.getByText(/count_if/));
  1641. const fieldLabels = screen.getAllByTestId('label');
  1642. expect(fieldLabels[0]).toHaveTextContent(/count_if/);
  1643. expect(fieldLabels[1]).toHaveTextContent('transaction.duration');
  1644. expect(screen.getByDisplayValue('300')).toBeInTheDocument();
  1645. });
  1646. });
  1647. describe('events-stats', function () {
  1648. beforeEach(function () {
  1649. widget = {
  1650. displayType: DisplayType.LINE,
  1651. interval: '1d',
  1652. queries: [
  1653. {
  1654. name: 'Test Widget',
  1655. fields: ['count()', 'count_unique(user)', 'epm()', 'project'],
  1656. columns: ['project'],
  1657. aggregates: ['count()', 'count_unique(user)', 'epm()'],
  1658. conditions: '',
  1659. orderby: '',
  1660. },
  1661. ],
  1662. title: 'Transactions',
  1663. id: '1',
  1664. };
  1665. dashboard = mockDashboard({widgets: [widget]});
  1666. });
  1667. it('selects the error discover split type as the dataset when the request completes', async function () {
  1668. eventsStatsMock = MockApiClient.addMockResponse({
  1669. url: '/organizations/org-slug/events-stats/',
  1670. method: 'GET',
  1671. statusCode: 200,
  1672. body: {
  1673. meta: {discoverSplitDecision: WidgetType.ERRORS},
  1674. data: [],
  1675. },
  1676. });
  1677. const mockUpdateDashboardSplitDecision = jest.fn();
  1678. renderTestComponent({
  1679. orgFeatures: [...defaultOrgFeatures, 'performance-discover-dataset-selector'],
  1680. dashboard,
  1681. params: {
  1682. widgetIndex: '0',
  1683. },
  1684. updateDashboardSplitDecision: mockUpdateDashboardSplitDecision,
  1685. });
  1686. await waitFor(() => {
  1687. expect(eventsStatsMock).toHaveBeenCalled();
  1688. });
  1689. expect(screen.getByRole('radio', {name: /errors/i})).toBeChecked();
  1690. expect(mockUpdateDashboardSplitDecision).toHaveBeenCalledWith(
  1691. '1',
  1692. WidgetType.ERRORS
  1693. );
  1694. expect(
  1695. await screen.findByText(
  1696. "We're splitting our datasets up to make it a bit easier to digest. We defaulted this widget to Errors. Edit as you see fit."
  1697. )
  1698. ).toBeInTheDocument();
  1699. });
  1700. it('selects the transaction discover split type as the dataset when the request completes', async function () {
  1701. eventsStatsMock = MockApiClient.addMockResponse({
  1702. url: '/organizations/org-slug/events-stats/',
  1703. method: 'GET',
  1704. statusCode: 200,
  1705. body: {
  1706. meta: {discoverSplitDecision: WidgetType.TRANSACTIONS},
  1707. data: [],
  1708. },
  1709. });
  1710. const mockUpdateDashboardSplitDecision = jest.fn();
  1711. renderTestComponent({
  1712. orgFeatures: [...defaultOrgFeatures, 'performance-discover-dataset-selector'],
  1713. dashboard,
  1714. params: {
  1715. widgetIndex: '0',
  1716. },
  1717. updateDashboardSplitDecision: mockUpdateDashboardSplitDecision,
  1718. });
  1719. await waitFor(() => {
  1720. expect(eventsStatsMock).toHaveBeenCalled();
  1721. });
  1722. expect(screen.getByRole('radio', {name: /transactions/i})).toBeChecked();
  1723. expect(mockUpdateDashboardSplitDecision).toHaveBeenCalledWith(
  1724. '1',
  1725. WidgetType.TRANSACTIONS
  1726. );
  1727. expect(
  1728. await screen.findByText(
  1729. "We're splitting our datasets up to make it a bit easier to digest. We defaulted this widget to Transactions. Edit as you see fit."
  1730. )
  1731. ).toBeInTheDocument();
  1732. });
  1733. it('persists the query state for timeseries when switching between errors and transactions', async function () {
  1734. dashboard = mockDashboard({
  1735. widgets: [
  1736. WidgetFixture({
  1737. displayType: DisplayType.LINE,
  1738. widgetType: WidgetType.TRANSACTIONS,
  1739. queries: [
  1740. {
  1741. name: 'Test Widget',
  1742. fields: ['p99(transaction.duration)'],
  1743. columns: [],
  1744. aggregates: ['p99(transaction.duration)'],
  1745. conditions: 'testFilter:value',
  1746. orderby: '',
  1747. },
  1748. ],
  1749. }),
  1750. ],
  1751. });
  1752. renderTestComponent({
  1753. orgFeatures: [...defaultOrgFeatures, 'performance-discover-dataset-selector'],
  1754. dashboard,
  1755. params: {
  1756. widgetIndex: '0',
  1757. },
  1758. });
  1759. expect(await screen.findByText(/p99\(…\)/i)).toBeInTheDocument();
  1760. expect(screen.getByText('transaction.duration')).toBeInTheDocument();
  1761. expect(screen.getByRole('row', {name: 'testFilter:value'})).toBeInTheDocument(); // Check for query builder token
  1762. expect(screen.getByRole('radio', {name: /transactions/i})).toBeChecked();
  1763. // Switch to errors
  1764. await userEvent.click(screen.getByRole('radio', {name: /errors/i}));
  1765. expect(screen.getByRole('radio', {name: /transactions/i})).not.toBeChecked();
  1766. expect(screen.getByRole('radio', {name: /errors/i})).toBeChecked();
  1767. // The state is still the same
  1768. expect(await screen.findByText(/p99\(…\)/i)).toBeInTheDocument();
  1769. expect(screen.getByText('transaction.duration')).toBeInTheDocument();
  1770. expect(screen.getByRole('row', {name: 'testFilter:value'})).toBeInTheDocument();
  1771. });
  1772. it('sets the correct default count_if parameters for the errors dataset', async function () {
  1773. dashboard = mockDashboard({
  1774. widgets: [
  1775. WidgetFixture({
  1776. displayType: DisplayType.LINE,
  1777. widgetType: WidgetType.ERRORS,
  1778. queries: [
  1779. {
  1780. name: 'Test Widget',
  1781. fields: ['count()'],
  1782. columns: [],
  1783. aggregates: ['count()'],
  1784. conditions: '',
  1785. orderby: '',
  1786. },
  1787. ],
  1788. }),
  1789. ],
  1790. });
  1791. renderTestComponent({
  1792. orgFeatures: [...defaultOrgFeatures, 'performance-discover-dataset-selector'],
  1793. dashboard,
  1794. params: {
  1795. widgetIndex: '0',
  1796. },
  1797. });
  1798. await userEvent.click(await screen.findByTestId('label'));
  1799. await userEvent.click(screen.getByText(/count_if/));
  1800. const fieldLabels = screen.getAllByTestId('label');
  1801. expect(fieldLabels[0]).toHaveTextContent(/count_if/);
  1802. expect(fieldLabels[1]).toHaveTextContent('event.type');
  1803. expect(screen.getByDisplayValue('error')).toBeInTheDocument();
  1804. });
  1805. it('sets the correct default count_if parameters for the transactions dataset', async function () {
  1806. dashboard = mockDashboard({
  1807. widgets: [
  1808. WidgetFixture({
  1809. displayType: DisplayType.LINE,
  1810. widgetType: WidgetType.TRANSACTIONS,
  1811. queries: [
  1812. {
  1813. name: 'Test Widget',
  1814. fields: ['count()'],
  1815. columns: [],
  1816. aggregates: ['count()'],
  1817. conditions: '',
  1818. orderby: '',
  1819. },
  1820. ],
  1821. }),
  1822. ],
  1823. });
  1824. renderTestComponent({
  1825. orgFeatures: [...defaultOrgFeatures, 'performance-discover-dataset-selector'],
  1826. dashboard,
  1827. params: {
  1828. widgetIndex: '0',
  1829. },
  1830. });
  1831. await userEvent.click(await screen.findByTestId('label'));
  1832. await userEvent.click(screen.getByText(/count_if/));
  1833. const fieldLabels = screen.getAllByTestId('label');
  1834. expect(fieldLabels[0]).toHaveTextContent(/count_if/);
  1835. expect(fieldLabels[1]).toHaveTextContent('transaction.duration');
  1836. expect(screen.getByDisplayValue('300')).toBeInTheDocument();
  1837. });
  1838. });
  1839. describe('discover split warning', function () {
  1840. it('does not show the alert if the widget type is already split', async function () {
  1841. dashboard = mockDashboard({
  1842. widgets: [WidgetFixture({widgetType: WidgetType.TRANSACTIONS})],
  1843. });
  1844. eventsStatsMock = MockApiClient.addMockResponse({
  1845. url: '/organizations/org-slug/events-stats/',
  1846. method: 'GET',
  1847. statusCode: 200,
  1848. body: {
  1849. meta: {},
  1850. data: [],
  1851. },
  1852. });
  1853. renderTestComponent({
  1854. orgFeatures: [...defaultOrgFeatures, 'performance-discover-dataset-selector'],
  1855. dashboard,
  1856. params: {
  1857. widgetIndex: '0',
  1858. },
  1859. });
  1860. await waitFor(() => {
  1861. expect(screen.getByRole('radio', {name: /transactions/i})).toBeChecked();
  1862. });
  1863. expect(
  1864. screen.queryByText(/we're splitting our datasets/i)
  1865. ).not.toBeInTheDocument();
  1866. });
  1867. it('shows the alert if the widget is split but the decision is forced', async function () {
  1868. dashboard = mockDashboard({
  1869. widgets: [
  1870. WidgetFixture({
  1871. widgetType: WidgetType.ERRORS,
  1872. datasetSource: DatasetSource.FORCED,
  1873. }),
  1874. ],
  1875. });
  1876. eventsStatsMock = MockApiClient.addMockResponse({
  1877. url: '/organizations/org-slug/events-stats/',
  1878. method: 'GET',
  1879. statusCode: 200,
  1880. body: {
  1881. meta: {},
  1882. data: [],
  1883. },
  1884. });
  1885. renderTestComponent({
  1886. orgFeatures: [...defaultOrgFeatures, 'performance-discover-dataset-selector'],
  1887. dashboard,
  1888. params: {
  1889. widgetIndex: '0',
  1890. },
  1891. });
  1892. await waitFor(() => {
  1893. expect(screen.getByRole('radio', {name: /errors/i})).toBeChecked();
  1894. });
  1895. expect(
  1896. await screen.findByText(
  1897. "We're splitting our datasets up to make it a bit easier to digest. We defaulted this widget to Errors. Edit as you see fit."
  1898. )
  1899. ).toBeInTheDocument();
  1900. });
  1901. it('is dismissable', async function () {
  1902. dashboard = mockDashboard({
  1903. widgets: [
  1904. WidgetFixture({
  1905. widgetType: WidgetType.ERRORS,
  1906. datasetSource: DatasetSource.FORCED,
  1907. }),
  1908. ],
  1909. });
  1910. eventsStatsMock = MockApiClient.addMockResponse({
  1911. url: '/organizations/org-slug/events-stats/',
  1912. method: 'GET',
  1913. statusCode: 200,
  1914. body: {
  1915. meta: {},
  1916. data: [],
  1917. },
  1918. });
  1919. renderTestComponent({
  1920. orgFeatures: [...defaultOrgFeatures, 'performance-discover-dataset-selector'],
  1921. dashboard,
  1922. params: {
  1923. widgetIndex: '0',
  1924. },
  1925. });
  1926. expect(
  1927. await screen.findByText(
  1928. "We're splitting our datasets up to make it a bit easier to digest. We defaulted this widget to Errors. Edit as you see fit."
  1929. )
  1930. ).toBeInTheDocument();
  1931. await userEvent.click(screen.getByRole('button', {name: 'Close'}));
  1932. expect(
  1933. screen.queryByText(/we're splitting our datasets/i)
  1934. ).not.toBeInTheDocument();
  1935. });
  1936. });
  1937. });
  1938. describe('Widget Library', function () {
  1939. it('only opens the modal when the query data is changed', async function () {
  1940. const mockModal = jest.spyOn(modals, 'openWidgetBuilderOverwriteModal');
  1941. renderTestComponent();
  1942. await screen.findByText('Widget Library');
  1943. await userEvent.click(screen.getByText('Duration Distribution'));
  1944. // Widget Library, Builder title, and Chart title
  1945. expect(screen.getAllByText('Duration Distribution')).toHaveLength(2);
  1946. // Confirm modal doesn't open because no changes were made
  1947. expect(mockModal).not.toHaveBeenCalled();
  1948. await userEvent.click(screen.getAllByLabelText('Remove this Y-Axis')[0]);
  1949. await userEvent.click(screen.getByText('High Throughput Transactions'));
  1950. // Should not have overwritten widget data, and confirm modal should open
  1951. expect(screen.getAllByText('Duration Distribution')).toHaveLength(2);
  1952. expect(mockModal).toHaveBeenCalled();
  1953. });
  1954. });
  1955. describe('group by field', function () {
  1956. it('does not contain functions as options', async function () {
  1957. renderTestComponent({
  1958. query: {displayType: 'line'},
  1959. orgFeatures: [...defaultOrgFeatures],
  1960. });
  1961. expect(await screen.findByText('Select group')).toBeInTheDocument();
  1962. await userEvent.click(screen.getByText('Select group'));
  1963. // Only one f(x) field set in the y-axis selector
  1964. expect(screen.getByText('f(x)')).toBeInTheDocument();
  1965. });
  1966. it('adds more fields when Add Group is clicked', async function () {
  1967. renderTestComponent({
  1968. query: {displayType: 'line'},
  1969. orgFeatures: [...defaultOrgFeatures],
  1970. });
  1971. await userEvent.click(await screen.findByText('Add Group'));
  1972. expect(await screen.findAllByText('Select group')).toHaveLength(2);
  1973. });
  1974. it("doesn't reset group by when changing y-axis", async function () {
  1975. renderTestComponent({
  1976. query: {displayType: 'line'},
  1977. orgFeatures: [...defaultOrgFeatures],
  1978. });
  1979. await selectEvent.select(await screen.findByText('Select group'), 'project');
  1980. await userEvent.click(screen.getAllByText('count()')[0], {
  1981. skipHover: true,
  1982. });
  1983. await userEvent.click(screen.getByText(/count_unique/), {
  1984. skipHover: true,
  1985. });
  1986. expect(await screen.findByText('project')).toBeInTheDocument();
  1987. });
  1988. it("doesn't erase the selection when switching to another time series", async function () {
  1989. renderTestComponent({
  1990. query: {displayType: 'line'},
  1991. orgFeatures: [...defaultOrgFeatures],
  1992. });
  1993. await selectEvent.select(await screen.findByText('Select group'), 'project');
  1994. await userEvent.click(screen.getByText('Line Chart'));
  1995. await userEvent.click(screen.getByText('Area Chart'));
  1996. expect(await screen.findByText('project')).toBeInTheDocument();
  1997. });
  1998. it('sends a top N request when a grouping is selected', async function () {
  1999. renderTestComponent({
  2000. query: {displayType: 'line'},
  2001. orgFeatures: [...defaultOrgFeatures],
  2002. });
  2003. await userEvent.click(await screen.findByText('Group your results'));
  2004. await userEvent.type(screen.getByText('Select group'), 'project{enter}');
  2005. await waitFor(() =>
  2006. expect(eventsStatsMock).toHaveBeenNthCalledWith(
  2007. 2,
  2008. '/organizations/org-slug/events-stats/',
  2009. expect.objectContaining({
  2010. query: expect.objectContaining({
  2011. query: '',
  2012. yAxis: ['count()'],
  2013. field: ['project', 'count()'],
  2014. topEvents: TOP_N,
  2015. orderby: '-count()',
  2016. }),
  2017. })
  2018. )
  2019. );
  2020. });
  2021. it('allows deleting groups until there is one left', async function () {
  2022. renderTestComponent({
  2023. query: {displayType: 'line'},
  2024. orgFeatures: [...defaultOrgFeatures],
  2025. });
  2026. await userEvent.click(await screen.findByText('Add Group'));
  2027. expect(screen.getAllByLabelText('Remove group')).toHaveLength(2);
  2028. await userEvent.click(screen.getAllByLabelText('Remove group')[1]);
  2029. await waitFor(() =>
  2030. expect(screen.queryByLabelText('Remove group')).not.toBeInTheDocument()
  2031. );
  2032. });
  2033. it("display 'remove' and 'drag to reorder' buttons", async function () {
  2034. renderTestComponent({
  2035. query: {displayType: 'line'},
  2036. orgFeatures: [...defaultOrgFeatures],
  2037. });
  2038. expect(screen.queryByLabelText('Remove group')).not.toBeInTheDocument();
  2039. await selectEvent.select(screen.getByText('Select group'), 'project');
  2040. expect(screen.getByLabelText('Remove group')).toBeInTheDocument();
  2041. expect(screen.queryByLabelText('Drag to reorder')).not.toBeInTheDocument();
  2042. await userEvent.click(screen.getByText('Add Group'));
  2043. expect(screen.getAllByLabelText('Remove group')).toHaveLength(2);
  2044. expect(screen.getAllByLabelText('Drag to reorder')).toHaveLength(2);
  2045. });
  2046. it.todo(
  2047. 'Since simulate drag and drop with RTL is not recommended because of browser layout, remember to create acceptance test for this'
  2048. );
  2049. });
  2050. describe('limit field', function () {
  2051. it('renders if groupBy value is present', async function () {
  2052. const handleSave = jest.fn();
  2053. renderTestComponent({
  2054. query: {displayType: 'line'},
  2055. orgFeatures: [...defaultOrgFeatures],
  2056. onSave: handleSave,
  2057. });
  2058. await selectEvent.select(await screen.findByText('Select group'), 'project');
  2059. expect(screen.getByText('Limit to 5 results')).toBeInTheDocument();
  2060. await userEvent.click(screen.getByText('Add Widget'));
  2061. await waitFor(() =>
  2062. expect(handleSave).toHaveBeenCalledWith([
  2063. expect.objectContaining({
  2064. limit: 5,
  2065. }),
  2066. ])
  2067. );
  2068. });
  2069. it('update value', async function () {
  2070. renderTestComponent({
  2071. query: {displayType: 'line'},
  2072. orgFeatures: [...defaultOrgFeatures],
  2073. });
  2074. await selectEvent.select(await screen.findByText('Select group'), 'project');
  2075. await userEvent.click(screen.getByText('Limit to 5 results'));
  2076. await userEvent.click(screen.getByText('Limit to 2 results'));
  2077. await waitFor(() =>
  2078. expect(eventsStatsMock).toHaveBeenCalledWith(
  2079. '/organizations/org-slug/events-stats/',
  2080. expect.objectContaining({
  2081. query: expect.objectContaining({
  2082. query: '',
  2083. yAxis: ['count()'],
  2084. field: ['project', 'count()'],
  2085. topEvents: 2,
  2086. orderby: '-count()',
  2087. }),
  2088. })
  2089. )
  2090. );
  2091. });
  2092. it('gets removed if no groupBy value', async function () {
  2093. renderTestComponent({
  2094. query: {displayType: 'line'},
  2095. orgFeatures: [...defaultOrgFeatures],
  2096. });
  2097. await selectEvent.select(await screen.findByText('Select group'), 'project');
  2098. expect(screen.getByText('Limit to 5 results')).toBeInTheDocument();
  2099. await userEvent.click(screen.getByLabelText('Remove group'));
  2100. await waitFor(() =>
  2101. expect(screen.queryByText('Limit to 5 results')).not.toBeInTheDocument()
  2102. );
  2103. });
  2104. it('applies a limit when switching from a table to timeseries chart with grouping', async function () {
  2105. const widget: Widget = {
  2106. displayType: DisplayType.TABLE,
  2107. interval: '1d',
  2108. queries: [
  2109. {
  2110. name: 'Test Widget',
  2111. fields: ['count()', 'count_unique(user)', 'epm()', 'project'],
  2112. columns: ['project'],
  2113. aggregates: ['count()', 'count_unique(user)', 'epm()'],
  2114. conditions: '',
  2115. orderby: '',
  2116. },
  2117. ],
  2118. title: 'Transactions',
  2119. id: '1',
  2120. };
  2121. const dashboard = mockDashboard({widgets: [widget]});
  2122. renderTestComponent({
  2123. dashboard,
  2124. orgFeatures: [...defaultOrgFeatures],
  2125. params: {
  2126. widgetIndex: '0',
  2127. },
  2128. });
  2129. await userEvent.click(await screen.findByText('Table'));
  2130. await userEvent.click(screen.getByText('Line Chart'));
  2131. expect(screen.getByText('Limit to 3 results')).toBeInTheDocument();
  2132. expect(eventsStatsMock).toHaveBeenCalledWith(
  2133. '/organizations/org-slug/events-stats/',
  2134. expect.objectContaining({
  2135. query: expect.objectContaining({
  2136. topEvents: 3,
  2137. }),
  2138. })
  2139. );
  2140. });
  2141. it('persists the limit when switching between timeseries charts', async function () {
  2142. const widget: Widget = {
  2143. displayType: DisplayType.AREA,
  2144. interval: '1d',
  2145. queries: [
  2146. {
  2147. name: 'Test Widget',
  2148. fields: ['count()', 'count_unique(user)', 'epm()', 'project'],
  2149. columns: ['project'],
  2150. aggregates: ['count()', 'count_unique(user)', 'epm()'],
  2151. conditions: '',
  2152. orderby: '',
  2153. },
  2154. ],
  2155. title: 'Transactions',
  2156. id: '1',
  2157. limit: 1,
  2158. };
  2159. const dashboard = mockDashboard({widgets: [widget]});
  2160. renderTestComponent({
  2161. dashboard,
  2162. orgFeatures: [...defaultOrgFeatures],
  2163. params: {
  2164. widgetIndex: '0',
  2165. },
  2166. });
  2167. await userEvent.click(await screen.findByText('Area Chart'));
  2168. await userEvent.click(screen.getByText('Line Chart'));
  2169. expect(screen.getByText('Limit to 1 result')).toBeInTheDocument();
  2170. expect(eventsStatsMock).toHaveBeenCalledWith(
  2171. '/organizations/org-slug/events-stats/',
  2172. expect.objectContaining({
  2173. query: expect.objectContaining({
  2174. topEvents: 1,
  2175. }),
  2176. })
  2177. );
  2178. });
  2179. it('unsets the limit when going from timeseries to table', async function () {
  2180. const widget: Widget = {
  2181. displayType: DisplayType.AREA,
  2182. interval: '1d',
  2183. queries: [
  2184. {
  2185. name: 'Test Widget',
  2186. fields: ['count()', 'count_unique(user)', 'epm()', 'project'],
  2187. columns: ['project'],
  2188. aggregates: ['count()', 'count_unique(user)', 'epm()'],
  2189. conditions: '',
  2190. orderby: '',
  2191. },
  2192. ],
  2193. title: 'Transactions',
  2194. id: '1',
  2195. limit: 1,
  2196. };
  2197. const dashboard = mockDashboard({widgets: [widget]});
  2198. renderTestComponent({
  2199. dashboard,
  2200. orgFeatures: [...defaultOrgFeatures],
  2201. params: {
  2202. widgetIndex: '0',
  2203. },
  2204. });
  2205. await userEvent.click(await screen.findByText('Area Chart'));
  2206. await userEvent.click(screen.getByText('Table'));
  2207. expect(screen.queryByText('Limit to 1 result')).not.toBeInTheDocument();
  2208. expect(eventsMock).toHaveBeenCalledWith(
  2209. '/organizations/org-slug/events/',
  2210. expect.objectContaining({
  2211. query: expect.objectContaining({
  2212. topEvents: undefined,
  2213. }),
  2214. })
  2215. );
  2216. });
  2217. });
  2218. });