customViewsHeader.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. import {useContext, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import debounce from 'lodash/debounce';
  4. import {Button} from 'sentry/components/button';
  5. import ButtonBar from 'sentry/components/buttonBar';
  6. import {TEMPORARY_TAB_KEY} from 'sentry/components/draggableTabs/draggableTabList';
  7. import GlobalEventProcessingAlert from 'sentry/components/globalEventProcessingAlert';
  8. import * as Layout from 'sentry/components/layouts/thirds';
  9. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  10. import {PageHeadingQuestionTooltip} from 'sentry/components/pageHeadingQuestionTooltip';
  11. import {Tabs, TabsContext} from 'sentry/components/tabs';
  12. import {IconMegaphone, IconPause, IconPlay} from 'sentry/icons';
  13. import {t} from 'sentry/locale';
  14. import {space} from 'sentry/styles/space';
  15. import type {InjectedRouter} from 'sentry/types/legacyReactRouter';
  16. import type {Organization} from 'sentry/types/organization';
  17. import {trackAnalytics} from 'sentry/utils/analytics';
  18. import normalizeUrl from 'sentry/utils/url/normalizeUrl';
  19. import {useEffectAfterFirstRender} from 'sentry/utils/useEffectAfterFirstRender';
  20. import {useFeedbackForm} from 'sentry/utils/useFeedbackForm';
  21. import {useLocation} from 'sentry/utils/useLocation';
  22. import {useNavigate} from 'sentry/utils/useNavigate';
  23. import usePageFilters from 'sentry/utils/usePageFilters';
  24. import useProjects from 'sentry/utils/useProjects';
  25. import {
  26. DraggableTabBar,
  27. type Tab,
  28. } from 'sentry/views/issueList/groupSearchViewTabs/draggableTabBar';
  29. import {useUpdateGroupSearchViews} from 'sentry/views/issueList/mutations/useUpdateGroupSearchViews';
  30. import {useFetchGroupSearchViews} from 'sentry/views/issueList/queries/useFetchGroupSearchViews';
  31. import type {UpdateGroupSearchViewPayload} from 'sentry/views/issueList/types';
  32. import {NewTabContext} from 'sentry/views/issueList/utils/newTabContext';
  33. import {IssueSortOptions} from './utils';
  34. type CustomViewsIssueListHeaderProps = {
  35. onRealtimeChange: (realtime: boolean) => void;
  36. organization: Organization;
  37. realtimeActive: boolean;
  38. router: InjectedRouter;
  39. selectedProjectIds: number[];
  40. };
  41. type CustomViewsIssueListHeaderTabsContentProps = {
  42. organization: Organization;
  43. router: InjectedRouter;
  44. views: UpdateGroupSearchViewPayload[];
  45. };
  46. function CustomViewsIssueListHeader({
  47. selectedProjectIds,
  48. realtimeActive,
  49. onRealtimeChange,
  50. ...props
  51. }: CustomViewsIssueListHeaderProps) {
  52. const {projects} = useProjects();
  53. const selectedProjects = projects.filter(({id}) =>
  54. selectedProjectIds.includes(Number(id))
  55. );
  56. const {data: groupSearchViews} = useFetchGroupSearchViews({
  57. orgSlug: props.organization.slug,
  58. });
  59. const realtimeTitle = realtimeActive
  60. ? t('Pause real-time updates')
  61. : t('Enable real-time updates');
  62. const {newViewActive} = useContext(NewTabContext);
  63. const openForm = useFeedbackForm();
  64. const hasNewLayout = props.organization.features.includes('issue-stream-table-layout');
  65. return (
  66. <Layout.Header
  67. noActionWrap
  68. // No viewId in the URL query means that a temp view is selected, which has a dashed border
  69. borderStyle={
  70. groupSearchViews && !props.router?.location.query.viewId ? 'dashed' : 'solid'
  71. }
  72. >
  73. <Layout.HeaderContent>
  74. <Layout.Title>
  75. {t('Issues')}
  76. <PageHeadingQuestionTooltip
  77. docsUrl="https://docs.sentry.io/product/issues/"
  78. title={t(
  79. 'Detailed views of errors and performance problems in your application grouped by events with a similar set of characteristics.'
  80. )}
  81. />
  82. </Layout.Title>
  83. </Layout.HeaderContent>
  84. <Layout.HeaderActions>
  85. <ButtonBar gap={1}>
  86. {openForm && hasNewLayout && (
  87. <Button
  88. size="sm"
  89. aria-label="issue-stream-feedback"
  90. icon={<IconMegaphone />}
  91. onClick={() =>
  92. openForm({
  93. messagePlaceholder: t(
  94. 'How can we make the issue stream better for you?'
  95. ),
  96. tags: {
  97. ['feedback.source']: 'new_issue_stream_layout',
  98. ['feedback.owner']: 'issues',
  99. },
  100. })
  101. }
  102. >
  103. {t('Give Feedback')}
  104. </Button>
  105. )}
  106. {!newViewActive && (
  107. <Button
  108. size="sm"
  109. data-test-id="real-time"
  110. title={realtimeTitle}
  111. aria-label={realtimeTitle}
  112. icon={realtimeActive ? <IconPause /> : <IconPlay />}
  113. onClick={() => onRealtimeChange(!realtimeActive)}
  114. />
  115. )}
  116. </ButtonBar>
  117. </Layout.HeaderActions>
  118. <StyledGlobalEventProcessingAlert projects={selectedProjects} />
  119. {groupSearchViews ? (
  120. <StyledTabs>
  121. <CustomViewsIssueListHeaderTabsContent {...props} views={groupSearchViews} />
  122. </StyledTabs>
  123. ) : (
  124. <div style={{height: 33}} />
  125. )}
  126. </Layout.Header>
  127. );
  128. }
  129. function CustomViewsIssueListHeaderTabsContent({
  130. organization,
  131. router,
  132. views,
  133. }: CustomViewsIssueListHeaderTabsContentProps) {
  134. // TODO(msun): Possible replace navigate with useSearchParams() in the future?
  135. const navigate = useNavigate();
  136. const location = useLocation();
  137. const {setNewViewActive, newViewActive} = useContext(NewTabContext);
  138. const pageFilters = usePageFilters();
  139. // TODO(msun): Use the location from useLocation instead of props router in the future
  140. const {cursor: _cursor, page: _page, ...queryParams} = router?.location.query;
  141. const {query, sort, viewId, project, environment} = queryParams;
  142. const queryParamsWithPageFilters = useMemo(() => {
  143. return {
  144. ...queryParams,
  145. project: project ?? pageFilters.selection.projects,
  146. environment: environment ?? pageFilters.selection.environments,
  147. ...normalizeDateTimeParams(pageFilters.selection.datetime),
  148. };
  149. }, [
  150. environment,
  151. pageFilters.selection.datetime,
  152. pageFilters.selection.environments,
  153. pageFilters.selection.projects,
  154. project,
  155. queryParams,
  156. ]);
  157. const [draggableTabs, setDraggableTabs] = useState<Tab[]>(
  158. views.map(({id, name, query: viewQuery, querySort: viewQuerySort}, index): Tab => {
  159. const tabId = id ?? `default${index.toString()}`;
  160. return {
  161. id: tabId,
  162. key: tabId,
  163. label: name,
  164. query: viewQuery,
  165. querySort: viewQuerySort,
  166. unsavedChanges: undefined,
  167. isCommitted: true,
  168. };
  169. })
  170. );
  171. const getInitialTabKey = () => {
  172. if (viewId && draggableTabs.find(tab => tab.id === viewId)) {
  173. return draggableTabs.find(tab => tab.id === viewId)!.key;
  174. }
  175. if (query) {
  176. return TEMPORARY_TAB_KEY;
  177. }
  178. return draggableTabs[0].key;
  179. };
  180. const {tabListState} = useContext(TabsContext);
  181. // TODO: Try to remove this state if possible
  182. const [tempTab, setTempTab] = useState<Tab | undefined>(
  183. getInitialTabKey() === TEMPORARY_TAB_KEY && query
  184. ? {
  185. id: TEMPORARY_TAB_KEY,
  186. key: TEMPORARY_TAB_KEY,
  187. label: t('Unsaved'),
  188. query: query,
  189. querySort: sort ?? IssueSortOptions.DATE,
  190. isCommitted: true,
  191. }
  192. : undefined
  193. );
  194. const {mutate: updateViews} = useUpdateGroupSearchViews();
  195. const debounceUpdateViews = useMemo(
  196. () =>
  197. debounce((newTabs: Tab[]) => {
  198. if (newTabs) {
  199. updateViews({
  200. orgSlug: organization.slug,
  201. groupSearchViews: newTabs
  202. .filter(tab => tab.isCommitted)
  203. .map(tab => ({
  204. // Do not send over an ID if it's a temporary or default tab so that
  205. // the backend will save these and generate permanent Ids for them
  206. ...(tab.id[0] !== '_' && !tab.id.startsWith('default')
  207. ? {id: tab.id}
  208. : {}),
  209. name: tab.label,
  210. query: tab.query,
  211. querySort: tab.querySort,
  212. })),
  213. });
  214. }
  215. }, 500),
  216. [organization.slug, updateViews]
  217. );
  218. // This insane useEffect ensures that the correct tab is selected when the url updates
  219. useEffect(() => {
  220. // If no query, sort, or viewId is present, set the first tab as the selected tab, update query accordingly
  221. if (!query && !sort && !viewId) {
  222. navigate(
  223. normalizeUrl({
  224. ...location,
  225. query: {
  226. ...queryParamsWithPageFilters,
  227. query: draggableTabs[0].query,
  228. sort: draggableTabs[0].querySort,
  229. viewId: draggableTabs[0].id,
  230. },
  231. }),
  232. {replace: true}
  233. );
  234. tabListState?.setSelectedKey(draggableTabs[0].key);
  235. return;
  236. }
  237. // if a viewId is present, check if it exists in the existing views.
  238. if (viewId) {
  239. const selectedTab = draggableTabs.find(tab => tab.id === viewId);
  240. if (selectedTab) {
  241. const issueSortOption = Object.values(IssueSortOptions).includes(sort)
  242. ? sort
  243. : IssueSortOptions.DATE;
  244. const newUnsavedChanges: [string, IssueSortOptions] | undefined =
  245. query === selectedTab.query && sort === selectedTab.querySort
  246. ? undefined
  247. : [query ?? selectedTab.query, issueSortOption];
  248. if (
  249. (newUnsavedChanges && !selectedTab.unsavedChanges) ||
  250. selectedTab.unsavedChanges?.[0] !== newUnsavedChanges?.[0] ||
  251. selectedTab.unsavedChanges?.[1] !== newUnsavedChanges?.[1]
  252. ) {
  253. // If there were no unsaved changes before, or the existing unsaved changes
  254. // don't match the new query and/or sort, update the unsaved changes
  255. setDraggableTabs(
  256. draggableTabs.map(tab =>
  257. tab.key === selectedTab.key
  258. ? {
  259. ...tab,
  260. unsavedChanges: newUnsavedChanges,
  261. }
  262. : tab
  263. )
  264. );
  265. } else if (!newUnsavedChanges && selectedTab.unsavedChanges) {
  266. // If there are no longer unsaved changes but there were before, remove them
  267. setDraggableTabs(
  268. draggableTabs.map(tab =>
  269. tab.key === selectedTab.key
  270. ? {
  271. ...tab,
  272. unsavedChanges: undefined,
  273. }
  274. : tab
  275. )
  276. );
  277. }
  278. if (!tabListState?.selectionManager.isSelected(selectedTab.key)) {
  279. navigate(
  280. normalizeUrl({
  281. ...location,
  282. query: {
  283. ...queryParamsWithPageFilters,
  284. query: newUnsavedChanges ? newUnsavedChanges[0] : selectedTab.query,
  285. sort: newUnsavedChanges ? newUnsavedChanges[1] : selectedTab.querySort,
  286. viewId: selectedTab.id,
  287. },
  288. }),
  289. {replace: true}
  290. );
  291. tabListState?.setSelectedKey(selectedTab.key);
  292. }
  293. } else {
  294. // if a viewId does not exist, remove it from the query
  295. tabListState?.setSelectedKey(TEMPORARY_TAB_KEY);
  296. navigate(
  297. normalizeUrl({
  298. ...location,
  299. query: {
  300. ...queryParamsWithPageFilters,
  301. viewId: undefined,
  302. },
  303. }),
  304. {replace: true}
  305. );
  306. trackAnalytics('issue_views.shared_view_opened', {
  307. organization,
  308. query,
  309. });
  310. }
  311. return;
  312. }
  313. if (query) {
  314. if (!tabListState?.selectionManager.isSelected(TEMPORARY_TAB_KEY)) {
  315. tabListState?.setSelectedKey(TEMPORARY_TAB_KEY);
  316. setTempTab({
  317. id: TEMPORARY_TAB_KEY,
  318. key: TEMPORARY_TAB_KEY,
  319. label: t('Unsaved'),
  320. query: query,
  321. querySort: sort ?? IssueSortOptions.DATE,
  322. isCommitted: true,
  323. });
  324. navigate(
  325. normalizeUrl({
  326. ...location,
  327. query: {
  328. ...queryParamsWithPageFilters,
  329. viewId: undefined,
  330. },
  331. }),
  332. {replace: true}
  333. );
  334. return;
  335. }
  336. }
  337. }, [
  338. navigate,
  339. organization.slug,
  340. query,
  341. sort,
  342. viewId,
  343. tabListState,
  344. location,
  345. queryParamsWithPageFilters,
  346. draggableTabs,
  347. organization,
  348. ]);
  349. // Update local tabs when new views are received from mutation request
  350. useEffectAfterFirstRender(() => {
  351. const newlyCreatedViews = views.filter(
  352. view => !draggableTabs.find(tab => tab.id === view.id)
  353. );
  354. const currentView = draggableTabs.find(tab => tab.id === viewId);
  355. setDraggableTabs(oldDraggableTabs => {
  356. const assignedIds = new Set();
  357. return oldDraggableTabs.map(tab => {
  358. // Temp viewIds are prefixed with '_'
  359. if (tab.id && tab.id[0] === '_') {
  360. const matchingView = newlyCreatedViews.find(
  361. view =>
  362. view.id &&
  363. !assignedIds.has(view.id) &&
  364. tab.query === view.query &&
  365. tab.querySort === view.querySort &&
  366. tab.label === view.name
  367. );
  368. if (matchingView?.id) {
  369. assignedIds.add(matchingView.id);
  370. return {
  371. ...tab,
  372. id: matchingView.id,
  373. };
  374. }
  375. }
  376. return tab;
  377. });
  378. });
  379. if (viewId?.startsWith('_') && currentView) {
  380. const matchingView = newlyCreatedViews.find(
  381. view =>
  382. view.id &&
  383. currentView.query === view.query &&
  384. currentView.querySort === view.querySort &&
  385. currentView.label === view.name
  386. );
  387. if (matchingView?.id) {
  388. navigate(
  389. normalizeUrl({
  390. ...location,
  391. query: {
  392. ...queryParamsWithPageFilters,
  393. viewId: matchingView.id,
  394. },
  395. }),
  396. {replace: true}
  397. );
  398. }
  399. }
  400. // eslint-disable-next-line react-hooks/exhaustive-deps
  401. }, [views]);
  402. useEffect(() => {
  403. if (viewId?.startsWith('_')) {
  404. if (draggableTabs.find(tab => tab.id === viewId)?.isCommitted) {
  405. return;
  406. }
  407. // If the user types in query manually while the new view flow is showing,
  408. // then replace the add view flow with the issue stream with the query loaded,
  409. // and persist the query
  410. if (newViewActive && query !== '') {
  411. setNewViewActive(false);
  412. const updatedTabs: Tab[] = draggableTabs.map(tab =>
  413. tab.id === viewId
  414. ? {
  415. ...tab,
  416. unsavedChanges: [query, sort ?? IssueSortOptions.DATE],
  417. isCommitted: true,
  418. }
  419. : tab
  420. );
  421. setDraggableTabs(updatedTabs);
  422. debounceUpdateViews(updatedTabs);
  423. trackAnalytics('issue_views.add_view.custom_query_saved', {
  424. organization,
  425. query,
  426. });
  427. } else {
  428. setNewViewActive(true);
  429. }
  430. } else {
  431. setNewViewActive(false);
  432. }
  433. // eslint-disable-next-line react-hooks/exhaustive-deps
  434. }, [viewId, query]);
  435. return (
  436. <DraggableTabBar
  437. initialTabKey={getInitialTabKey()}
  438. tabs={draggableTabs}
  439. setTabs={setDraggableTabs}
  440. tempTab={tempTab}
  441. setTempTab={setTempTab}
  442. orgSlug={organization.slug}
  443. onReorder={debounceUpdateViews}
  444. onAddView={debounceUpdateViews}
  445. onDelete={debounceUpdateViews}
  446. onDuplicate={debounceUpdateViews}
  447. onTabRenamed={newTabs => debounceUpdateViews(newTabs)}
  448. onSave={debounceUpdateViews}
  449. onSaveTempView={debounceUpdateViews}
  450. router={router}
  451. />
  452. );
  453. }
  454. export default CustomViewsIssueListHeader;
  455. const StyledTabs = styled(Tabs)`
  456. grid-column: 1 / -1;
  457. `;
  458. const StyledGlobalEventProcessingAlert = styled(GlobalEventProcessingAlert)`
  459. grid-column: 1/-1;
  460. margin-top: ${space(1)};
  461. margin-bottom: ${space(1)};
  462. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  463. margin-top: ${space(2)};
  464. margin-bottom: 0;
  465. }
  466. `;