dashboard.spec.tsx 14 KB

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