detail.spec.tsx 52 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688
  1. import {DashboardFixture} from 'sentry-fixture/dashboard';
  2. import {LocationFixture} from 'sentry-fixture/locationFixture';
  3. import {OrganizationFixture} from 'sentry-fixture/organization';
  4. import {ProjectFixture} from 'sentry-fixture/project';
  5. import {ReleaseFixture} from 'sentry-fixture/release';
  6. import {RouteComponentPropsFixture} from 'sentry-fixture/routeComponentPropsFixture';
  7. import {WidgetFixture} from 'sentry-fixture/widget';
  8. import {initializeOrg} from 'sentry-test/initializeOrg';
  9. import {
  10. act,
  11. render,
  12. screen,
  13. userEvent,
  14. waitFor,
  15. within,
  16. } from 'sentry-test/reactTestingLibrary';
  17. import * as modals from 'sentry/actionCreators/modal';
  18. import ProjectsStore from 'sentry/stores/projectsStore';
  19. import {browserHistory} from 'sentry/utils/browserHistory';
  20. import CreateDashboard from 'sentry/views/dashboards/create';
  21. import {handleUpdateDashboardSplit} from 'sentry/views/dashboards/detail';
  22. import * as types from 'sentry/views/dashboards/types';
  23. import ViewEditDashboard from 'sentry/views/dashboards/view';
  24. import {OrganizationContext} from 'sentry/views/organizationContext';
  25. describe('Dashboards > Detail', function () {
  26. const organization = OrganizationFixture({
  27. features: ['global-views', 'dashboards-basic', 'dashboards-edit', 'discover-query'],
  28. });
  29. const projects = [ProjectFixture()];
  30. describe('prebuilt dashboards', function () {
  31. let initialData;
  32. beforeEach(function () {
  33. act(() => ProjectsStore.loadInitialData(projects));
  34. initialData = initializeOrg({organization});
  35. MockApiClient.addMockResponse({
  36. url: '/organizations/org-slug/tags/',
  37. body: [],
  38. });
  39. MockApiClient.addMockResponse({
  40. url: '/organizations/org-slug/projects/',
  41. body: [ProjectFixture()],
  42. });
  43. MockApiClient.addMockResponse({
  44. url: '/organizations/org-slug/dashboards/',
  45. body: [
  46. DashboardFixture([], {id: 'default-overview', title: 'Default'}),
  47. DashboardFixture([], {id: '1', title: 'Custom Errors'}),
  48. ],
  49. });
  50. MockApiClient.addMockResponse({
  51. url: '/organizations/org-slug/dashboards/default-overview/',
  52. body: DashboardFixture([], {id: 'default-overview', title: 'Default'}),
  53. });
  54. MockApiClient.addMockResponse({
  55. url: '/organizations/org-slug/dashboards/1/visit/',
  56. method: 'POST',
  57. body: [],
  58. statusCode: 200,
  59. });
  60. MockApiClient.addMockResponse({
  61. url: '/organizations/org-slug/users/',
  62. method: 'GET',
  63. body: [],
  64. });
  65. MockApiClient.addMockResponse({
  66. url: '/organizations/org-slug/sdk-updates/',
  67. body: [],
  68. });
  69. MockApiClient.addMockResponse({
  70. url: '/organizations/org-slug/prompts-activity/',
  71. body: {},
  72. });
  73. MockApiClient.addMockResponse({
  74. url: '/organizations/org-slug/events/',
  75. method: 'GET',
  76. body: [],
  77. });
  78. MockApiClient.addMockResponse({
  79. url: '/organizations/org-slug/events-stats/',
  80. body: {data: []},
  81. });
  82. MockApiClient.addMockResponse({
  83. method: 'GET',
  84. url: '/organizations/org-slug/issues/',
  85. body: [],
  86. });
  87. MockApiClient.addMockResponse({
  88. url: '/organizations/org-slug/releases/',
  89. body: [],
  90. });
  91. MockApiClient.addMockResponse({
  92. url: '/organizations/org-slug/metrics/meta/',
  93. body: [],
  94. });
  95. });
  96. afterEach(function () {
  97. MockApiClient.clearMockResponses();
  98. });
  99. it('assigns unique IDs to all widgets so grid keys are unique', async function () {
  100. MockApiClient.addMockResponse({
  101. url: '/organizations/org-slug/events-stats/',
  102. body: {data: []},
  103. });
  104. MockApiClient.addMockResponse({
  105. url: '/organizations/org-slug/dashboards/default-overview/',
  106. body: DashboardFixture(
  107. [
  108. WidgetFixture({
  109. queries: [
  110. {
  111. name: '',
  112. conditions: 'event.type:error',
  113. fields: ['count()'],
  114. aggregates: ['count()'],
  115. columns: [],
  116. orderby: '-count()',
  117. },
  118. ],
  119. title: 'Default Widget 1',
  120. interval: '1d',
  121. }),
  122. WidgetFixture({
  123. queries: [
  124. {
  125. name: '',
  126. conditions: 'event.type:transaction',
  127. fields: ['count()'],
  128. aggregates: ['count()'],
  129. columns: [],
  130. orderby: '-count()',
  131. },
  132. ],
  133. title: 'Default Widget 2',
  134. interval: '1d',
  135. }),
  136. ],
  137. {id: 'default-overview', title: 'Default'}
  138. ),
  139. });
  140. initialData = initializeOrg({
  141. organization: OrganizationFixture({
  142. features: ['global-views', 'dashboards-basic', 'discover-query'],
  143. }),
  144. });
  145. render(
  146. <OrganizationContext.Provider value={initialData.organization}>
  147. <ViewEditDashboard
  148. {...RouteComponentPropsFixture()}
  149. organization={initialData.organization}
  150. params={{orgId: 'org-slug', dashboardId: 'default-overview'}}
  151. router={initialData.router}
  152. location={initialData.router.location}
  153. >
  154. {null}
  155. </ViewEditDashboard>
  156. </OrganizationContext.Provider>,
  157. {router: initialData.router}
  158. );
  159. expect(await screen.findByText('Default Widget 1')).toBeInTheDocument();
  160. expect(screen.getByText('Default Widget 2')).toBeInTheDocument();
  161. });
  162. it('opens the widget viewer modal in a prebuilt dashboard using the widget id specified in the url', async () => {
  163. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  164. render(
  165. <CreateDashboard
  166. {...RouteComponentPropsFixture()}
  167. organization={initialData.organization}
  168. params={{templateId: 'default-template', widgetId: '2'}}
  169. router={initialData.router}
  170. location={{...initialData.router.location, pathname: '/widget/2/'}}
  171. >
  172. {null}
  173. </CreateDashboard>,
  174. {router: initialData.router, organization: initialData.organization}
  175. );
  176. await waitFor(() => {
  177. expect(openWidgetViewerModal).toHaveBeenCalledWith(
  178. expect.objectContaining({
  179. organization: initialData.organization,
  180. widget: expect.objectContaining({
  181. displayType: 'line',
  182. interval: '5m',
  183. queries: [
  184. {
  185. aggregates: ['count()'],
  186. columns: [],
  187. conditions: '!event.type:transaction',
  188. fields: ['count()'],
  189. name: 'Events',
  190. orderby: 'count()',
  191. },
  192. ],
  193. title: 'Events',
  194. widgetType: types.WidgetType.DISCOVER,
  195. }),
  196. onClose: expect.anything(),
  197. })
  198. );
  199. });
  200. });
  201. });
  202. describe('custom dashboards', function () {
  203. let initialData, widgets, mockVisit, mockPut;
  204. beforeEach(function () {
  205. window.confirm = jest.fn();
  206. initialData = initializeOrg({
  207. organization,
  208. router: {
  209. location: LocationFixture(),
  210. },
  211. });
  212. widgets = [
  213. WidgetFixture({
  214. queries: [
  215. {
  216. name: '',
  217. conditions: 'event.type:error',
  218. fields: ['count()'],
  219. columns: [],
  220. aggregates: ['count()'],
  221. orderby: '-count()',
  222. },
  223. ],
  224. title: 'Errors',
  225. interval: '1d',
  226. widgetType: types.WidgetType.DISCOVER,
  227. id: '1',
  228. }),
  229. WidgetFixture({
  230. queries: [
  231. {
  232. name: '',
  233. conditions: 'event.type:transaction',
  234. fields: ['count()'],
  235. columns: [],
  236. aggregates: ['count()'],
  237. orderby: '-count()',
  238. },
  239. ],
  240. title: 'Transactions',
  241. interval: '1d',
  242. widgetType: types.WidgetType.DISCOVER,
  243. id: '2',
  244. }),
  245. WidgetFixture({
  246. queries: [
  247. {
  248. name: '',
  249. conditions: 'event.type:transaction transaction:/api/cats',
  250. fields: ['p50()'],
  251. columns: [],
  252. aggregates: ['p50()'],
  253. orderby: '-p50()',
  254. },
  255. ],
  256. title: 'p50 of /api/cats',
  257. interval: '1d',
  258. id: '3',
  259. }),
  260. ];
  261. mockVisit = MockApiClient.addMockResponse({
  262. url: '/organizations/org-slug/dashboards/1/visit/',
  263. method: 'POST',
  264. body: [],
  265. statusCode: 200,
  266. });
  267. MockApiClient.addMockResponse({
  268. url: '/organizations/org-slug/tags/',
  269. body: [],
  270. });
  271. MockApiClient.addMockResponse({
  272. url: '/organizations/org-slug/projects/',
  273. body: [ProjectFixture()],
  274. });
  275. MockApiClient.addMockResponse({
  276. url: '/organizations/org-slug/dashboards/',
  277. body: [
  278. {
  279. ...DashboardFixture([], {
  280. id: 'default-overview',
  281. title: 'Default',
  282. }),
  283. widgetDisplay: ['area'],
  284. },
  285. {
  286. ...DashboardFixture([], {
  287. id: '1',
  288. title: 'Custom Errors',
  289. }),
  290. widgetDisplay: ['area'],
  291. },
  292. ],
  293. });
  294. MockApiClient.addMockResponse({
  295. url: '/organizations/org-slug/dashboards/1/',
  296. body: DashboardFixture(widgets, {
  297. id: '1',
  298. title: 'Custom Errors',
  299. filters: {},
  300. }),
  301. });
  302. mockPut = MockApiClient.addMockResponse({
  303. url: '/organizations/org-slug/dashboards/1/',
  304. method: 'PUT',
  305. body: DashboardFixture(widgets, {id: '1', title: 'Custom Errors'}),
  306. });
  307. MockApiClient.addMockResponse({
  308. url: '/organizations/org-slug/events-stats/',
  309. body: {data: []},
  310. });
  311. MockApiClient.addMockResponse({
  312. method: 'POST',
  313. url: '/organizations/org-slug/dashboards/widgets/',
  314. body: [],
  315. });
  316. MockApiClient.addMockResponse({
  317. method: 'GET',
  318. url: '/organizations/org-slug/recent-searches/',
  319. body: [],
  320. });
  321. MockApiClient.addMockResponse({
  322. method: 'GET',
  323. url: '/organizations/org-slug/issues/',
  324. body: [],
  325. });
  326. MockApiClient.addMockResponse({
  327. url: '/organizations/org-slug/events/',
  328. method: 'GET',
  329. body: [],
  330. });
  331. MockApiClient.addMockResponse({
  332. url: '/organizations/org-slug/users/',
  333. method: 'GET',
  334. body: [],
  335. });
  336. MockApiClient.addMockResponse({
  337. url: '/organizations/org-slug/releases/',
  338. body: [],
  339. });
  340. MockApiClient.addMockResponse({
  341. url: '/organizations/org-slug/sdk-updates/',
  342. body: [],
  343. });
  344. MockApiClient.addMockResponse({
  345. url: '/organizations/org-slug/prompts-activity/',
  346. body: {},
  347. });
  348. MockApiClient.addMockResponse({
  349. url: '/organizations/org-slug/metrics/meta/',
  350. body: [],
  351. });
  352. });
  353. afterEach(function () {
  354. MockApiClient.clearMockResponses();
  355. jest.clearAllMocks();
  356. });
  357. it('can remove widgets', async function () {
  358. const updateMock = MockApiClient.addMockResponse({
  359. url: '/organizations/org-slug/dashboards/1/',
  360. method: 'PUT',
  361. body: DashboardFixture([widgets[0]], {id: '1', title: 'Custom Errors'}),
  362. });
  363. render(
  364. <OrganizationContext.Provider value={initialData.organization}>
  365. <ViewEditDashboard
  366. {...RouteComponentPropsFixture()}
  367. organization={initialData.organization}
  368. params={{orgId: 'org-slug', dashboardId: '1'}}
  369. router={initialData.router}
  370. location={initialData.router.location}
  371. >
  372. {null}
  373. </ViewEditDashboard>
  374. </OrganizationContext.Provider>,
  375. {router: initialData.router}
  376. );
  377. await waitFor(() => expect(mockVisit).toHaveBeenCalledTimes(1));
  378. // Enter edit mode.
  379. await userEvent.click(screen.getByRole('button', {name: 'Edit Dashboard'}));
  380. // Remove the second and third widgets
  381. await userEvent.click(screen.getAllByRole('button', {name: 'Delete Widget'})[1]);
  382. await userEvent.click(screen.getAllByRole('button', {name: 'Delete Widget'})[1]);
  383. // Save changes
  384. await userEvent.click(screen.getByRole('button', {name: 'Save and Finish'}));
  385. expect(updateMock).toHaveBeenCalled();
  386. expect(updateMock).toHaveBeenCalledWith(
  387. '/organizations/org-slug/dashboards/1/',
  388. expect.objectContaining({
  389. data: expect.objectContaining({
  390. title: 'Custom Errors',
  391. widgets: [expect.objectContaining(widgets[0])],
  392. }),
  393. })
  394. );
  395. // Visit should not be called again on dashboard update
  396. expect(mockVisit).toHaveBeenCalledTimes(1);
  397. });
  398. it('appends dashboard-level filters to series request', async function () {
  399. MockApiClient.addMockResponse({
  400. url: '/organizations/org-slug/dashboards/1/',
  401. body: DashboardFixture(widgets, {
  402. id: '1',
  403. title: 'Custom Errors',
  404. filters: {release: ['abc@1.2.0']},
  405. }),
  406. });
  407. const mock = MockApiClient.addMockResponse({
  408. url: '/organizations/org-slug/events-stats/',
  409. body: [],
  410. });
  411. render(
  412. <OrganizationContext.Provider value={initialData.organization}>
  413. <ViewEditDashboard
  414. {...RouteComponentPropsFixture()}
  415. organization={initialData.organization}
  416. params={{orgId: 'org-slug', dashboardId: '1'}}
  417. router={initialData.router}
  418. location={initialData.router.location}
  419. >
  420. {null}
  421. </ViewEditDashboard>
  422. </OrganizationContext.Provider>,
  423. {router: initialData.router}
  424. );
  425. await waitFor(() =>
  426. expect(mock).toHaveBeenLastCalledWith(
  427. '/organizations/org-slug/events-stats/',
  428. expect.objectContaining({
  429. query: expect.objectContaining({
  430. query:
  431. '(event.type:transaction transaction:/api/cats) release:"abc@1.2.0" ',
  432. }),
  433. })
  434. )
  435. );
  436. });
  437. it('shows add widget option', async function () {
  438. render(
  439. <OrganizationContext.Provider value={initialData.organization}>
  440. <ViewEditDashboard
  441. {...RouteComponentPropsFixture()}
  442. organization={initialData.organization}
  443. params={{orgId: 'org-slug', dashboardId: '1'}}
  444. router={initialData.router}
  445. location={initialData.router.location}
  446. >
  447. {null}
  448. </ViewEditDashboard>
  449. </OrganizationContext.Provider>,
  450. {router: initialData.router}
  451. );
  452. // Enter edit mode.
  453. await userEvent.click(screen.getByRole('button', {name: 'Edit Dashboard'}));
  454. expect(await screen.findByRole('button', {name: 'Add widget'})).toBeInTheDocument();
  455. });
  456. it('shows add widget option with dataset selector flag', async function () {
  457. initialData = initializeOrg({
  458. organization: OrganizationFixture({
  459. features: [
  460. 'global-views',
  461. 'dashboards-basic',
  462. 'dashboards-edit',
  463. 'discover-query',
  464. 'custom-metrics',
  465. 'performance-discover-dataset-selector',
  466. ],
  467. }),
  468. });
  469. render(
  470. <OrganizationContext.Provider value={initialData.organization}>
  471. <ViewEditDashboard
  472. {...RouteComponentPropsFixture()}
  473. organization={initialData.organization}
  474. params={{orgId: 'org-slug', dashboardId: '1'}}
  475. router={initialData.router}
  476. location={initialData.router.location}
  477. >
  478. {null}
  479. </ViewEditDashboard>
  480. </OrganizationContext.Provider>,
  481. {router: initialData.router}
  482. );
  483. await userEvent.click(screen.getAllByText('Add Widget')[0]);
  484. const menuOptions = await screen.findAllByTestId('menu-list-item-label');
  485. expect(menuOptions.map(e => e.textContent)).toEqual([
  486. 'Errors',
  487. 'Transactions',
  488. 'Issues',
  489. 'Metrics',
  490. ]);
  491. });
  492. it('shows add widget option without dataset selector flag', async function () {
  493. initialData = initializeOrg({
  494. organization: OrganizationFixture({
  495. features: [
  496. 'global-views',
  497. 'dashboards-basic',
  498. 'dashboards-edit',
  499. 'discover-query',
  500. 'custom-metrics',
  501. ],
  502. }),
  503. });
  504. render(
  505. <OrganizationContext.Provider value={initialData.organization}>
  506. <ViewEditDashboard
  507. {...RouteComponentPropsFixture()}
  508. organization={initialData.organization}
  509. params={{orgId: 'org-slug', dashboardId: '1'}}
  510. router={initialData.router}
  511. location={initialData.router.location}
  512. >
  513. {null}
  514. </ViewEditDashboard>
  515. </OrganizationContext.Provider>,
  516. {router: initialData.router}
  517. );
  518. await userEvent.click(screen.getAllByText('Add Widget')[0]);
  519. const menuOptions = await screen.findAllByTestId('menu-list-item-label');
  520. expect(menuOptions.map(e => e.textContent)).toEqual([
  521. 'Errors and Transactions',
  522. 'Issues',
  523. 'Metrics',
  524. ]);
  525. });
  526. it('shows top level release filter', async function () {
  527. const mockReleases = MockApiClient.addMockResponse({
  528. url: '/organizations/org-slug/releases/',
  529. body: [ReleaseFixture()],
  530. });
  531. initialData = initializeOrg({
  532. organization: OrganizationFixture({
  533. features: [
  534. 'global-views',
  535. 'dashboards-basic',
  536. 'dashboards-edit',
  537. 'discover-query',
  538. ],
  539. }),
  540. });
  541. render(
  542. <OrganizationContext.Provider value={initialData.organization}>
  543. <ViewEditDashboard
  544. {...RouteComponentPropsFixture()}
  545. organization={initialData.organization}
  546. params={{orgId: 'org-slug', dashboardId: '1'}}
  547. router={initialData.router}
  548. location={initialData.router.location}
  549. >
  550. {null}
  551. </ViewEditDashboard>
  552. </OrganizationContext.Provider>,
  553. {router: initialData.router}
  554. );
  555. expect(await screen.findByText('All Releases')).toBeInTheDocument();
  556. expect(mockReleases).toHaveBeenCalledTimes(1);
  557. });
  558. it('hides add widget option', async function () {
  559. // @ts-expect-error this is assigning to readonly property...
  560. types.MAX_WIDGETS = 1;
  561. render(
  562. <OrganizationContext.Provider value={initialData.organization}>
  563. <ViewEditDashboard
  564. {...RouteComponentPropsFixture()}
  565. organization={initialData.organization}
  566. params={{orgId: 'org-slug', dashboardId: '1'}}
  567. router={initialData.router}
  568. location={initialData.router.location}
  569. >
  570. {null}
  571. </ViewEditDashboard>
  572. </OrganizationContext.Provider>,
  573. {router: initialData.router}
  574. );
  575. // Enter edit mode.
  576. await userEvent.click(await screen.findByRole('button', {name: 'Edit Dashboard'}));
  577. expect(screen.queryByRole('button', {name: 'Add widget'})).not.toBeInTheDocument();
  578. });
  579. it('renders successfully if more widgets than stored layouts', async function () {
  580. // A case where someone has async added widgets to a dashboard
  581. MockApiClient.addMockResponse({
  582. url: '/organizations/org-slug/dashboards/1/',
  583. body: DashboardFixture(
  584. [
  585. WidgetFixture({
  586. queries: [
  587. {
  588. name: '',
  589. conditions: 'event.type:error',
  590. fields: ['count()'],
  591. aggregates: ['count()'],
  592. columns: [],
  593. orderby: '-count()',
  594. },
  595. ],
  596. title: 'First Widget',
  597. interval: '1d',
  598. id: '1',
  599. layout: {x: 0, y: 0, w: 2, h: 6, minH: 0},
  600. }),
  601. WidgetFixture({
  602. queries: [
  603. {
  604. name: '',
  605. conditions: 'event.type:error',
  606. fields: ['count()'],
  607. aggregates: ['count()'],
  608. columns: [],
  609. orderby: '-count()',
  610. },
  611. ],
  612. title: 'Second Widget',
  613. interval: '1d',
  614. id: '2',
  615. }),
  616. ],
  617. {id: '1', title: 'Custom Errors'}
  618. ),
  619. });
  620. render(
  621. <ViewEditDashboard
  622. {...RouteComponentPropsFixture()}
  623. organization={initialData.organization}
  624. params={{orgId: 'org-slug', dashboardId: '1'}}
  625. router={initialData.router}
  626. location={initialData.router.location}
  627. >
  628. {null}
  629. </ViewEditDashboard>,
  630. {router: initialData.router, organization: initialData.organization}
  631. );
  632. expect(await screen.findByText('First Widget')).toBeInTheDocument();
  633. expect(await screen.findByText('Second Widget')).toBeInTheDocument();
  634. });
  635. it('does not trigger request if layout not updated', async () => {
  636. MockApiClient.addMockResponse({
  637. url: '/organizations/org-slug/dashboards/1/',
  638. body: DashboardFixture(
  639. [
  640. WidgetFixture({
  641. queries: [
  642. {
  643. name: '',
  644. conditions: 'event.type:error',
  645. fields: ['count()'],
  646. aggregates: ['count()'],
  647. columns: [],
  648. orderby: '-count()',
  649. },
  650. ],
  651. title: 'First Widget',
  652. interval: '1d',
  653. id: '1',
  654. layout: {x: 0, y: 0, w: 2, h: 6, minH: 0},
  655. }),
  656. ],
  657. {id: '1', title: 'Custom Errors'}
  658. ),
  659. });
  660. render(
  661. <ViewEditDashboard
  662. {...RouteComponentPropsFixture()}
  663. organization={initialData.organization}
  664. params={{orgId: 'org-slug', dashboardId: '1'}}
  665. router={initialData.router}
  666. location={initialData.router.location}
  667. >
  668. {null}
  669. </ViewEditDashboard>,
  670. {router: initialData.router, organization: initialData.organization}
  671. );
  672. await userEvent.click(await screen.findByText('Edit Dashboard'));
  673. await userEvent.click(await screen.findByText('Save and Finish'));
  674. expect(screen.getByText('Edit Dashboard')).toBeInTheDocument();
  675. expect(mockPut).not.toHaveBeenCalled();
  676. });
  677. it('renders the custom resize handler for a widget', async () => {
  678. MockApiClient.addMockResponse({
  679. url: '/organizations/org-slug/dashboards/1/',
  680. body: DashboardFixture(
  681. [
  682. WidgetFixture({
  683. queries: [
  684. {
  685. name: '',
  686. conditions: 'event.type:error',
  687. fields: ['count()'],
  688. aggregates: ['count()'],
  689. columns: [],
  690. orderby: '-count()',
  691. },
  692. ],
  693. title: 'First Widget',
  694. interval: '1d',
  695. id: '1',
  696. layout: {x: 0, y: 0, w: 2, h: 6, minH: 0},
  697. }),
  698. ],
  699. {id: '1', title: 'Custom Errors'}
  700. ),
  701. });
  702. render(
  703. <ViewEditDashboard
  704. {...RouteComponentPropsFixture()}
  705. organization={initialData.organization}
  706. params={{orgId: 'org-slug', dashboardId: '1'}}
  707. router={initialData.router}
  708. location={initialData.router.location}
  709. >
  710. {null}
  711. </ViewEditDashboard>,
  712. {router: initialData.router, organization: initialData.organization}
  713. );
  714. await userEvent.click(await screen.findByText('Edit Dashboard'));
  715. const widget = screen
  716. .getByText('First Widget')
  717. .closest('.react-grid-item') as HTMLElement;
  718. const resizeHandle = within(widget).getByTestId('custom-resize-handle');
  719. expect(resizeHandle).toBeVisible();
  720. });
  721. it('does not trigger an alert when the widgets have no layout and user cancels without changes', async () => {
  722. MockApiClient.addMockResponse({
  723. url: '/organizations/org-slug/dashboards/1/',
  724. body: DashboardFixture(
  725. [
  726. WidgetFixture({
  727. queries: [
  728. {
  729. name: '',
  730. conditions: 'event.type:error',
  731. fields: ['count()'],
  732. aggregates: ['count()'],
  733. columns: [],
  734. orderby: '-count()',
  735. },
  736. ],
  737. title: 'First Widget',
  738. interval: '1d',
  739. id: '1',
  740. layout: null,
  741. }),
  742. ],
  743. {id: '1', title: 'Custom Errors'}
  744. ),
  745. });
  746. render(
  747. <ViewEditDashboard
  748. {...RouteComponentPropsFixture()}
  749. organization={initialData.organization}
  750. params={{orgId: 'org-slug', dashboardId: '1'}}
  751. router={initialData.router}
  752. location={initialData.router.location}
  753. >
  754. {null}
  755. </ViewEditDashboard>,
  756. {router: initialData.router, organization: initialData.organization}
  757. );
  758. await userEvent.click(await screen.findByText('Edit Dashboard'));
  759. await userEvent.click(await screen.findByText('Cancel'));
  760. expect(window.confirm).not.toHaveBeenCalled();
  761. });
  762. it('opens the widget viewer modal using the widget id specified in the url', async () => {
  763. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  764. const widget = WidgetFixture({
  765. queries: [
  766. {
  767. name: '',
  768. conditions: 'event.type:error',
  769. fields: ['count()'],
  770. aggregates: ['count()'],
  771. columns: [],
  772. orderby: '',
  773. },
  774. ],
  775. title: 'First Widget',
  776. interval: '1d',
  777. id: '1',
  778. layout: null,
  779. });
  780. MockApiClient.addMockResponse({
  781. url: '/organizations/org-slug/dashboards/1/',
  782. body: DashboardFixture([widget], {id: '1', title: 'Custom Errors'}),
  783. });
  784. render(
  785. <ViewEditDashboard
  786. {...RouteComponentPropsFixture()}
  787. organization={initialData.organization}
  788. params={{orgId: 'org-slug', dashboardId: '1', widgetId: 1}}
  789. router={initialData.router}
  790. location={{...initialData.router.location, pathname: '/widget/123/'}}
  791. >
  792. {null}
  793. </ViewEditDashboard>,
  794. {router: initialData.router, organization: initialData.organization}
  795. );
  796. await waitFor(() => {
  797. expect(openWidgetViewerModal).toHaveBeenCalledWith(
  798. expect.objectContaining({
  799. organization: initialData.organization,
  800. widget,
  801. onClose: expect.anything(),
  802. })
  803. );
  804. });
  805. });
  806. it('redirects user to dashboard url if widget is not found', async () => {
  807. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  808. MockApiClient.addMockResponse({
  809. url: '/organizations/org-slug/dashboards/1/',
  810. body: DashboardFixture([], {id: '1', title: 'Custom Errors'}),
  811. });
  812. render(
  813. <ViewEditDashboard
  814. {...RouteComponentPropsFixture()}
  815. organization={initialData.organization}
  816. params={{orgId: 'org-slug', dashboardId: '1', widgetId: 123}}
  817. router={initialData.router}
  818. location={{...initialData.router.location, pathname: '/widget/123/'}}
  819. >
  820. {null}
  821. </ViewEditDashboard>,
  822. {router: initialData.router, organization: initialData.organization}
  823. );
  824. expect(await screen.findByText('All Releases')).toBeInTheDocument();
  825. expect(openWidgetViewerModal).not.toHaveBeenCalled();
  826. expect(initialData.router.replace).toHaveBeenCalledWith(
  827. expect.objectContaining({
  828. pathname: '/organizations/org-slug/dashboard/1/',
  829. query: {},
  830. })
  831. );
  832. });
  833. it('saves a new dashboard with the page filters', async () => {
  834. const mockPOST = MockApiClient.addMockResponse({
  835. url: '/organizations/org-slug/dashboards/',
  836. method: 'POST',
  837. body: [],
  838. });
  839. render(
  840. <CreateDashboard
  841. {...RouteComponentPropsFixture()}
  842. organization={initialData.organization}
  843. params={{templateId: undefined}}
  844. router={initialData.router}
  845. location={{
  846. ...initialData.router.location,
  847. query: {
  848. ...initialData.router.location.query,
  849. statsPeriod: '7d',
  850. project: [2],
  851. environment: ['alpha', 'beta'],
  852. },
  853. }}
  854. >
  855. {null}
  856. </CreateDashboard>,
  857. {
  858. router: initialData.router,
  859. organization: initialData.organization,
  860. }
  861. );
  862. await userEvent.click(await screen.findByText('Save and Finish'));
  863. expect(mockPOST).toHaveBeenCalledWith(
  864. '/organizations/org-slug/dashboards/',
  865. expect.objectContaining({
  866. data: expect.objectContaining({
  867. projects: [2],
  868. environment: ['alpha', 'beta'],
  869. period: '7d',
  870. }),
  871. })
  872. );
  873. });
  874. it('saves a template with the page filters', async () => {
  875. const mockPOST = MockApiClient.addMockResponse({
  876. url: '/organizations/org-slug/dashboards/',
  877. method: 'POST',
  878. body: [],
  879. });
  880. render(
  881. <CreateDashboard
  882. {...RouteComponentPropsFixture()}
  883. organization={initialData.organization}
  884. params={{templateId: 'default-template'}}
  885. router={initialData.router}
  886. location={{
  887. ...initialData.router.location,
  888. query: {
  889. ...initialData.router.location.query,
  890. statsPeriod: '7d',
  891. project: [2],
  892. environment: ['alpha', 'beta'],
  893. },
  894. }}
  895. >
  896. {null}
  897. </CreateDashboard>,
  898. {
  899. router: initialData.router,
  900. organization: initialData.organization,
  901. }
  902. );
  903. await userEvent.click(await screen.findByText('Add Dashboard'));
  904. expect(mockPOST).toHaveBeenCalledWith(
  905. '/organizations/org-slug/dashboards/',
  906. expect.objectContaining({
  907. data: expect.objectContaining({
  908. projects: [2],
  909. environment: ['alpha', 'beta'],
  910. period: '7d',
  911. }),
  912. })
  913. );
  914. });
  915. it('does not render save and cancel buttons on templates', async () => {
  916. MockApiClient.addMockResponse({
  917. url: '/organizations/org-slug/releases/',
  918. body: [
  919. ReleaseFixture({
  920. shortVersion: 'sentry-android-shop@1.2.0',
  921. version: 'sentry-android-shop@1.2.0',
  922. }),
  923. ],
  924. });
  925. render(
  926. <CreateDashboard
  927. {...RouteComponentPropsFixture()}
  928. organization={initialData.organization}
  929. params={{templateId: 'default-template'}}
  930. router={initialData.router}
  931. location={initialData.router.location}
  932. >
  933. {null}
  934. </CreateDashboard>,
  935. {
  936. router: initialData.router,
  937. organization: initialData.organization,
  938. }
  939. );
  940. await userEvent.click(await screen.findByText('24H'));
  941. await userEvent.click(screen.getByText('Last 7 days'));
  942. await screen.findByText('7D');
  943. expect(screen.queryByText('Cancel')).not.toBeInTheDocument();
  944. expect(screen.queryByText('Save')).not.toBeInTheDocument();
  945. });
  946. it('opens the widget viewer with saved dashboard filters', async () => {
  947. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  948. MockApiClient.addMockResponse({
  949. url: '/organizations/org-slug/dashboards/1/',
  950. body: DashboardFixture(widgets, {
  951. id: '1',
  952. filters: {release: ['sentry-android-shop@1.2.0']},
  953. }),
  954. });
  955. render(
  956. <ViewEditDashboard
  957. {...RouteComponentPropsFixture()}
  958. organization={initialData.organization}
  959. params={{orgId: 'org-slug', dashboardId: '1', widgetId: 1}}
  960. router={initialData.router}
  961. location={{...initialData.router.location, pathname: '/widget/1/'}}
  962. >
  963. {null}
  964. </ViewEditDashboard>,
  965. {router: initialData.router, organization: initialData.organization}
  966. );
  967. await waitFor(() => {
  968. expect(openWidgetViewerModal).toHaveBeenCalledWith(
  969. expect.objectContaining({
  970. dashboardFilters: {release: ['sentry-android-shop@1.2.0']},
  971. })
  972. );
  973. });
  974. });
  975. it('opens the widget viewer with unsaved dashboard filters', async () => {
  976. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  977. MockApiClient.addMockResponse({
  978. url: '/organizations/org-slug/dashboards/1/',
  979. body: DashboardFixture(widgets, {
  980. id: '1',
  981. filters: {release: ['sentry-android-shop@1.2.0']},
  982. }),
  983. });
  984. render(
  985. <ViewEditDashboard
  986. {...RouteComponentPropsFixture()}
  987. organization={initialData.organization}
  988. params={{orgId: 'org-slug', dashboardId: '1', widgetId: 1}}
  989. router={initialData.router}
  990. location={{
  991. ...initialData.router.location,
  992. pathname: '/widget/1/',
  993. query: {release: ['unsaved-release-filter@1.2.0']},
  994. }}
  995. >
  996. {null}
  997. </ViewEditDashboard>,
  998. {router: initialData.router, organization: initialData.organization}
  999. );
  1000. await waitFor(() => {
  1001. expect(openWidgetViewerModal).toHaveBeenCalledWith(
  1002. expect.objectContaining({
  1003. dashboardFilters: {release: ['unsaved-release-filter@1.2.0']},
  1004. })
  1005. );
  1006. });
  1007. });
  1008. it('can save dashboard filters in existing dashboard', async () => {
  1009. MockApiClient.addMockResponse({
  1010. url: '/organizations/org-slug/releases/',
  1011. body: [
  1012. ReleaseFixture({
  1013. shortVersion: 'sentry-android-shop@1.2.0',
  1014. version: 'sentry-android-shop@1.2.0',
  1015. }),
  1016. ],
  1017. });
  1018. const testData = initializeOrg({
  1019. organization: OrganizationFixture({
  1020. features: [
  1021. 'global-views',
  1022. 'dashboards-basic',
  1023. 'dashboards-edit',
  1024. 'discover-query',
  1025. ],
  1026. }),
  1027. router: {
  1028. location: {
  1029. ...LocationFixture(),
  1030. query: {
  1031. statsPeriod: '7d',
  1032. release: ['sentry-android-shop@1.2.0'],
  1033. },
  1034. },
  1035. },
  1036. });
  1037. render(
  1038. <ViewEditDashboard
  1039. {...RouteComponentPropsFixture()}
  1040. organization={testData.organization}
  1041. params={{orgId: 'org-slug', dashboardId: '1'}}
  1042. router={testData.router}
  1043. location={testData.router.location}
  1044. >
  1045. {null}
  1046. </ViewEditDashboard>,
  1047. {router: testData.router, organization: testData.organization}
  1048. );
  1049. await userEvent.click(await screen.findByText('Save'));
  1050. expect(mockPut).toHaveBeenCalledWith(
  1051. '/organizations/org-slug/dashboards/1/',
  1052. expect.objectContaining({
  1053. data: expect.objectContaining({
  1054. period: '7d',
  1055. filters: {release: ['sentry-android-shop@1.2.0']},
  1056. }),
  1057. })
  1058. );
  1059. });
  1060. it('can clear dashboard filters in compact select', async () => {
  1061. MockApiClient.addMockResponse({
  1062. url: '/organizations/org-slug/dashboards/1/',
  1063. body: DashboardFixture(widgets, {
  1064. id: '1',
  1065. title: 'Custom Errors',
  1066. filters: {release: ['sentry-android-shop@1.2.0']},
  1067. }),
  1068. });
  1069. MockApiClient.addMockResponse({
  1070. url: '/organizations/org-slug/releases/',
  1071. body: [
  1072. ReleaseFixture({
  1073. shortVersion: 'sentry-android-shop@1.2.0',
  1074. version: 'sentry-android-shop@1.2.0',
  1075. }),
  1076. ],
  1077. });
  1078. const testData = initializeOrg({
  1079. organization: OrganizationFixture({
  1080. features: [
  1081. 'global-views',
  1082. 'dashboards-basic',
  1083. 'dashboards-edit',
  1084. 'discover-query',
  1085. ],
  1086. }),
  1087. router: {
  1088. location: {
  1089. ...LocationFixture(),
  1090. query: {
  1091. statsPeriod: '7d',
  1092. },
  1093. },
  1094. },
  1095. });
  1096. render(
  1097. <ViewEditDashboard
  1098. {...RouteComponentPropsFixture()}
  1099. organization={testData.organization}
  1100. params={{orgId: 'org-slug', dashboardId: '1'}}
  1101. router={testData.router}
  1102. location={testData.router.location}
  1103. >
  1104. {null}
  1105. </ViewEditDashboard>,
  1106. {router: testData.router, organization: testData.organization}
  1107. );
  1108. await screen.findByText('7D');
  1109. await userEvent.click(await screen.findByText('sentry-android-shop@1.2.0'));
  1110. await userEvent.click(screen.getAllByText('Clear')[0]);
  1111. screen.getByText('All Releases');
  1112. await userEvent.click(document.body);
  1113. await waitFor(() => {
  1114. expect(browserHistory.push).toHaveBeenCalledWith(
  1115. expect.objectContaining({
  1116. query: expect.objectContaining({
  1117. release: '',
  1118. }),
  1119. })
  1120. );
  1121. });
  1122. });
  1123. it('can save absolute time range in existing dashboard', async () => {
  1124. const testData = initializeOrg({
  1125. organization: OrganizationFixture({
  1126. features: [
  1127. 'global-views',
  1128. 'dashboards-basic',
  1129. 'dashboards-edit',
  1130. 'discover-query',
  1131. ],
  1132. }),
  1133. router: {
  1134. location: {
  1135. ...LocationFixture(),
  1136. query: {
  1137. start: '2022-07-14T07:00:00',
  1138. end: '2022-07-19T23:59:59',
  1139. utc: 'true',
  1140. },
  1141. },
  1142. },
  1143. });
  1144. render(
  1145. <ViewEditDashboard
  1146. {...RouteComponentPropsFixture()}
  1147. organization={testData.organization}
  1148. params={{orgId: 'org-slug', dashboardId: '1'}}
  1149. router={testData.router}
  1150. location={testData.router.location}
  1151. >
  1152. {null}
  1153. </ViewEditDashboard>,
  1154. {router: testData.router, organization: testData.organization}
  1155. );
  1156. await userEvent.click(await screen.findByText('Save'));
  1157. expect(mockPut).toHaveBeenCalledWith(
  1158. '/organizations/org-slug/dashboards/1/',
  1159. expect.objectContaining({
  1160. data: expect.objectContaining({
  1161. start: '2022-07-14T07:00:00.000',
  1162. end: '2022-07-19T23:59:59.000',
  1163. utc: true,
  1164. }),
  1165. })
  1166. );
  1167. });
  1168. it('can clear dashboard filters in existing dashboard', async () => {
  1169. MockApiClient.addMockResponse({
  1170. url: '/organizations/org-slug/releases/',
  1171. body: [
  1172. ReleaseFixture({
  1173. shortVersion: 'sentry-android-shop@1.2.0',
  1174. version: 'sentry-android-shop@1.2.0',
  1175. }),
  1176. ],
  1177. });
  1178. const testData = initializeOrg({
  1179. organization: OrganizationFixture({
  1180. features: [
  1181. 'global-views',
  1182. 'dashboards-basic',
  1183. 'dashboards-edit',
  1184. 'discover-query',
  1185. ],
  1186. }),
  1187. router: {
  1188. location: {
  1189. ...LocationFixture(),
  1190. query: {
  1191. statsPeriod: '7d',
  1192. environment: ['alpha', 'beta'],
  1193. },
  1194. },
  1195. },
  1196. });
  1197. render(
  1198. <ViewEditDashboard
  1199. {...RouteComponentPropsFixture()}
  1200. organization={testData.organization}
  1201. params={{orgId: 'org-slug', dashboardId: '1'}}
  1202. router={testData.router}
  1203. location={testData.router.location}
  1204. >
  1205. {null}
  1206. </ViewEditDashboard>,
  1207. {router: testData.router, organization: testData.organization}
  1208. );
  1209. await screen.findByText('7D');
  1210. await userEvent.click(await screen.findByText('All Releases'));
  1211. await userEvent.click(screen.getByText('sentry-android-shop@1.2.0'));
  1212. await userEvent.keyboard('{Escape}');
  1213. await userEvent.click(screen.getByText('Cancel'));
  1214. screen.getByText('All Releases');
  1215. expect(browserHistory.replace).toHaveBeenCalledWith(
  1216. expect.objectContaining({
  1217. query: expect.objectContaining({
  1218. project: undefined,
  1219. statsPeriod: undefined,
  1220. environment: undefined,
  1221. }),
  1222. })
  1223. );
  1224. });
  1225. it('disables the Edit Dashboard button when there are unsaved filters', async () => {
  1226. MockApiClient.addMockResponse({
  1227. url: '/organizations/org-slug/releases/',
  1228. body: [
  1229. ReleaseFixture({
  1230. shortVersion: 'sentry-android-shop@1.2.0',
  1231. version: 'sentry-android-shop@1.2.0',
  1232. }),
  1233. ],
  1234. });
  1235. const testData = initializeOrg({
  1236. organization: OrganizationFixture({
  1237. features: [
  1238. 'global-views',
  1239. 'dashboards-basic',
  1240. 'dashboards-edit',
  1241. 'discover-basic',
  1242. 'discover-query',
  1243. ],
  1244. }),
  1245. router: {
  1246. location: {
  1247. ...LocationFixture(),
  1248. query: {
  1249. statsPeriod: '7d',
  1250. environment: ['alpha', 'beta'],
  1251. },
  1252. },
  1253. },
  1254. });
  1255. render(
  1256. <ViewEditDashboard
  1257. {...RouteComponentPropsFixture()}
  1258. organization={testData.organization}
  1259. params={{orgId: 'org-slug', dashboardId: '1'}}
  1260. router={testData.router}
  1261. location={testData.router.location}
  1262. >
  1263. {null}
  1264. </ViewEditDashboard>,
  1265. {router: testData.router, organization: testData.organization}
  1266. );
  1267. expect(await screen.findByText('Save')).toBeInTheDocument();
  1268. expect(screen.getByText('Cancel')).toBeInTheDocument();
  1269. expect(screen.getByRole('button', {name: 'Edit Dashboard'})).toBeDisabled();
  1270. });
  1271. it('ignores the order of selection of page filters to render unsaved filters', async () => {
  1272. const testProjects = [
  1273. ProjectFixture({id: '1', name: 'first', environments: ['alpha', 'beta']}),
  1274. ProjectFixture({id: '2', name: 'second', environments: ['alpha', 'beta']}),
  1275. ];
  1276. act(() => ProjectsStore.loadInitialData(testProjects));
  1277. MockApiClient.addMockResponse({
  1278. url: '/organizations/org-slug/projects/',
  1279. body: testProjects,
  1280. });
  1281. MockApiClient.addMockResponse({
  1282. url: '/organizations/org-slug/dashboards/1/',
  1283. body: DashboardFixture(widgets, {
  1284. id: '1',
  1285. title: 'Custom Errors',
  1286. filters: {},
  1287. environment: ['alpha', 'beta'],
  1288. }),
  1289. });
  1290. const testData = initializeOrg({
  1291. organization: OrganizationFixture({
  1292. features: [
  1293. 'global-views',
  1294. 'dashboards-basic',
  1295. 'dashboards-edit',
  1296. 'discover-query',
  1297. ],
  1298. }),
  1299. router: {
  1300. location: {
  1301. ...LocationFixture(),
  1302. query: {
  1303. environment: ['beta', 'alpha'], // Reversed order from saved dashboard
  1304. },
  1305. },
  1306. },
  1307. });
  1308. render(
  1309. <ViewEditDashboard
  1310. {...RouteComponentPropsFixture()}
  1311. organization={testData.organization}
  1312. params={{orgId: 'org-slug', dashboardId: '1'}}
  1313. router={testData.router}
  1314. location={testData.router.location}
  1315. >
  1316. {null}
  1317. </ViewEditDashboard>,
  1318. {router: testData.router, organization: testData.organization}
  1319. );
  1320. await waitFor(() => expect(screen.queryAllByText('Loading\u2026')).toEqual([]));
  1321. await userEvent.click(screen.getByRole('button', {name: 'All Envs'}));
  1322. expect(screen.getByRole('row', {name: 'alpha'})).toHaveAttribute(
  1323. 'aria-selected',
  1324. 'true'
  1325. );
  1326. expect(screen.getByRole('row', {name: 'beta'})).toHaveAttribute(
  1327. 'aria-selected',
  1328. 'true'
  1329. );
  1330. // Save and Cancel should not appear because alpha, beta is the same as beta, alpha
  1331. expect(screen.queryByText('Save')).not.toBeInTheDocument();
  1332. expect(screen.queryByText('Cancel')).not.toBeInTheDocument();
  1333. });
  1334. it('uses releases from the URL query params', async function () {
  1335. const testData = initializeOrg({
  1336. organization: OrganizationFixture({
  1337. features: [
  1338. 'global-views',
  1339. 'dashboards-basic',
  1340. 'dashboards-edit',
  1341. 'discover-query',
  1342. ],
  1343. }),
  1344. router: {
  1345. location: {
  1346. ...LocationFixture(),
  1347. query: {
  1348. release: ['not-selected-1'],
  1349. },
  1350. },
  1351. },
  1352. });
  1353. render(
  1354. <ViewEditDashboard
  1355. {...RouteComponentPropsFixture()}
  1356. organization={testData.organization}
  1357. params={{orgId: 'org-slug', dashboardId: '1'}}
  1358. router={testData.router}
  1359. location={testData.router.location}
  1360. >
  1361. {null}
  1362. </ViewEditDashboard>,
  1363. {router: testData.router, organization: testData.organization}
  1364. );
  1365. await screen.findByText(/not-selected-1/);
  1366. screen.getByText('Save');
  1367. screen.getByText('Cancel');
  1368. });
  1369. it('resets release in URL params', async function () {
  1370. MockApiClient.addMockResponse({
  1371. url: '/organizations/org-slug/dashboards/1/',
  1372. body: DashboardFixture(widgets, {
  1373. id: '1',
  1374. title: 'Custom Errors',
  1375. filters: {
  1376. release: ['abc'],
  1377. },
  1378. }),
  1379. });
  1380. const testData = initializeOrg({
  1381. organization: OrganizationFixture({
  1382. features: [
  1383. 'global-views',
  1384. 'dashboards-basic',
  1385. 'dashboards-edit',
  1386. 'discover-query',
  1387. ],
  1388. }),
  1389. router: {
  1390. location: {
  1391. ...LocationFixture(),
  1392. query: {
  1393. release: ['not-selected-1'],
  1394. },
  1395. },
  1396. },
  1397. });
  1398. render(
  1399. <ViewEditDashboard
  1400. {...RouteComponentPropsFixture()}
  1401. organization={testData.organization}
  1402. params={{orgId: 'org-slug', dashboardId: '1'}}
  1403. router={testData.router}
  1404. location={testData.router.location}
  1405. >
  1406. {null}
  1407. </ViewEditDashboard>,
  1408. {router: testData.router, organization: testData.organization}
  1409. );
  1410. await screen.findByText(/not-selected-1/);
  1411. await userEvent.click(screen.getByText('Cancel'));
  1412. // release isn't used in the redirect
  1413. expect(browserHistory.replace).toHaveBeenCalledWith(
  1414. expect.objectContaining({
  1415. query: {
  1416. end: undefined,
  1417. environment: undefined,
  1418. project: undefined,
  1419. start: undefined,
  1420. statsPeriod: undefined,
  1421. utc: undefined,
  1422. },
  1423. })
  1424. );
  1425. });
  1426. it('reflects selections in the release filter in the query params', async function () {
  1427. MockApiClient.addMockResponse({
  1428. url: '/organizations/org-slug/releases/',
  1429. body: [
  1430. ReleaseFixture({
  1431. shortVersion: 'sentry-android-shop@1.2.0',
  1432. version: 'sentry-android-shop@1.2.0',
  1433. }),
  1434. ],
  1435. });
  1436. const testData = initializeOrg({
  1437. organization: OrganizationFixture({
  1438. features: [
  1439. 'global-views',
  1440. 'dashboards-basic',
  1441. 'dashboards-edit',
  1442. 'discover-query',
  1443. ],
  1444. }),
  1445. router: {
  1446. location: LocationFixture(),
  1447. },
  1448. });
  1449. render(
  1450. <ViewEditDashboard
  1451. {...RouteComponentPropsFixture()}
  1452. organization={testData.organization}
  1453. params={{orgId: 'org-slug', dashboardId: '1'}}
  1454. router={testData.router}
  1455. location={testData.router.location}
  1456. >
  1457. {null}
  1458. </ViewEditDashboard>,
  1459. {router: testData.router, organization: testData.organization}
  1460. );
  1461. await userEvent.click(await screen.findByText('All Releases'));
  1462. await userEvent.click(screen.getByText('sentry-android-shop@1.2.0'));
  1463. await userEvent.click(document.body);
  1464. await waitFor(() => {
  1465. expect(browserHistory.push).toHaveBeenCalledWith(
  1466. expect.objectContaining({
  1467. query: expect.objectContaining({
  1468. release: ['sentry-android-shop@1.2.0'],
  1469. }),
  1470. })
  1471. );
  1472. });
  1473. });
  1474. it('persists release selections made during search requests that do not appear in default query', async function () {
  1475. // Default response
  1476. MockApiClient.addMockResponse({
  1477. url: '/organizations/org-slug/releases/',
  1478. body: [
  1479. ReleaseFixture({
  1480. shortVersion: 'sentry-android-shop@1.2.0',
  1481. version: 'sentry-android-shop@1.2.0',
  1482. }),
  1483. ],
  1484. });
  1485. // Mocked search results
  1486. MockApiClient.addMockResponse({
  1487. url: '/organizations/org-slug/releases/',
  1488. body: [
  1489. ReleaseFixture({
  1490. id: '9',
  1491. shortVersion: 'search-result',
  1492. version: 'search-result',
  1493. }),
  1494. ],
  1495. match: [MockApiClient.matchData({query: 's'})],
  1496. });
  1497. const testData = initializeOrg({
  1498. organization: OrganizationFixture({
  1499. features: [
  1500. 'global-views',
  1501. 'dashboards-basic',
  1502. 'dashboards-edit',
  1503. 'discover-basic',
  1504. 'discover-query',
  1505. ],
  1506. }),
  1507. router: {
  1508. location: LocationFixture(),
  1509. },
  1510. });
  1511. render(
  1512. <ViewEditDashboard
  1513. {...RouteComponentPropsFixture()}
  1514. organization={testData.organization}
  1515. params={{orgId: 'org-slug', dashboardId: '1'}}
  1516. router={testData.router}
  1517. location={testData.router.location}
  1518. >
  1519. {null}
  1520. </ViewEditDashboard>,
  1521. {router: testData.router, organization: testData.organization}
  1522. );
  1523. await userEvent.click(await screen.findByText('All Releases'));
  1524. await userEvent.type(screen.getAllByPlaceholderText('Search\u2026')[2], 's');
  1525. await userEvent.click(await screen.findByRole('option', {name: 'search-result'}));
  1526. // Validate that after search is cleared, search result still appears
  1527. expect(await screen.findByText('Latest Release(s)')).toBeInTheDocument();
  1528. expect(screen.getByRole('option', {name: 'search-result'})).toBeInTheDocument();
  1529. });
  1530. describe('discover split', function () {
  1531. it('calls the dashboard callbacks with the correct widgetType for discover split', function () {
  1532. const widget = {
  1533. displayType: types.DisplayType.TABLE,
  1534. interval: '1d',
  1535. queries: [
  1536. {
  1537. name: 'Test Widget',
  1538. fields: ['count()', 'count_unique(user)', 'epm()', 'project'],
  1539. columns: ['project'],
  1540. aggregates: ['count()', 'count_unique(user)', 'epm()'],
  1541. conditions: '',
  1542. orderby: '',
  1543. },
  1544. ],
  1545. title: 'Transactions',
  1546. id: '1',
  1547. widgetType: types.WidgetType.DISCOVER,
  1548. };
  1549. const mockDashboard = DashboardFixture([widget], {
  1550. id: '1',
  1551. title: 'Custom Errors',
  1552. });
  1553. const mockModifiedDashboard = DashboardFixture([widget], {
  1554. id: '1',
  1555. title: 'Custom Errors',
  1556. });
  1557. const mockOnDashboardUpdate = jest.fn();
  1558. const mockStateSetter = jest
  1559. .fn()
  1560. .mockImplementation(fn => fn({modifiedDashboard: mockModifiedDashboard}));
  1561. handleUpdateDashboardSplit({
  1562. widgetId: '1',
  1563. splitDecision: types.WidgetType.ERRORS,
  1564. dashboard: mockDashboard,
  1565. modifiedDashboard: mockModifiedDashboard,
  1566. onDashboardUpdate: mockOnDashboardUpdate,
  1567. stateSetter: mockStateSetter,
  1568. });
  1569. expect(mockOnDashboardUpdate).toHaveBeenCalledWith({
  1570. ...mockDashboard,
  1571. widgets: [{...widget, widgetType: types.WidgetType.ERRORS}],
  1572. });
  1573. expect(mockStateSetter).toHaveReturnedWith({
  1574. modifiedDashboard: {
  1575. ...mockModifiedDashboard,
  1576. widgets: [{...widget, widgetType: types.WidgetType.ERRORS}],
  1577. },
  1578. });
  1579. });
  1580. });
  1581. });
  1582. });