detail.spec.tsx 52 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690
  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. 'Releases',
  490. 'Metrics',
  491. ]);
  492. });
  493. it('shows add widget option without dataset selector flag', async function () {
  494. initialData = initializeOrg({
  495. organization: OrganizationFixture({
  496. features: [
  497. 'global-views',
  498. 'dashboards-basic',
  499. 'dashboards-edit',
  500. 'discover-query',
  501. 'custom-metrics',
  502. ],
  503. }),
  504. });
  505. render(
  506. <OrganizationContext.Provider value={initialData.organization}>
  507. <ViewEditDashboard
  508. {...RouteComponentPropsFixture()}
  509. organization={initialData.organization}
  510. params={{orgId: 'org-slug', dashboardId: '1'}}
  511. router={initialData.router}
  512. location={initialData.router.location}
  513. >
  514. {null}
  515. </ViewEditDashboard>
  516. </OrganizationContext.Provider>,
  517. {router: initialData.router}
  518. );
  519. await userEvent.click(screen.getAllByText('Add Widget')[0]);
  520. const menuOptions = await screen.findAllByTestId('menu-list-item-label');
  521. expect(menuOptions.map(e => e.textContent)).toEqual([
  522. 'Errors and Transactions',
  523. 'Issues',
  524. 'Releases',
  525. 'Metrics',
  526. ]);
  527. });
  528. it('shows top level release filter', async function () {
  529. const mockReleases = MockApiClient.addMockResponse({
  530. url: '/organizations/org-slug/releases/',
  531. body: [ReleaseFixture()],
  532. });
  533. initialData = initializeOrg({
  534. organization: OrganizationFixture({
  535. features: [
  536. 'global-views',
  537. 'dashboards-basic',
  538. 'dashboards-edit',
  539. 'discover-query',
  540. ],
  541. }),
  542. });
  543. render(
  544. <OrganizationContext.Provider value={initialData.organization}>
  545. <ViewEditDashboard
  546. {...RouteComponentPropsFixture()}
  547. organization={initialData.organization}
  548. params={{orgId: 'org-slug', dashboardId: '1'}}
  549. router={initialData.router}
  550. location={initialData.router.location}
  551. >
  552. {null}
  553. </ViewEditDashboard>
  554. </OrganizationContext.Provider>,
  555. {router: initialData.router}
  556. );
  557. expect(await screen.findByText('All Releases')).toBeInTheDocument();
  558. expect(mockReleases).toHaveBeenCalledTimes(1);
  559. });
  560. it('hides add widget option', async function () {
  561. // @ts-expect-error this is assigning to readonly property...
  562. types.MAX_WIDGETS = 1;
  563. render(
  564. <OrganizationContext.Provider value={initialData.organization}>
  565. <ViewEditDashboard
  566. {...RouteComponentPropsFixture()}
  567. organization={initialData.organization}
  568. params={{orgId: 'org-slug', dashboardId: '1'}}
  569. router={initialData.router}
  570. location={initialData.router.location}
  571. >
  572. {null}
  573. </ViewEditDashboard>
  574. </OrganizationContext.Provider>,
  575. {router: initialData.router}
  576. );
  577. // Enter edit mode.
  578. await userEvent.click(await screen.findByRole('button', {name: 'Edit Dashboard'}));
  579. expect(screen.queryByRole('button', {name: 'Add widget'})).not.toBeInTheDocument();
  580. });
  581. it('renders successfully if more widgets than stored layouts', async function () {
  582. // A case where someone has async added widgets to a dashboard
  583. MockApiClient.addMockResponse({
  584. url: '/organizations/org-slug/dashboards/1/',
  585. body: DashboardFixture(
  586. [
  587. WidgetFixture({
  588. queries: [
  589. {
  590. name: '',
  591. conditions: 'event.type:error',
  592. fields: ['count()'],
  593. aggregates: ['count()'],
  594. columns: [],
  595. orderby: '-count()',
  596. },
  597. ],
  598. title: 'First Widget',
  599. interval: '1d',
  600. id: '1',
  601. layout: {x: 0, y: 0, w: 2, h: 6, minH: 0},
  602. }),
  603. WidgetFixture({
  604. queries: [
  605. {
  606. name: '',
  607. conditions: 'event.type:error',
  608. fields: ['count()'],
  609. aggregates: ['count()'],
  610. columns: [],
  611. orderby: '-count()',
  612. },
  613. ],
  614. title: 'Second Widget',
  615. interval: '1d',
  616. id: '2',
  617. }),
  618. ],
  619. {id: '1', title: 'Custom Errors'}
  620. ),
  621. });
  622. render(
  623. <ViewEditDashboard
  624. {...RouteComponentPropsFixture()}
  625. organization={initialData.organization}
  626. params={{orgId: 'org-slug', dashboardId: '1'}}
  627. router={initialData.router}
  628. location={initialData.router.location}
  629. >
  630. {null}
  631. </ViewEditDashboard>,
  632. {router: initialData.router, organization: initialData.organization}
  633. );
  634. expect(await screen.findByText('First Widget')).toBeInTheDocument();
  635. expect(await screen.findByText('Second Widget')).toBeInTheDocument();
  636. });
  637. it('does not trigger request if layout not updated', async () => {
  638. MockApiClient.addMockResponse({
  639. url: '/organizations/org-slug/dashboards/1/',
  640. body: DashboardFixture(
  641. [
  642. WidgetFixture({
  643. queries: [
  644. {
  645. name: '',
  646. conditions: 'event.type:error',
  647. fields: ['count()'],
  648. aggregates: ['count()'],
  649. columns: [],
  650. orderby: '-count()',
  651. },
  652. ],
  653. title: 'First Widget',
  654. interval: '1d',
  655. id: '1',
  656. layout: {x: 0, y: 0, w: 2, h: 6, minH: 0},
  657. }),
  658. ],
  659. {id: '1', title: 'Custom Errors'}
  660. ),
  661. });
  662. render(
  663. <ViewEditDashboard
  664. {...RouteComponentPropsFixture()}
  665. organization={initialData.organization}
  666. params={{orgId: 'org-slug', dashboardId: '1'}}
  667. router={initialData.router}
  668. location={initialData.router.location}
  669. >
  670. {null}
  671. </ViewEditDashboard>,
  672. {router: initialData.router, organization: initialData.organization}
  673. );
  674. await userEvent.click(await screen.findByText('Edit Dashboard'));
  675. await userEvent.click(await screen.findByText('Save and Finish'));
  676. expect(screen.getByText('Edit Dashboard')).toBeInTheDocument();
  677. expect(mockPut).not.toHaveBeenCalled();
  678. });
  679. it('renders the custom resize handler for a widget', async () => {
  680. MockApiClient.addMockResponse({
  681. url: '/organizations/org-slug/dashboards/1/',
  682. body: DashboardFixture(
  683. [
  684. WidgetFixture({
  685. queries: [
  686. {
  687. name: '',
  688. conditions: 'event.type:error',
  689. fields: ['count()'],
  690. aggregates: ['count()'],
  691. columns: [],
  692. orderby: '-count()',
  693. },
  694. ],
  695. title: 'First Widget',
  696. interval: '1d',
  697. id: '1',
  698. layout: {x: 0, y: 0, w: 2, h: 6, minH: 0},
  699. }),
  700. ],
  701. {id: '1', title: 'Custom Errors'}
  702. ),
  703. });
  704. render(
  705. <ViewEditDashboard
  706. {...RouteComponentPropsFixture()}
  707. organization={initialData.organization}
  708. params={{orgId: 'org-slug', dashboardId: '1'}}
  709. router={initialData.router}
  710. location={initialData.router.location}
  711. >
  712. {null}
  713. </ViewEditDashboard>,
  714. {router: initialData.router, organization: initialData.organization}
  715. );
  716. await userEvent.click(await screen.findByText('Edit Dashboard'));
  717. const widget = screen
  718. .getByText('First Widget')
  719. .closest('.react-grid-item') as HTMLElement;
  720. const resizeHandle = within(widget).getByTestId('custom-resize-handle');
  721. expect(resizeHandle).toBeVisible();
  722. });
  723. it('does not trigger an alert when the widgets have no layout and user cancels without changes', async () => {
  724. MockApiClient.addMockResponse({
  725. url: '/organizations/org-slug/dashboards/1/',
  726. body: DashboardFixture(
  727. [
  728. WidgetFixture({
  729. queries: [
  730. {
  731. name: '',
  732. conditions: 'event.type:error',
  733. fields: ['count()'],
  734. aggregates: ['count()'],
  735. columns: [],
  736. orderby: '-count()',
  737. },
  738. ],
  739. title: 'First Widget',
  740. interval: '1d',
  741. id: '1',
  742. layout: null,
  743. }),
  744. ],
  745. {id: '1', title: 'Custom Errors'}
  746. ),
  747. });
  748. render(
  749. <ViewEditDashboard
  750. {...RouteComponentPropsFixture()}
  751. organization={initialData.organization}
  752. params={{orgId: 'org-slug', dashboardId: '1'}}
  753. router={initialData.router}
  754. location={initialData.router.location}
  755. >
  756. {null}
  757. </ViewEditDashboard>,
  758. {router: initialData.router, organization: initialData.organization}
  759. );
  760. await userEvent.click(await screen.findByText('Edit Dashboard'));
  761. await userEvent.click(await screen.findByText('Cancel'));
  762. expect(window.confirm).not.toHaveBeenCalled();
  763. });
  764. it('opens the widget viewer modal using the widget id specified in the url', async () => {
  765. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  766. const widget = WidgetFixture({
  767. queries: [
  768. {
  769. name: '',
  770. conditions: 'event.type:error',
  771. fields: ['count()'],
  772. aggregates: ['count()'],
  773. columns: [],
  774. orderby: '',
  775. },
  776. ],
  777. title: 'First Widget',
  778. interval: '1d',
  779. id: '1',
  780. layout: null,
  781. });
  782. MockApiClient.addMockResponse({
  783. url: '/organizations/org-slug/dashboards/1/',
  784. body: DashboardFixture([widget], {id: '1', title: 'Custom Errors'}),
  785. });
  786. render(
  787. <ViewEditDashboard
  788. {...RouteComponentPropsFixture()}
  789. organization={initialData.organization}
  790. params={{orgId: 'org-slug', dashboardId: '1', widgetId: 1}}
  791. router={initialData.router}
  792. location={{...initialData.router.location, pathname: '/widget/123/'}}
  793. >
  794. {null}
  795. </ViewEditDashboard>,
  796. {router: initialData.router, organization: initialData.organization}
  797. );
  798. await waitFor(() => {
  799. expect(openWidgetViewerModal).toHaveBeenCalledWith(
  800. expect.objectContaining({
  801. organization: initialData.organization,
  802. widget,
  803. onClose: expect.anything(),
  804. })
  805. );
  806. });
  807. });
  808. it('redirects user to dashboard url if widget is not found', async () => {
  809. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  810. MockApiClient.addMockResponse({
  811. url: '/organizations/org-slug/dashboards/1/',
  812. body: DashboardFixture([], {id: '1', title: 'Custom Errors'}),
  813. });
  814. render(
  815. <ViewEditDashboard
  816. {...RouteComponentPropsFixture()}
  817. organization={initialData.organization}
  818. params={{orgId: 'org-slug', dashboardId: '1', widgetId: 123}}
  819. router={initialData.router}
  820. location={{...initialData.router.location, pathname: '/widget/123/'}}
  821. >
  822. {null}
  823. </ViewEditDashboard>,
  824. {router: initialData.router, organization: initialData.organization}
  825. );
  826. expect(await screen.findByText('All Releases')).toBeInTheDocument();
  827. expect(openWidgetViewerModal).not.toHaveBeenCalled();
  828. expect(initialData.router.replace).toHaveBeenCalledWith(
  829. expect.objectContaining({
  830. pathname: '/organizations/org-slug/dashboard/1/',
  831. query: {},
  832. })
  833. );
  834. });
  835. it('saves a new dashboard with the page filters', async () => {
  836. const mockPOST = MockApiClient.addMockResponse({
  837. url: '/organizations/org-slug/dashboards/',
  838. method: 'POST',
  839. body: [],
  840. });
  841. render(
  842. <CreateDashboard
  843. {...RouteComponentPropsFixture()}
  844. organization={initialData.organization}
  845. params={{templateId: undefined}}
  846. router={initialData.router}
  847. location={{
  848. ...initialData.router.location,
  849. query: {
  850. ...initialData.router.location.query,
  851. statsPeriod: '7d',
  852. project: [2],
  853. environment: ['alpha', 'beta'],
  854. },
  855. }}
  856. >
  857. {null}
  858. </CreateDashboard>,
  859. {
  860. router: initialData.router,
  861. organization: initialData.organization,
  862. }
  863. );
  864. await userEvent.click(await screen.findByText('Save and Finish'));
  865. expect(mockPOST).toHaveBeenCalledWith(
  866. '/organizations/org-slug/dashboards/',
  867. expect.objectContaining({
  868. data: expect.objectContaining({
  869. projects: [2],
  870. environment: ['alpha', 'beta'],
  871. period: '7d',
  872. }),
  873. })
  874. );
  875. });
  876. it('saves a template with the page filters', async () => {
  877. const mockPOST = MockApiClient.addMockResponse({
  878. url: '/organizations/org-slug/dashboards/',
  879. method: 'POST',
  880. body: [],
  881. });
  882. render(
  883. <CreateDashboard
  884. {...RouteComponentPropsFixture()}
  885. organization={initialData.organization}
  886. params={{templateId: 'default-template'}}
  887. router={initialData.router}
  888. location={{
  889. ...initialData.router.location,
  890. query: {
  891. ...initialData.router.location.query,
  892. statsPeriod: '7d',
  893. project: [2],
  894. environment: ['alpha', 'beta'],
  895. },
  896. }}
  897. >
  898. {null}
  899. </CreateDashboard>,
  900. {
  901. router: initialData.router,
  902. organization: initialData.organization,
  903. }
  904. );
  905. await userEvent.click(await screen.findByText('Add Dashboard'));
  906. expect(mockPOST).toHaveBeenCalledWith(
  907. '/organizations/org-slug/dashboards/',
  908. expect.objectContaining({
  909. data: expect.objectContaining({
  910. projects: [2],
  911. environment: ['alpha', 'beta'],
  912. period: '7d',
  913. }),
  914. })
  915. );
  916. });
  917. it('does not render save and cancel buttons on templates', async () => {
  918. MockApiClient.addMockResponse({
  919. url: '/organizations/org-slug/releases/',
  920. body: [
  921. ReleaseFixture({
  922. shortVersion: 'sentry-android-shop@1.2.0',
  923. version: 'sentry-android-shop@1.2.0',
  924. }),
  925. ],
  926. });
  927. render(
  928. <CreateDashboard
  929. {...RouteComponentPropsFixture()}
  930. organization={initialData.organization}
  931. params={{templateId: 'default-template'}}
  932. router={initialData.router}
  933. location={initialData.router.location}
  934. >
  935. {null}
  936. </CreateDashboard>,
  937. {
  938. router: initialData.router,
  939. organization: initialData.organization,
  940. }
  941. );
  942. await userEvent.click(await screen.findByText('24H'));
  943. await userEvent.click(screen.getByText('Last 7 days'));
  944. await screen.findByText('7D');
  945. expect(screen.queryByText('Cancel')).not.toBeInTheDocument();
  946. expect(screen.queryByText('Save')).not.toBeInTheDocument();
  947. });
  948. it('opens the widget viewer with saved dashboard filters', async () => {
  949. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  950. MockApiClient.addMockResponse({
  951. url: '/organizations/org-slug/dashboards/1/',
  952. body: DashboardFixture(widgets, {
  953. id: '1',
  954. filters: {release: ['sentry-android-shop@1.2.0']},
  955. }),
  956. });
  957. render(
  958. <ViewEditDashboard
  959. {...RouteComponentPropsFixture()}
  960. organization={initialData.organization}
  961. params={{orgId: 'org-slug', dashboardId: '1', widgetId: 1}}
  962. router={initialData.router}
  963. location={{...initialData.router.location, pathname: '/widget/1/'}}
  964. >
  965. {null}
  966. </ViewEditDashboard>,
  967. {router: initialData.router, organization: initialData.organization}
  968. );
  969. await waitFor(() => {
  970. expect(openWidgetViewerModal).toHaveBeenCalledWith(
  971. expect.objectContaining({
  972. dashboardFilters: {release: ['sentry-android-shop@1.2.0']},
  973. })
  974. );
  975. });
  976. });
  977. it('opens the widget viewer with unsaved dashboard filters', async () => {
  978. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  979. MockApiClient.addMockResponse({
  980. url: '/organizations/org-slug/dashboards/1/',
  981. body: DashboardFixture(widgets, {
  982. id: '1',
  983. filters: {release: ['sentry-android-shop@1.2.0']},
  984. }),
  985. });
  986. render(
  987. <ViewEditDashboard
  988. {...RouteComponentPropsFixture()}
  989. organization={initialData.organization}
  990. params={{orgId: 'org-slug', dashboardId: '1', widgetId: 1}}
  991. router={initialData.router}
  992. location={{
  993. ...initialData.router.location,
  994. pathname: '/widget/1/',
  995. query: {release: ['unsaved-release-filter@1.2.0']},
  996. }}
  997. >
  998. {null}
  999. </ViewEditDashboard>,
  1000. {router: initialData.router, organization: initialData.organization}
  1001. );
  1002. await waitFor(() => {
  1003. expect(openWidgetViewerModal).toHaveBeenCalledWith(
  1004. expect.objectContaining({
  1005. dashboardFilters: {release: ['unsaved-release-filter@1.2.0']},
  1006. })
  1007. );
  1008. });
  1009. });
  1010. it('can save dashboard filters in existing dashboard', async () => {
  1011. MockApiClient.addMockResponse({
  1012. url: '/organizations/org-slug/releases/',
  1013. body: [
  1014. ReleaseFixture({
  1015. shortVersion: 'sentry-android-shop@1.2.0',
  1016. version: 'sentry-android-shop@1.2.0',
  1017. }),
  1018. ],
  1019. });
  1020. const testData = initializeOrg({
  1021. organization: OrganizationFixture({
  1022. features: [
  1023. 'global-views',
  1024. 'dashboards-basic',
  1025. 'dashboards-edit',
  1026. 'discover-query',
  1027. ],
  1028. }),
  1029. router: {
  1030. location: {
  1031. ...LocationFixture(),
  1032. query: {
  1033. statsPeriod: '7d',
  1034. release: ['sentry-android-shop@1.2.0'],
  1035. },
  1036. },
  1037. },
  1038. });
  1039. render(
  1040. <ViewEditDashboard
  1041. {...RouteComponentPropsFixture()}
  1042. organization={testData.organization}
  1043. params={{orgId: 'org-slug', dashboardId: '1'}}
  1044. router={testData.router}
  1045. location={testData.router.location}
  1046. >
  1047. {null}
  1048. </ViewEditDashboard>,
  1049. {router: testData.router, organization: testData.organization}
  1050. );
  1051. await userEvent.click(await screen.findByText('Save'));
  1052. expect(mockPut).toHaveBeenCalledWith(
  1053. '/organizations/org-slug/dashboards/1/',
  1054. expect.objectContaining({
  1055. data: expect.objectContaining({
  1056. period: '7d',
  1057. filters: {release: ['sentry-android-shop@1.2.0']},
  1058. }),
  1059. })
  1060. );
  1061. });
  1062. it('can clear dashboard filters in compact select', async () => {
  1063. MockApiClient.addMockResponse({
  1064. url: '/organizations/org-slug/dashboards/1/',
  1065. body: DashboardFixture(widgets, {
  1066. id: '1',
  1067. title: 'Custom Errors',
  1068. filters: {release: ['sentry-android-shop@1.2.0']},
  1069. }),
  1070. });
  1071. MockApiClient.addMockResponse({
  1072. url: '/organizations/org-slug/releases/',
  1073. body: [
  1074. ReleaseFixture({
  1075. shortVersion: 'sentry-android-shop@1.2.0',
  1076. version: 'sentry-android-shop@1.2.0',
  1077. }),
  1078. ],
  1079. });
  1080. const testData = initializeOrg({
  1081. organization: OrganizationFixture({
  1082. features: [
  1083. 'global-views',
  1084. 'dashboards-basic',
  1085. 'dashboards-edit',
  1086. 'discover-query',
  1087. ],
  1088. }),
  1089. router: {
  1090. location: {
  1091. ...LocationFixture(),
  1092. query: {
  1093. statsPeriod: '7d',
  1094. },
  1095. },
  1096. },
  1097. });
  1098. render(
  1099. <ViewEditDashboard
  1100. {...RouteComponentPropsFixture()}
  1101. organization={testData.organization}
  1102. params={{orgId: 'org-slug', dashboardId: '1'}}
  1103. router={testData.router}
  1104. location={testData.router.location}
  1105. >
  1106. {null}
  1107. </ViewEditDashboard>,
  1108. {router: testData.router, organization: testData.organization}
  1109. );
  1110. await screen.findByText('7D');
  1111. await userEvent.click(await screen.findByText('sentry-android-shop@1.2.0'));
  1112. await userEvent.click(screen.getAllByText('Clear')[0]);
  1113. screen.getByText('All Releases');
  1114. await userEvent.click(document.body);
  1115. await waitFor(() => {
  1116. expect(browserHistory.push).toHaveBeenCalledWith(
  1117. expect.objectContaining({
  1118. query: expect.objectContaining({
  1119. release: '',
  1120. }),
  1121. })
  1122. );
  1123. });
  1124. });
  1125. it('can save absolute time range in existing dashboard', async () => {
  1126. const testData = initializeOrg({
  1127. organization: OrganizationFixture({
  1128. features: [
  1129. 'global-views',
  1130. 'dashboards-basic',
  1131. 'dashboards-edit',
  1132. 'discover-query',
  1133. ],
  1134. }),
  1135. router: {
  1136. location: {
  1137. ...LocationFixture(),
  1138. query: {
  1139. start: '2022-07-14T07:00:00',
  1140. end: '2022-07-19T23:59:59',
  1141. utc: 'true',
  1142. },
  1143. },
  1144. },
  1145. });
  1146. render(
  1147. <ViewEditDashboard
  1148. {...RouteComponentPropsFixture()}
  1149. organization={testData.organization}
  1150. params={{orgId: 'org-slug', dashboardId: '1'}}
  1151. router={testData.router}
  1152. location={testData.router.location}
  1153. >
  1154. {null}
  1155. </ViewEditDashboard>,
  1156. {router: testData.router, organization: testData.organization}
  1157. );
  1158. await userEvent.click(await screen.findByText('Save'));
  1159. expect(mockPut).toHaveBeenCalledWith(
  1160. '/organizations/org-slug/dashboards/1/',
  1161. expect.objectContaining({
  1162. data: expect.objectContaining({
  1163. start: '2022-07-14T07:00:00.000',
  1164. end: '2022-07-19T23:59:59.000',
  1165. utc: true,
  1166. }),
  1167. })
  1168. );
  1169. });
  1170. it('can clear dashboard filters in existing dashboard', async () => {
  1171. MockApiClient.addMockResponse({
  1172. url: '/organizations/org-slug/releases/',
  1173. body: [
  1174. ReleaseFixture({
  1175. shortVersion: 'sentry-android-shop@1.2.0',
  1176. version: 'sentry-android-shop@1.2.0',
  1177. }),
  1178. ],
  1179. });
  1180. const testData = initializeOrg({
  1181. organization: OrganizationFixture({
  1182. features: [
  1183. 'global-views',
  1184. 'dashboards-basic',
  1185. 'dashboards-edit',
  1186. 'discover-query',
  1187. ],
  1188. }),
  1189. router: {
  1190. location: {
  1191. ...LocationFixture(),
  1192. query: {
  1193. statsPeriod: '7d',
  1194. environment: ['alpha', 'beta'],
  1195. },
  1196. },
  1197. },
  1198. });
  1199. render(
  1200. <ViewEditDashboard
  1201. {...RouteComponentPropsFixture()}
  1202. organization={testData.organization}
  1203. params={{orgId: 'org-slug', dashboardId: '1'}}
  1204. router={testData.router}
  1205. location={testData.router.location}
  1206. >
  1207. {null}
  1208. </ViewEditDashboard>,
  1209. {router: testData.router, organization: testData.organization}
  1210. );
  1211. await screen.findByText('7D');
  1212. await userEvent.click(await screen.findByText('All Releases'));
  1213. await userEvent.click(screen.getByText('sentry-android-shop@1.2.0'));
  1214. await userEvent.keyboard('{Escape}');
  1215. await userEvent.click(screen.getByText('Cancel'));
  1216. screen.getByText('All Releases');
  1217. expect(browserHistory.replace).toHaveBeenCalledWith(
  1218. expect.objectContaining({
  1219. query: expect.objectContaining({
  1220. project: undefined,
  1221. statsPeriod: undefined,
  1222. environment: undefined,
  1223. }),
  1224. })
  1225. );
  1226. });
  1227. it('disables the Edit Dashboard button when there are unsaved filters', async () => {
  1228. MockApiClient.addMockResponse({
  1229. url: '/organizations/org-slug/releases/',
  1230. body: [
  1231. ReleaseFixture({
  1232. shortVersion: 'sentry-android-shop@1.2.0',
  1233. version: 'sentry-android-shop@1.2.0',
  1234. }),
  1235. ],
  1236. });
  1237. const testData = initializeOrg({
  1238. organization: OrganizationFixture({
  1239. features: [
  1240. 'global-views',
  1241. 'dashboards-basic',
  1242. 'dashboards-edit',
  1243. 'discover-basic',
  1244. 'discover-query',
  1245. ],
  1246. }),
  1247. router: {
  1248. location: {
  1249. ...LocationFixture(),
  1250. query: {
  1251. statsPeriod: '7d',
  1252. environment: ['alpha', 'beta'],
  1253. },
  1254. },
  1255. },
  1256. });
  1257. render(
  1258. <ViewEditDashboard
  1259. {...RouteComponentPropsFixture()}
  1260. organization={testData.organization}
  1261. params={{orgId: 'org-slug', dashboardId: '1'}}
  1262. router={testData.router}
  1263. location={testData.router.location}
  1264. >
  1265. {null}
  1266. </ViewEditDashboard>,
  1267. {router: testData.router, organization: testData.organization}
  1268. );
  1269. expect(await screen.findByText('Save')).toBeInTheDocument();
  1270. expect(screen.getByText('Cancel')).toBeInTheDocument();
  1271. expect(screen.getByRole('button', {name: 'Edit Dashboard'})).toBeDisabled();
  1272. });
  1273. it('ignores the order of selection of page filters to render unsaved filters', async () => {
  1274. const testProjects = [
  1275. ProjectFixture({id: '1', name: 'first', environments: ['alpha', 'beta']}),
  1276. ProjectFixture({id: '2', name: 'second', environments: ['alpha', 'beta']}),
  1277. ];
  1278. act(() => ProjectsStore.loadInitialData(testProjects));
  1279. MockApiClient.addMockResponse({
  1280. url: '/organizations/org-slug/projects/',
  1281. body: testProjects,
  1282. });
  1283. MockApiClient.addMockResponse({
  1284. url: '/organizations/org-slug/dashboards/1/',
  1285. body: DashboardFixture(widgets, {
  1286. id: '1',
  1287. title: 'Custom Errors',
  1288. filters: {},
  1289. environment: ['alpha', 'beta'],
  1290. }),
  1291. });
  1292. const testData = initializeOrg({
  1293. organization: OrganizationFixture({
  1294. features: [
  1295. 'global-views',
  1296. 'dashboards-basic',
  1297. 'dashboards-edit',
  1298. 'discover-query',
  1299. ],
  1300. }),
  1301. router: {
  1302. location: {
  1303. ...LocationFixture(),
  1304. query: {
  1305. environment: ['beta', 'alpha'], // Reversed order from saved dashboard
  1306. },
  1307. },
  1308. },
  1309. });
  1310. render(
  1311. <ViewEditDashboard
  1312. {...RouteComponentPropsFixture()}
  1313. organization={testData.organization}
  1314. params={{orgId: 'org-slug', dashboardId: '1'}}
  1315. router={testData.router}
  1316. location={testData.router.location}
  1317. >
  1318. {null}
  1319. </ViewEditDashboard>,
  1320. {router: testData.router, organization: testData.organization}
  1321. );
  1322. await waitFor(() => expect(screen.queryAllByText('Loading\u2026')).toEqual([]));
  1323. await userEvent.click(screen.getByRole('button', {name: 'All Envs'}));
  1324. expect(screen.getByRole('row', {name: 'alpha'})).toHaveAttribute(
  1325. 'aria-selected',
  1326. 'true'
  1327. );
  1328. expect(screen.getByRole('row', {name: 'beta'})).toHaveAttribute(
  1329. 'aria-selected',
  1330. 'true'
  1331. );
  1332. // Save and Cancel should not appear because alpha, beta is the same as beta, alpha
  1333. expect(screen.queryByText('Save')).not.toBeInTheDocument();
  1334. expect(screen.queryByText('Cancel')).not.toBeInTheDocument();
  1335. });
  1336. it('uses releases from the URL query params', async function () {
  1337. const testData = initializeOrg({
  1338. organization: OrganizationFixture({
  1339. features: [
  1340. 'global-views',
  1341. 'dashboards-basic',
  1342. 'dashboards-edit',
  1343. 'discover-query',
  1344. ],
  1345. }),
  1346. router: {
  1347. location: {
  1348. ...LocationFixture(),
  1349. query: {
  1350. release: ['not-selected-1'],
  1351. },
  1352. },
  1353. },
  1354. });
  1355. render(
  1356. <ViewEditDashboard
  1357. {...RouteComponentPropsFixture()}
  1358. organization={testData.organization}
  1359. params={{orgId: 'org-slug', dashboardId: '1'}}
  1360. router={testData.router}
  1361. location={testData.router.location}
  1362. >
  1363. {null}
  1364. </ViewEditDashboard>,
  1365. {router: testData.router, organization: testData.organization}
  1366. );
  1367. await screen.findByText(/not-selected-1/);
  1368. screen.getByText('Save');
  1369. screen.getByText('Cancel');
  1370. });
  1371. it('resets release in URL params', async function () {
  1372. MockApiClient.addMockResponse({
  1373. url: '/organizations/org-slug/dashboards/1/',
  1374. body: DashboardFixture(widgets, {
  1375. id: '1',
  1376. title: 'Custom Errors',
  1377. filters: {
  1378. release: ['abc'],
  1379. },
  1380. }),
  1381. });
  1382. const testData = initializeOrg({
  1383. organization: OrganizationFixture({
  1384. features: [
  1385. 'global-views',
  1386. 'dashboards-basic',
  1387. 'dashboards-edit',
  1388. 'discover-query',
  1389. ],
  1390. }),
  1391. router: {
  1392. location: {
  1393. ...LocationFixture(),
  1394. query: {
  1395. release: ['not-selected-1'],
  1396. },
  1397. },
  1398. },
  1399. });
  1400. render(
  1401. <ViewEditDashboard
  1402. {...RouteComponentPropsFixture()}
  1403. organization={testData.organization}
  1404. params={{orgId: 'org-slug', dashboardId: '1'}}
  1405. router={testData.router}
  1406. location={testData.router.location}
  1407. >
  1408. {null}
  1409. </ViewEditDashboard>,
  1410. {router: testData.router, organization: testData.organization}
  1411. );
  1412. await screen.findByText(/not-selected-1/);
  1413. await userEvent.click(screen.getByText('Cancel'));
  1414. // release isn't used in the redirect
  1415. expect(browserHistory.replace).toHaveBeenCalledWith(
  1416. expect.objectContaining({
  1417. query: {
  1418. end: undefined,
  1419. environment: undefined,
  1420. project: undefined,
  1421. start: undefined,
  1422. statsPeriod: undefined,
  1423. utc: undefined,
  1424. },
  1425. })
  1426. );
  1427. });
  1428. it('reflects selections in the release filter in the query params', async function () {
  1429. MockApiClient.addMockResponse({
  1430. url: '/organizations/org-slug/releases/',
  1431. body: [
  1432. ReleaseFixture({
  1433. shortVersion: 'sentry-android-shop@1.2.0',
  1434. version: 'sentry-android-shop@1.2.0',
  1435. }),
  1436. ],
  1437. });
  1438. const testData = initializeOrg({
  1439. organization: OrganizationFixture({
  1440. features: [
  1441. 'global-views',
  1442. 'dashboards-basic',
  1443. 'dashboards-edit',
  1444. 'discover-query',
  1445. ],
  1446. }),
  1447. router: {
  1448. location: LocationFixture(),
  1449. },
  1450. });
  1451. render(
  1452. <ViewEditDashboard
  1453. {...RouteComponentPropsFixture()}
  1454. organization={testData.organization}
  1455. params={{orgId: 'org-slug', dashboardId: '1'}}
  1456. router={testData.router}
  1457. location={testData.router.location}
  1458. >
  1459. {null}
  1460. </ViewEditDashboard>,
  1461. {router: testData.router, organization: testData.organization}
  1462. );
  1463. await userEvent.click(await screen.findByText('All Releases'));
  1464. await userEvent.click(screen.getByText('sentry-android-shop@1.2.0'));
  1465. await userEvent.click(document.body);
  1466. await waitFor(() => {
  1467. expect(browserHistory.push).toHaveBeenCalledWith(
  1468. expect.objectContaining({
  1469. query: expect.objectContaining({
  1470. release: ['sentry-android-shop@1.2.0'],
  1471. }),
  1472. })
  1473. );
  1474. });
  1475. });
  1476. it('persists release selections made during search requests that do not appear in default query', async function () {
  1477. // Default response
  1478. MockApiClient.addMockResponse({
  1479. url: '/organizations/org-slug/releases/',
  1480. body: [
  1481. ReleaseFixture({
  1482. shortVersion: 'sentry-android-shop@1.2.0',
  1483. version: 'sentry-android-shop@1.2.0',
  1484. }),
  1485. ],
  1486. });
  1487. // Mocked search results
  1488. MockApiClient.addMockResponse({
  1489. url: '/organizations/org-slug/releases/',
  1490. body: [
  1491. ReleaseFixture({
  1492. id: '9',
  1493. shortVersion: 'search-result',
  1494. version: 'search-result',
  1495. }),
  1496. ],
  1497. match: [MockApiClient.matchData({query: 's'})],
  1498. });
  1499. const testData = initializeOrg({
  1500. organization: OrganizationFixture({
  1501. features: [
  1502. 'global-views',
  1503. 'dashboards-basic',
  1504. 'dashboards-edit',
  1505. 'discover-basic',
  1506. 'discover-query',
  1507. ],
  1508. }),
  1509. router: {
  1510. location: LocationFixture(),
  1511. },
  1512. });
  1513. render(
  1514. <ViewEditDashboard
  1515. {...RouteComponentPropsFixture()}
  1516. organization={testData.organization}
  1517. params={{orgId: 'org-slug', dashboardId: '1'}}
  1518. router={testData.router}
  1519. location={testData.router.location}
  1520. >
  1521. {null}
  1522. </ViewEditDashboard>,
  1523. {router: testData.router, organization: testData.organization}
  1524. );
  1525. await userEvent.click(await screen.findByText('All Releases'));
  1526. await userEvent.type(screen.getAllByPlaceholderText('Search\u2026')[2], 's');
  1527. await userEvent.click(await screen.findByRole('option', {name: 'search-result'}));
  1528. // Validate that after search is cleared, search result still appears
  1529. expect(await screen.findByText('Latest Release(s)')).toBeInTheDocument();
  1530. expect(screen.getByRole('option', {name: 'search-result'})).toBeInTheDocument();
  1531. });
  1532. describe('discover split', function () {
  1533. it('calls the dashboard callbacks with the correct widgetType for discover split', function () {
  1534. const widget = {
  1535. displayType: types.DisplayType.TABLE,
  1536. interval: '1d',
  1537. queries: [
  1538. {
  1539. name: 'Test Widget',
  1540. fields: ['count()', 'count_unique(user)', 'epm()', 'project'],
  1541. columns: ['project'],
  1542. aggregates: ['count()', 'count_unique(user)', 'epm()'],
  1543. conditions: '',
  1544. orderby: '',
  1545. },
  1546. ],
  1547. title: 'Transactions',
  1548. id: '1',
  1549. widgetType: types.WidgetType.DISCOVER,
  1550. };
  1551. const mockDashboard = DashboardFixture([widget], {
  1552. id: '1',
  1553. title: 'Custom Errors',
  1554. });
  1555. const mockModifiedDashboard = DashboardFixture([widget], {
  1556. id: '1',
  1557. title: 'Custom Errors',
  1558. });
  1559. const mockOnDashboardUpdate = jest.fn();
  1560. const mockStateSetter = jest
  1561. .fn()
  1562. .mockImplementation(fn => fn({modifiedDashboard: mockModifiedDashboard}));
  1563. handleUpdateDashboardSplit({
  1564. widgetId: '1',
  1565. splitDecision: types.WidgetType.ERRORS,
  1566. dashboard: mockDashboard,
  1567. modifiedDashboard: mockModifiedDashboard,
  1568. onDashboardUpdate: mockOnDashboardUpdate,
  1569. stateSetter: mockStateSetter,
  1570. });
  1571. expect(mockOnDashboardUpdate).toHaveBeenCalledWith({
  1572. ...mockDashboard,
  1573. widgets: [{...widget, widgetType: types.WidgetType.ERRORS}],
  1574. });
  1575. expect(mockStateSetter).toHaveReturnedWith({
  1576. modifiedDashboard: {
  1577. ...mockModifiedDashboard,
  1578. widgets: [{...widget, widgetType: types.WidgetType.ERRORS}],
  1579. },
  1580. });
  1581. });
  1582. });
  1583. });
  1584. });