widgetBuilder.spec.tsx 59 KB

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