detail.spec.tsx 60 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905
  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 {UserFixture} from 'sentry-fixture/user';
  8. import {WidgetFixture} from 'sentry-fixture/widget';
  9. import {initializeOrg} from 'sentry-test/initializeOrg';
  10. import {
  11. act,
  12. render,
  13. screen,
  14. userEvent,
  15. waitFor,
  16. within,
  17. } from 'sentry-test/reactTestingLibrary';
  18. import * as modals from 'sentry/actionCreators/modal';
  19. import ConfigStore from 'sentry/stores/configStore';
  20. import PageFiltersStore from 'sentry/stores/pageFiltersStore';
  21. import ProjectsStore from 'sentry/stores/projectsStore';
  22. import {browserHistory} from 'sentry/utils/browserHistory';
  23. import CreateDashboard from 'sentry/views/dashboards/create';
  24. import {handleUpdateDashboardSplit} from 'sentry/views/dashboards/detail';
  25. import EditAccessSelector from 'sentry/views/dashboards/editAccessSelector';
  26. import * as types from 'sentry/views/dashboards/types';
  27. import ViewEditDashboard from 'sentry/views/dashboards/view';
  28. import {OrganizationContext} from 'sentry/views/organizationContext';
  29. describe('Dashboards > Detail', function () {
  30. const organization = OrganizationFixture({
  31. features: ['global-views', 'dashboards-basic', 'dashboards-edit', 'discover-query'],
  32. });
  33. const projects = [ProjectFixture()];
  34. describe('prebuilt dashboards', function () {
  35. let initialData;
  36. beforeEach(function () {
  37. act(() => ProjectsStore.loadInitialData(projects));
  38. initialData = initializeOrg({organization});
  39. MockApiClient.addMockResponse({
  40. url: '/organizations/org-slug/tags/',
  41. body: [],
  42. });
  43. MockApiClient.addMockResponse({
  44. url: '/organizations/org-slug/projects/',
  45. body: [ProjectFixture()],
  46. });
  47. MockApiClient.addMockResponse({
  48. url: '/organizations/org-slug/dashboards/',
  49. body: [
  50. DashboardFixture([], {id: 'default-overview', title: 'Default'}),
  51. DashboardFixture([], {id: '1', title: 'Custom Errors'}),
  52. ],
  53. });
  54. MockApiClient.addMockResponse({
  55. url: '/organizations/org-slug/dashboards/default-overview/',
  56. body: DashboardFixture([], {id: 'default-overview', title: 'Default'}),
  57. });
  58. MockApiClient.addMockResponse({
  59. url: '/organizations/org-slug/dashboards/1/visit/',
  60. method: 'POST',
  61. body: [],
  62. statusCode: 200,
  63. });
  64. MockApiClient.addMockResponse({
  65. url: '/organizations/org-slug/users/',
  66. method: 'GET',
  67. body: [],
  68. });
  69. MockApiClient.addMockResponse({
  70. url: '/organizations/org-slug/sdk-updates/',
  71. body: [],
  72. });
  73. MockApiClient.addMockResponse({
  74. url: '/organizations/org-slug/prompts-activity/',
  75. body: {},
  76. });
  77. MockApiClient.addMockResponse({
  78. url: '/organizations/org-slug/events/',
  79. method: 'GET',
  80. body: [],
  81. });
  82. MockApiClient.addMockResponse({
  83. url: '/organizations/org-slug/events-stats/',
  84. body: {data: []},
  85. });
  86. MockApiClient.addMockResponse({
  87. method: 'GET',
  88. url: '/organizations/org-slug/issues/',
  89. body: [],
  90. });
  91. MockApiClient.addMockResponse({
  92. url: '/organizations/org-slug/releases/',
  93. body: [],
  94. });
  95. MockApiClient.addMockResponse({
  96. url: '/organizations/org-slug/metrics/meta/',
  97. body: [],
  98. });
  99. });
  100. afterEach(function () {
  101. MockApiClient.clearMockResponses();
  102. });
  103. it('assigns unique IDs to all widgets so grid keys are unique', async function () {
  104. MockApiClient.addMockResponse({
  105. url: '/organizations/org-slug/events-stats/',
  106. body: {data: []},
  107. });
  108. MockApiClient.addMockResponse({
  109. url: '/organizations/org-slug/dashboards/default-overview/',
  110. body: DashboardFixture(
  111. [
  112. WidgetFixture({
  113. queries: [
  114. {
  115. name: '',
  116. conditions: 'event.type:error',
  117. fields: ['count()'],
  118. aggregates: ['count()'],
  119. columns: [],
  120. orderby: '-count()',
  121. },
  122. ],
  123. title: 'Default Widget 1',
  124. interval: '1d',
  125. }),
  126. WidgetFixture({
  127. queries: [
  128. {
  129. name: '',
  130. conditions: 'event.type:transaction',
  131. fields: ['count()'],
  132. aggregates: ['count()'],
  133. columns: [],
  134. orderby: '-count()',
  135. },
  136. ],
  137. title: 'Default Widget 2',
  138. interval: '1d',
  139. }),
  140. ],
  141. {id: 'default-overview', title: 'Default'}
  142. ),
  143. });
  144. initialData = initializeOrg({
  145. organization: OrganizationFixture({
  146. features: ['global-views', 'dashboards-basic', 'discover-query'],
  147. }),
  148. });
  149. render(
  150. <OrganizationContext.Provider value={initialData.organization}>
  151. <ViewEditDashboard
  152. {...RouteComponentPropsFixture()}
  153. organization={initialData.organization}
  154. params={{orgId: 'org-slug', dashboardId: 'default-overview'}}
  155. router={initialData.router}
  156. location={initialData.router.location}
  157. >
  158. {null}
  159. </ViewEditDashboard>
  160. </OrganizationContext.Provider>,
  161. {router: initialData.router}
  162. );
  163. expect(await screen.findByText('Default Widget 1')).toBeInTheDocument();
  164. expect(screen.getByText('Default Widget 2')).toBeInTheDocument();
  165. });
  166. it('opens the widget viewer modal in a prebuilt dashboard using the widget id specified in the url', async () => {
  167. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  168. render(
  169. <CreateDashboard
  170. {...RouteComponentPropsFixture()}
  171. organization={initialData.organization}
  172. params={{templateId: 'default-template', widgetId: '2'}}
  173. router={initialData.router}
  174. location={{...initialData.router.location, pathname: '/widget/2/'}}
  175. >
  176. {null}
  177. </CreateDashboard>,
  178. {router: initialData.router, organization: initialData.organization}
  179. );
  180. await waitFor(() => {
  181. expect(openWidgetViewerModal).toHaveBeenCalledWith(
  182. expect.objectContaining({
  183. organization: initialData.organization,
  184. widget: expect.objectContaining({
  185. displayType: 'line',
  186. interval: '5m',
  187. queries: [
  188. {
  189. aggregates: ['count()'],
  190. columns: [],
  191. conditions: '!event.type:transaction',
  192. fields: ['count()'],
  193. name: 'Events',
  194. orderby: 'count()',
  195. },
  196. ],
  197. title: 'Events',
  198. widgetType: types.WidgetType.DISCOVER,
  199. }),
  200. onClose: expect.anything(),
  201. })
  202. );
  203. });
  204. });
  205. });
  206. describe('custom dashboards', function () {
  207. let initialData, widgets, mockVisit, mockPut;
  208. beforeEach(function () {
  209. window.confirm = jest.fn();
  210. initialData = initializeOrg({
  211. organization,
  212. router: {
  213. location: LocationFixture(),
  214. },
  215. });
  216. PageFiltersStore.init();
  217. PageFiltersStore.onInitializeUrlState(
  218. {
  219. projects: [],
  220. environments: [],
  221. datetime: {start: null, end: null, period: '14d', utc: null},
  222. },
  223. new Set()
  224. );
  225. widgets = [
  226. WidgetFixture({
  227. queries: [
  228. {
  229. name: '',
  230. conditions: 'event.type:error',
  231. fields: ['count()'],
  232. columns: [],
  233. aggregates: ['count()'],
  234. orderby: '-count()',
  235. },
  236. ],
  237. title: 'Errors',
  238. interval: '1d',
  239. widgetType: types.WidgetType.DISCOVER,
  240. id: '1',
  241. }),
  242. WidgetFixture({
  243. queries: [
  244. {
  245. name: '',
  246. conditions: 'event.type:transaction',
  247. fields: ['count()'],
  248. columns: [],
  249. aggregates: ['count()'],
  250. orderby: '-count()',
  251. },
  252. ],
  253. title: 'Transactions',
  254. interval: '1d',
  255. widgetType: types.WidgetType.DISCOVER,
  256. id: '2',
  257. }),
  258. WidgetFixture({
  259. queries: [
  260. {
  261. name: '',
  262. conditions: 'event.type:transaction transaction:/api/cats',
  263. fields: ['p50()'],
  264. columns: [],
  265. aggregates: ['p50()'],
  266. orderby: '-p50()',
  267. },
  268. ],
  269. title: 'p50 of /api/cats',
  270. interval: '1d',
  271. id: '3',
  272. }),
  273. ];
  274. mockVisit = MockApiClient.addMockResponse({
  275. url: '/organizations/org-slug/dashboards/1/visit/',
  276. method: 'POST',
  277. body: [],
  278. statusCode: 200,
  279. });
  280. MockApiClient.addMockResponse({
  281. url: '/organizations/org-slug/tags/',
  282. body: [],
  283. });
  284. MockApiClient.addMockResponse({
  285. url: '/organizations/org-slug/projects/',
  286. body: [ProjectFixture()],
  287. });
  288. MockApiClient.addMockResponse({
  289. url: '/organizations/org-slug/dashboards/',
  290. body: [
  291. {
  292. ...DashboardFixture([], {
  293. id: 'default-overview',
  294. title: 'Default',
  295. }),
  296. widgetDisplay: ['area'],
  297. },
  298. {
  299. ...DashboardFixture([], {
  300. id: '1',
  301. title: 'Custom Errors',
  302. }),
  303. widgetDisplay: ['area'],
  304. },
  305. ],
  306. });
  307. MockApiClient.addMockResponse({
  308. url: '/organizations/org-slug/dashboards/1/',
  309. body: DashboardFixture(widgets, {
  310. id: '1',
  311. title: 'Custom Errors',
  312. filters: {},
  313. createdBy: UserFixture({id: '1'}),
  314. }),
  315. });
  316. mockPut = MockApiClient.addMockResponse({
  317. url: '/organizations/org-slug/dashboards/1/',
  318. method: 'PUT',
  319. body: DashboardFixture(widgets, {id: '1', title: 'Custom Errors'}),
  320. });
  321. MockApiClient.addMockResponse({
  322. url: '/organizations/org-slug/events-stats/',
  323. body: {data: []},
  324. });
  325. MockApiClient.addMockResponse({
  326. method: 'POST',
  327. url: '/organizations/org-slug/dashboards/widgets/',
  328. body: [],
  329. });
  330. MockApiClient.addMockResponse({
  331. method: 'GET',
  332. url: '/organizations/org-slug/recent-searches/',
  333. body: [],
  334. });
  335. MockApiClient.addMockResponse({
  336. method: 'GET',
  337. url: '/organizations/org-slug/issues/',
  338. body: [],
  339. });
  340. MockApiClient.addMockResponse({
  341. url: '/organizations/org-slug/events/',
  342. method: 'GET',
  343. body: [],
  344. });
  345. MockApiClient.addMockResponse({
  346. url: '/organizations/org-slug/users/',
  347. method: 'GET',
  348. body: [],
  349. });
  350. MockApiClient.addMockResponse({
  351. url: '/organizations/org-slug/releases/',
  352. body: [],
  353. });
  354. MockApiClient.addMockResponse({
  355. url: '/organizations/org-slug/sdk-updates/',
  356. body: [],
  357. });
  358. MockApiClient.addMockResponse({
  359. url: '/organizations/org-slug/prompts-activity/',
  360. body: {},
  361. });
  362. MockApiClient.addMockResponse({
  363. url: '/organizations/org-slug/metrics/meta/',
  364. body: [],
  365. });
  366. });
  367. afterEach(function () {
  368. MockApiClient.clearMockResponses();
  369. jest.clearAllMocks();
  370. });
  371. it('can remove widgets', async function () {
  372. const updateMock = MockApiClient.addMockResponse({
  373. url: '/organizations/org-slug/dashboards/1/',
  374. method: 'PUT',
  375. body: DashboardFixture([widgets[0]], {id: '1', title: 'Custom Errors'}),
  376. });
  377. render(
  378. <OrganizationContext.Provider value={initialData.organization}>
  379. <ViewEditDashboard
  380. {...RouteComponentPropsFixture()}
  381. organization={initialData.organization}
  382. params={{orgId: 'org-slug', dashboardId: '1'}}
  383. router={initialData.router}
  384. location={initialData.router.location}
  385. >
  386. {null}
  387. </ViewEditDashboard>
  388. </OrganizationContext.Provider>,
  389. {router: initialData.router}
  390. );
  391. await waitFor(() => expect(mockVisit).toHaveBeenCalledTimes(1));
  392. // Enter edit mode.
  393. await userEvent.click(screen.getByRole('button', {name: 'Edit Dashboard'}));
  394. // Remove the second and third widgets
  395. await userEvent.click(
  396. (await screen.findAllByRole('button', {name: 'Delete Widget'}))[1]
  397. );
  398. await userEvent.click(
  399. (await screen.findAllByRole('button', {name: 'Delete Widget'}))[1]
  400. );
  401. // Save changes
  402. await userEvent.click(screen.getByRole('button', {name: 'Save and Finish'}));
  403. expect(updateMock).toHaveBeenCalled();
  404. expect(updateMock).toHaveBeenCalledWith(
  405. '/organizations/org-slug/dashboards/1/',
  406. expect.objectContaining({
  407. data: expect.objectContaining({
  408. title: 'Custom Errors',
  409. widgets: [expect.objectContaining(widgets[0])],
  410. }),
  411. })
  412. );
  413. // Visit should not be called again on dashboard update
  414. expect(mockVisit).toHaveBeenCalledTimes(1);
  415. });
  416. it('appends dashboard-level filters to series request', async function () {
  417. MockApiClient.addMockResponse({
  418. url: '/organizations/org-slug/dashboards/1/',
  419. body: DashboardFixture(widgets, {
  420. id: '1',
  421. title: 'Custom Errors',
  422. filters: {release: ['abc@1.2.0']},
  423. }),
  424. });
  425. const mock = MockApiClient.addMockResponse({
  426. url: '/organizations/org-slug/events-stats/',
  427. body: [],
  428. });
  429. render(
  430. <OrganizationContext.Provider value={initialData.organization}>
  431. <ViewEditDashboard
  432. {...RouteComponentPropsFixture()}
  433. organization={initialData.organization}
  434. params={{orgId: 'org-slug', dashboardId: '1'}}
  435. router={initialData.router}
  436. location={initialData.router.location}
  437. >
  438. {null}
  439. </ViewEditDashboard>
  440. </OrganizationContext.Provider>,
  441. {router: initialData.router}
  442. );
  443. await waitFor(() =>
  444. expect(mock).toHaveBeenLastCalledWith(
  445. '/organizations/org-slug/events-stats/',
  446. expect.objectContaining({
  447. query: expect.objectContaining({
  448. query:
  449. '(event.type:transaction transaction:/api/cats) release:"abc@1.2.0" ',
  450. }),
  451. })
  452. )
  453. );
  454. });
  455. it('shows add widget option', async function () {
  456. render(
  457. <OrganizationContext.Provider value={initialData.organization}>
  458. <ViewEditDashboard
  459. {...RouteComponentPropsFixture()}
  460. organization={initialData.organization}
  461. params={{orgId: 'org-slug', dashboardId: '1'}}
  462. router={initialData.router}
  463. location={initialData.router.location}
  464. >
  465. {null}
  466. </ViewEditDashboard>
  467. </OrganizationContext.Provider>,
  468. {router: initialData.router}
  469. );
  470. // Enter edit mode.
  471. await userEvent.click(screen.getByRole('button', {name: 'Edit Dashboard'}));
  472. expect(await screen.findByRole('button', {name: 'Add widget'})).toBeInTheDocument();
  473. });
  474. it('shows add widget option with dataset selector flag', async function () {
  475. initialData = initializeOrg({
  476. organization: OrganizationFixture({
  477. features: [
  478. 'global-views',
  479. 'dashboards-basic',
  480. 'dashboards-edit',
  481. 'discover-query',
  482. 'custom-metrics',
  483. 'performance-discover-dataset-selector',
  484. ],
  485. }),
  486. });
  487. render(
  488. <OrganizationContext.Provider value={initialData.organization}>
  489. <ViewEditDashboard
  490. {...RouteComponentPropsFixture()}
  491. organization={initialData.organization}
  492. params={{orgId: 'org-slug', dashboardId: '1'}}
  493. router={initialData.router}
  494. location={initialData.router.location}
  495. >
  496. {null}
  497. </ViewEditDashboard>
  498. </OrganizationContext.Provider>,
  499. {router: initialData.router}
  500. );
  501. await userEvent.click(screen.getAllByText('Add Widget')[0]);
  502. const menuOptions = await screen.findAllByTestId('menu-list-item-label');
  503. expect(menuOptions.map(e => e.textContent)).toEqual([
  504. 'Errors',
  505. 'Transactions',
  506. 'Issues',
  507. 'Releases',
  508. 'Metrics',
  509. ]);
  510. });
  511. it('shows add widget option without dataset selector flag', async function () {
  512. initialData = initializeOrg({
  513. organization: OrganizationFixture({
  514. features: [
  515. 'global-views',
  516. 'dashboards-basic',
  517. 'dashboards-edit',
  518. 'discover-query',
  519. 'custom-metrics',
  520. ],
  521. }),
  522. });
  523. render(
  524. <OrganizationContext.Provider value={initialData.organization}>
  525. <ViewEditDashboard
  526. {...RouteComponentPropsFixture()}
  527. organization={initialData.organization}
  528. params={{orgId: 'org-slug', dashboardId: '1'}}
  529. router={initialData.router}
  530. location={initialData.router.location}
  531. >
  532. {null}
  533. </ViewEditDashboard>
  534. </OrganizationContext.Provider>,
  535. {router: initialData.router}
  536. );
  537. await userEvent.click(screen.getAllByText('Add Widget')[0]);
  538. const menuOptions = await screen.findAllByTestId('menu-list-item-label');
  539. expect(menuOptions.map(e => e.textContent)).toEqual([
  540. 'Errors and Transactions',
  541. 'Issues',
  542. 'Releases',
  543. 'Metrics',
  544. ]);
  545. });
  546. it('shows top level release filter', async function () {
  547. const mockReleases = MockApiClient.addMockResponse({
  548. url: '/organizations/org-slug/releases/',
  549. body: [ReleaseFixture()],
  550. });
  551. initialData = initializeOrg({
  552. organization: OrganizationFixture({
  553. features: [
  554. 'global-views',
  555. 'dashboards-basic',
  556. 'dashboards-edit',
  557. 'discover-query',
  558. ],
  559. }),
  560. });
  561. render(
  562. <OrganizationContext.Provider value={initialData.organization}>
  563. <ViewEditDashboard
  564. {...RouteComponentPropsFixture()}
  565. organization={initialData.organization}
  566. params={{orgId: 'org-slug', dashboardId: '1'}}
  567. router={initialData.router}
  568. location={initialData.router.location}
  569. >
  570. {null}
  571. </ViewEditDashboard>
  572. </OrganizationContext.Provider>,
  573. {router: initialData.router}
  574. );
  575. expect(await screen.findByText('All Releases')).toBeInTheDocument();
  576. expect(mockReleases).toHaveBeenCalledTimes(2); // Called once when PageFiltersStore is initialized
  577. });
  578. it('hides add widget option', async function () {
  579. // @ts-expect-error this is assigning to readonly property...
  580. types.MAX_WIDGETS = 1;
  581. render(
  582. <OrganizationContext.Provider value={initialData.organization}>
  583. <ViewEditDashboard
  584. {...RouteComponentPropsFixture()}
  585. organization={initialData.organization}
  586. params={{orgId: 'org-slug', dashboardId: '1'}}
  587. router={initialData.router}
  588. location={initialData.router.location}
  589. >
  590. {null}
  591. </ViewEditDashboard>
  592. </OrganizationContext.Provider>,
  593. {router: initialData.router}
  594. );
  595. // Enter edit mode.
  596. await userEvent.click(await screen.findByRole('button', {name: 'Edit Dashboard'}));
  597. expect(screen.queryByRole('button', {name: 'Add widget'})).not.toBeInTheDocument();
  598. });
  599. it('renders successfully if more widgets than stored layouts', async function () {
  600. // A case where someone has async added widgets to a dashboard
  601. MockApiClient.addMockResponse({
  602. url: '/organizations/org-slug/dashboards/1/',
  603. body: DashboardFixture(
  604. [
  605. WidgetFixture({
  606. queries: [
  607. {
  608. name: '',
  609. conditions: 'event.type:error',
  610. fields: ['count()'],
  611. aggregates: ['count()'],
  612. columns: [],
  613. orderby: '-count()',
  614. },
  615. ],
  616. title: 'First Widget',
  617. interval: '1d',
  618. id: '1',
  619. layout: {x: 0, y: 0, w: 2, h: 6, minH: 0},
  620. }),
  621. WidgetFixture({
  622. queries: [
  623. {
  624. name: '',
  625. conditions: 'event.type:error',
  626. fields: ['count()'],
  627. aggregates: ['count()'],
  628. columns: [],
  629. orderby: '-count()',
  630. },
  631. ],
  632. title: 'Second Widget',
  633. interval: '1d',
  634. id: '2',
  635. }),
  636. ],
  637. {id: '1', title: 'Custom Errors'}
  638. ),
  639. });
  640. render(
  641. <ViewEditDashboard
  642. {...RouteComponentPropsFixture()}
  643. organization={initialData.organization}
  644. params={{orgId: 'org-slug', dashboardId: '1'}}
  645. router={initialData.router}
  646. location={initialData.router.location}
  647. >
  648. {null}
  649. </ViewEditDashboard>,
  650. {router: initialData.router, organization: initialData.organization}
  651. );
  652. expect(await screen.findByText('First Widget')).toBeInTheDocument();
  653. expect(await screen.findByText('Second Widget')).toBeInTheDocument();
  654. });
  655. it('does not trigger request if layout not updated', async () => {
  656. MockApiClient.addMockResponse({
  657. url: '/organizations/org-slug/dashboards/1/',
  658. body: DashboardFixture(
  659. [
  660. WidgetFixture({
  661. queries: [
  662. {
  663. name: '',
  664. conditions: 'event.type:error',
  665. fields: ['count()'],
  666. aggregates: ['count()'],
  667. columns: [],
  668. orderby: '-count()',
  669. },
  670. ],
  671. title: 'First Widget',
  672. interval: '1d',
  673. id: '1',
  674. layout: {x: 0, y: 0, w: 2, h: 6, minH: 0},
  675. }),
  676. ],
  677. {id: '1', title: 'Custom Errors'}
  678. ),
  679. });
  680. render(
  681. <ViewEditDashboard
  682. {...RouteComponentPropsFixture()}
  683. organization={initialData.organization}
  684. params={{orgId: 'org-slug', dashboardId: '1'}}
  685. router={initialData.router}
  686. location={initialData.router.location}
  687. >
  688. {null}
  689. </ViewEditDashboard>,
  690. {router: initialData.router, organization: initialData.organization}
  691. );
  692. await userEvent.click(await screen.findByText('Edit Dashboard'));
  693. await userEvent.click(await screen.findByText('Save and Finish'));
  694. expect(screen.getByText('Edit Dashboard')).toBeInTheDocument();
  695. expect(mockPut).not.toHaveBeenCalled();
  696. });
  697. it('renders the custom resize handler for a widget', async () => {
  698. MockApiClient.addMockResponse({
  699. url: '/organizations/org-slug/dashboards/1/',
  700. body: DashboardFixture(
  701. [
  702. WidgetFixture({
  703. queries: [
  704. {
  705. name: '',
  706. conditions: 'event.type:error',
  707. fields: ['count()'],
  708. aggregates: ['count()'],
  709. columns: [],
  710. orderby: '-count()',
  711. },
  712. ],
  713. title: 'First Widget',
  714. interval: '1d',
  715. id: '1',
  716. layout: {x: 0, y: 0, w: 2, h: 6, minH: 0},
  717. }),
  718. ],
  719. {id: '1', title: 'Custom Errors'}
  720. ),
  721. });
  722. render(
  723. <ViewEditDashboard
  724. {...RouteComponentPropsFixture()}
  725. organization={initialData.organization}
  726. params={{orgId: 'org-slug', dashboardId: '1'}}
  727. router={initialData.router}
  728. location={initialData.router.location}
  729. >
  730. {null}
  731. </ViewEditDashboard>,
  732. {router: initialData.router, organization: initialData.organization}
  733. );
  734. await userEvent.click(await screen.findByText('Edit Dashboard'));
  735. const widget = (await screen.findByText('First Widget')).closest(
  736. '.react-grid-item'
  737. ) as HTMLElement;
  738. const resizeHandle = within(widget).getByTestId('custom-resize-handle');
  739. expect(resizeHandle).toBeVisible();
  740. });
  741. it('does not trigger an alert when the widgets have no layout and user cancels without changes', async () => {
  742. MockApiClient.addMockResponse({
  743. url: '/organizations/org-slug/dashboards/1/',
  744. body: DashboardFixture(
  745. [
  746. WidgetFixture({
  747. queries: [
  748. {
  749. name: '',
  750. conditions: 'event.type:error',
  751. fields: ['count()'],
  752. aggregates: ['count()'],
  753. columns: [],
  754. orderby: '-count()',
  755. },
  756. ],
  757. title: 'First Widget',
  758. interval: '1d',
  759. id: '1',
  760. layout: null,
  761. }),
  762. ],
  763. {id: '1', title: 'Custom Errors'}
  764. ),
  765. });
  766. render(
  767. <ViewEditDashboard
  768. {...RouteComponentPropsFixture()}
  769. organization={initialData.organization}
  770. params={{orgId: 'org-slug', dashboardId: '1'}}
  771. router={initialData.router}
  772. location={initialData.router.location}
  773. >
  774. {null}
  775. </ViewEditDashboard>,
  776. {router: initialData.router, organization: initialData.organization}
  777. );
  778. await userEvent.click(await screen.findByText('Edit Dashboard'));
  779. await userEvent.click(await screen.findByText('Cancel'));
  780. expect(window.confirm).not.toHaveBeenCalled();
  781. });
  782. it('opens the widget viewer modal using the widget id specified in the url', async () => {
  783. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  784. const widget = WidgetFixture({
  785. queries: [
  786. {
  787. name: '',
  788. conditions: 'event.type:error',
  789. fields: ['count()'],
  790. aggregates: ['count()'],
  791. columns: [],
  792. orderby: '',
  793. },
  794. ],
  795. title: 'First Widget',
  796. interval: '1d',
  797. id: '1',
  798. layout: null,
  799. });
  800. MockApiClient.addMockResponse({
  801. url: '/organizations/org-slug/dashboards/1/',
  802. body: DashboardFixture([widget], {id: '1', title: 'Custom Errors'}),
  803. });
  804. render(
  805. <ViewEditDashboard
  806. {...RouteComponentPropsFixture()}
  807. organization={initialData.organization}
  808. params={{orgId: 'org-slug', dashboardId: '1', widgetId: 1}}
  809. router={initialData.router}
  810. location={{...initialData.router.location, pathname: '/widget/123/'}}
  811. >
  812. {null}
  813. </ViewEditDashboard>,
  814. {router: initialData.router, organization: initialData.organization}
  815. );
  816. await waitFor(() => {
  817. expect(openWidgetViewerModal).toHaveBeenCalledWith(
  818. expect.objectContaining({
  819. organization: initialData.organization,
  820. widget,
  821. onClose: expect.anything(),
  822. })
  823. );
  824. });
  825. });
  826. it('redirects user to dashboard url if widget is not found', async () => {
  827. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  828. MockApiClient.addMockResponse({
  829. url: '/organizations/org-slug/dashboards/1/',
  830. body: DashboardFixture([], {id: '1', title: 'Custom Errors'}),
  831. });
  832. render(
  833. <ViewEditDashboard
  834. {...RouteComponentPropsFixture()}
  835. organization={initialData.organization}
  836. params={{orgId: 'org-slug', dashboardId: '1', widgetId: 123}}
  837. router={initialData.router}
  838. location={{...initialData.router.location, pathname: '/widget/123/'}}
  839. >
  840. {null}
  841. </ViewEditDashboard>,
  842. {router: initialData.router, organization: initialData.organization}
  843. );
  844. expect(await screen.findByText('All Releases')).toBeInTheDocument();
  845. expect(openWidgetViewerModal).not.toHaveBeenCalled();
  846. expect(initialData.router.replace).toHaveBeenCalledWith(
  847. expect.objectContaining({
  848. pathname: '/organizations/org-slug/dashboard/1/',
  849. query: {},
  850. })
  851. );
  852. });
  853. it('saves a new dashboard with the page filters', async () => {
  854. const mockPOST = MockApiClient.addMockResponse({
  855. url: '/organizations/org-slug/dashboards/',
  856. method: 'POST',
  857. body: [],
  858. });
  859. render(
  860. <CreateDashboard
  861. {...RouteComponentPropsFixture()}
  862. organization={initialData.organization}
  863. params={{templateId: undefined}}
  864. router={initialData.router}
  865. location={{
  866. ...initialData.router.location,
  867. query: {
  868. ...initialData.router.location.query,
  869. statsPeriod: '7d',
  870. project: [2],
  871. environment: ['alpha', 'beta'],
  872. },
  873. }}
  874. >
  875. {null}
  876. </CreateDashboard>,
  877. {
  878. router: initialData.router,
  879. organization: initialData.organization,
  880. }
  881. );
  882. await userEvent.click(await screen.findByText('Save and Finish'));
  883. expect(mockPOST).toHaveBeenCalledWith(
  884. '/organizations/org-slug/dashboards/',
  885. expect.objectContaining({
  886. data: expect.objectContaining({
  887. projects: [2],
  888. environment: ['alpha', 'beta'],
  889. period: '7d',
  890. }),
  891. })
  892. );
  893. });
  894. it('saves a template with the page filters', async () => {
  895. const mockPOST = MockApiClient.addMockResponse({
  896. url: '/organizations/org-slug/dashboards/',
  897. method: 'POST',
  898. body: [],
  899. });
  900. render(
  901. <CreateDashboard
  902. {...RouteComponentPropsFixture()}
  903. organization={initialData.organization}
  904. params={{templateId: 'default-template'}}
  905. router={initialData.router}
  906. location={{
  907. ...initialData.router.location,
  908. query: {
  909. ...initialData.router.location.query,
  910. statsPeriod: '7d',
  911. project: [2],
  912. environment: ['alpha', 'beta'],
  913. },
  914. }}
  915. >
  916. {null}
  917. </CreateDashboard>,
  918. {
  919. router: initialData.router,
  920. organization: initialData.organization,
  921. }
  922. );
  923. await userEvent.click(await screen.findByText('Add Dashboard'));
  924. expect(mockPOST).toHaveBeenCalledWith(
  925. '/organizations/org-slug/dashboards/',
  926. expect.objectContaining({
  927. data: expect.objectContaining({
  928. projects: [2],
  929. environment: ['alpha', 'beta'],
  930. period: '7d',
  931. }),
  932. })
  933. );
  934. });
  935. it('does not render save and cancel buttons on templates', async () => {
  936. MockApiClient.addMockResponse({
  937. url: '/organizations/org-slug/releases/',
  938. body: [
  939. ReleaseFixture({
  940. shortVersion: 'sentry-android-shop@1.2.0',
  941. version: 'sentry-android-shop@1.2.0',
  942. }),
  943. ],
  944. });
  945. render(
  946. <CreateDashboard
  947. {...RouteComponentPropsFixture()}
  948. organization={initialData.organization}
  949. params={{templateId: 'default-template'}}
  950. router={initialData.router}
  951. location={initialData.router.location}
  952. >
  953. {null}
  954. </CreateDashboard>,
  955. {
  956. router: initialData.router,
  957. organization: initialData.organization,
  958. }
  959. );
  960. await userEvent.click(await screen.findByText('24H'));
  961. await userEvent.click(screen.getByText('Last 7 days'));
  962. await screen.findByText('7D');
  963. expect(screen.queryByText('Cancel')).not.toBeInTheDocument();
  964. expect(screen.queryByText('Save')).not.toBeInTheDocument();
  965. });
  966. it('opens the widget viewer with saved dashboard filters', async () => {
  967. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  968. MockApiClient.addMockResponse({
  969. url: '/organizations/org-slug/dashboards/1/',
  970. body: DashboardFixture(widgets, {
  971. id: '1',
  972. filters: {release: ['sentry-android-shop@1.2.0']},
  973. }),
  974. });
  975. render(
  976. <ViewEditDashboard
  977. {...RouteComponentPropsFixture()}
  978. organization={initialData.organization}
  979. params={{orgId: 'org-slug', dashboardId: '1', widgetId: 1}}
  980. router={initialData.router}
  981. location={{...initialData.router.location, pathname: '/widget/1/'}}
  982. >
  983. {null}
  984. </ViewEditDashboard>,
  985. {router: initialData.router, organization: initialData.organization}
  986. );
  987. await waitFor(() => {
  988. expect(openWidgetViewerModal).toHaveBeenCalledWith(
  989. expect.objectContaining({
  990. dashboardFilters: {release: ['sentry-android-shop@1.2.0']},
  991. })
  992. );
  993. });
  994. });
  995. it('opens the widget viewer with unsaved dashboard filters', async () => {
  996. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  997. MockApiClient.addMockResponse({
  998. url: '/organizations/org-slug/dashboards/1/',
  999. body: DashboardFixture(widgets, {
  1000. id: '1',
  1001. filters: {release: ['sentry-android-shop@1.2.0']},
  1002. }),
  1003. });
  1004. render(
  1005. <ViewEditDashboard
  1006. {...RouteComponentPropsFixture()}
  1007. organization={initialData.organization}
  1008. params={{orgId: 'org-slug', dashboardId: '1', widgetId: 1}}
  1009. router={initialData.router}
  1010. location={{
  1011. ...initialData.router.location,
  1012. pathname: '/widget/1/',
  1013. query: {release: ['unsaved-release-filter@1.2.0']},
  1014. }}
  1015. >
  1016. {null}
  1017. </ViewEditDashboard>,
  1018. {router: initialData.router, organization: initialData.organization}
  1019. );
  1020. await waitFor(() => {
  1021. expect(openWidgetViewerModal).toHaveBeenCalledWith(
  1022. expect.objectContaining({
  1023. dashboardFilters: {release: ['unsaved-release-filter@1.2.0']},
  1024. })
  1025. );
  1026. });
  1027. });
  1028. it('can save dashboard filters in existing dashboard', async () => {
  1029. MockApiClient.addMockResponse({
  1030. url: '/organizations/org-slug/releases/',
  1031. body: [
  1032. ReleaseFixture({
  1033. shortVersion: 'sentry-android-shop@1.2.0',
  1034. version: 'sentry-android-shop@1.2.0',
  1035. }),
  1036. ],
  1037. });
  1038. const testData = initializeOrg({
  1039. organization: OrganizationFixture({
  1040. features: [
  1041. 'global-views',
  1042. 'dashboards-basic',
  1043. 'dashboards-edit',
  1044. 'discover-query',
  1045. ],
  1046. }),
  1047. router: {
  1048. location: {
  1049. ...LocationFixture(),
  1050. query: {
  1051. statsPeriod: '7d',
  1052. release: ['sentry-android-shop@1.2.0'],
  1053. },
  1054. },
  1055. },
  1056. });
  1057. render(
  1058. <ViewEditDashboard
  1059. {...RouteComponentPropsFixture()}
  1060. organization={testData.organization}
  1061. params={{orgId: 'org-slug', dashboardId: '1'}}
  1062. router={testData.router}
  1063. location={testData.router.location}
  1064. >
  1065. {null}
  1066. </ViewEditDashboard>,
  1067. {router: testData.router, organization: testData.organization}
  1068. );
  1069. await userEvent.click(await screen.findByText('Save'));
  1070. expect(mockPut).toHaveBeenCalledWith(
  1071. '/organizations/org-slug/dashboards/1/',
  1072. expect.objectContaining({
  1073. data: expect.objectContaining({
  1074. period: '7d',
  1075. filters: {release: ['sentry-android-shop@1.2.0']},
  1076. }),
  1077. })
  1078. );
  1079. });
  1080. it('can clear dashboard filters in compact select', async () => {
  1081. MockApiClient.addMockResponse({
  1082. url: '/organizations/org-slug/dashboards/1/',
  1083. body: DashboardFixture(widgets, {
  1084. id: '1',
  1085. title: 'Custom Errors',
  1086. filters: {release: ['sentry-android-shop@1.2.0']},
  1087. }),
  1088. });
  1089. MockApiClient.addMockResponse({
  1090. url: '/organizations/org-slug/releases/',
  1091. body: [
  1092. ReleaseFixture({
  1093. shortVersion: 'sentry-android-shop@1.2.0',
  1094. version: 'sentry-android-shop@1.2.0',
  1095. }),
  1096. ],
  1097. });
  1098. const testData = initializeOrg({
  1099. organization: OrganizationFixture({
  1100. features: [
  1101. 'global-views',
  1102. 'dashboards-basic',
  1103. 'dashboards-edit',
  1104. 'discover-query',
  1105. ],
  1106. }),
  1107. router: {
  1108. location: {
  1109. ...LocationFixture(),
  1110. query: {
  1111. statsPeriod: '7d',
  1112. },
  1113. },
  1114. },
  1115. });
  1116. render(
  1117. <ViewEditDashboard
  1118. {...RouteComponentPropsFixture()}
  1119. organization={testData.organization}
  1120. params={{orgId: 'org-slug', dashboardId: '1'}}
  1121. router={testData.router}
  1122. location={testData.router.location}
  1123. >
  1124. {null}
  1125. </ViewEditDashboard>,
  1126. {router: testData.router, organization: testData.organization}
  1127. );
  1128. await screen.findByText('7D');
  1129. await userEvent.click(await screen.findByText('sentry-android-shop@1.2.0'));
  1130. await userEvent.click(screen.getAllByText('Clear')[0]);
  1131. screen.getByText('All Releases');
  1132. await userEvent.click(document.body);
  1133. await waitFor(() => {
  1134. expect(browserHistory.push).toHaveBeenCalledWith(
  1135. expect.objectContaining({
  1136. query: expect.objectContaining({
  1137. release: '',
  1138. }),
  1139. })
  1140. );
  1141. });
  1142. });
  1143. it('can save absolute time range in existing dashboard', async () => {
  1144. const testData = initializeOrg({
  1145. organization: OrganizationFixture({
  1146. features: [
  1147. 'global-views',
  1148. 'dashboards-basic',
  1149. 'dashboards-edit',
  1150. 'discover-query',
  1151. ],
  1152. }),
  1153. router: {
  1154. location: {
  1155. ...LocationFixture(),
  1156. query: {
  1157. start: '2022-07-14T07:00:00',
  1158. end: '2022-07-19T23:59:59',
  1159. utc: 'true',
  1160. },
  1161. },
  1162. },
  1163. });
  1164. render(
  1165. <ViewEditDashboard
  1166. {...RouteComponentPropsFixture()}
  1167. organization={testData.organization}
  1168. params={{orgId: 'org-slug', dashboardId: '1'}}
  1169. router={testData.router}
  1170. location={testData.router.location}
  1171. >
  1172. {null}
  1173. </ViewEditDashboard>,
  1174. {router: testData.router, organization: testData.organization}
  1175. );
  1176. await userEvent.click(await screen.findByText('Save'));
  1177. expect(mockPut).toHaveBeenCalledWith(
  1178. '/organizations/org-slug/dashboards/1/',
  1179. expect.objectContaining({
  1180. data: expect.objectContaining({
  1181. start: '2022-07-14T07:00:00.000',
  1182. end: '2022-07-19T23:59:59.000',
  1183. utc: true,
  1184. }),
  1185. })
  1186. );
  1187. });
  1188. it('can clear dashboard filters in existing dashboard', async () => {
  1189. MockApiClient.addMockResponse({
  1190. url: '/organizations/org-slug/releases/',
  1191. body: [
  1192. ReleaseFixture({
  1193. shortVersion: 'sentry-android-shop@1.2.0',
  1194. version: 'sentry-android-shop@1.2.0',
  1195. }),
  1196. ],
  1197. });
  1198. const testData = initializeOrg({
  1199. organization: OrganizationFixture({
  1200. features: [
  1201. 'global-views',
  1202. 'dashboards-basic',
  1203. 'dashboards-edit',
  1204. 'discover-query',
  1205. ],
  1206. }),
  1207. router: {
  1208. location: {
  1209. ...LocationFixture(),
  1210. query: {
  1211. statsPeriod: '7d',
  1212. environment: ['alpha', 'beta'],
  1213. },
  1214. },
  1215. },
  1216. });
  1217. render(
  1218. <ViewEditDashboard
  1219. {...RouteComponentPropsFixture()}
  1220. organization={testData.organization}
  1221. params={{orgId: 'org-slug', dashboardId: '1'}}
  1222. router={testData.router}
  1223. location={testData.router.location}
  1224. >
  1225. {null}
  1226. </ViewEditDashboard>,
  1227. {router: testData.router, organization: testData.organization}
  1228. );
  1229. await screen.findByText('7D');
  1230. await userEvent.click(await screen.findByText('All Releases'));
  1231. await userEvent.click(screen.getByText('sentry-android-shop@1.2.0'));
  1232. await userEvent.keyboard('{Escape}');
  1233. await userEvent.click(screen.getByText('Cancel'));
  1234. screen.getByText('All Releases');
  1235. expect(browserHistory.replace).toHaveBeenCalledWith(
  1236. expect.objectContaining({
  1237. query: expect.objectContaining({
  1238. project: undefined,
  1239. statsPeriod: undefined,
  1240. environment: undefined,
  1241. }),
  1242. })
  1243. );
  1244. });
  1245. it('disables the Edit Dashboard button when there are unsaved filters', async () => {
  1246. MockApiClient.addMockResponse({
  1247. url: '/organizations/org-slug/releases/',
  1248. body: [
  1249. ReleaseFixture({
  1250. shortVersion: 'sentry-android-shop@1.2.0',
  1251. version: 'sentry-android-shop@1.2.0',
  1252. }),
  1253. ],
  1254. });
  1255. const testData = initializeOrg({
  1256. organization: OrganizationFixture({
  1257. features: [
  1258. 'global-views',
  1259. 'dashboards-basic',
  1260. 'dashboards-edit',
  1261. 'discover-basic',
  1262. 'discover-query',
  1263. ],
  1264. }),
  1265. router: {
  1266. location: {
  1267. ...LocationFixture(),
  1268. query: {
  1269. statsPeriod: '7d',
  1270. environment: ['alpha', 'beta'],
  1271. },
  1272. },
  1273. },
  1274. });
  1275. render(
  1276. <ViewEditDashboard
  1277. {...RouteComponentPropsFixture()}
  1278. organization={testData.organization}
  1279. params={{orgId: 'org-slug', dashboardId: '1'}}
  1280. router={testData.router}
  1281. location={testData.router.location}
  1282. >
  1283. {null}
  1284. </ViewEditDashboard>,
  1285. {router: testData.router, organization: testData.organization}
  1286. );
  1287. expect(await screen.findByText('Save')).toBeInTheDocument();
  1288. expect(screen.getByText('Cancel')).toBeInTheDocument();
  1289. expect(screen.getByRole('button', {name: 'Edit Dashboard'})).toBeDisabled();
  1290. });
  1291. it('ignores the order of selection of page filters to render unsaved filters', async () => {
  1292. const testProjects = [
  1293. ProjectFixture({id: '1', name: 'first', environments: ['alpha', 'beta']}),
  1294. ProjectFixture({id: '2', name: 'second', environments: ['alpha', 'beta']}),
  1295. ];
  1296. act(() => ProjectsStore.loadInitialData(testProjects));
  1297. MockApiClient.addMockResponse({
  1298. url: '/organizations/org-slug/projects/',
  1299. body: testProjects,
  1300. });
  1301. MockApiClient.addMockResponse({
  1302. url: '/organizations/org-slug/dashboards/1/',
  1303. body: DashboardFixture(widgets, {
  1304. id: '1',
  1305. title: 'Custom Errors',
  1306. filters: {},
  1307. environment: ['alpha', 'beta'],
  1308. }),
  1309. });
  1310. const testData = initializeOrg({
  1311. organization: OrganizationFixture({
  1312. features: [
  1313. 'global-views',
  1314. 'dashboards-basic',
  1315. 'dashboards-edit',
  1316. 'discover-query',
  1317. ],
  1318. }),
  1319. router: {
  1320. location: {
  1321. ...LocationFixture(),
  1322. query: {
  1323. environment: ['beta', 'alpha'], // Reversed order from saved dashboard
  1324. },
  1325. },
  1326. },
  1327. });
  1328. render(
  1329. <ViewEditDashboard
  1330. {...RouteComponentPropsFixture()}
  1331. organization={testData.organization}
  1332. params={{orgId: 'org-slug', dashboardId: '1'}}
  1333. router={testData.router}
  1334. location={testData.router.location}
  1335. >
  1336. {null}
  1337. </ViewEditDashboard>,
  1338. {router: testData.router, organization: testData.organization}
  1339. );
  1340. await waitFor(() => expect(screen.queryAllByText('Loading\u2026')).toEqual([]));
  1341. await userEvent.click(screen.getByRole('button', {name: 'All Envs'}));
  1342. expect(screen.getByRole('row', {name: 'alpha'})).toHaveAttribute(
  1343. 'aria-selected',
  1344. 'true'
  1345. );
  1346. expect(screen.getByRole('row', {name: 'beta'})).toHaveAttribute(
  1347. 'aria-selected',
  1348. 'true'
  1349. );
  1350. // Save and Cancel should not appear because alpha, beta is the same as beta, alpha
  1351. expect(screen.queryByText('Save')).not.toBeInTheDocument();
  1352. expect(screen.queryByText('Cancel')).not.toBeInTheDocument();
  1353. });
  1354. it('uses releases from the URL query params', async function () {
  1355. const testData = initializeOrg({
  1356. organization: OrganizationFixture({
  1357. features: [
  1358. 'global-views',
  1359. 'dashboards-basic',
  1360. 'dashboards-edit',
  1361. 'discover-query',
  1362. ],
  1363. }),
  1364. router: {
  1365. location: {
  1366. ...LocationFixture(),
  1367. query: {
  1368. release: ['not-selected-1'],
  1369. },
  1370. },
  1371. },
  1372. });
  1373. render(
  1374. <ViewEditDashboard
  1375. {...RouteComponentPropsFixture()}
  1376. organization={testData.organization}
  1377. params={{orgId: 'org-slug', dashboardId: '1'}}
  1378. router={testData.router}
  1379. location={testData.router.location}
  1380. >
  1381. {null}
  1382. </ViewEditDashboard>,
  1383. {router: testData.router, organization: testData.organization}
  1384. );
  1385. await screen.findByText(/not-selected-1/);
  1386. screen.getByText('Save');
  1387. screen.getByText('Cancel');
  1388. });
  1389. it('resets release in URL params', async function () {
  1390. MockApiClient.addMockResponse({
  1391. url: '/organizations/org-slug/dashboards/1/',
  1392. body: DashboardFixture(widgets, {
  1393. id: '1',
  1394. title: 'Custom Errors',
  1395. filters: {
  1396. release: ['abc'],
  1397. },
  1398. }),
  1399. });
  1400. const testData = initializeOrg({
  1401. organization: OrganizationFixture({
  1402. features: [
  1403. 'global-views',
  1404. 'dashboards-basic',
  1405. 'dashboards-edit',
  1406. 'discover-query',
  1407. ],
  1408. }),
  1409. router: {
  1410. location: {
  1411. ...LocationFixture(),
  1412. query: {
  1413. release: ['not-selected-1'],
  1414. },
  1415. },
  1416. },
  1417. });
  1418. render(
  1419. <ViewEditDashboard
  1420. {...RouteComponentPropsFixture()}
  1421. organization={testData.organization}
  1422. params={{orgId: 'org-slug', dashboardId: '1'}}
  1423. router={testData.router}
  1424. location={testData.router.location}
  1425. >
  1426. {null}
  1427. </ViewEditDashboard>,
  1428. {router: testData.router, organization: testData.organization}
  1429. );
  1430. await screen.findByText(/not-selected-1/);
  1431. await userEvent.click(screen.getByText('Cancel'));
  1432. // release isn't used in the redirect
  1433. expect(browserHistory.replace).toHaveBeenCalledWith(
  1434. expect.objectContaining({
  1435. query: {
  1436. end: undefined,
  1437. environment: undefined,
  1438. project: undefined,
  1439. start: undefined,
  1440. statsPeriod: undefined,
  1441. utc: undefined,
  1442. },
  1443. })
  1444. );
  1445. });
  1446. it('reflects selections in the release filter in the query params', async function () {
  1447. MockApiClient.addMockResponse({
  1448. url: '/organizations/org-slug/releases/',
  1449. body: [
  1450. ReleaseFixture({
  1451. shortVersion: 'sentry-android-shop@1.2.0',
  1452. version: 'sentry-android-shop@1.2.0',
  1453. }),
  1454. ],
  1455. });
  1456. const testData = initializeOrg({
  1457. organization: OrganizationFixture({
  1458. features: [
  1459. 'global-views',
  1460. 'dashboards-basic',
  1461. 'dashboards-edit',
  1462. 'discover-query',
  1463. ],
  1464. }),
  1465. router: {
  1466. location: LocationFixture(),
  1467. },
  1468. });
  1469. render(
  1470. <ViewEditDashboard
  1471. {...RouteComponentPropsFixture()}
  1472. organization={testData.organization}
  1473. params={{orgId: 'org-slug', dashboardId: '1'}}
  1474. router={testData.router}
  1475. location={testData.router.location}
  1476. >
  1477. {null}
  1478. </ViewEditDashboard>,
  1479. {router: testData.router, organization: testData.organization}
  1480. );
  1481. await userEvent.click(await screen.findByText('All Releases'));
  1482. await userEvent.click(screen.getByText('sentry-android-shop@1.2.0'));
  1483. await userEvent.click(document.body);
  1484. await waitFor(() => {
  1485. expect(browserHistory.push).toHaveBeenCalledWith(
  1486. expect.objectContaining({
  1487. query: expect.objectContaining({
  1488. release: ['sentry-android-shop@1.2.0'],
  1489. }),
  1490. })
  1491. );
  1492. });
  1493. });
  1494. it('persists release selections made during search requests that do not appear in default query', async function () {
  1495. // Default response
  1496. MockApiClient.addMockResponse({
  1497. url: '/organizations/org-slug/releases/',
  1498. body: [
  1499. ReleaseFixture({
  1500. shortVersion: 'sentry-android-shop@1.2.0',
  1501. version: 'sentry-android-shop@1.2.0',
  1502. }),
  1503. ],
  1504. });
  1505. // Mocked search results
  1506. MockApiClient.addMockResponse({
  1507. url: '/organizations/org-slug/releases/',
  1508. body: [
  1509. ReleaseFixture({
  1510. id: '9',
  1511. shortVersion: 'search-result',
  1512. version: 'search-result',
  1513. }),
  1514. ],
  1515. match: [MockApiClient.matchData({query: 's'})],
  1516. });
  1517. const testData = initializeOrg({
  1518. organization: OrganizationFixture({
  1519. features: [
  1520. 'global-views',
  1521. 'dashboards-basic',
  1522. 'dashboards-edit',
  1523. 'discover-basic',
  1524. 'discover-query',
  1525. ],
  1526. }),
  1527. router: {
  1528. location: LocationFixture(),
  1529. },
  1530. });
  1531. render(
  1532. <ViewEditDashboard
  1533. {...RouteComponentPropsFixture()}
  1534. organization={testData.organization}
  1535. params={{orgId: 'org-slug', dashboardId: '1'}}
  1536. router={testData.router}
  1537. location={testData.router.location}
  1538. >
  1539. {null}
  1540. </ViewEditDashboard>,
  1541. {router: testData.router, organization: testData.organization}
  1542. );
  1543. await userEvent.click(await screen.findByText('All Releases'));
  1544. await userEvent.type(screen.getAllByPlaceholderText('Search\u2026')[2], 's');
  1545. await userEvent.click(await screen.findByRole('option', {name: 'search-result'}));
  1546. // Validate that after search is cleared, search result still appears
  1547. expect(await screen.findByText('Latest Release(s)')).toBeInTheDocument();
  1548. expect(screen.getByRole('option', {name: 'search-result'})).toBeInTheDocument();
  1549. });
  1550. it('renders edit access selector', async function () {
  1551. render(
  1552. <EditAccessSelector
  1553. dashboard={DashboardFixture([], {id: '1', title: 'Custom Errors'})}
  1554. onChangeEditAccess={jest.fn()}
  1555. />,
  1556. {
  1557. router: initialData.router,
  1558. organization: {
  1559. features: ['dashboards-edit-access'],
  1560. ...initialData.organization,
  1561. },
  1562. }
  1563. );
  1564. await userEvent.click(await screen.findByText('Edit Access:'));
  1565. expect(screen.getByText('Creator')).toBeInTheDocument();
  1566. expect(screen.getByText('Everyone')).toBeInTheDocument();
  1567. });
  1568. it('creates and updates new permissions for dashboard with no edit perms initialized', async function () {
  1569. const mockPUT = MockApiClient.addMockResponse({
  1570. url: '/organizations/org-slug/dashboards/1/',
  1571. method: 'PUT',
  1572. body: DashboardFixture([], {id: '1', title: 'Custom Errors'}),
  1573. });
  1574. render(
  1575. <ViewEditDashboard
  1576. {...RouteComponentPropsFixture()}
  1577. organization={{
  1578. ...initialData.organization,
  1579. features: ['dashboards-edit-access', ...initialData.organization.features],
  1580. }}
  1581. params={{orgId: 'org-slug', dashboardId: '1'}}
  1582. router={initialData.router}
  1583. location={initialData.router.location}
  1584. >
  1585. {null}
  1586. </ViewEditDashboard>,
  1587. {
  1588. router: initialData.router,
  1589. organization: {
  1590. features: ['dashboards-edit-access', ...initialData.organization.features],
  1591. },
  1592. }
  1593. );
  1594. await userEvent.click(await screen.findByText('Edit Access:'));
  1595. // deselects 'Everyone' so only creator has edit access
  1596. expect(await screen.findByText('Everyone')).toBeEnabled();
  1597. expect(await screen.findByRole('option', {name: 'Everyone'})).toHaveAttribute(
  1598. 'aria-selected',
  1599. 'true'
  1600. );
  1601. await userEvent.click(screen.getByRole('option', {name: 'Everyone'}));
  1602. expect(await screen.findByRole('option', {name: 'Everyone'})).toHaveAttribute(
  1603. 'aria-selected',
  1604. 'false'
  1605. );
  1606. // clicks out of dropdown to trigger onClose()
  1607. await userEvent.click(await screen.findByText('Edit Access:'));
  1608. await waitFor(() => {
  1609. expect(mockPUT).toHaveBeenCalledTimes(1);
  1610. expect(mockPUT).toHaveBeenCalledWith(
  1611. '/organizations/org-slug/dashboards/1/',
  1612. expect.objectContaining({
  1613. data: expect.objectContaining({
  1614. permissions: {isCreatorOnlyEditable: true},
  1615. }),
  1616. })
  1617. );
  1618. });
  1619. });
  1620. it('creator can update permissions for dashboard', async function () {
  1621. const mockPUT = MockApiClient.addMockResponse({
  1622. url: '/organizations/org-slug/dashboards/1/',
  1623. method: 'PUT',
  1624. body: DashboardFixture([], {id: '1', title: 'Custom Errors'}),
  1625. });
  1626. MockApiClient.addMockResponse({
  1627. url: '/organizations/org-slug/dashboards/1/',
  1628. body: DashboardFixture([], {
  1629. id: '1',
  1630. title: 'Custom Errors',
  1631. createdBy: UserFixture({id: '781629'}),
  1632. permissions: {isCreatorOnlyEditable: true},
  1633. }),
  1634. });
  1635. const currentUser = UserFixture({id: '781629'});
  1636. ConfigStore.set('user', currentUser);
  1637. render(
  1638. <ViewEditDashboard
  1639. {...RouteComponentPropsFixture()}
  1640. organization={{
  1641. ...initialData.organization,
  1642. features: ['dashboards-edit-access', ...initialData.organization.features],
  1643. }}
  1644. params={{orgId: 'org-slug', dashboardId: '1'}}
  1645. router={initialData.router}
  1646. location={initialData.router.location}
  1647. >
  1648. {null}
  1649. </ViewEditDashboard>,
  1650. {
  1651. router: initialData.router,
  1652. organization: {
  1653. features: ['dashboards-edit-access', ...initialData.organization.features],
  1654. },
  1655. }
  1656. );
  1657. await userEvent.click(await screen.findByText('Edit Access:'));
  1658. // selects 'Everyone' so everyone has edit access
  1659. expect(await screen.findByText('Everyone')).toBeEnabled();
  1660. expect(await screen.findByRole('option', {name: 'Everyone'})).toHaveAttribute(
  1661. 'aria-selected',
  1662. 'false'
  1663. );
  1664. await userEvent.click(screen.getByRole('option', {name: 'Everyone'}));
  1665. expect(await screen.findByRole('option', {name: 'Everyone'})).toHaveAttribute(
  1666. 'aria-selected',
  1667. 'true'
  1668. );
  1669. // clicks out of dropdown to trigger onClose()
  1670. await userEvent.click(await screen.findByText('Edit Access:'));
  1671. await waitFor(() => {
  1672. expect(mockPUT).toHaveBeenCalledTimes(1);
  1673. expect(mockPUT).toHaveBeenCalledWith(
  1674. '/organizations/org-slug/dashboards/1/',
  1675. expect.objectContaining({
  1676. data: expect.objectContaining({
  1677. permissions: {isCreatorOnlyEditable: false},
  1678. }),
  1679. })
  1680. );
  1681. });
  1682. });
  1683. it('disables edit dashboard and add widget button if user cannot edit dashboard', async function () {
  1684. MockApiClient.addMockResponse({
  1685. url: '/organizations/org-slug/dashboards/',
  1686. body: [
  1687. DashboardFixture([], {
  1688. id: '1',
  1689. title: 'Custom Errors',
  1690. createdBy: UserFixture({id: '238900'}),
  1691. permissions: {isCreatorOnlyEditable: true},
  1692. }),
  1693. ],
  1694. });
  1695. MockApiClient.addMockResponse({
  1696. url: '/organizations/org-slug/dashboards/1/',
  1697. body: DashboardFixture([], {
  1698. id: '1',
  1699. title: 'Custom Errors',
  1700. createdBy: UserFixture({id: '238900'}),
  1701. permissions: {isCreatorOnlyEditable: true},
  1702. }),
  1703. });
  1704. const currentUser = UserFixture({id: '781629'});
  1705. ConfigStore.set('user', currentUser);
  1706. render(
  1707. <ViewEditDashboard
  1708. {...RouteComponentPropsFixture()}
  1709. organization={{
  1710. ...initialData.organization,
  1711. features: ['dashboards-edit-access', ...initialData.organization.features],
  1712. }}
  1713. params={{orgId: 'org-slug', dashboardId: '1'}}
  1714. router={initialData.router}
  1715. location={initialData.router.location}
  1716. >
  1717. {null}
  1718. </ViewEditDashboard>,
  1719. {
  1720. router: initialData.router,
  1721. organization: {
  1722. features: ['dashboards-edit-access', ...initialData.organization.features],
  1723. },
  1724. }
  1725. );
  1726. await screen.findByText('Edit Access:');
  1727. expect(screen.getByRole('button', {name: 'Edit Dashboard'})).toBeDisabled();
  1728. expect(screen.getByRole('button', {name: 'Add Widget'})).toBeDisabled();
  1729. });
  1730. describe('discover split', function () {
  1731. it('calls the dashboard callbacks with the correct widgetType for discover split', function () {
  1732. const widget = {
  1733. displayType: types.DisplayType.TABLE,
  1734. interval: '1d',
  1735. queries: [
  1736. {
  1737. name: 'Test Widget',
  1738. fields: ['count()', 'count_unique(user)', 'epm()', 'project'],
  1739. columns: ['project'],
  1740. aggregates: ['count()', 'count_unique(user)', 'epm()'],
  1741. conditions: '',
  1742. orderby: '',
  1743. },
  1744. ],
  1745. title: 'Transactions',
  1746. id: '1',
  1747. widgetType: types.WidgetType.DISCOVER,
  1748. };
  1749. const mockDashboard = DashboardFixture([widget], {
  1750. id: '1',
  1751. title: 'Custom Errors',
  1752. });
  1753. const mockModifiedDashboard = DashboardFixture([widget], {
  1754. id: '1',
  1755. title: 'Custom Errors',
  1756. });
  1757. const mockOnDashboardUpdate = jest.fn();
  1758. const mockStateSetter = jest
  1759. .fn()
  1760. .mockImplementation(fn => fn({modifiedDashboard: mockModifiedDashboard}));
  1761. handleUpdateDashboardSplit({
  1762. widgetId: '1',
  1763. splitDecision: types.WidgetType.ERRORS,
  1764. dashboard: mockDashboard,
  1765. modifiedDashboard: mockModifiedDashboard,
  1766. onDashboardUpdate: mockOnDashboardUpdate,
  1767. stateSetter: mockStateSetter,
  1768. });
  1769. expect(mockOnDashboardUpdate).toHaveBeenCalledWith({
  1770. ...mockDashboard,
  1771. widgets: [{...widget, widgetType: types.WidgetType.ERRORS}],
  1772. });
  1773. expect(mockStateSetter).toHaveReturnedWith({
  1774. modifiedDashboard: {
  1775. ...mockModifiedDashboard,
  1776. widgets: [{...widget, widgetType: types.WidgetType.ERRORS}],
  1777. },
  1778. });
  1779. });
  1780. });
  1781. });
  1782. });