widgetBuilder.spec.tsx 58 KB

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