widgetBuilder.spec.tsx 58 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007
  1. import selectEvent from 'react-select-event';
  2. import {urlEncode} from '@sentry/utils';
  3. import {initializeOrg} from 'sentry-test/initializeOrg';
  4. import {
  5. act,
  6. fireEvent,
  7. render,
  8. renderGlobalModal,
  9. screen,
  10. userEvent,
  11. waitFor,
  12. } from 'sentry-test/reactTestingLibrary';
  13. import * as 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/dashboards/types';
  24. import WidgetBuilder, {WidgetBuilderProps} from 'sentry/views/dashboards/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. organization: {
  60. features: orgFeatures ?? defaultOrgFeatures,
  61. },
  62. router: {
  63. location: {
  64. query: {
  65. source: DashboardWidgetSource.DASHBOARDS,
  66. ...query,
  67. },
  68. },
  69. },
  70. });
  71. render(
  72. <WidgetBuilder
  73. route={{}}
  74. router={router}
  75. routes={router.routes}
  76. routeParams={router.params}
  77. location={router.location}
  78. dashboard={{
  79. id: 'new',
  80. title: 'Dashboard',
  81. createdBy: undefined,
  82. dateCreated: '2020-01-01T00:00:00.000Z',
  83. widgets: [],
  84. projects: [],
  85. filters: {},
  86. ...dashboard,
  87. }}
  88. onSave={onSave ?? jest.fn()}
  89. params={{
  90. orgId: organization.slug,
  91. dashboardId: dashboard?.id ?? 'new',
  92. ...params,
  93. }}
  94. />,
  95. {
  96. context: routerContext,
  97. organization,
  98. }
  99. );
  100. return {router};
  101. }
  102. /**
  103. * This test suite contains tests that test the generic interactions
  104. * between most components in the WidgetBuilder. Tests for the
  105. * SortBy step can be found in (and should be added to)
  106. * ./widgetBuilderSortBy.spec.tsx and tests for specific dataset
  107. * behaviour can be found (and should be added to) ./widgetBuilderDataset.spec.tsx.
  108. * The test files are broken up to allow better parallelization
  109. * in CI (we currently parallelize files).
  110. */
  111. describe('WidgetBuilder', function () {
  112. const untitledDashboard: DashboardDetails = {
  113. id: '1',
  114. title: 'Untitled Dashboard',
  115. createdBy: undefined,
  116. dateCreated: '2020-01-01T00:00:00.000Z',
  117. widgets: [],
  118. projects: [],
  119. filters: {},
  120. };
  121. const testDashboard: DashboardDetails = {
  122. id: '2',
  123. title: 'Test Dashboard',
  124. createdBy: undefined,
  125. dateCreated: '2020-01-01T00:00:00.000Z',
  126. widgets: [],
  127. projects: [],
  128. filters: {},
  129. };
  130. let eventsStatsMock: jest.Mock | undefined;
  131. let eventsMock: jest.Mock | undefined;
  132. let tagsMock: jest.Mock | undefined;
  133. beforeEach(function () {
  134. MockApiClient.addMockResponse({
  135. url: '/organizations/org-slug/dashboards/',
  136. body: [
  137. {...untitledDashboard, widgetDisplay: [DisplayType.TABLE]},
  138. {...testDashboard, widgetDisplay: [DisplayType.AREA]},
  139. ],
  140. });
  141. MockApiClient.addMockResponse({
  142. url: '/organizations/org-slug/dashboards/widgets/',
  143. method: 'POST',
  144. statusCode: 200,
  145. body: [],
  146. });
  147. eventsMock = MockApiClient.addMockResponse({
  148. url: '/organizations/org-slug/events/',
  149. method: 'GET',
  150. statusCode: 200,
  151. body: {
  152. meta: {fields: {}},
  153. data: [],
  154. },
  155. });
  156. MockApiClient.addMockResponse({
  157. url: '/organizations/org-slug/projects/',
  158. method: 'GET',
  159. body: [],
  160. });
  161. MockApiClient.addMockResponse({
  162. url: '/organizations/org-slug/recent-searches/',
  163. method: 'GET',
  164. body: [],
  165. });
  166. MockApiClient.addMockResponse({
  167. url: '/organizations/org-slug/recent-searches/',
  168. method: 'POST',
  169. body: [],
  170. });
  171. MockApiClient.addMockResponse({
  172. url: '/organizations/org-slug/issues/',
  173. method: 'GET',
  174. body: [],
  175. });
  176. eventsStatsMock = MockApiClient.addMockResponse({
  177. url: '/organizations/org-slug/events-stats/',
  178. body: [],
  179. });
  180. MockApiClient.addMockResponse({
  181. url: '/organizations/org-slug/tags/event.type/values/',
  182. body: [{count: 2, name: 'Nvidia 1080ti'}],
  183. });
  184. MockApiClient.addMockResponse({
  185. url: '/organizations/org-slug/events-geo/',
  186. body: {data: [], meta: {}},
  187. });
  188. MockApiClient.addMockResponse({
  189. url: '/organizations/org-slug/users/',
  190. body: [],
  191. });
  192. MockApiClient.addMockResponse({
  193. method: 'GET',
  194. url: '/organizations/org-slug/sessions/',
  195. body: TestStubs.SessionsField({
  196. field: `sum(session)`,
  197. }),
  198. });
  199. MockApiClient.addMockResponse({
  200. method: 'GET',
  201. url: '/organizations/org-slug/metrics/data/',
  202. body: TestStubs.MetricsField({
  203. field: 'sum(sentry.sessions.session)',
  204. }),
  205. });
  206. tagsMock = MockApiClient.addMockResponse({
  207. url: '/organizations/org-slug/tags/',
  208. method: 'GET',
  209. body: TestStubs.Tags(),
  210. });
  211. MockApiClient.addMockResponse({
  212. url: '/organizations/org-slug/measurements-meta/',
  213. method: 'GET',
  214. body: {},
  215. });
  216. MockApiClient.addMockResponse({
  217. url: '/organizations/org-slug/tags/is/values/',
  218. method: 'GET',
  219. body: [],
  220. });
  221. MockApiClient.addMockResponse({
  222. url: '/organizations/org-slug/tags/transaction.duration/values/',
  223. method: 'GET',
  224. body: [],
  225. });
  226. MockApiClient.addMockResponse({
  227. url: '/organizations/org-slug/releases/',
  228. body: [],
  229. });
  230. TagStore.reset();
  231. });
  232. afterEach(function () {
  233. MockApiClient.clearMockResponses();
  234. jest.clearAllMocks();
  235. jest.useRealTimers();
  236. });
  237. it('no feature access', function () {
  238. renderTestComponent({orgFeatures: []});
  239. expect(screen.getByText("You don't have access to this feature")).toBeInTheDocument();
  240. });
  241. it('widget not found', function () {
  242. const widget: Widget = {
  243. displayType: DisplayType.AREA,
  244. interval: '1d',
  245. queries: [
  246. {
  247. name: 'Known Users',
  248. fields: [],
  249. columns: [],
  250. aggregates: [],
  251. conditions: '',
  252. orderby: '-time',
  253. },
  254. {
  255. name: 'Anonymous Users',
  256. fields: [],
  257. columns: [],
  258. aggregates: [],
  259. conditions: '',
  260. orderby: '-time',
  261. },
  262. ],
  263. title: 'Transactions',
  264. id: '1',
  265. };
  266. const dashboard = mockDashboard({widgets: [widget]});
  267. renderTestComponent({
  268. dashboard,
  269. orgFeatures: ['dashboards-edit'],
  270. params: {
  271. widgetIndex: '2', // Out of bounds, only one widget
  272. },
  273. });
  274. expect(
  275. screen.getByText('The widget you want to edit was not found.')
  276. ).toBeInTheDocument();
  277. });
  278. it('renders a widget not found message if the widget index url is not an integer', function () {
  279. const widget: Widget = {
  280. displayType: DisplayType.AREA,
  281. interval: '1d',
  282. queries: [
  283. {
  284. name: 'Known Users',
  285. fields: [],
  286. columns: [],
  287. aggregates: [],
  288. conditions: '',
  289. orderby: '-time',
  290. },
  291. ],
  292. title: 'Transactions',
  293. id: '1',
  294. };
  295. const dashboard = mockDashboard({widgets: [widget]});
  296. renderTestComponent({
  297. dashboard,
  298. orgFeatures: ['dashboards-edit'],
  299. params: {
  300. widgetIndex: '0.5', // Invalid index
  301. },
  302. });
  303. expect(
  304. screen.getByText('The widget you want to edit was not found.')
  305. ).toBeInTheDocument();
  306. });
  307. it('renders', async function () {
  308. renderTestComponent();
  309. // Header - Breadcrumbs
  310. expect(await screen.findByRole('link', {name: 'Dashboards'})).toHaveAttribute(
  311. 'href',
  312. '/organizations/org-slug/dashboards/'
  313. );
  314. expect(screen.getByRole('link', {name: 'Dashboard'})).toHaveAttribute(
  315. 'href',
  316. '/organizations/org-slug/dashboards/new/'
  317. );
  318. expect(screen.getByText('Widget Builder')).toBeInTheDocument();
  319. // Header - Widget Title
  320. expect(screen.getByRole('heading', {name: 'Custom Widget'})).toBeInTheDocument();
  321. // Footer - Actions
  322. expect(screen.getByLabelText('Cancel')).toBeInTheDocument();
  323. expect(screen.getByLabelText('Add Widget')).toBeInTheDocument();
  324. // Content - Step 1
  325. expect(
  326. screen.getByRole('heading', {name: 'Choose your dataset'})
  327. ).toBeInTheDocument();
  328. expect(screen.getByLabelText('Errors and Transactions')).toBeChecked();
  329. // Content - Step 2
  330. expect(
  331. screen.getByRole('heading', {name: 'Choose your visualization'})
  332. ).toBeInTheDocument();
  333. // Content - Step 3
  334. expect(
  335. screen.getByRole('heading', {name: 'Choose your columns'})
  336. ).toBeInTheDocument();
  337. // Content - Step 4
  338. expect(
  339. screen.getByRole('heading', {name: 'Filter your results'})
  340. ).toBeInTheDocument();
  341. // Content - Step 5
  342. expect(screen.getByRole('heading', {name: 'Sort by a column'})).toBeInTheDocument();
  343. });
  344. it('has links back to the new dashboard if creating', async function () {
  345. // Dashboard has undefined dashboardId when creating from a new dashboard
  346. // because of route setup
  347. renderTestComponent({params: {dashboardId: undefined}});
  348. expect(await screen.findByLabelText('Cancel')).toHaveAttribute(
  349. 'href',
  350. '/organizations/org-slug/dashboards/new/'
  351. );
  352. });
  353. it('renders new design', async function () {
  354. renderTestComponent({
  355. orgFeatures: [...defaultOrgFeatures],
  356. });
  357. // Switch to line chart for time series
  358. await userEvent.click(screen.getByText('Table'));
  359. await userEvent.click(screen.getByText('Line Chart'));
  360. // Header - Breadcrumbs
  361. expect(await screen.findByRole('link', {name: 'Dashboards'})).toHaveAttribute(
  362. 'href',
  363. '/organizations/org-slug/dashboards/'
  364. );
  365. expect(screen.getByRole('link', {name: 'Dashboard'})).toHaveAttribute(
  366. 'href',
  367. '/organizations/org-slug/dashboards/new/'
  368. );
  369. expect(screen.getByText('Widget Builder')).toBeInTheDocument();
  370. // Header - Widget Title
  371. expect(screen.getByRole('heading', {name: 'Custom Widget'})).toBeInTheDocument();
  372. // Footer - Actions
  373. expect(screen.getByLabelText('Cancel')).toBeInTheDocument();
  374. expect(screen.getByLabelText('Add Widget')).toBeInTheDocument();
  375. // Content - Step 1
  376. expect(
  377. screen.getByRole('heading', {name: 'Choose your dataset'})
  378. ).toBeInTheDocument();
  379. expect(screen.getByLabelText('Errors and Transactions')).toBeChecked();
  380. // Content - Step 2
  381. expect(
  382. screen.getByRole('heading', {name: 'Choose your visualization'})
  383. ).toBeInTheDocument();
  384. // Content - Step 3
  385. expect(
  386. screen.getByRole('heading', {name: 'Choose what to plot in the y-axis'})
  387. ).toBeInTheDocument();
  388. // Content - Step 4
  389. expect(
  390. screen.getByRole('heading', {name: 'Filter your results'})
  391. ).toBeInTheDocument();
  392. // Content - Step 5
  393. expect(screen.getByRole('heading', {name: 'Group your results'})).toBeInTheDocument();
  394. });
  395. it('can update the title', async function () {
  396. renderTestComponent({
  397. query: {source: DashboardWidgetSource.DISCOVERV2},
  398. });
  399. const customWidgetLabels = await screen.findAllByText('Custom Widget');
  400. // EditableText and chart title
  401. expect(customWidgetLabels).toHaveLength(2);
  402. await userEvent.click(customWidgetLabels[0]);
  403. await userEvent.clear(screen.getByRole('textbox', {name: 'Widget title'}));
  404. await userEvent.click(screen.getByRole('textbox', {name: 'Widget title'}));
  405. await userEvent.paste('Unique Users');
  406. await userEvent.keyboard('{enter}');
  407. expect(screen.queryByText('Custom Widget')).not.toBeInTheDocument();
  408. expect(screen.getAllByText('Unique Users')).toHaveLength(2);
  409. });
  410. it('can add query conditions', async function () {
  411. const {router} = renderTestComponent({
  412. query: {source: DashboardWidgetSource.DISCOVERV2},
  413. dashboard: testDashboard,
  414. });
  415. const search = await screen.findByTestId(/smart-search-input/);
  416. await userEvent.click(search);
  417. // Use fireEvent for performance reasons as this test suite is slow
  418. fireEvent.paste(search, {
  419. target: {value: 'color:blue'},
  420. clipboardData: {getData: () => 'color:blue'},
  421. });
  422. await userEvent.keyboard('{enter}');
  423. await userEvent.click(screen.getByText('Add Widget'));
  424. await waitFor(() => {
  425. expect(router.push).toHaveBeenCalledWith(
  426. expect.objectContaining({
  427. pathname: '/organizations/org-slug/dashboard/2/',
  428. query: {
  429. displayType: 'table',
  430. interval: '5m',
  431. title: 'Custom Widget',
  432. queryNames: [''],
  433. queryConditions: ['color:blue'],
  434. queryFields: ['count()'],
  435. queryOrderby: '-count()',
  436. start: null,
  437. end: null,
  438. statsPeriod: '24h',
  439. utc: null,
  440. project: [],
  441. environment: [],
  442. },
  443. })
  444. );
  445. });
  446. });
  447. it('can choose a field', async function () {
  448. const {router} = renderTestComponent({
  449. query: {source: DashboardWidgetSource.DISCOVERV2},
  450. dashboard: testDashboard,
  451. });
  452. expect(await screen.findAllByText('Custom Widget')).toHaveLength(2);
  453. // No delete button as there is only one query.
  454. expect(screen.queryByLabelText('Remove query')).not.toBeInTheDocument();
  455. // 1 in the table header, 1 in the column selector, 1 in the sort field
  456. const countFields = screen.getAllByText('count()');
  457. expect(countFields).toHaveLength(3);
  458. await selectEvent.select(countFields[1], ['last_seen()']);
  459. await userEvent.click(screen.getByText('Add Widget'));
  460. await waitFor(() => {
  461. expect(router.push).toHaveBeenCalledWith(
  462. expect.objectContaining({
  463. pathname: '/organizations/org-slug/dashboard/2/',
  464. query: {
  465. displayType: 'table',
  466. interval: '5m',
  467. title: 'Custom Widget',
  468. queryNames: [''],
  469. queryConditions: [''],
  470. queryFields: ['last_seen()'],
  471. queryOrderby: '-last_seen()',
  472. start: null,
  473. end: null,
  474. statsPeriod: '24h',
  475. utc: null,
  476. project: [],
  477. environment: [],
  478. },
  479. })
  480. );
  481. });
  482. });
  483. it('can add additional fields', async function () {
  484. const handleSave = jest.fn();
  485. renderTestComponent({onSave: handleSave});
  486. await userEvent.click(await screen.findByText('Table'));
  487. // Select line chart display
  488. await userEvent.click(screen.getByText('Line Chart'));
  489. // Click the add overlay button
  490. await userEvent.click(screen.getByLabelText('Add Overlay'));
  491. await selectEvent.select(screen.getByText('(Required)'), ['count_unique(…)']);
  492. await userEvent.click(screen.getByLabelText('Add Widget'));
  493. await waitFor(() => {
  494. expect(handleSave).toHaveBeenCalledWith([
  495. expect.objectContaining({
  496. title: 'Custom Widget',
  497. displayType: DisplayType.LINE,
  498. interval: '5m',
  499. widgetType: WidgetType.DISCOVER,
  500. queries: [
  501. {
  502. conditions: '',
  503. fields: ['count()', 'count_unique(user)'],
  504. aggregates: ['count()', 'count_unique(user)'],
  505. fieldAliases: [],
  506. columns: [],
  507. orderby: '',
  508. name: '',
  509. },
  510. ],
  511. }),
  512. ]);
  513. });
  514. expect(handleSave).toHaveBeenCalledTimes(1);
  515. });
  516. it('can add equation fields', async function () {
  517. const handleSave = jest.fn();
  518. renderTestComponent({onSave: handleSave});
  519. await userEvent.click(await screen.findByText('Table'));
  520. // Select line chart display
  521. await userEvent.click(screen.getByText('Line Chart'));
  522. // Click the add an equation button
  523. await userEvent.click(screen.getByLabelText('Add an Equation'));
  524. expect(screen.getByPlaceholderText('Equation')).toBeInTheDocument();
  525. await userEvent.click(screen.getByPlaceholderText('Equation'));
  526. await userEvent.paste('count() + 100');
  527. await userEvent.click(screen.getByLabelText('Add Widget'));
  528. await waitFor(() => {
  529. expect(handleSave).toHaveBeenCalledWith([
  530. expect.objectContaining({
  531. title: 'Custom Widget',
  532. displayType: DisplayType.LINE,
  533. interval: '5m',
  534. widgetType: WidgetType.DISCOVER,
  535. queries: [
  536. {
  537. name: '',
  538. fields: ['count()', 'equation|count() + 100'],
  539. aggregates: ['count()', 'equation|count() + 100'],
  540. columns: [],
  541. fieldAliases: [],
  542. conditions: '',
  543. orderby: '',
  544. },
  545. ],
  546. }),
  547. ]);
  548. });
  549. expect(handleSave).toHaveBeenCalledTimes(1);
  550. });
  551. it('can respond to validation feedback', async function () {
  552. jest.spyOn(indicators, 'addErrorMessage');
  553. renderTestComponent();
  554. await userEvent.click(await screen.findByText('Table'));
  555. const customWidgetLabels = await screen.findAllByText('Custom Widget');
  556. // EditableText and chart title
  557. expect(customWidgetLabels).toHaveLength(2);
  558. await userEvent.click(customWidgetLabels[0]);
  559. await userEvent.clear(screen.getByRole('textbox', {name: 'Widget title'}));
  560. await userEvent.keyboard('{enter}');
  561. expect(indicators.addErrorMessage).toHaveBeenCalledWith('Widget title is required');
  562. });
  563. it('sets up widget data in edit correctly', async function () {
  564. const widget: Widget = {
  565. id: '1',
  566. title: 'Errors over time',
  567. interval: '5m',
  568. displayType: DisplayType.LINE,
  569. queries: [
  570. {
  571. name: 'errors',
  572. conditions: 'event.type:error',
  573. fields: ['count()', 'count_unique(id)'],
  574. aggregates: ['count()', 'count_unique(id)'],
  575. columns: [],
  576. orderby: '',
  577. },
  578. {
  579. name: 'csp',
  580. conditions: 'event.type:csp',
  581. fields: ['count()', 'count_unique(id)'],
  582. aggregates: ['count()', 'count_unique(id)'],
  583. columns: [],
  584. orderby: '',
  585. },
  586. ],
  587. };
  588. const dashboard = mockDashboard({widgets: [widget]});
  589. renderTestComponent({dashboard, params: {widgetIndex: '0'}});
  590. await screen.findByText('Line Chart');
  591. // Should be in edit 'mode'
  592. expect(await screen.findByText('Update Widget')).toBeInTheDocument();
  593. // Should set widget data up.
  594. expect(screen.getByText('Update Widget')).toBeInTheDocument();
  595. // Filters
  596. expect(
  597. screen.getAllByPlaceholderText('Search for events, users, tags, and more')
  598. ).toHaveLength(2);
  599. expect(screen.getByText('event.type:csp')).toBeInTheDocument();
  600. expect(screen.getByText('event.type:error')).toBeInTheDocument();
  601. // Y-axis
  602. expect(screen.getAllByRole('button', {name: 'Remove query'})).toHaveLength(2);
  603. expect(screen.getByText('count()')).toBeInTheDocument();
  604. expect(screen.getByText('count_unique(…)')).toBeInTheDocument();
  605. expect(screen.getByText('id')).toBeInTheDocument();
  606. // Expect events-stats endpoint to be called for each search conditions with
  607. // the same y-axis parameters
  608. expect(eventsStatsMock).toHaveBeenNthCalledWith(
  609. 1,
  610. '/organizations/org-slug/events-stats/',
  611. expect.objectContaining({
  612. query: expect.objectContaining({
  613. query: 'event.type:error',
  614. yAxis: ['count()', 'count_unique(id)'],
  615. }),
  616. })
  617. );
  618. expect(eventsStatsMock).toHaveBeenNthCalledWith(
  619. 2,
  620. '/organizations/org-slug/events-stats/',
  621. expect.objectContaining({
  622. query: expect.objectContaining({
  623. query: 'event.type:csp',
  624. yAxis: ['count()', 'count_unique(id)'],
  625. }),
  626. })
  627. );
  628. });
  629. it('can edit a widget', async function () {
  630. const widget: Widget = {
  631. id: '1',
  632. title: 'Errors over time',
  633. interval: '5m',
  634. displayType: DisplayType.LINE,
  635. queries: [
  636. {
  637. name: 'errors',
  638. conditions: 'event.type:error',
  639. fields: ['count()', 'count_unique(id)'],
  640. aggregates: ['count()', 'count_unique(id)'],
  641. columns: [],
  642. orderby: '',
  643. },
  644. {
  645. name: 'csp',
  646. conditions: 'event.type:csp',
  647. fields: ['count()', 'count_unique(id)'],
  648. aggregates: ['count()', 'count_unique(id)'],
  649. columns: [],
  650. orderby: '',
  651. },
  652. ],
  653. };
  654. const dashboard = mockDashboard({widgets: [widget]});
  655. const handleSave = jest.fn();
  656. renderTestComponent({onSave: handleSave, dashboard, params: {widgetIndex: '0'}});
  657. await screen.findByText('Line Chart');
  658. // Should be in edit 'mode'
  659. expect(screen.getByText('Update Widget')).toBeInTheDocument();
  660. const customWidgetLabels = screen.getAllByText(widget.title);
  661. // EditableText and chart title
  662. expect(customWidgetLabels).toHaveLength(2);
  663. await userEvent.click(customWidgetLabels[0]);
  664. await userEvent.clear(screen.getByRole('textbox', {name: 'Widget title'}));
  665. await userEvent.click(screen.getByRole('textbox', {name: 'Widget title'}));
  666. await userEvent.paste('New Title');
  667. await 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. await userEvent.click(screen.getByLabelText('Add a Column'));
  732. await selectEvent.select(screen.getByText('(Required)'), 'trace');
  733. // Save widget
  734. await 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. await userEvent.click(screen.getByText('Line Chart'));
  779. await userEvent.click(screen.getByText('Table'));
  780. await waitFor(() => {
  781. expect(eventsMock).toHaveBeenLastCalledWith(
  782. '/organizations/org-slug/events/',
  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. await userEvent.click(await screen.findByText('Delete'));
  855. renderGlobalModal();
  856. await 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 userEvent.click(screen.getByText('Update Widget'));
  887. await waitFor(() => {
  888. expect(router.push).toHaveBeenLastCalledWith(
  889. expect.objectContaining({
  890. pathname: '/organizations/org-slug/dashboard/1/',
  891. query: expect.objectContaining({
  892. statsPeriod: '90d',
  893. }),
  894. })
  895. );
  896. });
  897. });
  898. it('renders page filters in the filter step', async () => {
  899. const mockReleases = MockApiClient.addMockResponse({
  900. url: '/organizations/org-slug/releases/',
  901. body: [TestStubs.Release()],
  902. });
  903. renderTestComponent({
  904. params: {orgId: 'org-slug'},
  905. query: {statsPeriod: '90d'},
  906. orgFeatures: defaultOrgFeatures,
  907. });
  908. expect(await screen.findByTestId('page-filter-timerange-selector')).toBeDisabled();
  909. expect(screen.getByTestId('page-filter-environment-selector')).toBeDisabled();
  910. expect(screen.getByTestId('page-filter-project-selector-loading')).toBeDisabled();
  911. expect(mockReleases).toHaveBeenCalled();
  912. expect(screen.getByRole('button', {name: /all releases/i})).toBeDisabled();
  913. });
  914. it('appends dashboard filters to widget builder fetch data request', async () => {
  915. MockApiClient.addMockResponse({
  916. url: '/organizations/org-slug/releases/',
  917. body: [TestStubs.Release()],
  918. });
  919. const mock = MockApiClient.addMockResponse({
  920. url: '/organizations/org-slug/events/',
  921. body: [],
  922. });
  923. renderTestComponent({
  924. dashboard: {
  925. id: 'new',
  926. title: 'Dashboard',
  927. createdBy: undefined,
  928. dateCreated: '2020-01-01T00:00:00.000Z',
  929. widgets: [],
  930. projects: [],
  931. filters: {release: ['abc@1.2.0']},
  932. },
  933. params: {orgId: 'org-slug'},
  934. query: {statsPeriod: '90d'},
  935. orgFeatures: defaultOrgFeatures,
  936. });
  937. await waitFor(() => {
  938. expect(mock).toHaveBeenCalledWith(
  939. '/organizations/org-slug/events/',
  940. expect.objectContaining({
  941. query: expect.objectContaining({
  942. query: ' release:abc@1.2.0 ',
  943. }),
  944. })
  945. );
  946. });
  947. });
  948. it('does not error when query conditions field is blurred', async function () {
  949. jest.useFakeTimers();
  950. const widget: Widget = {
  951. id: '0',
  952. title: 'sdk usage',
  953. interval: '5m',
  954. displayType: DisplayType.BAR,
  955. queries: [
  956. {
  957. name: 'filled in',
  958. conditions: 'event.type:error',
  959. fields: ['count()', 'count_unique(id)'],
  960. aggregates: ['count()', 'count_unique(id)'],
  961. columns: [],
  962. orderby: '-count()',
  963. },
  964. ],
  965. };
  966. const dashboard = mockDashboard({widgets: [widget]});
  967. const handleSave = jest.fn();
  968. renderTestComponent({dashboard, onSave: handleSave, params: {widgetIndex: '0'}});
  969. await userEvent.click(await screen.findByLabelText('Add Query'), {delay: null});
  970. // Triggering the onBlur of the new field should not error
  971. await userEvent.click(
  972. screen.getAllByPlaceholderText('Search for events, users, tags, and more')[1],
  973. {delay: null}
  974. );
  975. await userEvent.keyboard('{Escape}', {delay: null});
  976. // Run all timers because the handleBlur contains a setTimeout
  977. jest.runAllTimers();
  978. });
  979. it('does not wipe column changes when filters are modified', async function () {
  980. jest.useFakeTimers();
  981. // widgetIndex: undefined means creating a new widget
  982. renderTestComponent({params: {widgetIndex: undefined}});
  983. await userEvent.click(await screen.findByLabelText('Add a Column'), {delay: null});
  984. await selectEvent.select(screen.getByText('(Required)'), /project/);
  985. // Triggering the onBlur of the filter should not error
  986. await userEvent.click(
  987. screen.getByPlaceholderText('Search for events, users, tags, and more'),
  988. {delay: null}
  989. );
  990. await userEvent.keyboard('{enter}', {delay: null});
  991. expect(await screen.findAllByText('project')).toHaveLength(2);
  992. });
  993. it('renders fields with commas properly', async () => {
  994. const defaultWidgetQuery = {
  995. conditions: '',
  996. fields: ['equation|count_if(transaction.duration,equals,300)*2'],
  997. aggregates: ['equation|count_if(transaction.duration,equals,300)*2'],
  998. columns: [],
  999. orderby: '',
  1000. name: '',
  1001. };
  1002. const defaultTableColumns = [
  1003. 'count_if(transaction.duration,equals,300)',
  1004. 'equation|count_if(transaction.duration,equals,300)*2',
  1005. ];
  1006. renderTestComponent({
  1007. query: {
  1008. source: DashboardWidgetSource.DISCOVERV2,
  1009. defaultWidgetQuery: urlEncode(defaultWidgetQuery),
  1010. defaultTableColumns,
  1011. yAxis: ['equation|count_if(transaction.duration,equals,300)*2'],
  1012. },
  1013. });
  1014. expect(
  1015. await screen.findByText('count_if(transaction.duration,equals,300)*2')
  1016. ).toBeInTheDocument();
  1017. });
  1018. it('sets the correct fields for a top n widget', async () => {
  1019. renderTestComponent({
  1020. orgFeatures: [...defaultOrgFeatures, 'performance-view'],
  1021. query: {
  1022. displayType: DisplayType.TOP_N,
  1023. },
  1024. });
  1025. // Top N now opens as Area Chart
  1026. expect(await screen.findByText('Area Chart')).toBeInTheDocument();
  1027. // Add a group by
  1028. await userEvent.click(screen.getByText('Add Overlay'));
  1029. await selectEvent.select(screen.getByText('Select group'), /project/);
  1030. // Change the y-axis
  1031. await selectEvent.select(screen.getAllByText('count()')[0], 'eps()');
  1032. await waitFor(() => {
  1033. expect(eventsStatsMock).toHaveBeenLastCalledWith(
  1034. '/organizations/org-slug/events-stats/',
  1035. expect.objectContaining({
  1036. query: expect.objectContaining({
  1037. query: '',
  1038. yAxis: ['eps()'],
  1039. field: ['project', 'eps()'],
  1040. topEvents: TOP_N,
  1041. orderby: '-eps()',
  1042. }),
  1043. })
  1044. );
  1045. });
  1046. });
  1047. it('fetches tags when tag store is empty', async function () {
  1048. renderTestComponent();
  1049. await waitFor(() => {
  1050. expect(tagsMock).toHaveBeenCalled();
  1051. });
  1052. });
  1053. it('does not fetch tags when tag store is not empty', async function () {
  1054. await act(async () => {
  1055. TagStore.loadTagsSuccess(TestStubs.Tags());
  1056. renderTestComponent();
  1057. await tick();
  1058. });
  1059. expect(tagsMock).not.toHaveBeenCalled();
  1060. });
  1061. it('excludes the Other series when grouping and using multiple y-axes', async function () {
  1062. renderTestComponent({
  1063. orgFeatures: [...defaultOrgFeatures],
  1064. query: {
  1065. displayType: DisplayType.LINE,
  1066. },
  1067. });
  1068. await selectEvent.select(await screen.findByText('Select group'), 'project');
  1069. await userEvent.click(screen.getByText('Add Overlay'));
  1070. await selectEvent.select(screen.getByText('(Required)'), /count_unique/);
  1071. await waitFor(() => {
  1072. expect(eventsStatsMock).toHaveBeenCalledWith(
  1073. '/organizations/org-slug/events-stats/',
  1074. expect.objectContaining({
  1075. query: expect.objectContaining({excludeOther: '1'}),
  1076. })
  1077. );
  1078. });
  1079. });
  1080. it('excludes the Other series when grouping and using multiple queries', async function () {
  1081. renderTestComponent({
  1082. orgFeatures: [...defaultOrgFeatures],
  1083. query: {
  1084. displayType: DisplayType.LINE,
  1085. },
  1086. });
  1087. await selectEvent.select(await screen.findByText('Select group'), 'project');
  1088. await userEvent.click(screen.getByText('Add Query'));
  1089. await waitFor(() => {
  1090. expect(eventsStatsMock).toHaveBeenCalledWith(
  1091. '/organizations/org-slug/events-stats/',
  1092. expect.objectContaining({
  1093. query: expect.objectContaining({excludeOther: '1'}),
  1094. })
  1095. );
  1096. });
  1097. });
  1098. it('includes Other series when there is only one query and one y-axis', async function () {
  1099. renderTestComponent({
  1100. orgFeatures: [...defaultOrgFeatures],
  1101. query: {
  1102. displayType: DisplayType.LINE,
  1103. },
  1104. });
  1105. await selectEvent.select(await screen.findByText('Select group'), 'project');
  1106. await waitFor(() => {
  1107. expect(eventsStatsMock).toHaveBeenCalledWith(
  1108. '/organizations/org-slug/events-stats/',
  1109. expect.objectContaining({
  1110. query: expect.not.objectContaining({excludeOther: '1'}),
  1111. })
  1112. );
  1113. });
  1114. });
  1115. it('decreases the limit when more y-axes and queries are added', async function () {
  1116. renderTestComponent({
  1117. orgFeatures: [...defaultOrgFeatures],
  1118. query: {
  1119. displayType: DisplayType.LINE,
  1120. },
  1121. });
  1122. await selectEvent.select(await screen.findByText('Select group'), 'project');
  1123. screen.getByText('Limit to 5 results');
  1124. await userEvent.click(screen.getByText('Add Query'));
  1125. await userEvent.click(screen.getByText('Add Overlay'));
  1126. expect(screen.getByText('Limit to 2 results')).toBeInTheDocument();
  1127. });
  1128. it('alerts the user if there are unsaved changes', async function () {
  1129. const {router} = renderTestComponent();
  1130. const alertMock = jest.fn();
  1131. const setRouteLeaveHookMock = jest.spyOn(router, 'setRouteLeaveHook');
  1132. setRouteLeaveHookMock.mockImplementationOnce((_route, _callback) => {
  1133. alertMock();
  1134. });
  1135. const customWidgetLabels = await screen.findAllByText('Custom Widget');
  1136. // EditableText and chart title
  1137. expect(customWidgetLabels).toHaveLength(2);
  1138. // Change title text
  1139. await userEvent.click(customWidgetLabels[0]);
  1140. await userEvent.clear(screen.getByRole('textbox', {name: 'Widget title'}));
  1141. await userEvent.click(screen.getByRole('textbox', {name: 'Widget title'}));
  1142. await userEvent.paste('Unique Users');
  1143. await userEvent.keyboard('{Enter}');
  1144. // Click Cancel
  1145. await userEvent.click(screen.getByText('Cancel'));
  1146. // Assert an alert was triggered
  1147. expect(alertMock).toHaveBeenCalled();
  1148. });
  1149. it('does not trigger alert dialog if no changes', async function () {
  1150. const {router} = renderTestComponent();
  1151. const alertMock = jest.fn();
  1152. const setRouteLeaveHookMock = jest.spyOn(router, 'setRouteLeaveHook');
  1153. setRouteLeaveHookMock.mockImplementationOnce((_route, _callback) => {
  1154. alertMock();
  1155. });
  1156. // Click Cancel
  1157. await userEvent.click(await screen.findByText('Cancel'));
  1158. // Assert an alert was triggered
  1159. expect(alertMock).not.toHaveBeenCalled();
  1160. });
  1161. describe('Widget creation coming from other verticals', function () {
  1162. it('redirects correctly when creating a new dashboard', async function () {
  1163. const {router} = renderTestComponent({
  1164. query: {source: DashboardWidgetSource.DISCOVERV2},
  1165. });
  1166. await userEvent.click(await screen.findByText('Add Widget'));
  1167. await waitFor(() => {
  1168. expect(router.push).toHaveBeenCalledWith(
  1169. expect.objectContaining({
  1170. pathname: '/organizations/org-slug/dashboards/new/',
  1171. query: {
  1172. displayType: 'table',
  1173. interval: '5m',
  1174. title: 'Custom Widget',
  1175. queryNames: [''],
  1176. queryConditions: [''],
  1177. queryFields: ['count()'],
  1178. queryOrderby: '-count()',
  1179. start: null,
  1180. end: null,
  1181. statsPeriod: '24h',
  1182. utc: null,
  1183. project: [],
  1184. environment: [],
  1185. },
  1186. })
  1187. );
  1188. });
  1189. });
  1190. it('redirects correctly when choosing an existing dashboard', async function () {
  1191. const {router} = renderTestComponent({
  1192. query: {source: DashboardWidgetSource.DISCOVERV2},
  1193. dashboard: testDashboard,
  1194. });
  1195. await userEvent.click(await screen.findByText('Add Widget'));
  1196. await waitFor(() => {
  1197. expect(router.push).toHaveBeenCalledWith(
  1198. expect.objectContaining({
  1199. pathname: '/organizations/org-slug/dashboard/2/',
  1200. query: {
  1201. displayType: 'table',
  1202. interval: '5m',
  1203. title: 'Custom Widget',
  1204. queryNames: [''],
  1205. queryConditions: [''],
  1206. queryFields: ['count()'],
  1207. queryOrderby: '-count()',
  1208. start: null,
  1209. end: null,
  1210. statsPeriod: '24h',
  1211. utc: null,
  1212. project: [],
  1213. environment: [],
  1214. },
  1215. })
  1216. );
  1217. });
  1218. });
  1219. it('shows the correct orderby when switching from a line chart to table', async function () {
  1220. const defaultWidgetQuery = {
  1221. name: '',
  1222. fields: ['count_unique(user)'],
  1223. columns: [],
  1224. aggregates: ['count_unique(user)'],
  1225. conditions: '',
  1226. orderby: 'count_unique(user)',
  1227. };
  1228. const defaultTableColumns = ['title', 'count_unique(user)'];
  1229. renderTestComponent({
  1230. orgFeatures: [...defaultOrgFeatures],
  1231. query: {
  1232. source: DashboardWidgetSource.DISCOVERV2,
  1233. defaultWidgetQuery: urlEncode(defaultWidgetQuery),
  1234. displayType: DisplayType.LINE,
  1235. defaultTableColumns,
  1236. },
  1237. });
  1238. await userEvent.click(await screen.findByText('Line Chart'));
  1239. await userEvent.click(screen.getByText('Table'));
  1240. expect(screen.getAllByText('count_unique(user)')[0]).toBeInTheDocument();
  1241. await waitFor(() => {
  1242. expect(eventsMock).toHaveBeenLastCalledWith(
  1243. '/organizations/org-slug/events/',
  1244. expect.objectContaining({
  1245. query: expect.objectContaining({
  1246. field: defaultTableColumns,
  1247. sort: ['count_unique(user)'],
  1248. }),
  1249. })
  1250. );
  1251. });
  1252. });
  1253. it('does not send request with orderby if a timeseries chart without grouping', async function () {
  1254. const defaultWidgetQuery = {
  1255. name: '',
  1256. fields: ['count_unique(user)'],
  1257. columns: [],
  1258. aggregates: ['count_unique(user)'],
  1259. conditions: '',
  1260. orderby: 'count_unique(user)',
  1261. };
  1262. const defaultTableColumns = ['title', 'count_unique(user)'];
  1263. renderTestComponent({
  1264. orgFeatures: [...defaultOrgFeatures],
  1265. query: {
  1266. source: DashboardWidgetSource.DISCOVERV2,
  1267. defaultWidgetQuery: urlEncode(defaultWidgetQuery),
  1268. displayType: DisplayType.LINE,
  1269. defaultTableColumns,
  1270. },
  1271. });
  1272. await waitFor(() => {
  1273. expect(eventsStatsMock).toHaveBeenLastCalledWith(
  1274. '/organizations/org-slug/events-stats/',
  1275. expect.objectContaining({
  1276. query: expect.objectContaining({
  1277. orderby: '',
  1278. }),
  1279. })
  1280. );
  1281. });
  1282. });
  1283. });
  1284. it('opens top-N widgets as area display', async function () {
  1285. const widget: Widget = {
  1286. id: '1',
  1287. title: 'Errors over time',
  1288. interval: '5m',
  1289. displayType: DisplayType.TOP_N,
  1290. queries: [
  1291. {
  1292. name: '',
  1293. conditions: '',
  1294. fields: ['count()', 'count_unique(id)'],
  1295. aggregates: ['count()', 'count_unique(id)'],
  1296. columns: [],
  1297. orderby: '-count()',
  1298. },
  1299. ],
  1300. };
  1301. const dashboard = mockDashboard({widgets: [widget]});
  1302. renderTestComponent({
  1303. orgFeatures: [...defaultOrgFeatures],
  1304. dashboard,
  1305. params: {
  1306. widgetIndex: '0',
  1307. },
  1308. });
  1309. expect(await screen.findByText('Area Chart')).toBeInTheDocument();
  1310. });
  1311. it('Update table header values (field alias)', async function () {
  1312. const handleSave = jest.fn();
  1313. renderTestComponent({
  1314. onSave: handleSave,
  1315. orgFeatures: [...defaultOrgFeatures],
  1316. });
  1317. await userEvent.click(screen.getByPlaceholderText('Alias'));
  1318. await userEvent.paste('First Alias');
  1319. await userEvent.click(screen.getByLabelText('Add a Column'));
  1320. await userEvent.click(screen.getAllByPlaceholderText('Alias')[1]);
  1321. await userEvent.paste('Second Alias');
  1322. await userEvent.click(screen.getByText('Add Widget'));
  1323. await waitFor(() => {
  1324. expect(handleSave).toHaveBeenCalledWith([
  1325. expect.objectContaining({
  1326. queries: [
  1327. expect.objectContaining({fieldAliases: ['First Alias', 'Second Alias']}),
  1328. ],
  1329. }),
  1330. ]);
  1331. });
  1332. });
  1333. it('does not wipe equation aliases when a column alias is updated', async function () {
  1334. renderTestComponent({
  1335. orgFeatures: [...defaultOrgFeatures],
  1336. });
  1337. await userEvent.click(screen.getByText('Add an Equation'));
  1338. await userEvent.click(screen.getAllByPlaceholderText('Alias')[1]);
  1339. await userEvent.paste('This should persist');
  1340. await userEvent.type(screen.getAllByPlaceholderText('Alias')[0], 'A');
  1341. expect(await screen.findByText('This should persist')).toBeInTheDocument();
  1342. });
  1343. it('does not wipe equation aliases when a column selection is made', async function () {
  1344. renderTestComponent({
  1345. orgFeatures: [...defaultOrgFeatures],
  1346. });
  1347. await userEvent.click(screen.getByText('Add an Equation'));
  1348. await userEvent.click(screen.getAllByPlaceholderText('Alias')[1]);
  1349. await userEvent.paste('This should persist');
  1350. // 1 for the table, 1 for the the column selector, 1 for the sort
  1351. await waitFor(() => expect(screen.getAllByText('count()')).toHaveLength(3));
  1352. await selectEvent.select(screen.getAllByText('count()')[1], /count_unique/);
  1353. expect(screen.getByText('This should persist')).toBeInTheDocument();
  1354. });
  1355. it('copies over the orderby from the previous query if adding another', async function () {
  1356. renderTestComponent({
  1357. orgFeatures: [...defaultOrgFeatures],
  1358. });
  1359. await userEvent.click(await screen.findByText('Table'));
  1360. await userEvent.click(screen.getByText('Line Chart'));
  1361. await selectEvent.select(screen.getByText('Select group'), 'project');
  1362. await selectEvent.select(screen.getAllByText('count()')[1], 'count_unique(…)');
  1363. MockApiClient.clearMockResponses();
  1364. eventsStatsMock = MockApiClient.addMockResponse({
  1365. url: '/organizations/org-slug/events-stats/',
  1366. body: [],
  1367. });
  1368. await userEvent.click(screen.getByText('Add Query'));
  1369. // Assert on two calls, one for each query
  1370. const expectedArgs = expect.objectContaining({
  1371. query: expect.objectContaining({
  1372. orderby: '-count_unique(user)',
  1373. }),
  1374. });
  1375. expect(eventsStatsMock).toHaveBeenNthCalledWith(
  1376. 1,
  1377. '/organizations/org-slug/events-stats/',
  1378. expectedArgs
  1379. );
  1380. expect(eventsStatsMock).toHaveBeenNthCalledWith(
  1381. 2,
  1382. '/organizations/org-slug/events-stats/',
  1383. expectedArgs
  1384. );
  1385. });
  1386. it('disables add widget button and prevents widget previewing from firing widget query if widget query condition is invalid', async function () {
  1387. renderTestComponent({
  1388. orgFeatures: [...defaultOrgFeatures],
  1389. });
  1390. await userEvent.click(await screen.findByText('Table'));
  1391. await userEvent.click(screen.getByText('Line Chart'));
  1392. expect(eventsStatsMock).toHaveBeenCalledTimes(1);
  1393. await userEvent.type(
  1394. screen.getByTestId('smart-search-input'),
  1395. 'transaction.duration:123a'
  1396. );
  1397. // Unfocus input
  1398. await userEvent.click(screen.getByText('Filter your results'));
  1399. expect(screen.getByText('Add Widget').closest('button')).toBeDisabled();
  1400. expect(screen.getByText('Widget query condition is invalid.')).toBeInTheDocument();
  1401. expect(eventsStatsMock).toHaveBeenCalledTimes(1);
  1402. });
  1403. describe('Widget Library', function () {
  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. await userEvent.click(screen.getByText('Duration Distribution'));
  1409. // Widget Library, Builder title, and Chart title
  1410. expect(screen.getAllByText('Duration Distribution')).toHaveLength(3);
  1411. // Confirm modal doesn't open because no changes were made
  1412. expect(mockModal).not.toHaveBeenCalled();
  1413. await userEvent.click(screen.getAllByLabelText('Remove this Y-Axis')[0]);
  1414. await userEvent.click(screen.getByText('High Throughput Transactions'));
  1415. // Should not have overwritten widget data, and confirm modal should open
  1416. expect(screen.getAllByText('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. expect(await screen.findByText('Select group')).toBeInTheDocument();
  1427. await userEvent.click(screen.getByText('Select group'));
  1428. // Only one f(x) field set in the y-axis selector
  1429. expect(screen.getByText('f(x)')).toBeInTheDocument();
  1430. });
  1431. it('adds more fields when Add Group is clicked', async function () {
  1432. renderTestComponent({
  1433. query: {displayType: 'line'},
  1434. orgFeatures: [...defaultOrgFeatures],
  1435. });
  1436. await userEvent.click(await screen.findByText('Add Group'));
  1437. expect(await screen.findAllByText('Select group')).toHaveLength(2);
  1438. });
  1439. it("doesn't reset group by when changing y-axis", async function () {
  1440. renderTestComponent({
  1441. query: {displayType: 'line'},
  1442. orgFeatures: [...defaultOrgFeatures],
  1443. });
  1444. await selectEvent.select(await screen.findByText('Select group'), 'project');
  1445. await userEvent.click(screen.getAllByText('count()')[0], {
  1446. skipHover: true,
  1447. });
  1448. await userEvent.click(screen.getByText(/count_unique/), {
  1449. skipHover: true,
  1450. });
  1451. expect(await screen.findByText('project')).toBeInTheDocument();
  1452. });
  1453. it("doesn't erase the selection when switching to another time series", async function () {
  1454. renderTestComponent({
  1455. query: {displayType: 'line'},
  1456. orgFeatures: [...defaultOrgFeatures],
  1457. });
  1458. await selectEvent.select(await screen.findByText('Select group'), 'project');
  1459. await userEvent.click(screen.getByText('Line Chart'));
  1460. await userEvent.click(screen.getByText('Area Chart'));
  1461. expect(await screen.findByText('project')).toBeInTheDocument();
  1462. });
  1463. it('sends a top N request when a grouping is selected', async function () {
  1464. renderTestComponent({
  1465. query: {displayType: 'line'},
  1466. orgFeatures: [...defaultOrgFeatures],
  1467. });
  1468. await userEvent.click(await screen.findByText('Group your results'));
  1469. await userEvent.type(screen.getByText('Select group'), 'project{enter}');
  1470. await waitFor(() =>
  1471. expect(eventsStatsMock).toHaveBeenNthCalledWith(
  1472. 2,
  1473. '/organizations/org-slug/events-stats/',
  1474. expect.objectContaining({
  1475. query: expect.objectContaining({
  1476. query: '',
  1477. yAxis: ['count()'],
  1478. field: ['project', 'count()'],
  1479. topEvents: TOP_N,
  1480. orderby: '-count()',
  1481. }),
  1482. })
  1483. )
  1484. );
  1485. });
  1486. it('allows deleting groups until there is one left', async function () {
  1487. renderTestComponent({
  1488. query: {displayType: 'line'},
  1489. orgFeatures: [...defaultOrgFeatures],
  1490. });
  1491. await userEvent.click(await screen.findByText('Add Group'));
  1492. expect(screen.getAllByLabelText('Remove group')).toHaveLength(2);
  1493. await userEvent.click(screen.getAllByLabelText('Remove group')[1]);
  1494. await waitFor(() =>
  1495. expect(screen.queryByLabelText('Remove group')).not.toBeInTheDocument()
  1496. );
  1497. });
  1498. it("display 'remove' and 'drag to reorder' buttons", async function () {
  1499. renderTestComponent({
  1500. query: {displayType: 'line'},
  1501. orgFeatures: [...defaultOrgFeatures],
  1502. });
  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. await 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. await 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. await userEvent.click(screen.getByText('Limit to 5 results'));
  1541. await 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. await 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. await userEvent.click(await screen.findByText('Table'));
  1595. await 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. await userEvent.click(await screen.findByText('Area Chart'));
  1633. await 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. await userEvent.click(await screen.findByText('Area Chart'));
  1671. await userEvent.click(screen.getByText('Table'));
  1672. expect(screen.queryByText('Limit to 1 result')).not.toBeInTheDocument();
  1673. expect(eventsMock).toHaveBeenCalledWith(
  1674. '/organizations/org-slug/events/',
  1675. expect.objectContaining({
  1676. query: expect.objectContaining({
  1677. topEvents: undefined,
  1678. }),
  1679. })
  1680. );
  1681. });
  1682. });
  1683. });