dashboard.spec.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
  1. import {LocationFixture} from 'sentry-fixture/locationFixture';
  2. import {OrganizationFixture} from 'sentry-fixture/organization';
  3. import {RouterFixture} from 'sentry-fixture/routerFixture';
  4. import {TagsFixture} from 'sentry-fixture/tags';
  5. import {UserFixture} from 'sentry-fixture/user';
  6. import {WidgetFixture} from 'sentry-fixture/widget';
  7. import {initializeOrg} from 'sentry-test/initializeOrg';
  8. import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
  9. import MemberListStore from 'sentry/stores/memberListStore';
  10. import {DatasetSource} from 'sentry/utils/discover/types';
  11. import {MEPSettingProvider} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
  12. import Dashboard from 'sentry/views/dashboards/dashboard';
  13. import type {DashboardDetails, Widget} from 'sentry/views/dashboards/types';
  14. import {DisplayType, WidgetType} from 'sentry/views/dashboards/types';
  15. import {OrganizationContext} from '../organizationContext';
  16. import WidgetLegendSelectionState from './widgetLegendSelectionState';
  17. jest.mock('sentry/components/lazyRender', () => ({
  18. LazyRender: ({children}: {children: React.ReactNode}) => children,
  19. }));
  20. describe('Dashboards > Dashboard', () => {
  21. const organization = OrganizationFixture({
  22. features: ['dashboards-basic', 'dashboards-edit'],
  23. });
  24. const mockDashboard = {
  25. dateCreated: '2021-08-10T21:20:46.798237Z',
  26. id: '1',
  27. title: 'Test Dashboard',
  28. widgets: [],
  29. projects: [],
  30. filters: {},
  31. };
  32. const newWidget: Widget = {
  33. id: '1',
  34. title: 'Test Discover Widget',
  35. displayType: DisplayType.LINE,
  36. widgetType: WidgetType.DISCOVER,
  37. interval: '5m',
  38. queries: [
  39. {
  40. name: '',
  41. conditions: '',
  42. fields: ['count()'],
  43. aggregates: ['count()'],
  44. columns: [],
  45. orderby: '',
  46. },
  47. ],
  48. };
  49. const issueWidget: Widget = {
  50. id: '2',
  51. title: 'Test Issue Widget',
  52. displayType: DisplayType.TABLE,
  53. widgetType: WidgetType.ISSUE,
  54. interval: '5m',
  55. queries: [
  56. {
  57. name: '',
  58. conditions: '',
  59. fields: ['title', 'assignee'],
  60. aggregates: [],
  61. columns: ['title', 'assignee'],
  62. orderby: '',
  63. },
  64. ],
  65. };
  66. const widgetLegendState = new WidgetLegendSelectionState({
  67. organization,
  68. dashboard: mockDashboard,
  69. router: RouterFixture(),
  70. location: LocationFixture(),
  71. });
  72. let initialData: ReturnType<typeof initializeOrg>;
  73. let tagsMock: jest.Mock;
  74. beforeEach(() => {
  75. initialData = initializeOrg({organization, router: {}, projects: []});
  76. MockApiClient.addMockResponse({
  77. url: `/organizations/org-slug/dashboards/widgets/`,
  78. method: 'POST',
  79. body: [],
  80. });
  81. MockApiClient.addMockResponse({
  82. url: `/organizations/org-slug/releases/stats/`,
  83. body: [],
  84. });
  85. MockApiClient.addMockResponse({
  86. url: '/organizations/org-slug/events-stats/',
  87. method: 'GET',
  88. body: [],
  89. });
  90. MockApiClient.addMockResponse({
  91. url: '/organizations/org-slug/issues/',
  92. method: 'GET',
  93. body: [
  94. {
  95. annotations: [],
  96. id: '1',
  97. title: 'Error: Failed',
  98. project: {
  99. id: '3',
  100. },
  101. assignedTo: {
  102. email: 'test@sentry.io',
  103. type: 'user',
  104. id: '1',
  105. name: 'Test User',
  106. },
  107. },
  108. ],
  109. });
  110. MockApiClient.addMockResponse({
  111. url: '/organizations/org-slug/users/',
  112. method: 'GET',
  113. body: [
  114. {
  115. user: {
  116. id: '2',
  117. name: 'test@sentry.io',
  118. email: 'test@sentry.io',
  119. avatar: {
  120. avatarType: 'letter_avatar',
  121. avatarUuid: null,
  122. },
  123. },
  124. },
  125. ],
  126. });
  127. tagsMock = MockApiClient.addMockResponse({
  128. url: '/organizations/org-slug/tags/',
  129. method: 'GET',
  130. body: TagsFixture(),
  131. });
  132. });
  133. it('fetches tags', () => {
  134. render(
  135. <Dashboard
  136. paramDashboardId="1"
  137. dashboard={mockDashboard}
  138. organization={initialData.organization}
  139. onUpdate={() => undefined}
  140. handleUpdateWidgetList={() => undefined}
  141. handleAddCustomWidget={() => undefined}
  142. router={initialData.router}
  143. location={initialData.router.location}
  144. widgetLimitReached={false}
  145. isEditingDashboard={false}
  146. widgetLegendState={widgetLegendState}
  147. />,
  148. {router: initialData.router}
  149. );
  150. expect(tagsMock).toHaveBeenCalled();
  151. });
  152. it('dashboard adds new widget if component is mounted with newWidget prop', async () => {
  153. const mockHandleAddCustomWidget = jest.fn();
  154. const mockCallbackToUnsetNewWidget = jest.fn();
  155. render(
  156. <Dashboard
  157. paramDashboardId="1"
  158. dashboard={mockDashboard}
  159. organization={initialData.organization}
  160. isEditingDashboard={false}
  161. onUpdate={() => undefined}
  162. handleUpdateWidgetList={() => undefined}
  163. handleAddCustomWidget={mockHandleAddCustomWidget}
  164. router={initialData.router}
  165. location={initialData.router.location}
  166. newWidget={newWidget}
  167. widgetLimitReached={false}
  168. onSetNewWidget={mockCallbackToUnsetNewWidget}
  169. widgetLegendState={widgetLegendState}
  170. />,
  171. {router: initialData.router}
  172. );
  173. await waitFor(() => expect(mockHandleAddCustomWidget).toHaveBeenCalled());
  174. expect(mockCallbackToUnsetNewWidget).toHaveBeenCalled();
  175. });
  176. it('dashboard adds new widget if component updated with newWidget prop', async () => {
  177. const mockHandleAddCustomWidget = jest.fn();
  178. const mockCallbackToUnsetNewWidget = jest.fn();
  179. const {rerender} = render(
  180. <Dashboard
  181. paramDashboardId="1"
  182. dashboard={mockDashboard}
  183. organization={initialData.organization}
  184. isEditingDashboard={false}
  185. onUpdate={() => undefined}
  186. handleUpdateWidgetList={() => undefined}
  187. handleAddCustomWidget={mockHandleAddCustomWidget}
  188. router={initialData.router}
  189. location={initialData.router.location}
  190. widgetLimitReached={false}
  191. onSetNewWidget={mockCallbackToUnsetNewWidget}
  192. widgetLegendState={widgetLegendState}
  193. />,
  194. {router: initialData.router}
  195. );
  196. expect(mockHandleAddCustomWidget).not.toHaveBeenCalled();
  197. expect(mockCallbackToUnsetNewWidget).not.toHaveBeenCalled();
  198. // Re-render with newWidget prop
  199. rerender(
  200. <Dashboard
  201. paramDashboardId="1"
  202. dashboard={mockDashboard}
  203. organization={initialData.organization}
  204. isEditingDashboard={false}
  205. onUpdate={() => undefined}
  206. handleUpdateWidgetList={() => undefined}
  207. handleAddCustomWidget={mockHandleAddCustomWidget}
  208. router={initialData.router}
  209. location={initialData.router.location}
  210. widgetLimitReached={false}
  211. onSetNewWidget={mockCallbackToUnsetNewWidget}
  212. newWidget={newWidget}
  213. widgetLegendState={widgetLegendState}
  214. />
  215. );
  216. await waitFor(() => expect(mockHandleAddCustomWidget).toHaveBeenCalled());
  217. expect(mockCallbackToUnsetNewWidget).toHaveBeenCalled();
  218. });
  219. it('dashboard does not try to add new widget if no newWidget', () => {
  220. const mockHandleAddCustomWidget = jest.fn();
  221. const mockCallbackToUnsetNewWidget = jest.fn();
  222. render(
  223. <Dashboard
  224. paramDashboardId="1"
  225. dashboard={mockDashboard}
  226. organization={initialData.organization}
  227. isEditingDashboard={false}
  228. onUpdate={() => undefined}
  229. handleUpdateWidgetList={() => undefined}
  230. handleAddCustomWidget={mockHandleAddCustomWidget}
  231. router={initialData.router}
  232. location={initialData.router.location}
  233. widgetLimitReached={false}
  234. onSetNewWidget={mockCallbackToUnsetNewWidget}
  235. widgetLegendState={widgetLegendState}
  236. />,
  237. {router: initialData.router}
  238. );
  239. expect(mockHandleAddCustomWidget).not.toHaveBeenCalled();
  240. expect(mockCallbackToUnsetNewWidget).not.toHaveBeenCalled();
  241. });
  242. it('updates the widget dataset split', async () => {
  243. const splitWidget = {
  244. ...newWidget,
  245. widgetType: WidgetType.ERRORS,
  246. datasetSource: DatasetSource.FORCED,
  247. };
  248. const splitWidgets = [splitWidget];
  249. const dashboardWithOneWidget = {...mockDashboard, widgets: splitWidgets};
  250. const mockOnUpdate = jest.fn();
  251. const mockHandleUpdateWidgetList = jest.fn();
  252. render(
  253. <OrganizationContext.Provider value={initialData.organization}>
  254. <MEPSettingProvider forceTransactions={false}>
  255. <Dashboard
  256. paramDashboardId="1"
  257. dashboard={dashboardWithOneWidget}
  258. organization={initialData.organization}
  259. isEditingDashboard={false}
  260. onUpdate={mockOnUpdate}
  261. handleUpdateWidgetList={mockHandleUpdateWidgetList}
  262. handleAddCustomWidget={() => undefined}
  263. router={initialData.router}
  264. location={initialData.router.location}
  265. widgetLimitReached={false}
  266. onSetNewWidget={() => undefined}
  267. widgetLegendState={widgetLegendState}
  268. />
  269. </MEPSettingProvider>
  270. </OrganizationContext.Provider>
  271. );
  272. await userEvent.hover(screen.getByLabelText('Widget warnings'));
  273. expect(
  274. await screen.findByText(/We're splitting our datasets up/)
  275. ).toBeInTheDocument();
  276. await userEvent.click(await screen.findByText(/Switch to Transactions/));
  277. await waitFor(() => {
  278. expect(mockOnUpdate).toHaveBeenCalled();
  279. });
  280. expect(mockHandleUpdateWidgetList).toHaveBeenCalled();
  281. });
  282. it('handles duplicate widget in view mode', async () => {
  283. const mockOnUpdate = jest.fn();
  284. const mockHandleUpdateWidgetList = jest.fn();
  285. const dashboardWithOneWidget = {
  286. ...mockDashboard,
  287. widgets: [
  288. WidgetFixture({
  289. id: '1',
  290. layout: {
  291. h: 1,
  292. w: 1,
  293. x: 0,
  294. y: 0,
  295. minH: 1,
  296. },
  297. }),
  298. ],
  299. };
  300. render(
  301. <OrganizationContext.Provider value={initialData.organization}>
  302. <MEPSettingProvider forceTransactions={false}>
  303. <Dashboard
  304. paramDashboardId="1"
  305. dashboard={dashboardWithOneWidget}
  306. organization={initialData.organization}
  307. isEditingDashboard={false}
  308. onUpdate={mockOnUpdate}
  309. handleUpdateWidgetList={mockHandleUpdateWidgetList}
  310. handleAddCustomWidget={() => undefined}
  311. router={initialData.router}
  312. location={initialData.router.location}
  313. widgetLimitReached={false}
  314. onSetNewWidget={() => undefined}
  315. widgetLegendState={widgetLegendState}
  316. />
  317. </MEPSettingProvider>
  318. </OrganizationContext.Provider>
  319. );
  320. await userEvent.click(await screen.findByLabelText('Widget actions'));
  321. await userEvent.click(await screen.findByText('Duplicate Widget'));
  322. // The new widget is inserted before the duplicated widget
  323. const expectedWidgets = [
  324. // New Widget
  325. expect.objectContaining(
  326. WidgetFixture({
  327. id: undefined,
  328. layout: expect.objectContaining({h: 1, w: 1, x: 0, y: 0, minH: 1}),
  329. })
  330. ),
  331. // Duplicated Widget
  332. expect.objectContaining(
  333. WidgetFixture({
  334. id: '1',
  335. layout: expect.objectContaining({h: 1, w: 1, x: 0, y: 1, minH: 1}),
  336. })
  337. ),
  338. ];
  339. expect(mockHandleUpdateWidgetList).toHaveBeenCalledWith(expectedWidgets);
  340. expect(mockOnUpdate).toHaveBeenCalledWith(expectedWidgets);
  341. });
  342. describe('Issue Widgets', () => {
  343. beforeEach(() => {
  344. MemberListStore.init();
  345. });
  346. const mount = (dashboard: DashboardDetails, mockedOrg = initialData.organization) => {
  347. render(
  348. <OrganizationContext.Provider value={initialData.organization}>
  349. <MEPSettingProvider forceTransactions={false}>
  350. <Dashboard
  351. paramDashboardId="1"
  352. dashboard={dashboard}
  353. organization={mockedOrg}
  354. isEditingDashboard={false}
  355. onUpdate={() => undefined}
  356. handleUpdateWidgetList={() => undefined}
  357. handleAddCustomWidget={() => undefined}
  358. router={initialData.router}
  359. location={initialData.router.location}
  360. widgetLimitReached={false}
  361. widgetLegendState={widgetLegendState}
  362. />
  363. </MEPSettingProvider>
  364. </OrganizationContext.Provider>
  365. );
  366. };
  367. it('dashboard displays issue widgets if the user has issue widgets feature flag', async () => {
  368. const mockDashboardWithIssueWidget = {
  369. ...mockDashboard,
  370. widgets: [newWidget, issueWidget],
  371. };
  372. mount(mockDashboardWithIssueWidget, organization);
  373. expect(await screen.findByText('Test Discover Widget')).toBeInTheDocument();
  374. expect(screen.getByText('Test Issue Widget')).toBeInTheDocument();
  375. });
  376. it('renders assignee', async () => {
  377. MemberListStore.loadInitialData([
  378. UserFixture({
  379. name: 'Test User',
  380. email: 'test@sentry.io',
  381. avatar: {
  382. avatarType: 'letter_avatar',
  383. avatarUuid: null,
  384. },
  385. }),
  386. ]);
  387. const mockDashboardWithIssueWidget = {
  388. ...mockDashboard,
  389. widgets: [{...issueWidget}],
  390. };
  391. mount(mockDashboardWithIssueWidget, organization);
  392. expect(await screen.findByTitle('Test User')).toBeInTheDocument();
  393. });
  394. });
  395. describe('Edit mode', () => {
  396. let widgets: Widget[];
  397. const mount = ({
  398. dashboard,
  399. org = initialData.organization,
  400. router = initialData.router,
  401. location = initialData.router.location,
  402. isPreview = false,
  403. }: any) => {
  404. const getDashboardComponent = () => (
  405. <OrganizationContext.Provider value={initialData.organization}>
  406. <MEPSettingProvider forceTransactions={false}>
  407. <Dashboard
  408. paramDashboardId="1"
  409. dashboard={dashboard}
  410. organization={org}
  411. isEditingDashboard
  412. onUpdate={newWidgets => {
  413. widgets.splice(0, widgets.length, ...newWidgets);
  414. }}
  415. handleUpdateWidgetList={() => undefined}
  416. handleAddCustomWidget={() => undefined}
  417. router={router}
  418. location={location}
  419. widgetLimitReached={false}
  420. isPreview={isPreview}
  421. widgetLegendState={widgetLegendState}
  422. />
  423. </MEPSettingProvider>
  424. </OrganizationContext.Provider>
  425. );
  426. const {rerender} = render(getDashboardComponent());
  427. return {rerender: () => rerender(getDashboardComponent())};
  428. };
  429. beforeEach(() => {
  430. widgets = [newWidget];
  431. });
  432. it('displays the copy widget button in edit mode', async () => {
  433. const dashboardWithOneWidget = {...mockDashboard, widgets};
  434. mount({dashboard: dashboardWithOneWidget});
  435. expect(await screen.findByLabelText('Duplicate Widget')).toBeInTheDocument();
  436. });
  437. it('duplicates the widget', async () => {
  438. const dashboardWithOneWidget = {...mockDashboard, widgets};
  439. const {rerender} = mount({dashboard: dashboardWithOneWidget});
  440. await userEvent.click(await screen.findByLabelText('Duplicate Widget'));
  441. rerender();
  442. await waitFor(() => {
  443. expect(screen.getAllByText('Test Discover Widget')).toHaveLength(2);
  444. });
  445. });
  446. it('opens the widget builder when editing with the modal access flag', async function () {
  447. const testData = initializeOrg({
  448. organization: {
  449. features: ['dashboards-basic', 'dashboards-edit'],
  450. },
  451. });
  452. const dashboardWithOneWidget = {
  453. ...mockDashboard,
  454. widgets: [newWidget],
  455. };
  456. mount({
  457. dashboard: dashboardWithOneWidget,
  458. org: testData.organization,
  459. router: testData.router,
  460. location: testData.router.location,
  461. });
  462. await userEvent.click(await screen.findByLabelText('Edit Widget'));
  463. expect(testData.router.push).toHaveBeenCalledWith(
  464. expect.objectContaining({
  465. pathname: '/organizations/org-slug/dashboard/1/widget/0/edit/',
  466. })
  467. );
  468. });
  469. it('does not show the add widget button if dashboard is in preview mode', async function () {
  470. const testData = initializeOrg({
  471. organization: {
  472. features: ['dashboards-basic', 'dashboards-edit', 'custom-metrics'],
  473. },
  474. });
  475. const dashboardWithOneWidget = {
  476. ...mockDashboard,
  477. widgets: [newWidget],
  478. };
  479. mount({
  480. dashboard: dashboardWithOneWidget,
  481. org: testData.organization,
  482. isPreview: true,
  483. });
  484. await screen.findByText('Test Discover Widget');
  485. expect(screen.queryByRole('button', {name: /add widget/i})).not.toBeInTheDocument();
  486. });
  487. });
  488. });