detail.spec.jsx 31 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018
  1. import {enforceActOnUseLegacyStoreHook, mountWithTheme} from 'sentry-test/enzyme';
  2. import {initializeOrg} from 'sentry-test/initializeOrg';
  3. import {
  4. act,
  5. render,
  6. renderGlobalModal,
  7. screen,
  8. userEvent,
  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 {constructGridItemKey} from 'sentry/views/dashboardsV2/layoutUtils';
  15. import {DashboardWidgetSource} from 'sentry/views/dashboardsV2/types';
  16. import * as types from 'sentry/views/dashboardsV2/types';
  17. import ViewEditDashboard from 'sentry/views/dashboardsV2/view';
  18. import {OrganizationContext} from 'sentry/views/organizationContext';
  19. describe('Dashboards > Detail', function () {
  20. enforceActOnUseLegacyStoreHook();
  21. const organization = TestStubs.Organization({
  22. features: [
  23. 'global-views',
  24. 'dashboards-basic',
  25. 'dashboards-edit',
  26. 'discover-query',
  27. 'dashboard-grid-layout',
  28. ],
  29. });
  30. const projects = [TestStubs.Project()];
  31. describe('prebuilt dashboards', function () {
  32. let wrapper, initialData;
  33. beforeEach(function () {
  34. act(() => ProjectsStore.loadInitialData(projects));
  35. initialData = initializeOrg({organization});
  36. MockApiClient.addMockResponse({
  37. url: '/organizations/org-slug/tags/',
  38. body: [],
  39. });
  40. MockApiClient.addMockResponse({
  41. url: '/organizations/org-slug/projects/',
  42. body: [TestStubs.Project()],
  43. });
  44. MockApiClient.addMockResponse({
  45. url: '/organizations/org-slug/dashboards/',
  46. body: [
  47. TestStubs.Dashboard([], {id: 'default-overview', title: 'Default'}),
  48. TestStubs.Dashboard([], {id: '1', title: 'Custom Errors'}),
  49. ],
  50. });
  51. MockApiClient.addMockResponse({
  52. url: '/organizations/org-slug/dashboards/default-overview/',
  53. body: TestStubs.Dashboard([], {id: 'default-overview', title: 'Default'}),
  54. });
  55. MockApiClient.addMockResponse({
  56. url: '/organizations/org-slug/dashboards/1/visit/',
  57. method: 'POST',
  58. body: [],
  59. statusCode: 200,
  60. });
  61. MockApiClient.addMockResponse({
  62. url: '/organizations/org-slug/users/',
  63. method: 'GET',
  64. body: [],
  65. });
  66. });
  67. afterEach(function () {
  68. MockApiClient.clearMockResponses();
  69. if (wrapper) {
  70. wrapper.unmount();
  71. }
  72. });
  73. it('assigns unique IDs to all widgets so grid keys are unique', async function () {
  74. MockApiClient.addMockResponse({
  75. url: '/organizations/org-slug/events-stats/',
  76. body: {data: []},
  77. });
  78. MockApiClient.addMockResponse({
  79. url: '/organizations/org-slug/dashboards/default-overview/',
  80. body: TestStubs.Dashboard(
  81. [
  82. TestStubs.Widget(
  83. [
  84. {
  85. name: '',
  86. conditions: 'event.type:error',
  87. fields: ['count()'],
  88. aggregates: ['count()'],
  89. columns: [],
  90. },
  91. ],
  92. {
  93. title: 'Default Widget 1',
  94. interval: '1d',
  95. }
  96. ),
  97. TestStubs.Widget(
  98. [
  99. {
  100. name: '',
  101. conditions: 'event.type:transaction',
  102. fields: ['count()'],
  103. aggregates: ['count()'],
  104. columns: [],
  105. },
  106. ],
  107. {
  108. title: 'Default Widget 2',
  109. interval: '1d',
  110. }
  111. ),
  112. ],
  113. {id: 'default-overview', title: 'Default'}
  114. ),
  115. });
  116. initialData = initializeOrg({
  117. organization: TestStubs.Organization({
  118. features: [
  119. 'global-views',
  120. 'dashboards-basic',
  121. 'discover-query',
  122. 'dashboard-grid-layout',
  123. ],
  124. projects: [TestStubs.Project()],
  125. }),
  126. });
  127. wrapper = mountWithTheme(
  128. <OrganizationContext.Provider value={initialData.organization}>
  129. <ViewEditDashboard
  130. organization={initialData.organization}
  131. params={{orgId: 'org-slug', dashboardId: 'default-overview'}}
  132. router={initialData.router}
  133. location={initialData.router.location}
  134. />
  135. </OrganizationContext.Provider>,
  136. initialData.routerContext
  137. );
  138. await tick();
  139. wrapper.update();
  140. const dashboardInstance = wrapper.find('Dashboard').instance();
  141. const assignedIds = new Set(
  142. dashboardInstance.props.dashboard.widgets.map(constructGridItemKey)
  143. );
  144. expect(assignedIds.size).toBe(dashboardInstance.props.dashboard.widgets.length);
  145. });
  146. });
  147. describe('custom dashboards', function () {
  148. let wrapper, initialData, widgets, mockVisit, mockPut;
  149. const openEditModal = jest.spyOn(modals, 'openAddDashboardWidgetModal');
  150. beforeEach(function () {
  151. window.confirm = jest.fn();
  152. initialData = initializeOrg({organization});
  153. widgets = [
  154. TestStubs.Widget(
  155. [
  156. {
  157. name: '',
  158. conditions: 'event.type:error',
  159. fields: ['count()'],
  160. columns: [],
  161. aggregates: ['count()'],
  162. },
  163. ],
  164. {
  165. title: 'Errors',
  166. interval: '1d',
  167. id: '1',
  168. }
  169. ),
  170. TestStubs.Widget(
  171. [
  172. {
  173. name: '',
  174. conditions: 'event.type:transaction',
  175. fields: ['count()'],
  176. columns: [],
  177. aggregates: ['count()'],
  178. },
  179. ],
  180. {
  181. title: 'Transactions',
  182. interval: '1d',
  183. id: '2',
  184. }
  185. ),
  186. TestStubs.Widget(
  187. [
  188. {
  189. name: '',
  190. conditions: 'event.type:transaction transaction:/api/cats',
  191. fields: ['p50()'],
  192. columns: [],
  193. aggregates: ['p50()'],
  194. },
  195. ],
  196. {
  197. title: 'p50 of /api/cats',
  198. interval: '1d',
  199. id: '3',
  200. }
  201. ),
  202. ];
  203. mockVisit = MockApiClient.addMockResponse({
  204. url: '/organizations/org-slug/dashboards/1/visit/',
  205. method: 'POST',
  206. body: [],
  207. statusCode: 200,
  208. });
  209. MockApiClient.addMockResponse({
  210. url: '/organizations/org-slug/tags/',
  211. body: [],
  212. });
  213. MockApiClient.addMockResponse({
  214. url: '/organizations/org-slug/projects/',
  215. body: [TestStubs.Project()],
  216. });
  217. MockApiClient.addMockResponse({
  218. url: '/organizations/org-slug/dashboards/',
  219. body: [
  220. TestStubs.Dashboard([], {
  221. id: 'default-overview',
  222. title: 'Default',
  223. widgetDisplay: ['area'],
  224. }),
  225. TestStubs.Dashboard([], {
  226. id: '1',
  227. title: 'Custom Errors',
  228. widgetDisplay: ['area'],
  229. }),
  230. ],
  231. });
  232. MockApiClient.addMockResponse({
  233. url: '/organizations/org-slug/dashboards/1/',
  234. body: TestStubs.Dashboard(widgets, {
  235. id: '1',
  236. title: 'Custom Errors',
  237. filters: {release: ['abc@1.2.0']},
  238. }),
  239. });
  240. mockPut = MockApiClient.addMockResponse({
  241. url: '/organizations/org-slug/dashboards/1/',
  242. method: 'PUT',
  243. body: TestStubs.Dashboard(widgets, {id: '1', title: 'Custom Errors'}),
  244. });
  245. MockApiClient.addMockResponse({
  246. url: '/organizations/org-slug/events-stats/',
  247. body: {data: []},
  248. });
  249. MockApiClient.addMockResponse({
  250. method: 'POST',
  251. url: '/organizations/org-slug/dashboards/widgets/',
  252. body: [],
  253. });
  254. MockApiClient.addMockResponse({
  255. method: 'GET',
  256. url: '/organizations/org-slug/recent-searches/',
  257. body: [],
  258. });
  259. MockApiClient.addMockResponse({
  260. method: 'GET',
  261. url: '/organizations/org-slug/issues/',
  262. body: [],
  263. });
  264. MockApiClient.addMockResponse({
  265. url: '/organizations/org-slug/eventsv2/',
  266. method: 'GET',
  267. body: [],
  268. });
  269. MockApiClient.addMockResponse({
  270. url: '/organizations/org-slug/users/',
  271. method: 'GET',
  272. body: [],
  273. });
  274. MockApiClient.addMockResponse({
  275. url: '/organizations/org-slug/events-geo/',
  276. body: {data: [], meta: {}},
  277. });
  278. });
  279. afterEach(function () {
  280. MockApiClient.clearMockResponses();
  281. jest.clearAllMocks();
  282. if (wrapper) {
  283. wrapper.unmount();
  284. wrapper = null;
  285. }
  286. });
  287. it('can remove widgets', async function () {
  288. const updateMock = MockApiClient.addMockResponse({
  289. url: '/organizations/org-slug/dashboards/1/',
  290. method: 'PUT',
  291. body: TestStubs.Dashboard([widgets[0]], {id: '1', title: 'Custom Errors'}),
  292. });
  293. wrapper = mountWithTheme(
  294. <OrganizationContext.Provider value={initialData.organization}>
  295. <ViewEditDashboard
  296. organization={initialData.organization}
  297. params={{orgId: 'org-slug', dashboardId: '1'}}
  298. router={initialData.router}
  299. location={initialData.router.location}
  300. />
  301. </OrganizationContext.Provider>,
  302. initialData.routerContext
  303. );
  304. await tick();
  305. wrapper.update();
  306. expect(mockVisit).toHaveBeenCalledTimes(1);
  307. // Enter edit mode.
  308. wrapper.find('Controls Button[data-test-id="dashboard-edit"]').simulate('click');
  309. // Remove the second and third widgets
  310. wrapper
  311. .find('WidgetCard')
  312. .at(1)
  313. .find('Button[data-test-id="widget-delete"]')
  314. .simulate('click');
  315. wrapper
  316. .find('WidgetCard')
  317. .at(1)
  318. .find('Button[data-test-id="widget-delete"]')
  319. .simulate('click');
  320. // Save changes
  321. wrapper.find('Controls Button[data-test-id="dashboard-commit"]').simulate('click');
  322. await tick();
  323. expect(updateMock).toHaveBeenCalled();
  324. expect(updateMock).toHaveBeenCalledWith(
  325. '/organizations/org-slug/dashboards/1/',
  326. expect.objectContaining({
  327. data: expect.objectContaining({
  328. title: 'Custom Errors',
  329. widgets: [expect.objectContaining(widgets[0])],
  330. }),
  331. })
  332. );
  333. // Visit should not be called again on dashboard update
  334. expect(mockVisit).toHaveBeenCalledTimes(1);
  335. });
  336. it('appends dashboard-level filters to series request', async function () {
  337. const mock = MockApiClient.addMockResponse({
  338. url: '/organizations/org-slug/events-stats/',
  339. body: [],
  340. });
  341. wrapper = mountWithTheme(
  342. <OrganizationContext.Provider value={initialData.organization}>
  343. <ViewEditDashboard
  344. organization={initialData.organization}
  345. params={{orgId: 'org-slug', dashboardId: '1'}}
  346. router={initialData.router}
  347. location={initialData.router.location}
  348. />
  349. </OrganizationContext.Provider>,
  350. initialData.routerContext
  351. );
  352. await tick();
  353. wrapper.update();
  354. expect(mock).toHaveBeenLastCalledWith(
  355. '/organizations/org-slug/events-stats/',
  356. expect.objectContaining({
  357. query: expect.objectContaining({
  358. query: 'event.type:transaction transaction:/api/cats release:abc@1.2.0 ',
  359. }),
  360. })
  361. );
  362. });
  363. it('can enter edit mode for widgets', async function () {
  364. wrapper = mountWithTheme(
  365. <OrganizationContext.Provider value={initialData.organization}>
  366. <ViewEditDashboard
  367. organization={initialData.organization}
  368. params={{orgId: 'org-slug', dashboardId: '1'}}
  369. router={initialData.router}
  370. location={initialData.router.location}
  371. />
  372. </OrganizationContext.Provider>,
  373. initialData.routerContext
  374. );
  375. await tick();
  376. wrapper.update();
  377. // Enter edit mode.
  378. wrapper.find('Controls Button[data-test-id="dashboard-edit"]').simulate('click');
  379. wrapper.update();
  380. const card = wrapper.find('WidgetCard').first();
  381. card.find('WidgetCardPanel').simulate('mouseOver');
  382. // Edit the first widget
  383. wrapper
  384. .find('WidgetCard')
  385. .first()
  386. .find('Button[data-test-id="widget-edit"]')
  387. .simulate('click');
  388. expect(openEditModal).toHaveBeenCalledTimes(1);
  389. expect(openEditModal).toHaveBeenCalledWith(
  390. expect.objectContaining({
  391. widget: {
  392. id: '1',
  393. interval: '1d',
  394. layout: {h: 2, minH: 2, w: 2, x: 0, y: 0},
  395. queries: [
  396. {
  397. conditions: 'event.type:error',
  398. fields: ['count()'],
  399. aggregates: ['count()'],
  400. columns: [],
  401. name: '',
  402. },
  403. ],
  404. title: 'Errors',
  405. type: 'line',
  406. },
  407. })
  408. );
  409. });
  410. it('shows add widget option', async function () {
  411. wrapper = mountWithTheme(
  412. <OrganizationContext.Provider value={initialData.organization}>
  413. <ViewEditDashboard
  414. organization={initialData.organization}
  415. params={{orgId: 'org-slug', dashboardId: '1'}}
  416. router={initialData.router}
  417. location={initialData.router.location}
  418. />
  419. </OrganizationContext.Provider>,
  420. initialData.routerContext
  421. );
  422. await tick();
  423. wrapper.update();
  424. // Enter edit mode.
  425. wrapper.find('Controls Button[data-test-id="dashboard-edit"]').simulate('click');
  426. wrapper.update();
  427. expect(wrapper.find('AddWidget').exists()).toBe(true);
  428. });
  429. it('opens custom modal when add widget option is clicked', async function () {
  430. wrapper = mountWithTheme(
  431. <OrganizationContext.Provider value={initialData.organization}>
  432. <ViewEditDashboard
  433. organization={initialData.organization}
  434. params={{orgId: 'org-slug', dashboardId: '1'}}
  435. router={initialData.router}
  436. location={initialData.router.location}
  437. />
  438. </OrganizationContext.Provider>,
  439. initialData.routerContext
  440. );
  441. await tick();
  442. wrapper.update();
  443. // Enter edit mode.
  444. wrapper.find('Controls Button[data-test-id="dashboard-edit"]').simulate('click');
  445. wrapper.update();
  446. wrapper.find('AddButton[data-test-id="widget-add"]').simulate('click');
  447. expect(openEditModal).toHaveBeenCalledTimes(1);
  448. });
  449. // eslint-disable-next-line jest/no-disabled-tests
  450. it.skip('shows top level release filter', async function () {
  451. const mockReleases = MockApiClient.addMockResponse({
  452. url: '/organizations/org-slug/releases/',
  453. body: [TestStubs.Release()],
  454. });
  455. initialData = initializeOrg({
  456. organization: TestStubs.Organization({
  457. features: [
  458. 'global-views',
  459. 'dashboards-basic',
  460. 'dashboards-edit',
  461. 'discover-query',
  462. 'dashboards-top-level-filter',
  463. ],
  464. projects: [TestStubs.Project()],
  465. }),
  466. });
  467. wrapper = mountWithTheme(
  468. <OrganizationContext.Provider value={initialData.organization}>
  469. <ViewEditDashboard
  470. organization={initialData.organization}
  471. params={{orgId: 'org-slug', dashboardId: '1'}}
  472. router={initialData.router}
  473. location={initialData.router.location}
  474. />
  475. </OrganizationContext.Provider>,
  476. initialData.routerContext
  477. );
  478. await act(async () => {
  479. await tick();
  480. wrapper.update();
  481. });
  482. expect(wrapper.find('ReleasesSelectControl').exists()).toBe(true);
  483. expect(mockReleases).toHaveBeenCalledTimes(1);
  484. });
  485. it('opens widget library when add widget option is clicked', async function () {
  486. initialData = initializeOrg({
  487. organization: TestStubs.Organization({
  488. features: [
  489. 'global-views',
  490. 'dashboards-basic',
  491. 'dashboards-edit',
  492. 'discover-query',
  493. ],
  494. projects: [TestStubs.Project()],
  495. }),
  496. });
  497. wrapper = mountWithTheme(
  498. <OrganizationContext.Provider value={initialData.organization}>
  499. <ViewEditDashboard
  500. organization={initialData.organization}
  501. params={{orgId: 'org-slug', dashboardId: '1'}}
  502. router={initialData.router}
  503. location={initialData.router.location}
  504. />
  505. </OrganizationContext.Provider>,
  506. initialData.routerContext
  507. );
  508. await tick();
  509. wrapper.update();
  510. // Enter edit mode.
  511. wrapper.find('Controls Button[data-test-id="dashboard-edit"]').simulate('click');
  512. wrapper.update();
  513. wrapper.find('AddButton[data-test-id="widget-add"]').simulate('click');
  514. expect(openEditModal).toHaveBeenCalledTimes(1);
  515. expect(openEditModal).toHaveBeenCalledWith(
  516. expect.objectContaining({
  517. source: types.DashboardWidgetSource.LIBRARY,
  518. })
  519. );
  520. });
  521. it('hides add widget option', async function () {
  522. types.MAX_WIDGETS = 1;
  523. wrapper = mountWithTheme(
  524. <OrganizationContext.Provider value={initialData.organization}>
  525. <ViewEditDashboard
  526. organization={initialData.organization}
  527. params={{orgId: 'org-slug', dashboardId: '1'}}
  528. router={initialData.router}
  529. location={initialData.router.location}
  530. />
  531. </OrganizationContext.Provider>,
  532. initialData.routerContext
  533. );
  534. await tick();
  535. wrapper.update();
  536. // Enter edit mode.
  537. wrapper.find('Controls Button[data-test-id="dashboard-edit"]').simulate('click');
  538. wrapper.update();
  539. expect(wrapper.find('AddWidget').exists()).toBe(false);
  540. });
  541. it('renders successfully if more widgets than stored layouts', async function () {
  542. // A case where someone has async added widgets to a dashboard
  543. MockApiClient.addMockResponse({
  544. url: '/organizations/org-slug/dashboards/1/',
  545. body: TestStubs.Dashboard(
  546. [
  547. TestStubs.Widget(
  548. [
  549. {
  550. name: '',
  551. conditions: 'event.type:error',
  552. fields: ['count()'],
  553. aggregates: ['count()'],
  554. columns: [],
  555. },
  556. ],
  557. {
  558. title: 'First Widget',
  559. interval: '1d',
  560. id: '1',
  561. layout: {i: 'grid-item-1', x: 0, y: 0, w: 2, h: 6},
  562. }
  563. ),
  564. TestStubs.Widget(
  565. [
  566. {
  567. name: '',
  568. conditions: 'event.type:error',
  569. fields: ['count()'],
  570. aggregates: ['count()'],
  571. columns: [],
  572. },
  573. ],
  574. {
  575. title: 'Second Widget',
  576. interval: '1d',
  577. id: '2',
  578. }
  579. ),
  580. ],
  581. {id: '1', title: 'Custom Errors'}
  582. ),
  583. });
  584. render(
  585. <ViewEditDashboard
  586. organization={initialData.organization}
  587. params={{orgId: 'org-slug', dashboardId: '1'}}
  588. router={initialData.router}
  589. location={initialData.router.location}
  590. />,
  591. {context: initialData.routerContext, organization: initialData.organization}
  592. );
  593. await tick();
  594. await screen.findByText('First Widget');
  595. await screen.findByText('Second Widget');
  596. });
  597. it('does not trigger request if layout not updated', async () => {
  598. MockApiClient.addMockResponse({
  599. url: '/organizations/org-slug/dashboards/1/',
  600. body: TestStubs.Dashboard(
  601. [
  602. TestStubs.Widget(
  603. [
  604. {
  605. name: '',
  606. conditions: 'event.type:error',
  607. fields: ['count()'],
  608. aggregates: ['count()'],
  609. columns: [],
  610. },
  611. ],
  612. {
  613. title: 'First Widget',
  614. interval: '1d',
  615. id: '1',
  616. layout: {i: 'grid-item-1', x: 0, y: 0, w: 2, h: 6},
  617. }
  618. ),
  619. ],
  620. {id: '1', title: 'Custom Errors'}
  621. ),
  622. });
  623. render(
  624. <ViewEditDashboard
  625. organization={initialData.organization}
  626. params={{orgId: 'org-slug', dashboardId: '1'}}
  627. router={initialData.router}
  628. location={initialData.router.location}
  629. />,
  630. {context: initialData.routerContext, organization: initialData.organization}
  631. );
  632. await tick();
  633. userEvent.click(screen.getByText('Edit Dashboard'));
  634. userEvent.click(screen.getByText('Save and Finish'));
  635. await tick();
  636. expect(screen.getByText('Edit Dashboard')).toBeInTheDocument();
  637. expect(mockPut).not.toHaveBeenCalled();
  638. });
  639. it('renders the custom resize handler for a widget', async () => {
  640. MockApiClient.addMockResponse({
  641. url: '/organizations/org-slug/dashboards/1/',
  642. body: TestStubs.Dashboard(
  643. [
  644. TestStubs.Widget(
  645. [
  646. {
  647. name: '',
  648. conditions: 'event.type:error',
  649. fields: ['count()'],
  650. aggregates: ['count()'],
  651. columns: [],
  652. },
  653. ],
  654. {
  655. title: 'First Widget',
  656. interval: '1d',
  657. id: '1',
  658. layout: {i: 'grid-item-1', x: 0, y: 0, w: 2, h: 6},
  659. }
  660. ),
  661. ],
  662. {id: '1', title: 'Custom Errors'}
  663. ),
  664. });
  665. render(
  666. <ViewEditDashboard
  667. organization={initialData.organization}
  668. params={{orgId: 'org-slug', dashboardId: '1'}}
  669. router={initialData.router}
  670. location={initialData.router.location}
  671. />,
  672. {context: initialData.routerContext, organization: initialData.organization}
  673. );
  674. await tick();
  675. userEvent.click(await screen.findByText('Edit Dashboard'));
  676. const widget = screen.getByText('First Widget').closest('.react-grid-item');
  677. const resizeHandle = within(widget).getByTestId('custom-resize-handle');
  678. expect(resizeHandle).toBeVisible();
  679. });
  680. it('does not trigger an alert when the widgets have no layout and user cancels without changes', async () => {
  681. MockApiClient.addMockResponse({
  682. url: '/organizations/org-slug/dashboards/1/',
  683. body: TestStubs.Dashboard(
  684. [
  685. TestStubs.Widget(
  686. [
  687. {
  688. name: '',
  689. conditions: 'event.type:error',
  690. fields: ['count()'],
  691. aggregates: ['count()'],
  692. columns: [],
  693. },
  694. ],
  695. {
  696. title: 'First Widget',
  697. interval: '1d',
  698. id: '1',
  699. layout: null,
  700. }
  701. ),
  702. ],
  703. {id: '1', title: 'Custom Errors'}
  704. ),
  705. });
  706. render(
  707. <ViewEditDashboard
  708. organization={initialData.organization}
  709. params={{orgId: 'org-slug', dashboardId: '1'}}
  710. router={initialData.router}
  711. location={initialData.router.location}
  712. />,
  713. {context: initialData.routerContext, organization: initialData.organization}
  714. );
  715. await tick();
  716. userEvent.click(await screen.findByText('Edit Dashboard'));
  717. userEvent.click(await screen.findByText('Cancel'));
  718. expect(window.confirm).not.toHaveBeenCalled();
  719. });
  720. it('opens the widget viewer modal using the widget id specified in the url', () => {
  721. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  722. const widget = TestStubs.Widget(
  723. [
  724. {
  725. name: '',
  726. conditions: 'event.type:error',
  727. fields: ['count()'],
  728. aggregates: ['count()'],
  729. columns: [],
  730. orderby: '',
  731. },
  732. ],
  733. {
  734. title: 'First Widget',
  735. interval: '1d',
  736. id: '1',
  737. layout: null,
  738. }
  739. );
  740. MockApiClient.addMockResponse({
  741. url: '/organizations/org-slug/dashboards/1/',
  742. body: TestStubs.Dashboard([widget], {id: '1', title: 'Custom Errors'}),
  743. });
  744. render(
  745. <ViewEditDashboard
  746. organization={initialData.organization}
  747. params={{orgId: 'org-slug', dashboardId: '1', widgetId: '1'}}
  748. router={initialData.router}
  749. location={{...initialData.router.location, pathname: '/widget/123/'}}
  750. />,
  751. {context: initialData.routerContext, organization: initialData.organization}
  752. );
  753. expect(openWidgetViewerModal).toHaveBeenCalledWith(
  754. expect.objectContaining({
  755. organization: initialData.organization,
  756. widget,
  757. onClose: expect.anything(),
  758. })
  759. );
  760. });
  761. it('redirects user to dashboard url if widget is not found', () => {
  762. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  763. MockApiClient.addMockResponse({
  764. url: '/organizations/org-slug/dashboards/1/',
  765. body: TestStubs.Dashboard([], {id: '1', title: 'Custom Errors'}),
  766. });
  767. render(
  768. <ViewEditDashboard
  769. organization={initialData.organization}
  770. params={{orgId: 'org-slug', dashboardId: '1', widgetId: '123'}}
  771. router={initialData.router}
  772. location={{...initialData.router.location, pathname: '/widget/123/'}}
  773. />,
  774. {context: initialData.routerContext, organization: initialData.organization}
  775. );
  776. expect(openWidgetViewerModal).not.toHaveBeenCalled();
  777. expect(initialData.router.replace).toHaveBeenCalledWith(
  778. expect.objectContaining({
  779. pathname: '/organizations/org-slug/dashboard/1/',
  780. query: {},
  781. })
  782. );
  783. });
  784. it('opens the edit widget modal when clicking the edit button', async () => {
  785. const widget = TestStubs.Widget(
  786. [
  787. {
  788. name: '',
  789. conditions: 'event.type:error',
  790. fields: ['count()'],
  791. aggregates: ['count()'],
  792. columns: [],
  793. orderby: '',
  794. },
  795. ],
  796. {
  797. title: 'First Widget',
  798. interval: '1d',
  799. id: '1',
  800. layout: null,
  801. }
  802. );
  803. MockApiClient.addMockResponse({
  804. url: '/organizations/org-slug/dashboards/1/',
  805. body: TestStubs.Dashboard([widget], {id: '1', title: 'Custom Errors'}),
  806. });
  807. render(
  808. <ViewEditDashboard
  809. organization={initialData.organization}
  810. params={{orgId: 'org-slug', dashboardId: '1', widgetId: '1'}}
  811. router={initialData.router}
  812. location={{...initialData.router.location, pathname: '/widget/123/'}}
  813. />,
  814. {context: initialData.routerContext, organization: initialData.organization}
  815. );
  816. renderGlobalModal({context: initialData.routerContext});
  817. userEvent.click(await screen.findByRole('button', {name: 'Edit Widget'}));
  818. expect(openEditModal).toHaveBeenCalledWith(
  819. expect.objectContaining({
  820. widget,
  821. organization: initialData.organization,
  822. source: DashboardWidgetSource.DASHBOARDS,
  823. })
  824. );
  825. });
  826. it('opens the widget viewer modal in a prebuilt dashboard using the widget id specified in the url', () => {
  827. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  828. render(
  829. <CreateDashboard
  830. organization={initialData.organization}
  831. params={{orgId: 'org-slug', templateId: 'default-template', widgetId: '2'}}
  832. router={initialData.router}
  833. location={{...initialData.router.location, pathname: '/widget/2/'}}
  834. />,
  835. {context: initialData.routerContext, organization: initialData.organization}
  836. );
  837. expect(openWidgetViewerModal).toHaveBeenCalledWith(
  838. expect.objectContaining({
  839. organization: initialData.organization,
  840. widget: expect.objectContaining({
  841. displayType: 'line',
  842. interval: '5m',
  843. queries: [
  844. {
  845. aggregates: ['count()'],
  846. columns: [],
  847. conditions: '!event.type:transaction',
  848. fields: ['count()'],
  849. name: 'Events',
  850. orderby: 'count()',
  851. },
  852. ],
  853. title: 'Events',
  854. widgetType: 'discover',
  855. }),
  856. onClose: expect.anything(),
  857. })
  858. );
  859. });
  860. it('saves a new dashboard with the page filters', async () => {
  861. const mockPOST = MockApiClient.addMockResponse({
  862. url: '/organizations/org-slug/dashboards/',
  863. method: 'POST',
  864. body: [],
  865. });
  866. render(
  867. <CreateDashboard
  868. organization={{
  869. ...initialData.organization,
  870. features: [
  871. ...initialData.organization.features,
  872. 'dashboards-top-level-filter',
  873. ],
  874. }}
  875. params={{orgId: 'org-slug'}}
  876. router={initialData.router}
  877. location={{
  878. ...initialData.router.location,
  879. query: {
  880. ...initialData.router.location.query,
  881. statsPeriod: '7d',
  882. project: [2],
  883. environment: ['alpha', 'beta'],
  884. },
  885. }}
  886. />,
  887. {
  888. context: initialData.routerContext,
  889. organization: initialData.organization,
  890. }
  891. );
  892. userEvent.click(await screen.findByText('Save and Finish'));
  893. expect(mockPOST).toHaveBeenCalledWith(
  894. '/organizations/org-slug/dashboards/',
  895. expect.objectContaining({
  896. data: expect.objectContaining({
  897. projects: [2],
  898. environment: ['alpha', 'beta'],
  899. period: '7d',
  900. }),
  901. })
  902. );
  903. });
  904. it('saves a template with the page filters', async () => {
  905. const mockPOST = MockApiClient.addMockResponse({
  906. url: '/organizations/org-slug/dashboards/',
  907. method: 'POST',
  908. body: [],
  909. });
  910. render(
  911. <CreateDashboard
  912. organization={{
  913. ...initialData.organization,
  914. features: [
  915. ...initialData.organization.features,
  916. 'dashboards-top-level-filter',
  917. ],
  918. }}
  919. params={{orgId: 'org-slug', templateId: 'default-template'}}
  920. router={initialData.router}
  921. location={{
  922. ...initialData.router.location,
  923. query: {
  924. ...initialData.router.location.query,
  925. statsPeriod: '7d',
  926. project: [2],
  927. environment: ['alpha', 'beta'],
  928. },
  929. }}
  930. />,
  931. {
  932. context: initialData.routerContext,
  933. organization: initialData.organization,
  934. }
  935. );
  936. userEvent.click(await screen.findByText('Add Dashboard'));
  937. expect(mockPOST).toHaveBeenCalledWith(
  938. '/organizations/org-slug/dashboards/',
  939. expect.objectContaining({
  940. data: expect.objectContaining({
  941. projects: [2],
  942. environment: ['alpha', 'beta'],
  943. period: '7d',
  944. }),
  945. })
  946. );
  947. });
  948. });
  949. });