widgetBuilder.spec.tsx 59 KB

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