widgetBuilder.spec.tsx 63 KB

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