detail.spec.jsx 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017
  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. it('shows top level release filter', async function () {
  450. const mockReleases = MockApiClient.addMockResponse({
  451. url: '/organizations/org-slug/releases/',
  452. body: [TestStubs.Release()],
  453. });
  454. initialData = initializeOrg({
  455. organization: TestStubs.Organization({
  456. features: [
  457. 'global-views',
  458. 'dashboards-basic',
  459. 'dashboards-edit',
  460. 'discover-query',
  461. 'dashboards-top-level-filter',
  462. ],
  463. projects: [TestStubs.Project()],
  464. }),
  465. });
  466. wrapper = mountWithTheme(
  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. initialData.routerContext
  476. );
  477. await act(async () => {
  478. await tick();
  479. wrapper.update();
  480. });
  481. expect(wrapper.find('ReleasesSelectControl').exists()).toBe(true);
  482. expect(mockReleases).toHaveBeenCalledTimes(1);
  483. });
  484. it('opens widget library when add widget option is clicked', async function () {
  485. initialData = initializeOrg({
  486. organization: TestStubs.Organization({
  487. features: [
  488. 'global-views',
  489. 'dashboards-basic',
  490. 'dashboards-edit',
  491. 'discover-query',
  492. ],
  493. projects: [TestStubs.Project()],
  494. }),
  495. });
  496. wrapper = mountWithTheme(
  497. <OrganizationContext.Provider value={initialData.organization}>
  498. <ViewEditDashboard
  499. organization={initialData.organization}
  500. params={{orgId: 'org-slug', dashboardId: '1'}}
  501. router={initialData.router}
  502. location={initialData.router.location}
  503. />
  504. </OrganizationContext.Provider>,
  505. initialData.routerContext
  506. );
  507. await tick();
  508. wrapper.update();
  509. // Enter edit mode.
  510. wrapper.find('Controls Button[data-test-id="dashboard-edit"]').simulate('click');
  511. wrapper.update();
  512. wrapper.find('AddButton[data-test-id="widget-add"]').simulate('click');
  513. expect(openEditModal).toHaveBeenCalledTimes(1);
  514. expect(openEditModal).toHaveBeenCalledWith(
  515. expect.objectContaining({
  516. source: types.DashboardWidgetSource.LIBRARY,
  517. })
  518. );
  519. });
  520. it('hides add widget option', async function () {
  521. types.MAX_WIDGETS = 1;
  522. wrapper = mountWithTheme(
  523. <OrganizationContext.Provider value={initialData.organization}>
  524. <ViewEditDashboard
  525. organization={initialData.organization}
  526. params={{orgId: 'org-slug', dashboardId: '1'}}
  527. router={initialData.router}
  528. location={initialData.router.location}
  529. />
  530. </OrganizationContext.Provider>,
  531. initialData.routerContext
  532. );
  533. await tick();
  534. wrapper.update();
  535. // Enter edit mode.
  536. wrapper.find('Controls Button[data-test-id="dashboard-edit"]').simulate('click');
  537. wrapper.update();
  538. expect(wrapper.find('AddWidget').exists()).toBe(false);
  539. });
  540. it('renders successfully if more widgets than stored layouts', async function () {
  541. // A case where someone has async added widgets to a dashboard
  542. MockApiClient.addMockResponse({
  543. url: '/organizations/org-slug/dashboards/1/',
  544. body: TestStubs.Dashboard(
  545. [
  546. TestStubs.Widget(
  547. [
  548. {
  549. name: '',
  550. conditions: 'event.type:error',
  551. fields: ['count()'],
  552. aggregates: ['count()'],
  553. columns: [],
  554. },
  555. ],
  556. {
  557. title: 'First Widget',
  558. interval: '1d',
  559. id: '1',
  560. layout: {i: 'grid-item-1', x: 0, y: 0, w: 2, h: 6},
  561. }
  562. ),
  563. TestStubs.Widget(
  564. [
  565. {
  566. name: '',
  567. conditions: 'event.type:error',
  568. fields: ['count()'],
  569. aggregates: ['count()'],
  570. columns: [],
  571. },
  572. ],
  573. {
  574. title: 'Second Widget',
  575. interval: '1d',
  576. id: '2',
  577. }
  578. ),
  579. ],
  580. {id: '1', title: 'Custom Errors'}
  581. ),
  582. });
  583. render(
  584. <ViewEditDashboard
  585. organization={initialData.organization}
  586. params={{orgId: 'org-slug', dashboardId: '1'}}
  587. router={initialData.router}
  588. location={initialData.router.location}
  589. />,
  590. {context: initialData.routerContext, organization: initialData.organization}
  591. );
  592. await tick();
  593. await screen.findByText('First Widget');
  594. await screen.findByText('Second Widget');
  595. });
  596. it('does not trigger request if layout not updated', async () => {
  597. MockApiClient.addMockResponse({
  598. url: '/organizations/org-slug/dashboards/1/',
  599. body: TestStubs.Dashboard(
  600. [
  601. TestStubs.Widget(
  602. [
  603. {
  604. name: '',
  605. conditions: 'event.type:error',
  606. fields: ['count()'],
  607. aggregates: ['count()'],
  608. columns: [],
  609. },
  610. ],
  611. {
  612. title: 'First Widget',
  613. interval: '1d',
  614. id: '1',
  615. layout: {i: 'grid-item-1', x: 0, y: 0, w: 2, h: 6},
  616. }
  617. ),
  618. ],
  619. {id: '1', title: 'Custom Errors'}
  620. ),
  621. });
  622. render(
  623. <ViewEditDashboard
  624. organization={initialData.organization}
  625. params={{orgId: 'org-slug', dashboardId: '1'}}
  626. router={initialData.router}
  627. location={initialData.router.location}
  628. />,
  629. {context: initialData.routerContext, organization: initialData.organization}
  630. );
  631. await tick();
  632. userEvent.click(screen.getByText('Edit Dashboard'));
  633. userEvent.click(screen.getByText('Save and Finish'));
  634. await tick();
  635. expect(screen.getByText('Edit Dashboard')).toBeInTheDocument();
  636. expect(mockPut).not.toHaveBeenCalled();
  637. });
  638. it('renders the custom resize handler for a widget', async () => {
  639. MockApiClient.addMockResponse({
  640. url: '/organizations/org-slug/dashboards/1/',
  641. body: TestStubs.Dashboard(
  642. [
  643. TestStubs.Widget(
  644. [
  645. {
  646. name: '',
  647. conditions: 'event.type:error',
  648. fields: ['count()'],
  649. aggregates: ['count()'],
  650. columns: [],
  651. },
  652. ],
  653. {
  654. title: 'First Widget',
  655. interval: '1d',
  656. id: '1',
  657. layout: {i: 'grid-item-1', x: 0, y: 0, w: 2, h: 6},
  658. }
  659. ),
  660. ],
  661. {id: '1', title: 'Custom Errors'}
  662. ),
  663. });
  664. render(
  665. <ViewEditDashboard
  666. organization={initialData.organization}
  667. params={{orgId: 'org-slug', dashboardId: '1'}}
  668. router={initialData.router}
  669. location={initialData.router.location}
  670. />,
  671. {context: initialData.routerContext, organization: initialData.organization}
  672. );
  673. await tick();
  674. userEvent.click(await screen.findByText('Edit Dashboard'));
  675. const widget = screen.getByText('First Widget').closest('.react-grid-item');
  676. const resizeHandle = within(widget).getByTestId('custom-resize-handle');
  677. expect(resizeHandle).toBeVisible();
  678. });
  679. it('does not trigger an alert when the widgets have no layout and user cancels without changes', async () => {
  680. MockApiClient.addMockResponse({
  681. url: '/organizations/org-slug/dashboards/1/',
  682. body: TestStubs.Dashboard(
  683. [
  684. TestStubs.Widget(
  685. [
  686. {
  687. name: '',
  688. conditions: 'event.type:error',
  689. fields: ['count()'],
  690. aggregates: ['count()'],
  691. columns: [],
  692. },
  693. ],
  694. {
  695. title: 'First Widget',
  696. interval: '1d',
  697. id: '1',
  698. layout: null,
  699. }
  700. ),
  701. ],
  702. {id: '1', title: 'Custom Errors'}
  703. ),
  704. });
  705. render(
  706. <ViewEditDashboard
  707. organization={initialData.organization}
  708. params={{orgId: 'org-slug', dashboardId: '1'}}
  709. router={initialData.router}
  710. location={initialData.router.location}
  711. />,
  712. {context: initialData.routerContext, organization: initialData.organization}
  713. );
  714. await tick();
  715. userEvent.click(await screen.findByText('Edit Dashboard'));
  716. userEvent.click(await screen.findByText('Cancel'));
  717. expect(window.confirm).not.toHaveBeenCalled();
  718. });
  719. it('opens the widget viewer modal using the widget id specified in the url', () => {
  720. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  721. const widget = TestStubs.Widget(
  722. [
  723. {
  724. name: '',
  725. conditions: 'event.type:error',
  726. fields: ['count()'],
  727. aggregates: ['count()'],
  728. columns: [],
  729. orderby: '',
  730. },
  731. ],
  732. {
  733. title: 'First Widget',
  734. interval: '1d',
  735. id: '1',
  736. layout: null,
  737. }
  738. );
  739. MockApiClient.addMockResponse({
  740. url: '/organizations/org-slug/dashboards/1/',
  741. body: TestStubs.Dashboard([widget], {id: '1', title: 'Custom Errors'}),
  742. });
  743. render(
  744. <ViewEditDashboard
  745. organization={initialData.organization}
  746. params={{orgId: 'org-slug', dashboardId: '1', widgetId: '1'}}
  747. router={initialData.router}
  748. location={{...initialData.router.location, pathname: '/widget/123/'}}
  749. />,
  750. {context: initialData.routerContext, organization: initialData.organization}
  751. );
  752. expect(openWidgetViewerModal).toHaveBeenCalledWith(
  753. expect.objectContaining({
  754. organization: initialData.organization,
  755. widget,
  756. onClose: expect.anything(),
  757. })
  758. );
  759. });
  760. it('redirects user to dashboard url if widget is not found', () => {
  761. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  762. MockApiClient.addMockResponse({
  763. url: '/organizations/org-slug/dashboards/1/',
  764. body: TestStubs.Dashboard([], {id: '1', title: 'Custom Errors'}),
  765. });
  766. render(
  767. <ViewEditDashboard
  768. organization={initialData.organization}
  769. params={{orgId: 'org-slug', dashboardId: '1', widgetId: '123'}}
  770. router={initialData.router}
  771. location={{...initialData.router.location, pathname: '/widget/123/'}}
  772. />,
  773. {context: initialData.routerContext, organization: initialData.organization}
  774. );
  775. expect(openWidgetViewerModal).not.toHaveBeenCalled();
  776. expect(initialData.router.replace).toHaveBeenCalledWith(
  777. expect.objectContaining({
  778. pathname: '/organizations/org-slug/dashboard/1/',
  779. query: {},
  780. })
  781. );
  782. });
  783. it('opens the edit widget modal when clicking the edit button', async () => {
  784. const widget = TestStubs.Widget(
  785. [
  786. {
  787. name: '',
  788. conditions: 'event.type:error',
  789. fields: ['count()'],
  790. aggregates: ['count()'],
  791. columns: [],
  792. orderby: '',
  793. },
  794. ],
  795. {
  796. title: 'First Widget',
  797. interval: '1d',
  798. id: '1',
  799. layout: null,
  800. }
  801. );
  802. MockApiClient.addMockResponse({
  803. url: '/organizations/org-slug/dashboards/1/',
  804. body: TestStubs.Dashboard([widget], {id: '1', title: 'Custom Errors'}),
  805. });
  806. render(
  807. <ViewEditDashboard
  808. organization={initialData.organization}
  809. params={{orgId: 'org-slug', dashboardId: '1', widgetId: '1'}}
  810. router={initialData.router}
  811. location={{...initialData.router.location, pathname: '/widget/123/'}}
  812. />,
  813. {context: initialData.routerContext, organization: initialData.organization}
  814. );
  815. renderGlobalModal({context: initialData.routerContext});
  816. userEvent.click(await screen.findByRole('button', {name: 'Edit Widget'}));
  817. expect(openEditModal).toHaveBeenCalledWith(
  818. expect.objectContaining({
  819. widget,
  820. organization: initialData.organization,
  821. source: DashboardWidgetSource.DASHBOARDS,
  822. })
  823. );
  824. });
  825. it('opens the widget viewer modal in a prebuilt dashboard using the widget id specified in the url', () => {
  826. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  827. render(
  828. <CreateDashboard
  829. organization={initialData.organization}
  830. params={{orgId: 'org-slug', templateId: 'default-template', widgetId: '2'}}
  831. router={initialData.router}
  832. location={{...initialData.router.location, pathname: '/widget/2/'}}
  833. />,
  834. {context: initialData.routerContext, organization: initialData.organization}
  835. );
  836. expect(openWidgetViewerModal).toHaveBeenCalledWith(
  837. expect.objectContaining({
  838. organization: initialData.organization,
  839. widget: expect.objectContaining({
  840. displayType: 'line',
  841. interval: '5m',
  842. queries: [
  843. {
  844. aggregates: ['count()'],
  845. columns: [],
  846. conditions: '!event.type:transaction',
  847. fields: ['count()'],
  848. name: 'Events',
  849. orderby: 'count()',
  850. },
  851. ],
  852. title: 'Events',
  853. widgetType: 'discover',
  854. }),
  855. onClose: expect.anything(),
  856. })
  857. );
  858. });
  859. it('saves a new dashboard with the page filters', async () => {
  860. const mockPOST = MockApiClient.addMockResponse({
  861. url: '/organizations/org-slug/dashboards/',
  862. method: 'POST',
  863. body: [],
  864. });
  865. render(
  866. <CreateDashboard
  867. organization={{
  868. ...initialData.organization,
  869. features: [
  870. ...initialData.organization.features,
  871. 'dashboards-top-level-filter',
  872. ],
  873. }}
  874. params={{orgId: 'org-slug'}}
  875. router={initialData.router}
  876. location={{
  877. ...initialData.router.location,
  878. query: {
  879. ...initialData.router.location.query,
  880. statsPeriod: '7d',
  881. project: [2],
  882. environment: ['alpha', 'beta'],
  883. },
  884. }}
  885. />,
  886. {
  887. context: initialData.routerContext,
  888. organization: initialData.organization,
  889. }
  890. );
  891. userEvent.click(await screen.findByText('Save and Finish'));
  892. expect(mockPOST).toHaveBeenCalledWith(
  893. '/organizations/org-slug/dashboards/',
  894. expect.objectContaining({
  895. data: expect.objectContaining({
  896. projects: [2],
  897. environment: ['alpha', 'beta'],
  898. period: '7d',
  899. }),
  900. })
  901. );
  902. });
  903. it('saves a template with the page filters', async () => {
  904. const mockPOST = MockApiClient.addMockResponse({
  905. url: '/organizations/org-slug/dashboards/',
  906. method: 'POST',
  907. body: [],
  908. });
  909. render(
  910. <CreateDashboard
  911. organization={{
  912. ...initialData.organization,
  913. features: [
  914. ...initialData.organization.features,
  915. 'dashboards-top-level-filter',
  916. ],
  917. }}
  918. params={{orgId: 'org-slug', templateId: 'default-template'}}
  919. router={initialData.router}
  920. location={{
  921. ...initialData.router.location,
  922. query: {
  923. ...initialData.router.location.query,
  924. statsPeriod: '7d',
  925. project: [2],
  926. environment: ['alpha', 'beta'],
  927. },
  928. }}
  929. />,
  930. {
  931. context: initialData.routerContext,
  932. organization: initialData.organization,
  933. }
  934. );
  935. userEvent.click(await screen.findByText('Add Dashboard'));
  936. expect(mockPOST).toHaveBeenCalledWith(
  937. '/organizations/org-slug/dashboards/',
  938. expect.objectContaining({
  939. data: expect.objectContaining({
  940. projects: [2],
  941. environment: ['alpha', 'beta'],
  942. period: '7d',
  943. }),
  944. })
  945. );
  946. });
  947. });
  948. });