detail.spec.tsx 79 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476
  1. import {DashboardFixture} from 'sentry-fixture/dashboard';
  2. import {LocationFixture} from 'sentry-fixture/locationFixture';
  3. import {OrganizationFixture} from 'sentry-fixture/organization';
  4. import {ProjectFixture} from 'sentry-fixture/project';
  5. import {ReleaseFixture} from 'sentry-fixture/release';
  6. import {RouteComponentPropsFixture} from 'sentry-fixture/routeComponentPropsFixture';
  7. import {TeamFixture} from 'sentry-fixture/team';
  8. import {UserFixture} from 'sentry-fixture/user';
  9. import {WidgetFixture} from 'sentry-fixture/widget';
  10. import {initializeOrg} from 'sentry-test/initializeOrg';
  11. import {
  12. act,
  13. render,
  14. screen,
  15. userEvent,
  16. waitFor,
  17. within,
  18. } from 'sentry-test/reactTestingLibrary';
  19. import * as dashboardActions from 'sentry/actionCreators/dashboards';
  20. import {addLoadingMessage} from 'sentry/actionCreators/indicator';
  21. import * as modals from 'sentry/actionCreators/modal';
  22. import ConfigStore from 'sentry/stores/configStore';
  23. import PageFiltersStore from 'sentry/stores/pageFiltersStore';
  24. import ProjectsStore from 'sentry/stores/projectsStore';
  25. import TeamStore from 'sentry/stores/teamStore';
  26. import CreateDashboard from 'sentry/views/dashboards/create';
  27. import DashboardDetail, {
  28. handleUpdateDashboardSplit,
  29. } from 'sentry/views/dashboards/detail';
  30. import EditAccessSelector from 'sentry/views/dashboards/editAccessSelector';
  31. import * as types from 'sentry/views/dashboards/types';
  32. import {DashboardState} from 'sentry/views/dashboards/types';
  33. import ViewEditDashboard from 'sentry/views/dashboards/view';
  34. import useWidgetBuilderState from 'sentry/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState';
  35. import {OrganizationContext} from 'sentry/views/organizationContext';
  36. jest.mock('sentry/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState');
  37. jest.mock('sentry/actionCreators/indicator');
  38. describe('Dashboards > Detail', function () {
  39. const organization = OrganizationFixture({
  40. features: ['global-views', 'dashboards-basic', 'dashboards-edit', 'discover-query'],
  41. });
  42. const projects = [ProjectFixture()];
  43. describe('prebuilt dashboards', function () {
  44. let initialData!: ReturnType<typeof initializeOrg>;
  45. beforeEach(function () {
  46. act(() => ProjectsStore.loadInitialData(projects));
  47. initialData = initializeOrg({organization});
  48. MockApiClient.addMockResponse({
  49. url: '/organizations/org-slug/tags/',
  50. body: [],
  51. });
  52. MockApiClient.addMockResponse({
  53. url: '/organizations/org-slug/projects/',
  54. body: [ProjectFixture()],
  55. });
  56. MockApiClient.addMockResponse({
  57. url: '/organizations/org-slug/dashboards/',
  58. body: [
  59. DashboardFixture([], {id: 'default-overview', title: 'Default'}),
  60. DashboardFixture([], {id: '1', title: 'Custom Errors'}),
  61. ],
  62. });
  63. MockApiClient.addMockResponse({
  64. url: '/organizations/org-slug/dashboards/default-overview/',
  65. body: DashboardFixture([], {id: 'default-overview', title: 'Default'}),
  66. });
  67. MockApiClient.addMockResponse({
  68. url: '/organizations/org-slug/dashboards/1/visit/',
  69. method: 'POST',
  70. body: [],
  71. statusCode: 200,
  72. });
  73. MockApiClient.addMockResponse({
  74. url: '/organizations/org-slug/users/',
  75. method: 'GET',
  76. body: [],
  77. });
  78. MockApiClient.addMockResponse({
  79. url: '/organizations/org-slug/sdk-updates/',
  80. body: [],
  81. });
  82. MockApiClient.addMockResponse({
  83. url: '/organizations/org-slug/prompts-activity/',
  84. body: {},
  85. });
  86. MockApiClient.addMockResponse({
  87. url: '/organizations/org-slug/events/',
  88. method: 'GET',
  89. body: [],
  90. });
  91. MockApiClient.addMockResponse({
  92. url: '/organizations/org-slug/events-stats/',
  93. body: {data: []},
  94. });
  95. MockApiClient.addMockResponse({
  96. method: 'GET',
  97. url: '/organizations/org-slug/issues/',
  98. body: [],
  99. });
  100. MockApiClient.addMockResponse({
  101. url: '/organizations/org-slug/releases/',
  102. body: [],
  103. });
  104. MockApiClient.addMockResponse({
  105. url: '/organizations/org-slug/releases/stats/',
  106. body: [],
  107. });
  108. MockApiClient.addMockResponse({
  109. url: '/organizations/org-slug/metrics/meta/',
  110. body: [],
  111. });
  112. });
  113. afterEach(function () {
  114. MockApiClient.clearMockResponses();
  115. });
  116. it('assigns unique IDs to all widgets so grid keys are unique', async function () {
  117. MockApiClient.addMockResponse({
  118. url: '/organizations/org-slug/events-stats/',
  119. body: {data: []},
  120. });
  121. MockApiClient.addMockResponse({
  122. url: '/organizations/org-slug/dashboards/default-overview/',
  123. body: DashboardFixture(
  124. [
  125. WidgetFixture({
  126. queries: [
  127. {
  128. name: '',
  129. conditions: 'event.type:error',
  130. fields: ['count()'],
  131. aggregates: ['count()'],
  132. columns: [],
  133. orderby: '-count()',
  134. },
  135. ],
  136. title: 'Default Widget 1',
  137. interval: '1d',
  138. }),
  139. WidgetFixture({
  140. queries: [
  141. {
  142. name: '',
  143. conditions: 'event.type:transaction',
  144. fields: ['count()'],
  145. aggregates: ['count()'],
  146. columns: [],
  147. orderby: '-count()',
  148. },
  149. ],
  150. title: 'Default Widget 2',
  151. interval: '1d',
  152. }),
  153. ],
  154. {id: 'default-overview', title: 'Default'}
  155. ),
  156. });
  157. initialData = initializeOrg({
  158. organization: OrganizationFixture({
  159. features: ['global-views', 'dashboards-basic', 'discover-query'],
  160. }),
  161. });
  162. render(
  163. <OrganizationContext.Provider value={initialData.organization}>
  164. <ViewEditDashboard
  165. {...RouteComponentPropsFixture()}
  166. organization={initialData.organization}
  167. params={{orgId: 'org-slug', dashboardId: 'default-overview'}}
  168. router={initialData.router}
  169. location={initialData.router.location}
  170. >
  171. {null}
  172. </ViewEditDashboard>
  173. </OrganizationContext.Provider>,
  174. {router: initialData.router}
  175. );
  176. expect(await screen.findByText('Default Widget 1')).toBeInTheDocument();
  177. expect(screen.getByText('Default Widget 2')).toBeInTheDocument();
  178. });
  179. it('opens the widget viewer modal in a prebuilt dashboard using the widget id specified in the url', async () => {
  180. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  181. render(
  182. <CreateDashboard
  183. {...RouteComponentPropsFixture()}
  184. organization={initialData.organization}
  185. params={{templateId: 'default-template', widgetId: '2'}}
  186. router={initialData.router}
  187. location={{...initialData.router.location, pathname: '/widget/2/'}}
  188. >
  189. {null}
  190. </CreateDashboard>,
  191. {router: initialData.router, organization: initialData.organization}
  192. );
  193. await waitFor(() => {
  194. expect(openWidgetViewerModal).toHaveBeenCalledWith(
  195. expect.objectContaining({
  196. organization: initialData.organization,
  197. widget: expect.objectContaining({
  198. displayType: 'line',
  199. interval: '5m',
  200. queries: [
  201. {
  202. aggregates: ['count()'],
  203. columns: [],
  204. conditions: '!event.type:transaction',
  205. fields: ['count()'],
  206. name: 'Events',
  207. orderby: 'count()',
  208. },
  209. ],
  210. title: 'Events',
  211. widgetType: types.WidgetType.DISCOVER,
  212. }),
  213. onClose: expect.anything(),
  214. })
  215. );
  216. });
  217. });
  218. });
  219. describe('custom dashboards', function () {
  220. let initialData!: ReturnType<typeof initializeOrg>;
  221. let widgets!: Array<ReturnType<typeof WidgetFixture>>;
  222. let mockVisit!: jest.Mock;
  223. let mockPut!: jest.Mock;
  224. beforeEach(function () {
  225. window.confirm = jest.fn();
  226. initialData = initializeOrg({
  227. organization,
  228. router: {
  229. location: LocationFixture(),
  230. },
  231. });
  232. PageFiltersStore.init();
  233. PageFiltersStore.onInitializeUrlState(
  234. {
  235. projects: [],
  236. environments: [],
  237. datetime: {start: null, end: null, period: '14d', utc: null},
  238. },
  239. new Set()
  240. );
  241. widgets = [
  242. WidgetFixture({
  243. queries: [
  244. {
  245. name: '',
  246. conditions: 'event.type:error',
  247. fields: ['count()'],
  248. columns: [],
  249. aggregates: ['count()'],
  250. orderby: '-count()',
  251. },
  252. ],
  253. title: 'Errors',
  254. interval: '1d',
  255. widgetType: types.WidgetType.DISCOVER,
  256. id: '1',
  257. }),
  258. WidgetFixture({
  259. queries: [
  260. {
  261. name: '',
  262. conditions: 'event.type:transaction',
  263. fields: ['count()'],
  264. columns: [],
  265. aggregates: ['count()'],
  266. orderby: '-count()',
  267. },
  268. ],
  269. title: 'Transactions',
  270. interval: '1d',
  271. widgetType: types.WidgetType.DISCOVER,
  272. id: '2',
  273. }),
  274. WidgetFixture({
  275. queries: [
  276. {
  277. name: '',
  278. conditions: 'event.type:transaction transaction:/api/cats',
  279. fields: ['p50()'],
  280. columns: [],
  281. aggregates: ['p50()'],
  282. orderby: '-p50()',
  283. },
  284. ],
  285. title: 'p50 of /api/cats',
  286. interval: '1d',
  287. id: '3',
  288. }),
  289. ];
  290. mockVisit = MockApiClient.addMockResponse({
  291. url: '/organizations/org-slug/dashboards/1/visit/',
  292. method: 'POST',
  293. body: [],
  294. statusCode: 200,
  295. });
  296. MockApiClient.addMockResponse({
  297. url: '/organizations/org-slug/tags/',
  298. body: [],
  299. });
  300. MockApiClient.addMockResponse({
  301. url: '/organizations/org-slug/projects/',
  302. body: [ProjectFixture()],
  303. });
  304. MockApiClient.addMockResponse({
  305. url: '/organizations/org-slug/dashboards/',
  306. body: [
  307. {
  308. ...DashboardFixture([], {
  309. id: 'default-overview',
  310. title: 'Default',
  311. }),
  312. widgetDisplay: ['area'],
  313. },
  314. {
  315. ...DashboardFixture([], {
  316. id: '1',
  317. title: 'Custom Errors',
  318. }),
  319. widgetDisplay: ['area'],
  320. },
  321. ],
  322. });
  323. MockApiClient.addMockResponse({
  324. url: '/organizations/org-slug/dashboards/1/',
  325. body: DashboardFixture(widgets, {
  326. id: '1',
  327. title: 'Custom Errors',
  328. filters: {},
  329. createdBy: UserFixture({id: '1'}),
  330. }),
  331. });
  332. mockPut = MockApiClient.addMockResponse({
  333. url: '/organizations/org-slug/dashboards/1/',
  334. method: 'PUT',
  335. body: DashboardFixture(widgets, {id: '1', title: 'Custom Errors'}),
  336. });
  337. MockApiClient.addMockResponse({
  338. url: '/organizations/org-slug/events-stats/',
  339. body: {data: []},
  340. });
  341. MockApiClient.addMockResponse({
  342. method: 'POST',
  343. url: '/organizations/org-slug/dashboards/widgets/',
  344. body: [],
  345. });
  346. MockApiClient.addMockResponse({
  347. method: 'GET',
  348. url: '/organizations/org-slug/recent-searches/',
  349. body: [],
  350. });
  351. MockApiClient.addMockResponse({
  352. method: 'GET',
  353. url: '/organizations/org-slug/issues/',
  354. body: [],
  355. });
  356. MockApiClient.addMockResponse({
  357. url: '/organizations/org-slug/events/',
  358. method: 'GET',
  359. body: [],
  360. });
  361. MockApiClient.addMockResponse({
  362. url: '/organizations/org-slug/users/',
  363. method: 'GET',
  364. body: [],
  365. });
  366. MockApiClient.addMockResponse({
  367. url: '/organizations/org-slug/releases/',
  368. body: [],
  369. });
  370. MockApiClient.addMockResponse({
  371. url: '/organizations/org-slug/releases/stats/',
  372. body: [],
  373. });
  374. MockApiClient.addMockResponse({
  375. url: '/organizations/org-slug/sdk-updates/',
  376. body: [],
  377. });
  378. MockApiClient.addMockResponse({
  379. url: '/organizations/org-slug/prompts-activity/',
  380. body: {},
  381. });
  382. MockApiClient.addMockResponse({
  383. url: '/organizations/org-slug/metrics/meta/',
  384. body: [],
  385. });
  386. MockApiClient.addMockResponse({
  387. url: '/organizations/org-slug/measurements-meta/',
  388. body: [],
  389. });
  390. });
  391. afterEach(function () {
  392. MockApiClient.clearMockResponses();
  393. jest.clearAllMocks();
  394. });
  395. it('can remove widgets', async function () {
  396. const updateMock = MockApiClient.addMockResponse({
  397. url: '/organizations/org-slug/dashboards/1/',
  398. method: 'PUT',
  399. body: DashboardFixture([widgets[0]!], {id: '1', title: 'Custom Errors'}),
  400. });
  401. render(
  402. <OrganizationContext.Provider value={initialData.organization}>
  403. <ViewEditDashboard
  404. {...RouteComponentPropsFixture()}
  405. organization={initialData.organization}
  406. params={{orgId: 'org-slug', dashboardId: '1'}}
  407. router={initialData.router}
  408. location={initialData.router.location}
  409. >
  410. {null}
  411. </ViewEditDashboard>
  412. </OrganizationContext.Provider>,
  413. {router: initialData.router}
  414. );
  415. await waitFor(() => expect(mockVisit).toHaveBeenCalledTimes(1));
  416. // Enter edit mode.
  417. await userEvent.click(screen.getByRole('button', {name: 'Edit Dashboard'}));
  418. // Remove the second and third widgets
  419. await userEvent.click(
  420. (await screen.findAllByRole('button', {name: 'Delete Widget'}))[1]!
  421. );
  422. await userEvent.click(
  423. (await screen.findAllByRole('button', {name: 'Delete Widget'}))[1]!
  424. );
  425. // Save changes
  426. await userEvent.click(screen.getByRole('button', {name: 'Save and Finish'}));
  427. expect(updateMock).toHaveBeenCalled();
  428. expect(updateMock).toHaveBeenCalledWith(
  429. '/organizations/org-slug/dashboards/1/',
  430. expect.objectContaining({
  431. data: expect.objectContaining({
  432. title: 'Custom Errors',
  433. widgets: [expect.objectContaining(widgets[0])],
  434. }),
  435. })
  436. );
  437. // Visit should not be called again on dashboard update
  438. expect(mockVisit).toHaveBeenCalledTimes(1);
  439. });
  440. it('appends dashboard-level filters to series request', async function () {
  441. MockApiClient.addMockResponse({
  442. url: '/organizations/org-slug/dashboards/1/',
  443. body: DashboardFixture(widgets, {
  444. id: '1',
  445. title: 'Custom Errors',
  446. filters: {release: ['abc@1.2.0']},
  447. }),
  448. });
  449. const mock = MockApiClient.addMockResponse({
  450. url: '/organizations/org-slug/events-stats/',
  451. body: [],
  452. });
  453. render(
  454. <OrganizationContext.Provider value={initialData.organization}>
  455. <ViewEditDashboard
  456. {...RouteComponentPropsFixture()}
  457. organization={initialData.organization}
  458. params={{orgId: 'org-slug', dashboardId: '1'}}
  459. router={initialData.router}
  460. location={initialData.router.location}
  461. >
  462. {null}
  463. </ViewEditDashboard>
  464. </OrganizationContext.Provider>,
  465. {router: initialData.router}
  466. );
  467. await waitFor(() =>
  468. expect(mock).toHaveBeenLastCalledWith(
  469. '/organizations/org-slug/events-stats/',
  470. expect.objectContaining({
  471. query: expect.objectContaining({
  472. query:
  473. '(event.type:transaction transaction:/api/cats) release:"abc@1.2.0" ',
  474. }),
  475. })
  476. )
  477. );
  478. });
  479. it('shows add widget option', async function () {
  480. render(
  481. <OrganizationContext.Provider value={initialData.organization}>
  482. <ViewEditDashboard
  483. {...RouteComponentPropsFixture()}
  484. organization={initialData.organization}
  485. params={{orgId: 'org-slug', dashboardId: '1'}}
  486. router={initialData.router}
  487. location={initialData.router.location}
  488. >
  489. {null}
  490. </ViewEditDashboard>
  491. </OrganizationContext.Provider>,
  492. {router: initialData.router}
  493. );
  494. // Enter edit mode.
  495. await userEvent.click(screen.getByRole('button', {name: 'Edit Dashboard'}));
  496. expect(await screen.findByRole('button', {name: 'Add widget'})).toBeInTheDocument();
  497. });
  498. it('shows add widget option with dataset selector flag', async function () {
  499. initialData = initializeOrg({
  500. organization: OrganizationFixture({
  501. features: [
  502. 'global-views',
  503. 'dashboards-basic',
  504. 'dashboards-edit',
  505. 'discover-query',
  506. 'custom-metrics',
  507. 'performance-discover-dataset-selector',
  508. ],
  509. }),
  510. });
  511. render(
  512. <OrganizationContext.Provider value={initialData.organization}>
  513. <ViewEditDashboard
  514. {...RouteComponentPropsFixture()}
  515. organization={initialData.organization}
  516. params={{orgId: 'org-slug', dashboardId: '1'}}
  517. router={initialData.router}
  518. location={initialData.router.location}
  519. >
  520. {null}
  521. </ViewEditDashboard>
  522. </OrganizationContext.Provider>,
  523. {router: initialData.router}
  524. );
  525. await userEvent.click(screen.getAllByText('Add Widget')[0]!);
  526. const menuOptions = await screen.findAllByTestId('menu-list-item-label');
  527. expect(menuOptions.map(e => e.textContent)).toEqual([
  528. 'Errors',
  529. 'Transactions',
  530. 'Issues',
  531. 'Releases',
  532. 'Metrics',
  533. ]);
  534. });
  535. it('shows add widget option without dataset selector flag', async function () {
  536. initialData = initializeOrg({
  537. organization: OrganizationFixture({
  538. features: [
  539. 'global-views',
  540. 'dashboards-basic',
  541. 'dashboards-edit',
  542. 'discover-query',
  543. 'custom-metrics',
  544. ],
  545. }),
  546. });
  547. render(
  548. <OrganizationContext.Provider value={initialData.organization}>
  549. <ViewEditDashboard
  550. {...RouteComponentPropsFixture()}
  551. organization={initialData.organization}
  552. params={{orgId: 'org-slug', dashboardId: '1'}}
  553. router={initialData.router}
  554. location={initialData.router.location}
  555. >
  556. {null}
  557. </ViewEditDashboard>
  558. </OrganizationContext.Provider>,
  559. {router: initialData.router}
  560. );
  561. await userEvent.click(screen.getAllByText('Add Widget')[0]!);
  562. const menuOptions = await screen.findAllByTestId('menu-list-item-label');
  563. expect(menuOptions.map(e => e.textContent)).toEqual([
  564. 'Errors and Transactions',
  565. 'Issues',
  566. 'Releases',
  567. 'Metrics',
  568. ]);
  569. });
  570. it('shows top level release filter', async function () {
  571. const mockReleases = MockApiClient.addMockResponse({
  572. url: '/organizations/org-slug/releases/',
  573. body: [ReleaseFixture()],
  574. });
  575. initialData = initializeOrg({
  576. organization: OrganizationFixture({
  577. features: [
  578. 'global-views',
  579. 'dashboards-basic',
  580. 'dashboards-edit',
  581. 'discover-query',
  582. ],
  583. }),
  584. });
  585. render(
  586. <OrganizationContext.Provider value={initialData.organization}>
  587. <ViewEditDashboard
  588. {...RouteComponentPropsFixture()}
  589. organization={initialData.organization}
  590. params={{orgId: 'org-slug', dashboardId: '1'}}
  591. router={initialData.router}
  592. location={initialData.router.location}
  593. >
  594. {null}
  595. </ViewEditDashboard>
  596. </OrganizationContext.Provider>,
  597. {router: initialData.router}
  598. );
  599. expect(await screen.findByText('All Releases')).toBeInTheDocument();
  600. expect(mockReleases).toHaveBeenCalledTimes(2); // Called once when PageFiltersStore is initialized
  601. });
  602. it('hides add widget option', async function () {
  603. // @ts-expect-error this is assigning to readonly property...
  604. types.MAX_WIDGETS = 1;
  605. render(
  606. <OrganizationContext.Provider value={initialData.organization}>
  607. <ViewEditDashboard
  608. {...RouteComponentPropsFixture()}
  609. organization={initialData.organization}
  610. params={{orgId: 'org-slug', dashboardId: '1'}}
  611. router={initialData.router}
  612. location={initialData.router.location}
  613. >
  614. {null}
  615. </ViewEditDashboard>
  616. </OrganizationContext.Provider>,
  617. {router: initialData.router}
  618. );
  619. // Enter edit mode.
  620. await userEvent.click(await screen.findByRole('button', {name: 'Edit Dashboard'}));
  621. expect(screen.queryByRole('button', {name: 'Add widget'})).not.toBeInTheDocument();
  622. });
  623. it('renders successfully if more widgets than stored layouts', async function () {
  624. // A case where someone has async added widgets to a dashboard
  625. MockApiClient.addMockResponse({
  626. url: '/organizations/org-slug/dashboards/1/',
  627. body: DashboardFixture(
  628. [
  629. WidgetFixture({
  630. queries: [
  631. {
  632. name: '',
  633. conditions: 'event.type:error',
  634. fields: ['count()'],
  635. aggregates: ['count()'],
  636. columns: [],
  637. orderby: '-count()',
  638. },
  639. ],
  640. title: 'First Widget',
  641. interval: '1d',
  642. id: '1',
  643. layout: {x: 0, y: 0, w: 2, h: 6, minH: 0},
  644. }),
  645. WidgetFixture({
  646. queries: [
  647. {
  648. name: '',
  649. conditions: 'event.type:error',
  650. fields: ['count()'],
  651. aggregates: ['count()'],
  652. columns: [],
  653. orderby: '-count()',
  654. },
  655. ],
  656. title: 'Second Widget',
  657. interval: '1d',
  658. id: '2',
  659. }),
  660. ],
  661. {id: '1', title: 'Custom Errors'}
  662. ),
  663. });
  664. render(
  665. <ViewEditDashboard
  666. {...RouteComponentPropsFixture()}
  667. organization={initialData.organization}
  668. params={{orgId: 'org-slug', dashboardId: '1'}}
  669. router={initialData.router}
  670. location={initialData.router.location}
  671. >
  672. {null}
  673. </ViewEditDashboard>,
  674. {router: initialData.router, organization: initialData.organization}
  675. );
  676. expect(await screen.findByText('First Widget')).toBeInTheDocument();
  677. expect(await screen.findByText('Second Widget')).toBeInTheDocument();
  678. });
  679. it('does not trigger request if layout not updated', async () => {
  680. MockApiClient.addMockResponse({
  681. url: '/organizations/org-slug/dashboards/1/',
  682. body: DashboardFixture(
  683. [
  684. WidgetFixture({
  685. queries: [
  686. {
  687. name: '',
  688. conditions: 'event.type:error',
  689. fields: ['count()'],
  690. aggregates: ['count()'],
  691. columns: [],
  692. orderby: '-count()',
  693. },
  694. ],
  695. title: 'First Widget',
  696. interval: '1d',
  697. id: '1',
  698. layout: {x: 0, y: 0, w: 2, h: 6, minH: 0},
  699. }),
  700. ],
  701. {id: '1', title: 'Custom Errors'}
  702. ),
  703. });
  704. render(
  705. <ViewEditDashboard
  706. {...RouteComponentPropsFixture()}
  707. organization={initialData.organization}
  708. params={{orgId: 'org-slug', dashboardId: '1'}}
  709. router={initialData.router}
  710. location={initialData.router.location}
  711. >
  712. {null}
  713. </ViewEditDashboard>,
  714. {router: initialData.router, organization: initialData.organization}
  715. );
  716. await userEvent.click(await screen.findByText('Edit Dashboard'));
  717. await userEvent.click(await screen.findByText('Save and Finish'));
  718. expect(screen.getByText('Edit Dashboard')).toBeInTheDocument();
  719. expect(mockPut).not.toHaveBeenCalled();
  720. });
  721. it('renders the custom resize handler for a widget', async () => {
  722. MockApiClient.addMockResponse({
  723. url: '/organizations/org-slug/dashboards/1/',
  724. body: DashboardFixture(
  725. [
  726. WidgetFixture({
  727. queries: [
  728. {
  729. name: '',
  730. conditions: 'event.type:error',
  731. fields: ['count()'],
  732. aggregates: ['count()'],
  733. columns: [],
  734. orderby: '-count()',
  735. },
  736. ],
  737. title: 'First Widget',
  738. interval: '1d',
  739. id: '1',
  740. layout: {x: 0, y: 0, w: 2, h: 6, minH: 0},
  741. }),
  742. ],
  743. {id: '1', title: 'Custom Errors'}
  744. ),
  745. });
  746. render(
  747. <ViewEditDashboard
  748. {...RouteComponentPropsFixture()}
  749. organization={initialData.organization}
  750. params={{orgId: 'org-slug', dashboardId: '1'}}
  751. router={initialData.router}
  752. location={initialData.router.location}
  753. >
  754. {null}
  755. </ViewEditDashboard>,
  756. {router: initialData.router, organization: initialData.organization}
  757. );
  758. await userEvent.click(await screen.findByText('Edit Dashboard'));
  759. const widget = (await screen.findByText('First Widget')).closest(
  760. '.react-grid-item'
  761. ) as HTMLElement;
  762. const resizeHandle = within(widget).getByTestId('custom-resize-handle');
  763. expect(resizeHandle).toBeVisible();
  764. });
  765. it('does not trigger an alert when the widgets have no layout and user cancels without changes', async () => {
  766. MockApiClient.addMockResponse({
  767. url: '/organizations/org-slug/dashboards/1/',
  768. body: DashboardFixture(
  769. [
  770. WidgetFixture({
  771. queries: [
  772. {
  773. name: '',
  774. conditions: 'event.type:error',
  775. fields: ['count()'],
  776. aggregates: ['count()'],
  777. columns: [],
  778. orderby: '-count()',
  779. },
  780. ],
  781. title: 'First Widget',
  782. interval: '1d',
  783. id: '1',
  784. layout: null,
  785. }),
  786. ],
  787. {id: '1', title: 'Custom Errors'}
  788. ),
  789. });
  790. render(
  791. <ViewEditDashboard
  792. {...RouteComponentPropsFixture()}
  793. organization={initialData.organization}
  794. params={{orgId: 'org-slug', dashboardId: '1'}}
  795. router={initialData.router}
  796. location={initialData.router.location}
  797. >
  798. {null}
  799. </ViewEditDashboard>,
  800. {router: initialData.router, organization: initialData.organization}
  801. );
  802. await userEvent.click(await screen.findByText('Edit Dashboard'));
  803. await userEvent.click(await screen.findByText('Cancel'));
  804. expect(window.confirm).not.toHaveBeenCalled();
  805. });
  806. it('opens the widget viewer modal using the widget id specified in the url', async () => {
  807. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  808. const widget = WidgetFixture({
  809. queries: [
  810. {
  811. name: '',
  812. conditions: 'event.type:error',
  813. fields: ['count()'],
  814. aggregates: ['count()'],
  815. columns: [],
  816. orderby: '',
  817. },
  818. ],
  819. title: 'First Widget',
  820. interval: '1d',
  821. id: '1',
  822. layout: null,
  823. });
  824. MockApiClient.addMockResponse({
  825. url: '/organizations/org-slug/dashboards/1/',
  826. body: DashboardFixture([widget], {id: '1', title: 'Custom Errors'}),
  827. });
  828. render(
  829. <ViewEditDashboard
  830. {...RouteComponentPropsFixture()}
  831. organization={initialData.organization}
  832. params={{orgId: 'org-slug', dashboardId: '1', widgetId: 1}}
  833. router={initialData.router}
  834. location={{...initialData.router.location, pathname: '/widget/123/'}}
  835. >
  836. {null}
  837. </ViewEditDashboard>,
  838. {router: initialData.router, organization: initialData.organization}
  839. );
  840. await waitFor(() => {
  841. expect(openWidgetViewerModal).toHaveBeenCalledWith(
  842. expect.objectContaining({
  843. organization: initialData.organization,
  844. widget,
  845. onClose: expect.anything(),
  846. })
  847. );
  848. });
  849. });
  850. it('redirects user to dashboard url if widget is not found', async () => {
  851. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  852. MockApiClient.addMockResponse({
  853. url: '/organizations/org-slug/dashboards/1/',
  854. body: DashboardFixture([], {id: '1', title: 'Custom Errors'}),
  855. });
  856. render(
  857. <ViewEditDashboard
  858. {...RouteComponentPropsFixture()}
  859. organization={initialData.organization}
  860. params={{orgId: 'org-slug', dashboardId: '1', widgetId: 123}}
  861. router={initialData.router}
  862. location={{...initialData.router.location, pathname: '/widget/123/'}}
  863. >
  864. {null}
  865. </ViewEditDashboard>,
  866. {router: initialData.router, organization: initialData.organization}
  867. );
  868. expect(await screen.findByText('All Releases')).toBeInTheDocument();
  869. expect(openWidgetViewerModal).not.toHaveBeenCalled();
  870. expect(initialData.router.replace).toHaveBeenCalledWith(
  871. expect.objectContaining({
  872. pathname: '/organizations/org-slug/dashboard/1/',
  873. query: {},
  874. })
  875. );
  876. });
  877. it('saves a new dashboard with the page filters', async () => {
  878. const mockPOST = MockApiClient.addMockResponse({
  879. url: '/organizations/org-slug/dashboards/',
  880. method: 'POST',
  881. body: [],
  882. });
  883. render(
  884. <CreateDashboard
  885. {...RouteComponentPropsFixture()}
  886. organization={initialData.organization}
  887. params={{templateId: undefined}}
  888. router={initialData.router}
  889. location={{
  890. ...initialData.router.location,
  891. query: {
  892. ...initialData.router.location.query,
  893. statsPeriod: '7d',
  894. project: [2],
  895. environment: ['alpha', 'beta'],
  896. },
  897. }}
  898. >
  899. {null}
  900. </CreateDashboard>,
  901. {
  902. router: initialData.router,
  903. organization: initialData.organization,
  904. }
  905. );
  906. await userEvent.click(await screen.findByText('Save and Finish'));
  907. expect(mockPOST).toHaveBeenCalledWith(
  908. '/organizations/org-slug/dashboards/',
  909. expect.objectContaining({
  910. data: expect.objectContaining({
  911. projects: [2],
  912. environment: ['alpha', 'beta'],
  913. period: '7d',
  914. }),
  915. })
  916. );
  917. });
  918. it('saves a template with the page filters', async () => {
  919. const mockPOST = MockApiClient.addMockResponse({
  920. url: '/organizations/org-slug/dashboards/',
  921. method: 'POST',
  922. body: [],
  923. });
  924. render(
  925. <CreateDashboard
  926. {...RouteComponentPropsFixture()}
  927. organization={initialData.organization}
  928. params={{templateId: 'default-template'}}
  929. router={initialData.router}
  930. location={{
  931. ...initialData.router.location,
  932. query: {
  933. ...initialData.router.location.query,
  934. statsPeriod: '7d',
  935. project: [2],
  936. environment: ['alpha', 'beta'],
  937. },
  938. }}
  939. >
  940. {null}
  941. </CreateDashboard>,
  942. {
  943. router: initialData.router,
  944. organization: initialData.organization,
  945. }
  946. );
  947. await userEvent.click(await screen.findByText('Add Dashboard'));
  948. expect(mockPOST).toHaveBeenCalledWith(
  949. '/organizations/org-slug/dashboards/',
  950. expect.objectContaining({
  951. data: expect.objectContaining({
  952. projects: [2],
  953. environment: ['alpha', 'beta'],
  954. period: '7d',
  955. }),
  956. })
  957. );
  958. });
  959. it('does not render save and cancel buttons on templates', async () => {
  960. MockApiClient.addMockResponse({
  961. url: '/organizations/org-slug/releases/',
  962. body: [
  963. ReleaseFixture({
  964. shortVersion: 'sentry-android-shop@1.2.0',
  965. version: 'sentry-android-shop@1.2.0',
  966. }),
  967. ],
  968. });
  969. render(
  970. <CreateDashboard
  971. {...RouteComponentPropsFixture()}
  972. organization={initialData.organization}
  973. params={{templateId: 'default-template'}}
  974. router={initialData.router}
  975. location={initialData.router.location}
  976. >
  977. {null}
  978. </CreateDashboard>,
  979. {
  980. router: initialData.router,
  981. organization: initialData.organization,
  982. }
  983. );
  984. await userEvent.click(await screen.findByText('24H'));
  985. await userEvent.click(screen.getByText('Last 7 days'));
  986. await screen.findByText('7D');
  987. expect(screen.queryByTestId('filter-bar-cancel')).not.toBeInTheDocument();
  988. expect(screen.queryByText('Save')).not.toBeInTheDocument();
  989. });
  990. it('opens the widget viewer with saved dashboard filters', async () => {
  991. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  992. MockApiClient.addMockResponse({
  993. url: '/organizations/org-slug/dashboards/1/',
  994. body: DashboardFixture(widgets, {
  995. id: '1',
  996. filters: {release: ['sentry-android-shop@1.2.0']},
  997. }),
  998. });
  999. render(
  1000. <ViewEditDashboard
  1001. {...RouteComponentPropsFixture()}
  1002. organization={initialData.organization}
  1003. params={{orgId: 'org-slug', dashboardId: '1', widgetId: 1}}
  1004. router={initialData.router}
  1005. location={{...initialData.router.location, pathname: '/widget/1/'}}
  1006. >
  1007. {null}
  1008. </ViewEditDashboard>,
  1009. {router: initialData.router, organization: initialData.organization}
  1010. );
  1011. await waitFor(() => {
  1012. expect(openWidgetViewerModal).toHaveBeenCalledWith(
  1013. expect.objectContaining({
  1014. dashboardFilters: {release: ['sentry-android-shop@1.2.0']},
  1015. })
  1016. );
  1017. });
  1018. });
  1019. it('opens the widget viewer with unsaved dashboard filters', async () => {
  1020. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  1021. MockApiClient.addMockResponse({
  1022. url: '/organizations/org-slug/dashboards/1/',
  1023. body: DashboardFixture(widgets, {
  1024. id: '1',
  1025. filters: {release: ['sentry-android-shop@1.2.0']},
  1026. }),
  1027. });
  1028. render(
  1029. <ViewEditDashboard
  1030. {...RouteComponentPropsFixture()}
  1031. organization={initialData.organization}
  1032. params={{orgId: 'org-slug', dashboardId: '1', widgetId: 1}}
  1033. router={initialData.router}
  1034. location={{
  1035. ...initialData.router.location,
  1036. pathname: '/widget/1/',
  1037. query: {release: ['unsaved-release-filter@1.2.0']},
  1038. }}
  1039. >
  1040. {null}
  1041. </ViewEditDashboard>,
  1042. {router: initialData.router, organization: initialData.organization}
  1043. );
  1044. await waitFor(() => {
  1045. expect(openWidgetViewerModal).toHaveBeenCalledWith(
  1046. expect.objectContaining({
  1047. dashboardFilters: {release: ['unsaved-release-filter@1.2.0']},
  1048. })
  1049. );
  1050. });
  1051. });
  1052. it('can save dashboard filters in existing dashboard', async () => {
  1053. MockApiClient.addMockResponse({
  1054. url: '/organizations/org-slug/releases/',
  1055. body: [
  1056. ReleaseFixture({
  1057. shortVersion: 'sentry-android-shop@1.2.0',
  1058. version: 'sentry-android-shop@1.2.0',
  1059. }),
  1060. ],
  1061. });
  1062. const testData = initializeOrg({
  1063. organization: OrganizationFixture({
  1064. features: [
  1065. 'global-views',
  1066. 'dashboards-basic',
  1067. 'dashboards-edit',
  1068. 'discover-query',
  1069. ],
  1070. }),
  1071. router: {
  1072. location: {
  1073. ...LocationFixture(),
  1074. query: {
  1075. statsPeriod: '7d',
  1076. release: ['sentry-android-shop@1.2.0'],
  1077. },
  1078. },
  1079. },
  1080. });
  1081. render(
  1082. <ViewEditDashboard
  1083. {...RouteComponentPropsFixture()}
  1084. organization={testData.organization}
  1085. params={{orgId: 'org-slug', dashboardId: '1'}}
  1086. router={testData.router}
  1087. location={testData.router.location}
  1088. >
  1089. {null}
  1090. </ViewEditDashboard>,
  1091. {router: testData.router, organization: testData.organization}
  1092. );
  1093. await userEvent.click(await screen.findByText('Save'));
  1094. expect(mockPut).toHaveBeenCalledWith(
  1095. '/organizations/org-slug/dashboards/1/',
  1096. expect.objectContaining({
  1097. data: expect.objectContaining({
  1098. period: '7d',
  1099. filters: {release: ['sentry-android-shop@1.2.0']},
  1100. }),
  1101. })
  1102. );
  1103. });
  1104. it('can clear dashboard filters in compact select', async () => {
  1105. MockApiClient.addMockResponse({
  1106. url: '/organizations/org-slug/dashboards/1/',
  1107. body: DashboardFixture(widgets, {
  1108. id: '1',
  1109. title: 'Custom Errors',
  1110. filters: {release: ['sentry-android-shop@1.2.0']},
  1111. }),
  1112. });
  1113. MockApiClient.addMockResponse({
  1114. url: '/organizations/org-slug/releases/',
  1115. body: [
  1116. ReleaseFixture({
  1117. shortVersion: 'sentry-android-shop@1.2.0',
  1118. version: 'sentry-android-shop@1.2.0',
  1119. }),
  1120. ],
  1121. });
  1122. const testData = initializeOrg({
  1123. organization: OrganizationFixture({
  1124. features: [
  1125. 'global-views',
  1126. 'dashboards-basic',
  1127. 'dashboards-edit',
  1128. 'discover-query',
  1129. ],
  1130. }),
  1131. router: {
  1132. location: {
  1133. ...LocationFixture(),
  1134. query: {
  1135. statsPeriod: '7d',
  1136. },
  1137. },
  1138. },
  1139. });
  1140. render(
  1141. <ViewEditDashboard
  1142. {...RouteComponentPropsFixture()}
  1143. organization={testData.organization}
  1144. params={{orgId: 'org-slug', dashboardId: '1'}}
  1145. router={testData.router}
  1146. location={testData.router.location}
  1147. >
  1148. {null}
  1149. </ViewEditDashboard>,
  1150. {router: testData.router, organization: testData.organization}
  1151. );
  1152. await screen.findByText('7D');
  1153. await userEvent.click(await screen.findByText('sentry-android-shop@1.2.0'));
  1154. await userEvent.click(screen.getAllByText('Clear')[0]!);
  1155. screen.getByText('All Releases');
  1156. await userEvent.click(document.body);
  1157. await waitFor(() => {
  1158. expect(testData.router.push).toHaveBeenCalledWith(
  1159. expect.objectContaining({
  1160. query: expect.objectContaining({
  1161. release: '',
  1162. }),
  1163. })
  1164. );
  1165. });
  1166. });
  1167. it('can save absolute time range in existing dashboard', async () => {
  1168. const testData = initializeOrg({
  1169. organization: OrganizationFixture({
  1170. features: [
  1171. 'global-views',
  1172. 'dashboards-basic',
  1173. 'dashboards-edit',
  1174. 'discover-query',
  1175. ],
  1176. }),
  1177. router: {
  1178. location: {
  1179. ...LocationFixture(),
  1180. query: {
  1181. start: '2022-07-14T07:00:00',
  1182. end: '2022-07-19T23:59:59',
  1183. utc: 'true',
  1184. },
  1185. },
  1186. },
  1187. });
  1188. render(
  1189. <ViewEditDashboard
  1190. {...RouteComponentPropsFixture()}
  1191. organization={testData.organization}
  1192. params={{orgId: 'org-slug', dashboardId: '1'}}
  1193. router={testData.router}
  1194. location={testData.router.location}
  1195. >
  1196. {null}
  1197. </ViewEditDashboard>,
  1198. {router: testData.router, organization: testData.organization}
  1199. );
  1200. await userEvent.click(await screen.findByText('Save'));
  1201. expect(mockPut).toHaveBeenCalledWith(
  1202. '/organizations/org-slug/dashboards/1/',
  1203. expect.objectContaining({
  1204. data: expect.objectContaining({
  1205. start: '2022-07-14T07:00:00.000',
  1206. end: '2022-07-19T23:59:59.000',
  1207. utc: true,
  1208. }),
  1209. })
  1210. );
  1211. });
  1212. it('can clear dashboard filters in existing dashboard', async () => {
  1213. MockApiClient.addMockResponse({
  1214. url: '/organizations/org-slug/releases/',
  1215. body: [
  1216. ReleaseFixture({
  1217. shortVersion: 'sentry-android-shop@1.2.0',
  1218. version: 'sentry-android-shop@1.2.0',
  1219. }),
  1220. ],
  1221. });
  1222. const testData = initializeOrg({
  1223. organization: OrganizationFixture({
  1224. features: [
  1225. 'global-views',
  1226. 'dashboards-basic',
  1227. 'dashboards-edit',
  1228. 'discover-query',
  1229. ],
  1230. }),
  1231. router: {
  1232. location: {
  1233. ...LocationFixture(),
  1234. query: {
  1235. statsPeriod: '7d',
  1236. environment: ['alpha', 'beta'],
  1237. },
  1238. },
  1239. },
  1240. });
  1241. render(
  1242. <ViewEditDashboard
  1243. {...RouteComponentPropsFixture()}
  1244. organization={testData.organization}
  1245. params={{orgId: 'org-slug', dashboardId: '1'}}
  1246. router={testData.router}
  1247. location={testData.router.location}
  1248. >
  1249. {null}
  1250. </ViewEditDashboard>,
  1251. {router: testData.router, organization: testData.organization}
  1252. );
  1253. await screen.findByText('7D');
  1254. await userEvent.click(await screen.findByText('All Releases'));
  1255. await userEvent.click(screen.getByText('sentry-android-shop@1.2.0'));
  1256. await userEvent.keyboard('{Escape}');
  1257. await userEvent.click(screen.getByTestId('filter-bar-cancel'));
  1258. screen.getByText('All Releases');
  1259. expect(testData.router.replace).toHaveBeenCalledWith(
  1260. expect.objectContaining({
  1261. query: expect.objectContaining({
  1262. project: undefined,
  1263. statsPeriod: undefined,
  1264. environment: undefined,
  1265. }),
  1266. })
  1267. );
  1268. });
  1269. it('disables the Edit Dashboard button when there are unsaved filters', async () => {
  1270. MockApiClient.addMockResponse({
  1271. url: '/organizations/org-slug/releases/',
  1272. body: [
  1273. ReleaseFixture({
  1274. shortVersion: 'sentry-android-shop@1.2.0',
  1275. version: 'sentry-android-shop@1.2.0',
  1276. }),
  1277. ],
  1278. });
  1279. const testData = initializeOrg({
  1280. organization: OrganizationFixture({
  1281. features: [
  1282. 'global-views',
  1283. 'dashboards-basic',
  1284. 'dashboards-edit',
  1285. 'discover-basic',
  1286. 'discover-query',
  1287. ],
  1288. }),
  1289. router: {
  1290. location: {
  1291. ...LocationFixture(),
  1292. query: {
  1293. statsPeriod: '7d',
  1294. environment: ['alpha', 'beta'],
  1295. },
  1296. },
  1297. },
  1298. });
  1299. render(
  1300. <ViewEditDashboard
  1301. {...RouteComponentPropsFixture()}
  1302. organization={testData.organization}
  1303. params={{orgId: 'org-slug', dashboardId: '1'}}
  1304. router={testData.router}
  1305. location={testData.router.location}
  1306. >
  1307. {null}
  1308. </ViewEditDashboard>,
  1309. {router: testData.router, organization: testData.organization}
  1310. );
  1311. expect(await screen.findByText('Save')).toBeInTheDocument();
  1312. expect(screen.getByTestId('filter-bar-cancel')).toBeInTheDocument();
  1313. expect(screen.getByRole('button', {name: 'Edit Dashboard'})).toBeDisabled();
  1314. });
  1315. it('ignores the order of selection of page filters to render unsaved filters', async () => {
  1316. const testProjects = [
  1317. ProjectFixture({id: '1', name: 'first', environments: ['alpha', 'beta']}),
  1318. ProjectFixture({id: '2', name: 'second', environments: ['alpha', 'beta']}),
  1319. ];
  1320. act(() => ProjectsStore.loadInitialData(testProjects));
  1321. MockApiClient.addMockResponse({
  1322. url: '/organizations/org-slug/projects/',
  1323. body: testProjects,
  1324. });
  1325. MockApiClient.addMockResponse({
  1326. url: '/organizations/org-slug/dashboards/1/',
  1327. body: DashboardFixture(widgets, {
  1328. id: '1',
  1329. title: 'Custom Errors',
  1330. filters: {},
  1331. environment: ['alpha', 'beta'],
  1332. }),
  1333. });
  1334. const testData = initializeOrg({
  1335. organization: OrganizationFixture({
  1336. features: [
  1337. 'global-views',
  1338. 'dashboards-basic',
  1339. 'dashboards-edit',
  1340. 'discover-query',
  1341. ],
  1342. }),
  1343. router: {
  1344. location: {
  1345. ...LocationFixture(),
  1346. query: {
  1347. environment: ['beta', 'alpha'], // Reversed order from saved dashboard
  1348. },
  1349. },
  1350. },
  1351. });
  1352. render(
  1353. <ViewEditDashboard
  1354. {...RouteComponentPropsFixture()}
  1355. organization={testData.organization}
  1356. params={{orgId: 'org-slug', dashboardId: '1'}}
  1357. router={testData.router}
  1358. location={testData.router.location}
  1359. >
  1360. {null}
  1361. </ViewEditDashboard>,
  1362. {router: testData.router, organization: testData.organization}
  1363. );
  1364. await waitFor(() => expect(screen.queryAllByText('Loading\u2026')).toEqual([]));
  1365. await userEvent.click(screen.getByRole('button', {name: 'All Envs'}));
  1366. expect(screen.getByRole('row', {name: 'alpha'})).toHaveAttribute(
  1367. 'aria-selected',
  1368. 'true'
  1369. );
  1370. expect(screen.getByRole('row', {name: 'beta'})).toHaveAttribute(
  1371. 'aria-selected',
  1372. 'true'
  1373. );
  1374. // Save and Cancel should not appear because alpha, beta is the same as beta, alpha
  1375. expect(screen.queryByText('Save')).not.toBeInTheDocument();
  1376. expect(screen.queryByTestId('filter-bar-cancel')).not.toBeInTheDocument();
  1377. });
  1378. it('uses releases from the URL query params', async function () {
  1379. const testData = initializeOrg({
  1380. organization: OrganizationFixture({
  1381. features: [
  1382. 'global-views',
  1383. 'dashboards-basic',
  1384. 'dashboards-edit',
  1385. 'discover-query',
  1386. ],
  1387. }),
  1388. router: {
  1389. location: {
  1390. ...LocationFixture(),
  1391. query: {
  1392. release: ['not-selected-1'],
  1393. },
  1394. },
  1395. },
  1396. });
  1397. render(
  1398. <ViewEditDashboard
  1399. {...RouteComponentPropsFixture()}
  1400. organization={testData.organization}
  1401. params={{orgId: 'org-slug', dashboardId: '1'}}
  1402. router={testData.router}
  1403. location={testData.router.location}
  1404. >
  1405. {null}
  1406. </ViewEditDashboard>,
  1407. {router: testData.router, organization: testData.organization}
  1408. );
  1409. await screen.findByText(/not-selected-1/);
  1410. screen.getByText('Save');
  1411. screen.getByTestId('filter-bar-cancel');
  1412. });
  1413. it('resets release in URL params', async function () {
  1414. MockApiClient.addMockResponse({
  1415. url: '/organizations/org-slug/dashboards/1/',
  1416. body: DashboardFixture(widgets, {
  1417. id: '1',
  1418. title: 'Custom Errors',
  1419. filters: {
  1420. release: ['abc'],
  1421. },
  1422. }),
  1423. });
  1424. const testData = initializeOrg({
  1425. organization: OrganizationFixture({
  1426. features: [
  1427. 'global-views',
  1428. 'dashboards-basic',
  1429. 'dashboards-edit',
  1430. 'discover-query',
  1431. ],
  1432. }),
  1433. router: {
  1434. location: {
  1435. ...LocationFixture(),
  1436. query: {
  1437. release: ['not-selected-1'],
  1438. },
  1439. },
  1440. },
  1441. });
  1442. render(
  1443. <ViewEditDashboard
  1444. {...RouteComponentPropsFixture()}
  1445. organization={testData.organization}
  1446. params={{orgId: 'org-slug', dashboardId: '1'}}
  1447. router={testData.router}
  1448. location={testData.router.location}
  1449. >
  1450. {null}
  1451. </ViewEditDashboard>,
  1452. {router: testData.router, organization: testData.organization}
  1453. );
  1454. await screen.findByText(/not-selected-1/);
  1455. await userEvent.click(screen.getByTestId('filter-bar-cancel'));
  1456. // release isn't used in the redirect
  1457. expect(testData.router.replace).toHaveBeenCalledWith(
  1458. expect.objectContaining({
  1459. query: {
  1460. end: undefined,
  1461. environment: undefined,
  1462. project: undefined,
  1463. start: undefined,
  1464. statsPeriod: undefined,
  1465. utc: undefined,
  1466. },
  1467. })
  1468. );
  1469. });
  1470. it('reflects selections in the release filter in the query params', async function () {
  1471. MockApiClient.addMockResponse({
  1472. url: '/organizations/org-slug/releases/',
  1473. body: [
  1474. ReleaseFixture({
  1475. shortVersion: 'sentry-android-shop@1.2.0',
  1476. version: 'sentry-android-shop@1.2.0',
  1477. }),
  1478. ],
  1479. });
  1480. const testData = initializeOrg({
  1481. organization: OrganizationFixture({
  1482. features: [
  1483. 'global-views',
  1484. 'dashboards-basic',
  1485. 'dashboards-edit',
  1486. 'discover-query',
  1487. ],
  1488. }),
  1489. router: {
  1490. location: LocationFixture(),
  1491. },
  1492. });
  1493. render(
  1494. <ViewEditDashboard
  1495. {...RouteComponentPropsFixture()}
  1496. organization={testData.organization}
  1497. params={{orgId: 'org-slug', dashboardId: '1'}}
  1498. router={testData.router}
  1499. location={testData.router.location}
  1500. >
  1501. {null}
  1502. </ViewEditDashboard>,
  1503. {router: testData.router, organization: testData.organization}
  1504. );
  1505. await userEvent.click(await screen.findByText('All Releases'));
  1506. await userEvent.click(screen.getByText('sentry-android-shop@1.2.0'));
  1507. await userEvent.click(document.body);
  1508. await waitFor(() => {
  1509. expect(testData.router.push).toHaveBeenCalledWith(
  1510. expect.objectContaining({
  1511. query: expect.objectContaining({
  1512. release: ['sentry-android-shop@1.2.0'],
  1513. }),
  1514. })
  1515. );
  1516. });
  1517. });
  1518. it('persists release selections made during search requests that do not appear in default query', async function () {
  1519. // Default response
  1520. MockApiClient.addMockResponse({
  1521. url: '/organizations/org-slug/releases/',
  1522. body: [
  1523. ReleaseFixture({
  1524. shortVersion: 'sentry-android-shop@1.2.0',
  1525. version: 'sentry-android-shop@1.2.0',
  1526. }),
  1527. ],
  1528. });
  1529. // Mocked search results
  1530. MockApiClient.addMockResponse({
  1531. url: '/organizations/org-slug/releases/',
  1532. body: [
  1533. ReleaseFixture({
  1534. id: '9',
  1535. shortVersion: 'search-result',
  1536. version: 'search-result',
  1537. }),
  1538. ],
  1539. match: [MockApiClient.matchData({query: 's'})],
  1540. });
  1541. const testData = initializeOrg({
  1542. organization: OrganizationFixture({
  1543. features: [
  1544. 'global-views',
  1545. 'dashboards-basic',
  1546. 'dashboards-edit',
  1547. 'discover-basic',
  1548. 'discover-query',
  1549. ],
  1550. }),
  1551. router: {
  1552. location: LocationFixture(),
  1553. },
  1554. });
  1555. render(
  1556. <ViewEditDashboard
  1557. {...RouteComponentPropsFixture()}
  1558. organization={testData.organization}
  1559. params={{orgId: 'org-slug', dashboardId: '1'}}
  1560. router={testData.router}
  1561. location={testData.router.location}
  1562. >
  1563. {null}
  1564. </ViewEditDashboard>,
  1565. {router: testData.router, organization: testData.organization}
  1566. );
  1567. await userEvent.click(await screen.findByText('All Releases'));
  1568. await userEvent.type(screen.getAllByPlaceholderText('Search\u2026')[2]!, 's');
  1569. await userEvent.click(await screen.findByRole('option', {name: 'search-result'}));
  1570. // Validate that after search is cleared, search result still appears
  1571. expect(await screen.findByText('Latest Release(s)')).toBeInTheDocument();
  1572. expect(screen.getByRole('option', {name: 'search-result'})).toBeInTheDocument();
  1573. });
  1574. it('renders edit access selector', async function () {
  1575. render(
  1576. <EditAccessSelector
  1577. dashboard={DashboardFixture([], {id: '1', title: 'Custom Errors'})}
  1578. onChangeEditAccess={jest.fn()}
  1579. />,
  1580. {
  1581. router: initialData.router,
  1582. organization: initialData.organization,
  1583. }
  1584. );
  1585. await userEvent.click(await screen.findByText('Edit Access:'));
  1586. expect(screen.getByText('Creator')).toBeInTheDocument();
  1587. expect(screen.getByText('All users')).toBeInTheDocument();
  1588. });
  1589. it('creates and updates new permissions for dashboard with no edit perms initialized', async function () {
  1590. const mockPUT = MockApiClient.addMockResponse({
  1591. url: '/organizations/org-slug/dashboards/1/',
  1592. method: 'PUT',
  1593. body: DashboardFixture([], {id: '1', title: 'Custom Errors'}),
  1594. });
  1595. render(
  1596. <ViewEditDashboard
  1597. {...RouteComponentPropsFixture()}
  1598. organization={initialData.organization}
  1599. params={{orgId: 'org-slug', dashboardId: '1'}}
  1600. router={initialData.router}
  1601. location={initialData.router.location}
  1602. >
  1603. {null}
  1604. </ViewEditDashboard>,
  1605. {
  1606. router: initialData.router,
  1607. organization: initialData.organization,
  1608. }
  1609. );
  1610. await userEvent.click(await screen.findByText('Edit Access:'));
  1611. // deselects 'All users' so only creator has edit access
  1612. expect(await screen.findByText('All users')).toBeEnabled();
  1613. expect(await screen.findByRole('option', {name: 'All users'})).toHaveAttribute(
  1614. 'aria-selected',
  1615. 'true'
  1616. );
  1617. await userEvent.click(screen.getByRole('option', {name: 'All users'}));
  1618. expect(await screen.findByRole('option', {name: 'All users'})).toHaveAttribute(
  1619. 'aria-selected',
  1620. 'false'
  1621. );
  1622. await userEvent.click(await screen.findByText('Save Changes'));
  1623. await waitFor(() => {
  1624. expect(mockPUT).toHaveBeenCalledTimes(1);
  1625. });
  1626. expect(mockPUT).toHaveBeenCalledWith(
  1627. '/organizations/org-slug/dashboards/1/',
  1628. expect.objectContaining({
  1629. data: expect.objectContaining({
  1630. permissions: {isEditableByEveryone: false, teamsWithEditAccess: []},
  1631. }),
  1632. })
  1633. );
  1634. });
  1635. it('creator can update permissions for dashboard', async function () {
  1636. const mockPUT = MockApiClient.addMockResponse({
  1637. url: '/organizations/org-slug/dashboards/1/',
  1638. method: 'PUT',
  1639. body: DashboardFixture([], {id: '1', title: 'Custom Errors'}),
  1640. });
  1641. MockApiClient.addMockResponse({
  1642. url: '/organizations/org-slug/dashboards/1/',
  1643. body: DashboardFixture([], {
  1644. id: '1',
  1645. title: 'Custom Errors',
  1646. createdBy: UserFixture({id: '781629'}),
  1647. permissions: {isEditableByEveryone: false},
  1648. }),
  1649. });
  1650. const currentUser = UserFixture({id: '781629'});
  1651. ConfigStore.set('user', currentUser);
  1652. render(
  1653. <ViewEditDashboard
  1654. {...RouteComponentPropsFixture()}
  1655. organization={initialData.organization}
  1656. params={{orgId: 'org-slug', dashboardId: '1'}}
  1657. router={initialData.router}
  1658. location={initialData.router.location}
  1659. >
  1660. {null}
  1661. </ViewEditDashboard>,
  1662. {
  1663. router: initialData.router,
  1664. organization: initialData.organization,
  1665. }
  1666. );
  1667. await userEvent.click(await screen.findByText('Edit Access:'));
  1668. // selects 'All users' so everyone has edit access
  1669. expect(await screen.findByText('All users')).toBeEnabled();
  1670. expect(await screen.findByRole('option', {name: 'All users'})).toHaveAttribute(
  1671. 'aria-selected',
  1672. 'false'
  1673. );
  1674. await userEvent.click(screen.getByRole('option', {name: 'All users'}));
  1675. expect(await screen.findByRole('option', {name: 'All users'})).toHaveAttribute(
  1676. 'aria-selected',
  1677. 'true'
  1678. );
  1679. await userEvent.click(await screen.findByText('Save Changes'));
  1680. await waitFor(() => {
  1681. expect(mockPUT).toHaveBeenCalledTimes(1);
  1682. });
  1683. expect(mockPUT).toHaveBeenCalledWith(
  1684. '/organizations/org-slug/dashboards/1/',
  1685. expect.objectContaining({
  1686. data: expect.objectContaining({
  1687. permissions: {isEditableByEveryone: true, teamsWithEditAccess: []},
  1688. }),
  1689. })
  1690. );
  1691. });
  1692. it('creator can update permissions with teams for dashboard', async function () {
  1693. const mockPUT = MockApiClient.addMockResponse({
  1694. url: '/organizations/org-slug/dashboards/1/',
  1695. method: 'PUT',
  1696. body: DashboardFixture([], {id: '1', title: 'Custom Errors'}),
  1697. });
  1698. MockApiClient.addMockResponse({
  1699. url: '/organizations/org-slug/dashboards/1/',
  1700. body: DashboardFixture([], {
  1701. id: '1',
  1702. title: 'Custom Errors',
  1703. createdBy: UserFixture({id: '781629'}),
  1704. permissions: {isEditableByEveryone: false},
  1705. }),
  1706. });
  1707. const currentUser = UserFixture({id: '781629'});
  1708. ConfigStore.set('user', currentUser);
  1709. const teamData = [
  1710. {
  1711. id: '1',
  1712. slug: 'team1',
  1713. name: 'Team 1',
  1714. },
  1715. {
  1716. id: '2',
  1717. slug: 'team2',
  1718. name: 'Team 2',
  1719. },
  1720. {
  1721. id: '3',
  1722. slug: 'team3',
  1723. name: 'Team 3',
  1724. },
  1725. ];
  1726. const teams = teamData.map(data => TeamFixture(data));
  1727. TeamStore.loadInitialData(teams);
  1728. render(
  1729. <ViewEditDashboard
  1730. {...RouteComponentPropsFixture()}
  1731. organization={initialData.organization}
  1732. params={{orgId: 'org-slug', dashboardId: '1'}}
  1733. router={initialData.router}
  1734. location={initialData.router.location}
  1735. >
  1736. {null}
  1737. </ViewEditDashboard>,
  1738. {
  1739. router: initialData.router,
  1740. organization: initialData.organization,
  1741. }
  1742. );
  1743. await userEvent.click(await screen.findByText('Edit Access:'));
  1744. expect(await screen.findByText('All users')).toBeEnabled();
  1745. expect(await screen.findByRole('option', {name: 'All users'})).toHaveAttribute(
  1746. 'aria-selected',
  1747. 'false'
  1748. );
  1749. await userEvent.click(screen.getByRole('option', {name: '#team1'}));
  1750. await userEvent.click(screen.getByRole('option', {name: '#team2'}));
  1751. await userEvent.click(await screen.findByText('Save Changes'));
  1752. await waitFor(() => {
  1753. expect(mockPUT).toHaveBeenCalledTimes(1);
  1754. });
  1755. expect(mockPUT).toHaveBeenCalledWith(
  1756. '/organizations/org-slug/dashboards/1/',
  1757. expect.objectContaining({
  1758. data: expect.objectContaining({
  1759. permissions: {isEditableByEveryone: false, teamsWithEditAccess: [1, 2]},
  1760. }),
  1761. })
  1762. );
  1763. });
  1764. it('disables edit dashboard and add widget button if user cannot edit dashboard', async function () {
  1765. MockApiClient.addMockResponse({
  1766. url: '/organizations/org-slug/dashboards/',
  1767. body: [
  1768. DashboardFixture([], {
  1769. id: '1',
  1770. title: 'Custom Errors',
  1771. createdBy: UserFixture({id: '238900'}),
  1772. permissions: {isEditableByEveryone: false},
  1773. }),
  1774. ],
  1775. });
  1776. MockApiClient.addMockResponse({
  1777. url: '/organizations/org-slug/dashboards/1/',
  1778. body: DashboardFixture([], {
  1779. id: '1',
  1780. title: 'Custom Errors',
  1781. createdBy: UserFixture({id: '238900'}),
  1782. permissions: {isEditableByEveryone: false},
  1783. }),
  1784. });
  1785. const currentUser = UserFixture({id: '781629', isSuperuser: false});
  1786. ConfigStore.set('user', currentUser);
  1787. render(
  1788. <ViewEditDashboard
  1789. {...RouteComponentPropsFixture()}
  1790. organization={{
  1791. ...initialData.organization,
  1792. features: initialData.organization.features,
  1793. access: ['org:read'],
  1794. }}
  1795. params={{orgId: 'org-slug', dashboardId: '1'}}
  1796. router={initialData.router}
  1797. location={initialData.router.location}
  1798. >
  1799. {null}
  1800. </ViewEditDashboard>,
  1801. {
  1802. router: initialData.router,
  1803. organization: {
  1804. features: initialData.organization.features,
  1805. access: ['org:read'],
  1806. },
  1807. }
  1808. );
  1809. await screen.findByText('Edit Access:');
  1810. expect(screen.getByRole('button', {name: 'Edit Dashboard'})).toBeDisabled();
  1811. expect(screen.getByRole('button', {name: 'Add Widget'})).toBeDisabled();
  1812. });
  1813. it('disables widget edit, duplicate, and delete button when user does not have edit perms', async function () {
  1814. const widget = {
  1815. displayType: types.DisplayType.TABLE,
  1816. interval: '1d',
  1817. queries: [
  1818. {
  1819. name: 'Test Widget',
  1820. fields: ['count()', 'count_unique(user)', 'epm()', 'project'],
  1821. columns: ['project'],
  1822. aggregates: ['count()', 'count_unique(user)', 'epm()'],
  1823. conditions: '',
  1824. orderby: '',
  1825. },
  1826. ],
  1827. title: 'Transactions',
  1828. id: '1',
  1829. widgetType: types.WidgetType.DISCOVER,
  1830. };
  1831. const mockDashboard = DashboardFixture([widget], {
  1832. id: '1',
  1833. title: 'Custom Errors',
  1834. createdBy: UserFixture({id: '238900'}),
  1835. permissions: {isEditableByEveryone: false},
  1836. });
  1837. MockApiClient.addMockResponse({
  1838. url: '/organizations/org-slug/dashboards/',
  1839. body: mockDashboard,
  1840. });
  1841. MockApiClient.addMockResponse({
  1842. url: '/organizations/org-slug/dashboards/1/',
  1843. body: mockDashboard,
  1844. });
  1845. const currentUser = UserFixture({id: '781629'});
  1846. ConfigStore.set('user', currentUser);
  1847. render(
  1848. <ViewEditDashboard
  1849. {...RouteComponentPropsFixture()}
  1850. organization={{
  1851. ...initialData.organization,
  1852. features: initialData.organization.features,
  1853. access: ['org:read'],
  1854. }}
  1855. params={{orgId: 'org-slug', dashboardId: '1'}}
  1856. router={initialData.router}
  1857. location={initialData.router.location}
  1858. >
  1859. {null}
  1860. </ViewEditDashboard>,
  1861. {
  1862. router: initialData.router,
  1863. organization: {
  1864. features: initialData.organization.features,
  1865. access: ['org:read'],
  1866. },
  1867. }
  1868. );
  1869. await screen.findByText('Edit Access:');
  1870. expect(screen.getByRole('button', {name: 'Edit Dashboard'})).toBeDisabled();
  1871. expect(screen.getByRole('button', {name: 'Add Widget'})).toBeDisabled();
  1872. await userEvent.click(await screen.findByLabelText('Widget actions'));
  1873. expect(
  1874. screen.getByRole('menuitemradio', {name: 'Duplicate Widget'})
  1875. ).toHaveAttribute('aria-disabled', 'true');
  1876. expect(screen.getByRole('menuitemradio', {name: 'Delete Widget'})).toHaveAttribute(
  1877. 'aria-disabled',
  1878. 'true'
  1879. );
  1880. expect(screen.getByRole('menuitemradio', {name: 'Edit Widget'})).toHaveAttribute(
  1881. 'aria-disabled',
  1882. 'true'
  1883. );
  1884. });
  1885. it('renders favorite button in unfavorited state', async function () {
  1886. MockApiClient.addMockResponse({
  1887. url: '/organizations/org-slug/dashboards/1/',
  1888. body: DashboardFixture([], {id: '1', title: 'Custom Errors', isFavorited: false}),
  1889. });
  1890. render(
  1891. <ViewEditDashboard
  1892. {...RouteComponentPropsFixture()}
  1893. organization={initialData.organization}
  1894. params={{orgId: 'org-slug', dashboardId: '1'}}
  1895. router={initialData.router}
  1896. location={initialData.router.location}
  1897. >
  1898. {null}
  1899. </ViewEditDashboard>,
  1900. {
  1901. router: initialData.router,
  1902. organization: {
  1903. features: ['dashboards-favourite', ...initialData.organization.features],
  1904. },
  1905. }
  1906. );
  1907. const favoriteButton = await screen.findByLabelText('dashboards-favourite');
  1908. expect(favoriteButton).toBeInTheDocument();
  1909. expect(await screen.findByLabelText('Favorite')).toBeInTheDocument();
  1910. });
  1911. it('renders favorite button in favorited state', async function () {
  1912. MockApiClient.addMockResponse({
  1913. url: '/organizations/org-slug/dashboards/1/',
  1914. body: DashboardFixture([], {id: '1', title: 'Custom Errors', isFavorited: true}),
  1915. });
  1916. render(
  1917. <ViewEditDashboard
  1918. {...RouteComponentPropsFixture()}
  1919. organization={initialData.organization}
  1920. params={{orgId: 'org-slug', dashboardId: '1'}}
  1921. router={initialData.router}
  1922. location={initialData.router.location}
  1923. >
  1924. {null}
  1925. </ViewEditDashboard>,
  1926. {
  1927. router: initialData.router,
  1928. organization: {
  1929. features: ['dashboards-favourite', ...initialData.organization.features],
  1930. },
  1931. }
  1932. );
  1933. const favoriteButton = await screen.findByLabelText('dashboards-favourite');
  1934. expect(favoriteButton).toBeInTheDocument();
  1935. expect(await screen.findByLabelText('UnFavorite')).toBeInTheDocument();
  1936. });
  1937. it('toggles favorite button', async function () {
  1938. MockApiClient.addMockResponse({
  1939. url: '/organizations/org-slug/dashboards/1/',
  1940. body: DashboardFixture([], {id: '1', title: 'Custom Errors', isFavorited: true}),
  1941. });
  1942. MockApiClient.addMockResponse({
  1943. url: '/organizations/org-slug/dashboards/1/favorite/',
  1944. method: 'PUT',
  1945. body: {isFavorited: false},
  1946. });
  1947. render(
  1948. <ViewEditDashboard
  1949. {...RouteComponentPropsFixture()}
  1950. organization={initialData.organization}
  1951. params={{orgId: 'org-slug', dashboardId: '1'}}
  1952. router={initialData.router}
  1953. location={initialData.router.location}
  1954. >
  1955. {null}
  1956. </ViewEditDashboard>,
  1957. {
  1958. router: initialData.router,
  1959. organization: {
  1960. features: ['dashboards-favourite', ...initialData.organization.features],
  1961. },
  1962. }
  1963. );
  1964. const favoriteButton = await screen.findByLabelText('dashboards-favourite');
  1965. expect(favoriteButton).toBeInTheDocument();
  1966. expect(await screen.findByLabelText('UnFavorite')).toBeInTheDocument();
  1967. await userEvent.click(favoriteButton);
  1968. expect(await screen.findByLabelText('Favorite')).toBeInTheDocument();
  1969. });
  1970. describe('widget builder redesign', function () {
  1971. let mockUpdateDashboard!: jest.SpyInstance;
  1972. beforeEach(function () {
  1973. initialData = initializeOrg({
  1974. organization: OrganizationFixture({
  1975. features: [
  1976. 'global-views',
  1977. 'dashboards-basic',
  1978. 'dashboards-edit',
  1979. 'discover-query',
  1980. 'performance-discover-dataset-selector',
  1981. 'dashboards-widget-builder-redesign',
  1982. ],
  1983. }),
  1984. });
  1985. // Mock just the updateDashboard function
  1986. mockUpdateDashboard = jest
  1987. .spyOn(dashboardActions, 'updateDashboard')
  1988. .mockResolvedValue({
  1989. ...DashboardFixture([WidgetFixture({id: '1', title: 'Custom Widget'})]),
  1990. });
  1991. jest.mocked(useWidgetBuilderState).mockReturnValue({
  1992. dispatch: jest.fn(),
  1993. state: {},
  1994. });
  1995. });
  1996. afterEach(() => {
  1997. mockUpdateDashboard.mockRestore();
  1998. });
  1999. it('opens the widget builder slideout when clicking add widget', async function () {
  2000. render(
  2001. <DashboardDetail
  2002. {...RouteComponentPropsFixture()}
  2003. initialState={DashboardState.VIEW}
  2004. dashboard={DashboardFixture([])}
  2005. dashboards={[]}
  2006. onDashboardUpdate={jest.fn()}
  2007. newWidget={undefined}
  2008. onSetNewWidget={() => {}}
  2009. />,
  2010. {organization: initialData.organization}
  2011. );
  2012. await userEvent.click(await screen.findByRole('button', {name: 'Add Widget'}));
  2013. await userEvent.click(
  2014. await screen.findByRole('menuitemradio', {name: 'Create Custom Widget'})
  2015. );
  2016. expect(await screen.findByText('Create Custom Widget')).toBeInTheDocument();
  2017. });
  2018. it('opens the widget builder library slideout when clicking add widget from widget library', async function () {
  2019. render(
  2020. <DashboardDetail
  2021. {...RouteComponentPropsFixture()}
  2022. initialState={DashboardState.VIEW}
  2023. dashboard={DashboardFixture([])}
  2024. dashboards={[]}
  2025. onDashboardUpdate={jest.fn()}
  2026. newWidget={undefined}
  2027. onSetNewWidget={() => {}}
  2028. />,
  2029. {organization: initialData.organization}
  2030. );
  2031. await userEvent.click(await screen.findByRole('button', {name: 'Add Widget'}));
  2032. await userEvent.click(
  2033. await screen.findByRole('menuitemradio', {name: 'From Widget Library'})
  2034. );
  2035. expect(await screen.findByText('Add from Widget Library')).toBeInTheDocument();
  2036. });
  2037. it('opens the widget builder slideout when clicking add widget in edit mode', async function () {
  2038. render(
  2039. <DashboardDetail
  2040. {...RouteComponentPropsFixture()}
  2041. initialState={DashboardState.EDIT}
  2042. dashboard={DashboardFixture([])}
  2043. dashboards={[]}
  2044. onDashboardUpdate={jest.fn()}
  2045. newWidget={undefined}
  2046. onSetNewWidget={() => {}}
  2047. />,
  2048. {organization: initialData.organization}
  2049. );
  2050. await userEvent.click(await screen.findByLabelText('Add Widget'));
  2051. await userEvent.click(
  2052. await screen.findByRole('menuitemradio', {name: 'Create Custom Widget'})
  2053. );
  2054. expect(await screen.findByText('Create Custom Widget')).toBeInTheDocument();
  2055. });
  2056. it('opens the widget builder library slideout when clicking add widget from widget library in edit mode', async function () {
  2057. render(
  2058. <DashboardDetail
  2059. {...RouteComponentPropsFixture()}
  2060. initialState={DashboardState.EDIT}
  2061. dashboard={DashboardFixture([])}
  2062. dashboards={[]}
  2063. onDashboardUpdate={jest.fn()}
  2064. newWidget={undefined}
  2065. onSetNewWidget={() => {}}
  2066. />,
  2067. {organization: initialData.organization}
  2068. );
  2069. await userEvent.click(await screen.findByLabelText('Add Widget'));
  2070. await userEvent.click(
  2071. await screen.findByRole('menuitemradio', {name: 'From Widget Library'})
  2072. );
  2073. expect(await screen.findByText('Add from Widget Library')).toBeInTheDocument();
  2074. });
  2075. it('allows for editing a widget in edit mode', async function () {
  2076. const mockWidget = WidgetFixture({id: '1', title: 'Custom Widget'});
  2077. const mockDashboard = DashboardFixture([mockWidget], {
  2078. id: '1',
  2079. title: 'Custom Errors',
  2080. });
  2081. jest.mocked(useWidgetBuilderState).mockReturnValue({
  2082. dispatch: jest.fn(),
  2083. state: {
  2084. title: 'Updated Widget',
  2085. },
  2086. });
  2087. render(
  2088. <DashboardDetail
  2089. {...RouteComponentPropsFixture()}
  2090. initialState={DashboardState.EDIT}
  2091. dashboard={mockDashboard}
  2092. dashboards={[]}
  2093. onDashboardUpdate={jest.fn()}
  2094. newWidget={undefined}
  2095. onSetNewWidget={() => {}}
  2096. />,
  2097. {
  2098. organization: initialData.organization,
  2099. // Mock the widgetIndex param so it's available when the widget builder opens
  2100. router: {...initialData.router, params: {widgetIndex: '0'}},
  2101. }
  2102. );
  2103. expect(await screen.findByText('Custom Widget')).toBeInTheDocument();
  2104. await userEvent.click(await screen.findByRole('button', {name: 'Edit Widget'}));
  2105. expect(await screen.findByText('Edit Widget')).toBeInTheDocument();
  2106. await userEvent.click(screen.getByText('Update Widget'));
  2107. // The widget builder is closed after the widget is updated
  2108. await waitFor(() => {
  2109. expect(screen.queryByText('Edit Widget')).not.toBeInTheDocument();
  2110. });
  2111. // The widget is updated in the dashboard
  2112. expect(screen.getByText('Updated Widget')).toBeInTheDocument();
  2113. });
  2114. it('allows for creating a widget in edit mode', async function () {
  2115. const mockDashboard = DashboardFixture([], {
  2116. id: '1',
  2117. title: 'Custom Errors',
  2118. });
  2119. jest.mocked(useWidgetBuilderState).mockReturnValue({
  2120. dispatch: jest.fn(),
  2121. state: {
  2122. title: 'Totally new widget',
  2123. },
  2124. });
  2125. render(
  2126. <DashboardDetail
  2127. {...RouteComponentPropsFixture()}
  2128. initialState={DashboardState.EDIT}
  2129. dashboard={mockDashboard}
  2130. dashboards={[]}
  2131. onDashboardUpdate={jest.fn()}
  2132. newWidget={undefined}
  2133. onSetNewWidget={() => {}}
  2134. />,
  2135. {
  2136. organization: initialData.organization,
  2137. }
  2138. );
  2139. await userEvent.click(await screen.findByLabelText('Add Widget'));
  2140. await userEvent.click(
  2141. await screen.findByRole('menuitemradio', {name: 'Create Custom Widget'})
  2142. );
  2143. expect(await screen.findByText('Create Custom Widget')).toBeInTheDocument();
  2144. await userEvent.click(screen.getByText('Add Widget'));
  2145. // The widget builder is closed after the widget is updated
  2146. await waitFor(() => {
  2147. expect(screen.queryByText('Create Custom Widget')).not.toBeInTheDocument();
  2148. });
  2149. // The widget is added in the dashboard
  2150. expect(screen.getByText('Totally new widget')).toBeInTheDocument();
  2151. });
  2152. it('allows for editing a widget in view mode', async function () {
  2153. const mockWidget = WidgetFixture({id: '1', title: 'Custom Widget'});
  2154. const mockDashboard = DashboardFixture([mockWidget], {
  2155. id: '1',
  2156. title: 'Custom Errors',
  2157. });
  2158. jest.mocked(useWidgetBuilderState).mockReturnValue({
  2159. dispatch: jest.fn(),
  2160. state: {
  2161. title: 'Updated Widget Title',
  2162. },
  2163. });
  2164. render(
  2165. <DashboardDetail
  2166. {...RouteComponentPropsFixture()}
  2167. initialState={DashboardState.VIEW}
  2168. dashboard={mockDashboard}
  2169. dashboards={[]}
  2170. onDashboardUpdate={jest.fn()}
  2171. newWidget={undefined}
  2172. onSetNewWidget={() => {}}
  2173. />,
  2174. {
  2175. organization: initialData.organization,
  2176. // Mock the widgetIndex param so it's available when the widget builder opens
  2177. router: {...initialData.router, params: {widgetIndex: '0'}},
  2178. }
  2179. );
  2180. expect(await screen.findByText('Custom Widget')).toBeInTheDocument();
  2181. await userEvent.click(await screen.findByLabelText('Widget actions'));
  2182. await userEvent.click(
  2183. await screen.findByRole('menuitemradio', {name: 'Edit Widget'})
  2184. );
  2185. expect(await screen.findByText('Edit Widget')).toBeInTheDocument();
  2186. await userEvent.click(screen.getByText('Update Widget'));
  2187. // The widget builder is closed after the widget is updated
  2188. await waitFor(() => {
  2189. expect(screen.queryByText('Edit Widget')).not.toBeInTheDocument();
  2190. });
  2191. // The update action is called with the updated widget
  2192. expect(mockUpdateDashboard).toHaveBeenCalledWith(
  2193. expect.anything(),
  2194. expect.anything(),
  2195. expect.objectContaining({
  2196. widgets: [expect.objectContaining({title: 'Updated Widget Title'})],
  2197. })
  2198. );
  2199. });
  2200. it('allows for creating a widget in view mode', async function () {
  2201. const mockDashboard = DashboardFixture([], {
  2202. id: '1',
  2203. title: 'Custom Errors',
  2204. });
  2205. jest.mocked(useWidgetBuilderState).mockReturnValue({
  2206. dispatch: jest.fn(),
  2207. state: {
  2208. title: 'Totally new widget',
  2209. },
  2210. });
  2211. render(
  2212. <DashboardDetail
  2213. {...RouteComponentPropsFixture()}
  2214. initialState={DashboardState.VIEW}
  2215. dashboard={mockDashboard}
  2216. dashboards={[]}
  2217. onDashboardUpdate={jest.fn()}
  2218. newWidget={undefined}
  2219. onSetNewWidget={() => {}}
  2220. />,
  2221. {
  2222. organization: initialData.organization,
  2223. }
  2224. );
  2225. await userEvent.click(await screen.findByRole('button', {name: 'Add Widget'}));
  2226. await userEvent.click(
  2227. await screen.findByRole('menuitemradio', {name: 'Create Custom Widget'})
  2228. );
  2229. expect(await screen.findByText('Create Custom Widget')).toBeInTheDocument();
  2230. await userEvent.click(
  2231. await within(screen.getByTestId('widget-slideout')).findByText('Add Widget')
  2232. );
  2233. // The widget builder is closed after the widget is updated
  2234. await waitFor(() => {
  2235. expect(screen.queryByText('Create Custom Widget')).not.toBeInTheDocument();
  2236. });
  2237. // The update action is called with the new widget
  2238. expect(mockUpdateDashboard).toHaveBeenCalledWith(
  2239. expect.anything(),
  2240. expect.anything(),
  2241. expect.objectContaining({
  2242. widgets: [expect.objectContaining({title: 'Totally new widget'})],
  2243. })
  2244. );
  2245. await waitFor(() => {
  2246. expect(addLoadingMessage).toHaveBeenCalledWith('Saving widget');
  2247. });
  2248. });
  2249. });
  2250. describe('discover split', function () {
  2251. it('calls the dashboard callbacks with the correct widgetType for discover split', function () {
  2252. const widget = {
  2253. displayType: types.DisplayType.TABLE,
  2254. interval: '1d',
  2255. queries: [
  2256. {
  2257. name: 'Test Widget',
  2258. fields: ['count()', 'count_unique(user)', 'epm()', 'project'],
  2259. columns: ['project'],
  2260. aggregates: ['count()', 'count_unique(user)', 'epm()'],
  2261. conditions: '',
  2262. orderby: '',
  2263. },
  2264. ],
  2265. title: 'Transactions',
  2266. id: '1',
  2267. widgetType: types.WidgetType.DISCOVER,
  2268. };
  2269. const mockDashboard = DashboardFixture([widget], {
  2270. id: '1',
  2271. title: 'Custom Errors',
  2272. });
  2273. const mockModifiedDashboard = DashboardFixture([widget], {
  2274. id: '1',
  2275. title: 'Custom Errors',
  2276. });
  2277. const mockOnDashboardUpdate = jest.fn();
  2278. const mockStateSetter = jest
  2279. .fn()
  2280. .mockImplementation(fn => fn({modifiedDashboard: mockModifiedDashboard}));
  2281. handleUpdateDashboardSplit({
  2282. widgetId: '1',
  2283. splitDecision: types.WidgetType.ERRORS,
  2284. dashboard: mockDashboard,
  2285. modifiedDashboard: mockModifiedDashboard,
  2286. onDashboardUpdate: mockOnDashboardUpdate,
  2287. stateSetter: mockStateSetter,
  2288. });
  2289. expect(mockOnDashboardUpdate).toHaveBeenCalledWith({
  2290. ...mockDashboard,
  2291. widgets: [{...widget, widgetType: types.WidgetType.ERRORS}],
  2292. });
  2293. expect(mockStateSetter).toHaveReturnedWith({
  2294. modifiedDashboard: {
  2295. ...mockModifiedDashboard,
  2296. widgets: [{...widget, widgetType: types.WidgetType.ERRORS}],
  2297. },
  2298. });
  2299. });
  2300. });
  2301. });
  2302. });