detail.spec.jsx 44 KB

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