detail.spec.jsx 44 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465
  1. import {browserHistory} from 'react-router';
  2. import {initializeOrg} from 'sentry-test/initializeOrg';
  3. import {
  4. act,
  5. render,
  6. screen,
  7. userEvent,
  8. waitFor,
  9. within,
  10. } from 'sentry-test/reactTestingLibrary';
  11. import * as modals from 'sentry/actionCreators/modal';
  12. import ProjectsStore from 'sentry/stores/projectsStore';
  13. import CreateDashboard from 'sentry/views/dashboardsV2/create';
  14. import * as types from 'sentry/views/dashboardsV2/types';
  15. import ViewEditDashboard from 'sentry/views/dashboardsV2/view';
  16. import {OrganizationContext} from 'sentry/views/organizationContext';
  17. jest.mock('sentry/components/charts/worldMapChart', () => ({
  18. WorldMapChart: () => null,
  19. }));
  20. describe('Dashboards > Detail', function () {
  21. const organization = TestStubs.Organization({
  22. features: [
  23. 'global-views',
  24. 'dashboards-basic',
  25. 'dashboards-edit',
  26. 'discover-query',
  27. 'dashboard-grid-layout',
  28. ],
  29. });
  30. const projects = [TestStubs.Project()];
  31. describe('prebuilt dashboards', function () {
  32. let initialData;
  33. beforeEach(function () {
  34. act(() => ProjectsStore.loadInitialData(projects));
  35. initialData = initializeOrg({organization});
  36. MockApiClient.addMockResponse({
  37. url: '/organizations/org-slug/tags/',
  38. body: [],
  39. });
  40. MockApiClient.addMockResponse({
  41. url: '/organizations/org-slug/projects/',
  42. body: [TestStubs.Project()],
  43. });
  44. MockApiClient.addMockResponse({
  45. url: '/organizations/org-slug/dashboards/',
  46. body: [
  47. TestStubs.Dashboard([], {id: 'default-overview', title: 'Default'}),
  48. TestStubs.Dashboard([], {id: '1', title: 'Custom Errors'}),
  49. ],
  50. });
  51. MockApiClient.addMockResponse({
  52. url: '/organizations/org-slug/dashboards/default-overview/',
  53. body: TestStubs.Dashboard([], {id: 'default-overview', title: 'Default'}),
  54. });
  55. MockApiClient.addMockResponse({
  56. url: '/organizations/org-slug/dashboards/1/visit/',
  57. method: 'POST',
  58. body: [],
  59. statusCode: 200,
  60. });
  61. MockApiClient.addMockResponse({
  62. url: '/organizations/org-slug/users/',
  63. method: 'GET',
  64. body: [],
  65. });
  66. MockApiClient.addMockResponse({
  67. url: '/organizations/org-slug/sdk-updates/',
  68. body: [],
  69. });
  70. MockApiClient.addMockResponse({
  71. url: '/prompts-activity/',
  72. body: {},
  73. });
  74. MockApiClient.addMockResponse({
  75. url: '/organizations/org-slug/events-geo/',
  76. body: {data: [], meta: {}},
  77. });
  78. MockApiClient.addMockResponse({
  79. url: '/organizations/org-slug/eventsv2/',
  80. method: 'GET',
  81. body: [],
  82. });
  83. MockApiClient.addMockResponse({
  84. url: '/organizations/org-slug/events-stats/',
  85. body: {data: []},
  86. });
  87. MockApiClient.addMockResponse({
  88. method: 'GET',
  89. url: '/organizations/org-slug/issues/',
  90. body: [],
  91. });
  92. });
  93. afterEach(function () {
  94. MockApiClient.clearMockResponses();
  95. });
  96. it('assigns unique IDs to all widgets so grid keys are unique', async function () {
  97. MockApiClient.addMockResponse({
  98. url: '/organizations/org-slug/events-stats/',
  99. body: {data: []},
  100. });
  101. MockApiClient.addMockResponse({
  102. url: '/organizations/org-slug/dashboards/default-overview/',
  103. body: TestStubs.Dashboard(
  104. [
  105. TestStubs.Widget(
  106. [
  107. {
  108. name: '',
  109. conditions: 'event.type:error',
  110. fields: ['count()'],
  111. aggregates: ['count()'],
  112. columns: [],
  113. },
  114. ],
  115. {
  116. title: 'Default Widget 1',
  117. interval: '1d',
  118. }
  119. ),
  120. TestStubs.Widget(
  121. [
  122. {
  123. name: '',
  124. conditions: 'event.type:transaction',
  125. fields: ['count()'],
  126. aggregates: ['count()'],
  127. columns: [],
  128. },
  129. ],
  130. {
  131. title: 'Default Widget 2',
  132. interval: '1d',
  133. }
  134. ),
  135. ],
  136. {id: 'default-overview', title: 'Default'}
  137. ),
  138. });
  139. initialData = initializeOrg({
  140. organization: TestStubs.Organization({
  141. features: [
  142. 'global-views',
  143. 'dashboards-basic',
  144. 'discover-query',
  145. 'dashboard-grid-layout',
  146. ],
  147. projects: [TestStubs.Project()],
  148. }),
  149. });
  150. render(
  151. <OrganizationContext.Provider value={initialData.organization}>
  152. <ViewEditDashboard
  153. organization={initialData.organization}
  154. params={{orgId: 'org-slug', dashboardId: 'default-overview'}}
  155. router={initialData.router}
  156. location={initialData.router.location}
  157. />
  158. </OrganizationContext.Provider>,
  159. {context: initialData.routerContext}
  160. );
  161. expect(await screen.findByText('Default Widget 1')).toBeInTheDocument();
  162. expect(screen.getByText('Default Widget 2')).toBeInTheDocument();
  163. });
  164. it('opens the widget viewer modal in a prebuilt dashboard using the widget id specified in the url', async () => {
  165. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  166. render(
  167. <CreateDashboard
  168. organization={initialData.organization}
  169. params={{orgId: 'org-slug', templateId: 'default-template', widgetId: '2'}}
  170. router={initialData.router}
  171. location={{...initialData.router.location, pathname: '/widget/2/'}}
  172. />,
  173. {context: initialData.routerContext, organization: initialData.organization}
  174. );
  175. await waitFor(() => {
  176. expect(openWidgetViewerModal).toHaveBeenCalledWith(
  177. expect.objectContaining({
  178. organization: initialData.organization,
  179. widget: expect.objectContaining({
  180. displayType: 'line',
  181. interval: '5m',
  182. queries: [
  183. {
  184. aggregates: ['count()'],
  185. columns: [],
  186. conditions: '!event.type:transaction',
  187. fields: ['count()'],
  188. name: 'Events',
  189. orderby: 'count()',
  190. },
  191. ],
  192. title: 'Events',
  193. widgetType: 'discover',
  194. }),
  195. onClose: expect.anything(),
  196. })
  197. );
  198. });
  199. });
  200. });
  201. describe('custom dashboards', function () {
  202. let initialData, widgets, mockVisit, mockPut;
  203. beforeEach(function () {
  204. window.confirm = jest.fn();
  205. initialData = initializeOrg({
  206. organization,
  207. router: {
  208. location: TestStubs.location(),
  209. },
  210. });
  211. widgets = [
  212. TestStubs.Widget(
  213. [
  214. {
  215. name: '',
  216. conditions: 'event.type:error',
  217. fields: ['count()'],
  218. columns: [],
  219. aggregates: ['count()'],
  220. },
  221. ],
  222. {
  223. title: 'Errors',
  224. interval: '1d',
  225. widgetType: 'discover',
  226. id: '1',
  227. }
  228. ),
  229. TestStubs.Widget(
  230. [
  231. {
  232. name: '',
  233. conditions: 'event.type:transaction',
  234. fields: ['count()'],
  235. columns: [],
  236. aggregates: ['count()'],
  237. },
  238. ],
  239. {
  240. title: 'Transactions',
  241. interval: '1d',
  242. widgetType: 'discover',
  243. id: '2',
  244. }
  245. ),
  246. TestStubs.Widget(
  247. [
  248. {
  249. name: '',
  250. conditions: 'event.type:transaction transaction:/api/cats',
  251. fields: ['p50()'],
  252. columns: [],
  253. aggregates: ['p50()'],
  254. },
  255. ],
  256. {
  257. title: 'p50 of /api/cats',
  258. interval: '1d',
  259. id: '3',
  260. }
  261. ),
  262. ];
  263. mockVisit = MockApiClient.addMockResponse({
  264. url: '/organizations/org-slug/dashboards/1/visit/',
  265. method: 'POST',
  266. body: [],
  267. statusCode: 200,
  268. });
  269. MockApiClient.addMockResponse({
  270. url: '/organizations/org-slug/tags/',
  271. body: [],
  272. });
  273. MockApiClient.addMockResponse({
  274. url: '/organizations/org-slug/projects/',
  275. body: [TestStubs.Project()],
  276. });
  277. MockApiClient.addMockResponse({
  278. url: '/organizations/org-slug/dashboards/',
  279. body: [
  280. TestStubs.Dashboard([], {
  281. id: 'default-overview',
  282. title: 'Default',
  283. widgetDisplay: ['area'],
  284. }),
  285. TestStubs.Dashboard([], {
  286. id: '1',
  287. title: 'Custom Errors',
  288. widgetDisplay: ['area'],
  289. }),
  290. ],
  291. });
  292. MockApiClient.addMockResponse({
  293. url: '/organizations/org-slug/dashboards/1/',
  294. body: TestStubs.Dashboard(widgets, {
  295. id: '1',
  296. title: 'Custom Errors',
  297. filters: {},
  298. }),
  299. });
  300. mockPut = MockApiClient.addMockResponse({
  301. url: '/organizations/org-slug/dashboards/1/',
  302. method: 'PUT',
  303. body: TestStubs.Dashboard(widgets, {id: '1', title: 'Custom Errors'}),
  304. });
  305. MockApiClient.addMockResponse({
  306. url: '/organizations/org-slug/events-stats/',
  307. body: {data: []},
  308. });
  309. MockApiClient.addMockResponse({
  310. method: 'POST',
  311. url: '/organizations/org-slug/dashboards/widgets/',
  312. body: [],
  313. });
  314. MockApiClient.addMockResponse({
  315. method: 'GET',
  316. url: '/organizations/org-slug/recent-searches/',
  317. body: [],
  318. });
  319. MockApiClient.addMockResponse({
  320. method: 'GET',
  321. url: '/organizations/org-slug/issues/',
  322. body: [],
  323. });
  324. MockApiClient.addMockResponse({
  325. url: '/organizations/org-slug/eventsv2/',
  326. method: 'GET',
  327. body: [],
  328. });
  329. MockApiClient.addMockResponse({
  330. url: '/organizations/org-slug/users/',
  331. method: 'GET',
  332. body: [],
  333. });
  334. MockApiClient.addMockResponse({
  335. url: '/organizations/org-slug/events-geo/',
  336. body: {data: [], meta: {}},
  337. });
  338. MockApiClient.addMockResponse({
  339. url: '/organizations/org-slug/releases/',
  340. body: [],
  341. });
  342. MockApiClient.addMockResponse({
  343. url: '/organizations/org-slug/sdk-updates/',
  344. body: [],
  345. });
  346. MockApiClient.addMockResponse({
  347. url: '/prompts-activity/',
  348. body: {},
  349. });
  350. });
  351. afterEach(function () {
  352. MockApiClient.clearMockResponses();
  353. jest.clearAllMocks();
  354. });
  355. it('can remove widgets', async function () {
  356. const updateMock = MockApiClient.addMockResponse({
  357. url: '/organizations/org-slug/dashboards/1/',
  358. method: 'PUT',
  359. body: TestStubs.Dashboard([widgets[0]], {id: '1', title: 'Custom Errors'}),
  360. });
  361. render(
  362. <OrganizationContext.Provider value={initialData.organization}>
  363. <ViewEditDashboard
  364. organization={initialData.organization}
  365. params={{orgId: 'org-slug', dashboardId: '1'}}
  366. router={initialData.router}
  367. location={initialData.router.location}
  368. />
  369. </OrganizationContext.Provider>,
  370. {context: initialData.routerContext}
  371. );
  372. await waitFor(() => expect(mockVisit).toHaveBeenCalledTimes(1));
  373. // Enter edit mode.
  374. userEvent.click(screen.getByRole('button', {name: 'Edit Dashboard'}));
  375. // Remove the second and third widgets
  376. userEvent.click(screen.getAllByRole('button', {name: 'Delete Widget'})[1]);
  377. userEvent.click(screen.getAllByRole('button', {name: 'Delete Widget'})[1]);
  378. // Save changes
  379. userEvent.click(screen.getByRole('button', {name: 'Save and Finish'}));
  380. expect(updateMock).toHaveBeenCalled();
  381. expect(updateMock).toHaveBeenCalledWith(
  382. '/organizations/org-slug/dashboards/1/',
  383. expect.objectContaining({
  384. data: expect.objectContaining({
  385. title: 'Custom Errors',
  386. widgets: [expect.objectContaining(widgets[0])],
  387. }),
  388. })
  389. );
  390. // Visit should not be called again on dashboard update
  391. expect(mockVisit).toHaveBeenCalledTimes(1);
  392. });
  393. it('appends dashboard-level filters to series request', async function () {
  394. MockApiClient.addMockResponse({
  395. url: '/organizations/org-slug/dashboards/1/',
  396. body: TestStubs.Dashboard(widgets, {
  397. id: '1',
  398. title: 'Custom Errors',
  399. filters: {release: ['abc@1.2.0']},
  400. }),
  401. });
  402. const mock = MockApiClient.addMockResponse({
  403. url: '/organizations/org-slug/events-stats/',
  404. body: [],
  405. });
  406. render(
  407. <OrganizationContext.Provider value={initialData.organization}>
  408. <ViewEditDashboard
  409. organization={initialData.organization}
  410. params={{orgId: 'org-slug', dashboardId: '1'}}
  411. router={initialData.router}
  412. location={initialData.router.location}
  413. />
  414. </OrganizationContext.Provider>,
  415. {context: initialData.routerContext}
  416. );
  417. await waitFor(() =>
  418. expect(mock).toHaveBeenLastCalledWith(
  419. '/organizations/org-slug/events-stats/',
  420. expect.objectContaining({
  421. query: expect.objectContaining({
  422. query: 'event.type:transaction transaction:/api/cats release:abc@1.2.0 ',
  423. }),
  424. })
  425. )
  426. );
  427. });
  428. it('shows add widget option', async function () {
  429. render(
  430. <OrganizationContext.Provider value={initialData.organization}>
  431. <ViewEditDashboard
  432. organization={initialData.organization}
  433. params={{orgId: 'org-slug', dashboardId: '1'}}
  434. router={initialData.router}
  435. location={initialData.router.location}
  436. />
  437. </OrganizationContext.Provider>,
  438. {context: initialData.routerContext}
  439. );
  440. // Enter edit mode.
  441. userEvent.click(screen.getByRole('button', {name: 'Edit Dashboard'}));
  442. expect(await screen.findByRole('button', {name: 'Add widget'})).toBeInTheDocument();
  443. });
  444. it('shows top level release filter', async function () {
  445. const mockReleases = MockApiClient.addMockResponse({
  446. url: '/organizations/org-slug/releases/',
  447. body: [TestStubs.Release()],
  448. });
  449. initialData = initializeOrg({
  450. organization: TestStubs.Organization({
  451. features: [
  452. 'global-views',
  453. 'dashboards-basic',
  454. 'dashboards-edit',
  455. 'discover-query',
  456. 'dashboards-top-level-filter',
  457. ],
  458. projects: [TestStubs.Project()],
  459. }),
  460. });
  461. render(
  462. <OrganizationContext.Provider value={initialData.organization}>
  463. <ViewEditDashboard
  464. organization={initialData.organization}
  465. params={{orgId: 'org-slug', dashboardId: '1'}}
  466. router={initialData.router}
  467. location={initialData.router.location}
  468. />
  469. </OrganizationContext.Provider>,
  470. {context: initialData.routerContext}
  471. );
  472. expect(await screen.findByText('All Releases')).toBeInTheDocument();
  473. expect(mockReleases).toHaveBeenCalledTimes(1);
  474. });
  475. it('hides add widget option', async function () {
  476. types.MAX_WIDGETS = 1;
  477. render(
  478. <OrganizationContext.Provider value={initialData.organization}>
  479. <ViewEditDashboard
  480. organization={initialData.organization}
  481. params={{orgId: 'org-slug', dashboardId: '1'}}
  482. router={initialData.router}
  483. location={initialData.router.location}
  484. />
  485. </OrganizationContext.Provider>,
  486. {context: initialData.routerContext}
  487. );
  488. // Enter edit mode.
  489. userEvent.click(await screen.findByRole('button', {name: 'Edit Dashboard'}));
  490. expect(screen.queryByRole('button', {name: 'Add widget'})).not.toBeInTheDocument();
  491. });
  492. it('renders successfully if more widgets than stored layouts', async function () {
  493. // A case where someone has async added widgets to a dashboard
  494. MockApiClient.addMockResponse({
  495. url: '/organizations/org-slug/dashboards/1/',
  496. body: TestStubs.Dashboard(
  497. [
  498. TestStubs.Widget(
  499. [
  500. {
  501. name: '',
  502. conditions: 'event.type:error',
  503. fields: ['count()'],
  504. aggregates: ['count()'],
  505. columns: [],
  506. },
  507. ],
  508. {
  509. title: 'First Widget',
  510. interval: '1d',
  511. id: '1',
  512. layout: {i: 'grid-item-1', x: 0, y: 0, w: 2, h: 6},
  513. }
  514. ),
  515. TestStubs.Widget(
  516. [
  517. {
  518. name: '',
  519. conditions: 'event.type:error',
  520. fields: ['count()'],
  521. aggregates: ['count()'],
  522. columns: [],
  523. },
  524. ],
  525. {
  526. title: 'Second Widget',
  527. interval: '1d',
  528. id: '2',
  529. }
  530. ),
  531. ],
  532. {id: '1', title: 'Custom Errors'}
  533. ),
  534. });
  535. await act(async () => {
  536. render(
  537. <ViewEditDashboard
  538. organization={initialData.organization}
  539. params={{orgId: 'org-slug', dashboardId: '1'}}
  540. router={initialData.router}
  541. location={initialData.router.location}
  542. />,
  543. {context: initialData.routerContext, organization: initialData.organization}
  544. );
  545. await tick();
  546. });
  547. await screen.findByText('First Widget');
  548. await screen.findByText('Second Widget');
  549. });
  550. it('does not trigger request if layout not updated', async () => {
  551. MockApiClient.addMockResponse({
  552. url: '/organizations/org-slug/dashboards/1/',
  553. body: TestStubs.Dashboard(
  554. [
  555. TestStubs.Widget(
  556. [
  557. {
  558. name: '',
  559. conditions: 'event.type:error',
  560. fields: ['count()'],
  561. aggregates: ['count()'],
  562. columns: [],
  563. },
  564. ],
  565. {
  566. title: 'First Widget',
  567. interval: '1d',
  568. id: '1',
  569. layout: {i: 'grid-item-1', x: 0, y: 0, w: 2, h: 6},
  570. }
  571. ),
  572. ],
  573. {id: '1', title: 'Custom Errors'}
  574. ),
  575. });
  576. await act(async () => {
  577. render(
  578. <ViewEditDashboard
  579. organization={initialData.organization}
  580. params={{orgId: 'org-slug', dashboardId: '1'}}
  581. router={initialData.router}
  582. location={initialData.router.location}
  583. />,
  584. {context: initialData.routerContext, organization: initialData.organization}
  585. );
  586. await tick();
  587. userEvent.click(screen.getByText('Edit Dashboard'));
  588. userEvent.click(screen.getByText('Save and Finish'));
  589. await tick();
  590. });
  591. expect(screen.getByText('Edit Dashboard')).toBeInTheDocument();
  592. expect(mockPut).not.toHaveBeenCalled();
  593. });
  594. it('renders the custom resize handler for a widget', async () => {
  595. MockApiClient.addMockResponse({
  596. url: '/organizations/org-slug/dashboards/1/',
  597. body: TestStubs.Dashboard(
  598. [
  599. TestStubs.Widget(
  600. [
  601. {
  602. name: '',
  603. conditions: 'event.type:error',
  604. fields: ['count()'],
  605. aggregates: ['count()'],
  606. columns: [],
  607. },
  608. ],
  609. {
  610. title: 'First Widget',
  611. interval: '1d',
  612. id: '1',
  613. layout: {i: 'grid-item-1', x: 0, y: 0, w: 2, h: 6},
  614. }
  615. ),
  616. ],
  617. {id: '1', title: 'Custom Errors'}
  618. ),
  619. });
  620. await act(async () => {
  621. render(
  622. <ViewEditDashboard
  623. organization={initialData.organization}
  624. params={{orgId: 'org-slug', dashboardId: '1'}}
  625. router={initialData.router}
  626. location={initialData.router.location}
  627. />,
  628. {context: initialData.routerContext, organization: initialData.organization}
  629. );
  630. await tick();
  631. });
  632. userEvent.click(await screen.findByText('Edit Dashboard'));
  633. const widget = screen.getByText('First Widget').closest('.react-grid-item');
  634. const resizeHandle = within(widget).getByTestId('custom-resize-handle');
  635. expect(resizeHandle).toBeVisible();
  636. });
  637. it('does not trigger an alert when the widgets have no layout and user cancels without changes', async () => {
  638. MockApiClient.addMockResponse({
  639. url: '/organizations/org-slug/dashboards/1/',
  640. body: TestStubs.Dashboard(
  641. [
  642. TestStubs.Widget(
  643. [
  644. {
  645. name: '',
  646. conditions: 'event.type:error',
  647. fields: ['count()'],
  648. aggregates: ['count()'],
  649. columns: [],
  650. },
  651. ],
  652. {
  653. title: 'First Widget',
  654. interval: '1d',
  655. id: '1',
  656. layout: null,
  657. }
  658. ),
  659. ],
  660. {id: '1', title: 'Custom Errors'}
  661. ),
  662. });
  663. await act(async () => {
  664. render(
  665. <ViewEditDashboard
  666. organization={initialData.organization}
  667. params={{orgId: 'org-slug', dashboardId: '1'}}
  668. router={initialData.router}
  669. location={initialData.router.location}
  670. />,
  671. {context: initialData.routerContext, organization: initialData.organization}
  672. );
  673. await tick();
  674. });
  675. await act(async () => {
  676. userEvent.click(await screen.findByText('Edit Dashboard'));
  677. userEvent.click(await screen.findByText('Cancel'));
  678. });
  679. expect(window.confirm).not.toHaveBeenCalled();
  680. });
  681. it('opens the widget viewer modal using the widget id specified in the url', async () => {
  682. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  683. const widget = TestStubs.Widget(
  684. [
  685. {
  686. name: '',
  687. conditions: 'event.type:error',
  688. fields: ['count()'],
  689. aggregates: ['count()'],
  690. columns: [],
  691. orderby: '',
  692. },
  693. ],
  694. {
  695. title: 'First Widget',
  696. interval: '1d',
  697. id: '1',
  698. layout: null,
  699. }
  700. );
  701. MockApiClient.addMockResponse({
  702. url: '/organizations/org-slug/dashboards/1/',
  703. body: TestStubs.Dashboard([widget], {id: '1', title: 'Custom Errors'}),
  704. });
  705. await act(async () => {
  706. render(
  707. <ViewEditDashboard
  708. organization={initialData.organization}
  709. params={{orgId: 'org-slug', dashboardId: '1', widgetId: '1'}}
  710. router={initialData.router}
  711. location={{...initialData.router.location, pathname: '/widget/123/'}}
  712. />,
  713. {context: initialData.routerContext, organization: initialData.organization}
  714. );
  715. await tick();
  716. });
  717. expect(openWidgetViewerModal).toHaveBeenCalledWith(
  718. expect.objectContaining({
  719. organization: initialData.organization,
  720. widget,
  721. onClose: expect.anything(),
  722. })
  723. );
  724. });
  725. it('redirects user to dashboard url if widget is not found', () => {
  726. const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
  727. MockApiClient.addMockResponse({
  728. url: '/organizations/org-slug/dashboards/1/',
  729. body: TestStubs.Dashboard([], {id: '1', title: 'Custom Errors'}),
  730. });
  731. render(
  732. <ViewEditDashboard
  733. organization={initialData.organization}
  734. params={{orgId: 'org-slug', dashboardId: '1', widgetId: '123'}}
  735. router={initialData.router}
  736. location={{...initialData.router.location, pathname: '/widget/123/'}}
  737. />,
  738. {context: initialData.routerContext, organization: initialData.organization}
  739. );
  740. expect(openWidgetViewerModal).not.toHaveBeenCalled();
  741. expect(initialData.router.replace).toHaveBeenCalledWith(
  742. expect.objectContaining({
  743. pathname: '/organizations/org-slug/dashboard/1/',
  744. query: {},
  745. })
  746. );
  747. });
  748. it('saves a new dashboard with the page filters', async () => {
  749. const mockPOST = MockApiClient.addMockResponse({
  750. url: '/organizations/org-slug/dashboards/',
  751. method: 'POST',
  752. body: [],
  753. });
  754. render(
  755. <CreateDashboard
  756. organization={{
  757. ...initialData.organization,
  758. features: [
  759. ...initialData.organization.features,
  760. 'dashboards-top-level-filter',
  761. ],
  762. }}
  763. params={{orgId: 'org-slug'}}
  764. router={initialData.router}
  765. location={{
  766. ...initialData.router.location,
  767. query: {
  768. ...initialData.router.location.query,
  769. statsPeriod: '7d',
  770. project: [2],
  771. environment: ['alpha', 'beta'],
  772. },
  773. }}
  774. />,
  775. {
  776. context: initialData.routerContext,
  777. organization: initialData.organization,
  778. }
  779. );
  780. userEvent.click(await screen.findByText('Save and Finish'));
  781. expect(mockPOST).toHaveBeenCalledWith(
  782. '/organizations/org-slug/dashboards/',
  783. expect.objectContaining({
  784. data: expect.objectContaining({
  785. projects: [2],
  786. environment: ['alpha', 'beta'],
  787. period: '7d',
  788. }),
  789. })
  790. );
  791. });
  792. it('saves a template with the page filters', async () => {
  793. const mockPOST = MockApiClient.addMockResponse({
  794. url: '/organizations/org-slug/dashboards/',
  795. method: 'POST',
  796. body: [],
  797. });
  798. render(
  799. <CreateDashboard
  800. organization={{
  801. ...initialData.organization,
  802. features: [
  803. ...initialData.organization.features,
  804. 'dashboards-top-level-filter',
  805. ],
  806. }}
  807. params={{orgId: 'org-slug', templateId: 'default-template'}}
  808. router={initialData.router}
  809. location={{
  810. ...initialData.router.location,
  811. query: {
  812. ...initialData.router.location.query,
  813. statsPeriod: '7d',
  814. project: [2],
  815. environment: ['alpha', 'beta'],
  816. },
  817. }}
  818. />,
  819. {
  820. context: initialData.routerContext,
  821. organization: initialData.organization,
  822. }
  823. );
  824. userEvent.click(await screen.findByText('Add Dashboard'));
  825. expect(mockPOST).toHaveBeenCalledWith(
  826. '/organizations/org-slug/dashboards/',
  827. expect.objectContaining({
  828. data: expect.objectContaining({
  829. projects: [2],
  830. environment: ['alpha', 'beta'],
  831. period: '7d',
  832. }),
  833. })
  834. );
  835. });
  836. it('does not render save and cancel buttons on templates', async () => {
  837. MockApiClient.addMockResponse({
  838. url: '/organizations/org-slug/releases/',
  839. body: [
  840. TestStubs.Release({
  841. shortVersion: 'sentry-android-shop@1.2.0',
  842. version: 'sentry-android-shop@1.2.0',
  843. }),
  844. ],
  845. });
  846. render(
  847. <CreateDashboard
  848. organization={{
  849. ...initialData.organization,
  850. features: [
  851. ...initialData.organization.features,
  852. 'dashboards-top-level-filter',
  853. ],
  854. }}
  855. params={{orgId: 'org-slug', templateId: 'default-template'}}
  856. router={initialData.router}
  857. location={initialData.router.location}
  858. />,
  859. {
  860. context: initialData.routerContext,
  861. organization: initialData.organization,
  862. }
  863. );
  864. userEvent.click(await screen.findByText('24H'));
  865. userEvent.click(screen.getByText('Last 7 days'));
  866. expect(screen.queryByText('Cancel')).not.toBeInTheDocument();
  867. expect(screen.queryByText('Save')).not.toBeInTheDocument();
  868. });
  869. it('can save dashboard filters in existing dashboard', async () => {
  870. MockApiClient.addMockResponse({
  871. url: '/organizations/org-slug/releases/',
  872. body: [
  873. TestStubs.Release({
  874. shortVersion: 'sentry-android-shop@1.2.0',
  875. version: 'sentry-android-shop@1.2.0',
  876. }),
  877. ],
  878. });
  879. const testData = initializeOrg({
  880. organization: TestStubs.Organization({
  881. features: [
  882. 'global-views',
  883. 'dashboards-basic',
  884. 'dashboards-edit',
  885. 'discover-query',
  886. 'dashboard-grid-layout',
  887. 'dashboards-top-level-filter',
  888. ],
  889. }),
  890. router: {
  891. location: {
  892. ...TestStubs.location(),
  893. query: {
  894. statsPeriod: '7d',
  895. release: ['sentry-android-shop@1.2.0'],
  896. },
  897. },
  898. },
  899. });
  900. render(
  901. <ViewEditDashboard
  902. organization={testData.organization}
  903. params={{orgId: 'org-slug', dashboardId: '1'}}
  904. router={testData.router}
  905. location={testData.router.location}
  906. />,
  907. {context: testData.routerContext, organization: testData.organization}
  908. );
  909. userEvent.click(await screen.findByText('Save'));
  910. expect(mockPut).toHaveBeenCalledWith(
  911. '/organizations/org-slug/dashboards/1/',
  912. expect.objectContaining({
  913. data: expect.objectContaining({
  914. period: '7d',
  915. filters: {release: ['sentry-android-shop@1.2.0']},
  916. }),
  917. })
  918. );
  919. });
  920. it('can clear dashboard filters in compact select', async () => {
  921. MockApiClient.addMockResponse({
  922. url: '/organizations/org-slug/dashboards/1/',
  923. body: TestStubs.Dashboard(widgets, {
  924. id: '1',
  925. title: 'Custom Errors',
  926. filters: {release: ['sentry-android-shop@1.2.0']},
  927. }),
  928. });
  929. MockApiClient.addMockResponse({
  930. url: '/organizations/org-slug/releases/',
  931. body: [
  932. TestStubs.Release({
  933. shortVersion: 'sentry-android-shop@1.2.0',
  934. version: 'sentry-android-shop@1.2.0',
  935. }),
  936. ],
  937. });
  938. const testData = initializeOrg({
  939. organization: TestStubs.Organization({
  940. features: [
  941. 'global-views',
  942. 'dashboards-basic',
  943. 'dashboards-edit',
  944. 'discover-query',
  945. 'dashboard-grid-layout',
  946. 'dashboards-top-level-filter',
  947. ],
  948. }),
  949. router: {
  950. location: {
  951. ...TestStubs.location(),
  952. query: {
  953. statsPeriod: '7d',
  954. },
  955. },
  956. },
  957. });
  958. render(
  959. <ViewEditDashboard
  960. organization={testData.organization}
  961. params={{orgId: 'org-slug', dashboardId: '1'}}
  962. router={testData.router}
  963. location={testData.router.location}
  964. />,
  965. {context: testData.routerContext, organization: testData.organization}
  966. );
  967. await screen.findByText('7D');
  968. userEvent.click(await screen.findByText('sentry-android-shop@1.2.0'));
  969. userEvent.click(screen.getByText('Clear'));
  970. screen.getByText('All Releases');
  971. userEvent.click(document.body);
  972. expect(browserHistory.push).toHaveBeenCalledWith(
  973. expect.objectContaining({
  974. query: expect.objectContaining({
  975. release: '',
  976. }),
  977. })
  978. );
  979. });
  980. it('can save absolute time range in existing dashboard', async () => {
  981. const testData = initializeOrg({
  982. organization: TestStubs.Organization({
  983. features: [
  984. 'global-views',
  985. 'dashboards-basic',
  986. 'dashboards-edit',
  987. 'discover-query',
  988. 'dashboard-grid-layout',
  989. 'dashboards-top-level-filter',
  990. ],
  991. }),
  992. router: {
  993. location: {
  994. ...TestStubs.location(),
  995. query: {
  996. start: '2022-07-14T07:00:00',
  997. end: '2022-07-19T23:59:59',
  998. utc: 'true',
  999. },
  1000. },
  1001. },
  1002. });
  1003. render(
  1004. <ViewEditDashboard
  1005. organization={testData.organization}
  1006. params={{orgId: 'org-slug', dashboardId: '1'}}
  1007. router={testData.router}
  1008. location={testData.router.location}
  1009. />,
  1010. {context: testData.routerContext, organization: testData.organization}
  1011. );
  1012. userEvent.click(await screen.findByText('Save'));
  1013. expect(mockPut).toHaveBeenCalledWith(
  1014. '/organizations/org-slug/dashboards/1/',
  1015. expect.objectContaining({
  1016. data: expect.objectContaining({
  1017. start: '2022-07-14T07:00:00.000',
  1018. end: '2022-07-19T23:59:59.000',
  1019. utc: true,
  1020. }),
  1021. })
  1022. );
  1023. });
  1024. it('can clear dashboard filters in existing dashboard', async () => {
  1025. MockApiClient.addMockResponse({
  1026. url: '/organizations/org-slug/releases/',
  1027. body: [
  1028. TestStubs.Release({
  1029. shortVersion: 'sentry-android-shop@1.2.0',
  1030. version: 'sentry-android-shop@1.2.0',
  1031. }),
  1032. ],
  1033. });
  1034. const testData = initializeOrg({
  1035. organization: TestStubs.Organization({
  1036. features: [
  1037. 'global-views',
  1038. 'dashboards-basic',
  1039. 'dashboards-edit',
  1040. 'discover-query',
  1041. 'dashboard-grid-layout',
  1042. 'dashboards-top-level-filter',
  1043. ],
  1044. }),
  1045. router: {
  1046. location: {
  1047. ...TestStubs.location(),
  1048. query: {
  1049. statsPeriod: '7d',
  1050. project: [1, 2],
  1051. environment: ['alpha', 'beta'],
  1052. },
  1053. },
  1054. },
  1055. });
  1056. render(
  1057. <ViewEditDashboard
  1058. organization={testData.organization}
  1059. params={{orgId: 'org-slug', dashboardId: '1'}}
  1060. router={testData.router}
  1061. location={testData.router.location}
  1062. />,
  1063. {context: testData.routerContext, organization: testData.organization}
  1064. );
  1065. await screen.findByText('7D');
  1066. userEvent.click(await screen.findByText('All Releases'));
  1067. userEvent.click(screen.getByText('sentry-android-shop@1.2.0'));
  1068. userEvent.keyboard('{esc}');
  1069. userEvent.click(screen.getByText('Cancel'));
  1070. screen.getByText('All Releases');
  1071. expect(browserHistory.replace).toHaveBeenCalledWith(
  1072. expect.objectContaining({
  1073. query: expect.objectContaining({
  1074. project: undefined,
  1075. statsPeriod: undefined,
  1076. environment: undefined,
  1077. }),
  1078. })
  1079. );
  1080. });
  1081. it('disables the Edit Dashboard button when there are unsaved filters', async () => {
  1082. MockApiClient.addMockResponse({
  1083. url: '/organizations/org-slug/releases/',
  1084. body: [
  1085. TestStubs.Release({
  1086. shortVersion: 'sentry-android-shop@1.2.0',
  1087. version: 'sentry-android-shop@1.2.0',
  1088. }),
  1089. ],
  1090. });
  1091. const testData = initializeOrg({
  1092. organization: TestStubs.Organization({
  1093. features: [
  1094. 'global-views',
  1095. 'dashboards-basic',
  1096. 'dashboards-edit',
  1097. 'discover-basic',
  1098. 'discover-query',
  1099. 'dashboard-grid-layout',
  1100. 'dashboards-top-level-filter',
  1101. ],
  1102. }),
  1103. router: {
  1104. location: {
  1105. ...TestStubs.location(),
  1106. query: {
  1107. statsPeriod: '7d',
  1108. project: [1, 2],
  1109. environment: ['alpha', 'beta'],
  1110. },
  1111. },
  1112. },
  1113. });
  1114. render(
  1115. <ViewEditDashboard
  1116. organization={testData.organization}
  1117. params={{orgId: 'org-slug', dashboardId: '1'}}
  1118. router={testData.router}
  1119. location={testData.router.location}
  1120. />,
  1121. {context: testData.routerContext, organization: testData.organization}
  1122. );
  1123. expect(await screen.findByText('Save')).toBeInTheDocument();
  1124. expect(screen.getByText('Cancel')).toBeInTheDocument();
  1125. expect(screen.getByRole('button', {name: 'Edit Dashboard'})).toBeDisabled();
  1126. });
  1127. it('ignores the order of selection of page filters to render unsaved filters', async () => {
  1128. const testProjects = [
  1129. TestStubs.Project({id: '1', name: 'first', environments: ['alpha', 'beta']}),
  1130. TestStubs.Project({id: '2', name: 'second', environments: ['alpha', 'beta']}),
  1131. ];
  1132. act(() => ProjectsStore.loadInitialData(testProjects));
  1133. MockApiClient.addMockResponse({
  1134. url: '/organizations/org-slug/projects/',
  1135. body: testProjects,
  1136. });
  1137. MockApiClient.addMockResponse({
  1138. url: '/organizations/org-slug/dashboards/1/',
  1139. body: TestStubs.Dashboard(widgets, {
  1140. id: '1',
  1141. title: 'Custom Errors',
  1142. filters: {},
  1143. environment: ['alpha', 'beta'],
  1144. }),
  1145. });
  1146. const testData = initializeOrg({
  1147. organization: TestStubs.Organization({
  1148. features: [
  1149. 'global-views',
  1150. 'dashboards-basic',
  1151. 'dashboards-edit',
  1152. 'discover-query',
  1153. 'dashboard-grid-layout',
  1154. 'dashboards-top-level-filter',
  1155. ],
  1156. }),
  1157. router: {
  1158. location: {
  1159. ...TestStubs.location(),
  1160. query: {
  1161. environment: ['beta', 'alpha'], // Reversed order from saved dashboard
  1162. },
  1163. },
  1164. },
  1165. });
  1166. render(
  1167. <ViewEditDashboard
  1168. organization={testData.organization}
  1169. params={{orgId: 'org-slug', dashboardId: '1'}}
  1170. router={testData.router}
  1171. location={testData.router.location}
  1172. />,
  1173. {context: testData.routerContext, organization: testData.organization}
  1174. );
  1175. await waitFor(() => expect(screen.queryAllByText('Loading\u2026')).toEqual([]));
  1176. await screen.findByText(/beta, alpha/);
  1177. // Save and Cancel should not appear because alpha, beta is the same as beta, alpha
  1178. expect(screen.queryByText('Save')).not.toBeInTheDocument();
  1179. expect(screen.queryByText('Cancel')).not.toBeInTheDocument();
  1180. });
  1181. it('uses releases from the URL query params', async function () {
  1182. const testData = initializeOrg({
  1183. organization: TestStubs.Organization({
  1184. features: [
  1185. 'global-views',
  1186. 'dashboards-basic',
  1187. 'dashboards-edit',
  1188. 'discover-query',
  1189. 'dashboard-grid-layout',
  1190. 'dashboards-top-level-filter',
  1191. ],
  1192. }),
  1193. router: {
  1194. location: {
  1195. ...TestStubs.location(),
  1196. query: {
  1197. release: ['not-selected-1'],
  1198. },
  1199. },
  1200. },
  1201. });
  1202. render(
  1203. <ViewEditDashboard
  1204. organization={testData.organization}
  1205. params={{orgId: 'org-slug', dashboardId: '1'}}
  1206. router={testData.router}
  1207. location={testData.router.location}
  1208. />,
  1209. {context: testData.routerContext, organization: testData.organization}
  1210. );
  1211. await screen.findByText(/not-selected-1/);
  1212. screen.getByText('Save');
  1213. screen.getByText('Cancel');
  1214. });
  1215. it('resets release in URL params', async function () {
  1216. MockApiClient.addMockResponse({
  1217. url: '/organizations/org-slug/dashboards/1/',
  1218. body: TestStubs.Dashboard(widgets, {
  1219. id: '1',
  1220. title: 'Custom Errors',
  1221. filters: {
  1222. release: ['abc'],
  1223. },
  1224. }),
  1225. });
  1226. const testData = initializeOrg({
  1227. organization: TestStubs.Organization({
  1228. features: [
  1229. 'global-views',
  1230. 'dashboards-basic',
  1231. 'dashboards-edit',
  1232. 'discover-query',
  1233. 'dashboard-grid-layout',
  1234. 'dashboards-top-level-filter',
  1235. ],
  1236. }),
  1237. router: {
  1238. location: {
  1239. ...TestStubs.location(),
  1240. query: {
  1241. release: ['not-selected-1'],
  1242. },
  1243. },
  1244. },
  1245. });
  1246. render(
  1247. <ViewEditDashboard
  1248. organization={testData.organization}
  1249. params={{orgId: 'org-slug', dashboardId: '1'}}
  1250. router={testData.router}
  1251. location={testData.router.location}
  1252. />,
  1253. {context: testData.routerContext, organization: testData.organization}
  1254. );
  1255. await screen.findByText(/not-selected-1/);
  1256. userEvent.click(screen.getByText('Cancel'));
  1257. // release isn't used in the redirect
  1258. expect(browserHistory.replace).toHaveBeenCalledWith(
  1259. expect.objectContaining({
  1260. query: {
  1261. end: undefined,
  1262. environment: undefined,
  1263. project: undefined,
  1264. start: undefined,
  1265. statsPeriod: undefined,
  1266. utc: undefined,
  1267. },
  1268. })
  1269. );
  1270. });
  1271. it('reflects selections in the release filter in the query params', async function () {
  1272. MockApiClient.addMockResponse({
  1273. url: '/organizations/org-slug/releases/',
  1274. body: [
  1275. TestStubs.Release({
  1276. shortVersion: 'sentry-android-shop@1.2.0',
  1277. version: 'sentry-android-shop@1.2.0',
  1278. }),
  1279. ],
  1280. });
  1281. const testData = initializeOrg({
  1282. organization: TestStubs.Organization({
  1283. features: [
  1284. 'global-views',
  1285. 'dashboards-basic',
  1286. 'dashboards-edit',
  1287. 'discover-query',
  1288. 'dashboard-grid-layout',
  1289. 'dashboards-top-level-filter',
  1290. ],
  1291. }),
  1292. router: {
  1293. location: TestStubs.location(),
  1294. },
  1295. });
  1296. render(
  1297. <ViewEditDashboard
  1298. organization={testData.organization}
  1299. params={{orgId: 'org-slug', dashboardId: '1'}}
  1300. router={testData.router}
  1301. location={testData.router.location}
  1302. />,
  1303. {context: testData.routerContext, organization: testData.organization}
  1304. );
  1305. userEvent.click(await screen.findByText('All Releases'));
  1306. userEvent.click(screen.getByText('sentry-android-shop@1.2.0'));
  1307. userEvent.click(document.body);
  1308. expect(browserHistory.push).toHaveBeenCalledWith(
  1309. expect.objectContaining({
  1310. query: expect.objectContaining({
  1311. release: ['sentry-android-shop@1.2.0'],
  1312. }),
  1313. })
  1314. );
  1315. });
  1316. it('persists release selections made during search requests that do not appear in default query', async function () {
  1317. // Default response
  1318. MockApiClient.addMockResponse({
  1319. url: '/organizations/org-slug/releases/',
  1320. body: [
  1321. TestStubs.Release({
  1322. shortVersion: 'sentry-android-shop@1.2.0',
  1323. version: 'sentry-android-shop@1.2.0',
  1324. }),
  1325. ],
  1326. });
  1327. // Mocked search results
  1328. MockApiClient.addMockResponse({
  1329. url: '/organizations/org-slug/releases/',
  1330. body: [
  1331. TestStubs.Release({
  1332. id: '9',
  1333. shortVersion: 'search-result',
  1334. version: 'search-result',
  1335. }),
  1336. ],
  1337. match: [MockApiClient.matchData({query: 's'})],
  1338. });
  1339. const testData = initializeOrg({
  1340. organization: TestStubs.Organization({
  1341. features: [
  1342. 'global-views',
  1343. 'dashboards-basic',
  1344. 'dashboards-edit',
  1345. 'discover-basic',
  1346. 'discover-query',
  1347. 'dashboard-grid-layout',
  1348. 'dashboards-top-level-filter',
  1349. ],
  1350. }),
  1351. router: {
  1352. location: TestStubs.location(),
  1353. },
  1354. });
  1355. render(
  1356. <ViewEditDashboard
  1357. organization={testData.organization}
  1358. params={{orgId: 'org-slug', dashboardId: '1'}}
  1359. router={testData.router}
  1360. location={testData.router.location}
  1361. />,
  1362. {context: testData.routerContext, organization: testData.organization}
  1363. );
  1364. userEvent.click(await screen.findByText('All Releases'));
  1365. userEvent.type(screen.getByText('Search\u2026'), 's');
  1366. await act(async () => {
  1367. userEvent.click(await screen.findByText('search-result'));
  1368. });
  1369. // Validate that after search is cleared, search result still appears
  1370. await screen.findByText('Latest Release(s)');
  1371. screen.getByTestId('search-result');
  1372. });
  1373. });
  1374. });