widgetBuilder.spec.tsx 59 KB

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