widgetBuilder.spec.tsx 73 KB

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