widgetBuilder.spec.tsx 69 KB

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