widgetBuilder.spec.tsx 63 KB

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