widgetBuilder.spec.tsx 59 KB

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