issueViewsHeader.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. import {useContext, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {Node} from '@react-types/shared';
  4. import {addSuccessMessage} from 'sentry/actionCreators/indicator';
  5. import {Button} from 'sentry/components/button';
  6. import ButtonBar from 'sentry/components/buttonBar';
  7. import {
  8. DraggableTabList,
  9. TEMPORARY_TAB_KEY,
  10. } from 'sentry/components/draggableTabs/draggableTabList';
  11. import type {DraggableTabListItemProps} from 'sentry/components/draggableTabs/item';
  12. import GlobalEventProcessingAlert from 'sentry/components/globalEventProcessingAlert';
  13. import * as Layout from 'sentry/components/layouts/thirds';
  14. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  15. import {PageHeadingQuestionTooltip} from 'sentry/components/pageHeadingQuestionTooltip';
  16. import {IconMegaphone, IconPause, IconPlay} from 'sentry/icons';
  17. import {t} from 'sentry/locale';
  18. import {space} from 'sentry/styles/space';
  19. import type {InjectedRouter} from 'sentry/types/legacyReactRouter';
  20. import {trackAnalytics} from 'sentry/utils/analytics';
  21. import normalizeUrl from 'sentry/utils/url/normalizeUrl';
  22. import {useFeedbackForm} from 'sentry/utils/useFeedbackForm';
  23. import {useHotkeys} from 'sentry/utils/useHotkeys';
  24. import {useLocation} from 'sentry/utils/useLocation';
  25. import {useNavigate} from 'sentry/utils/useNavigate';
  26. import useOrganization from 'sentry/utils/useOrganization';
  27. import usePageFilters from 'sentry/utils/usePageFilters';
  28. import useProjects from 'sentry/utils/useProjects';
  29. import {
  30. generateTempViewId,
  31. type IssueView,
  32. IssueViews,
  33. IssueViewsContext,
  34. } from 'sentry/views/issueList/issueViews/issueViews';
  35. import {IssueViewTab} from 'sentry/views/issueList/issueViews/issueViewTab';
  36. import {useFetchGroupSearchViews} from 'sentry/views/issueList/queries/useFetchGroupSearchViews';
  37. import {NewTabContext} from 'sentry/views/issueList/utils/newTabContext';
  38. import {IssueSortOptions} from './utils';
  39. type IssueViewsIssueListHeaderProps = {
  40. onRealtimeChange: (realtime: boolean) => void;
  41. realtimeActive: boolean;
  42. router: InjectedRouter;
  43. selectedProjectIds: number[];
  44. };
  45. type IssueViewsIssueListHeaderTabsContentProps = {
  46. router: InjectedRouter;
  47. };
  48. function IssueViewsIssueListHeader({
  49. selectedProjectIds,
  50. realtimeActive,
  51. onRealtimeChange,
  52. router,
  53. }: IssueViewsIssueListHeaderProps) {
  54. const organization = useOrganization();
  55. const {projects} = useProjects();
  56. const selectedProjects = projects.filter(({id}) =>
  57. selectedProjectIds.includes(Number(id))
  58. );
  59. const {newViewActive} = useContext(NewTabContext);
  60. const {data: groupSearchViews} = useFetchGroupSearchViews({
  61. orgSlug: organization.slug,
  62. });
  63. const realtimeTitle = realtimeActive
  64. ? t('Pause real-time updates')
  65. : t('Enable real-time updates');
  66. const openForm = useFeedbackForm();
  67. const hasNewLayout = organization.features.includes('issue-stream-table-layout');
  68. return (
  69. <Layout.Header
  70. noActionWrap
  71. // No viewId in the URL query means that a temp view is selected, which has a dashed border
  72. borderStyle={
  73. groupSearchViews && !router?.location.query.viewId ? 'dashed' : 'solid'
  74. }
  75. >
  76. <Layout.HeaderContent>
  77. <Layout.Title>
  78. {t('Issues')}
  79. <PageHeadingQuestionTooltip
  80. docsUrl="https://docs.sentry.io/product/issues/"
  81. title={t(
  82. 'Detailed views of errors and performance problems in your application grouped by events with a similar set of characteristics.'
  83. )}
  84. />
  85. </Layout.Title>
  86. </Layout.HeaderContent>
  87. <Layout.HeaderActions>
  88. <ButtonBar gap={1}>
  89. {openForm && hasNewLayout && (
  90. <Button
  91. size="sm"
  92. aria-label="issue-stream-feedback"
  93. icon={<IconMegaphone />}
  94. onClick={() =>
  95. openForm({
  96. messagePlaceholder: t(
  97. 'How can we make the issue stream better for you?'
  98. ),
  99. tags: {
  100. ['feedback.source']: 'new_issue_stream_layout',
  101. ['feedback.owner']: 'issues',
  102. },
  103. })
  104. }
  105. >
  106. {t('Give Feedback')}
  107. </Button>
  108. )}
  109. {!newViewActive && (
  110. <Button
  111. size="sm"
  112. data-test-id="real-time"
  113. title={realtimeTitle}
  114. aria-label={realtimeTitle}
  115. icon={realtimeActive ? <IconPause /> : <IconPlay />}
  116. onClick={() => onRealtimeChange(!realtimeActive)}
  117. />
  118. )}
  119. </ButtonBar>
  120. </Layout.HeaderActions>
  121. <StyledGlobalEventProcessingAlert projects={selectedProjects} />
  122. {groupSearchViews ? (
  123. <StyledIssueViews
  124. router={router}
  125. initialViews={groupSearchViews.map(
  126. (
  127. {id, name, query: viewQuery, querySort: viewQuerySort},
  128. index
  129. ): IssueView => {
  130. const tabId = id ?? `default${index.toString()}`;
  131. return {
  132. id: tabId,
  133. key: tabId,
  134. label: name,
  135. query: viewQuery,
  136. querySort: viewQuerySort,
  137. isCommitted: true,
  138. };
  139. }
  140. )}
  141. >
  142. <IssueViewsIssueListHeaderTabsContent router={router} />
  143. </StyledIssueViews>
  144. ) : (
  145. <div style={{height: 33}} />
  146. )}
  147. </Layout.Header>
  148. );
  149. }
  150. function IssueViewsIssueListHeaderTabsContent({
  151. router,
  152. }: IssueViewsIssueListHeaderTabsContentProps) {
  153. const organization = useOrganization();
  154. const navigate = useNavigate();
  155. const location = useLocation();
  156. const pageFilters = usePageFilters();
  157. const {newViewActive, setNewViewActive} = useContext(NewTabContext);
  158. const {tabListState, state, dispatch} = useContext(IssueViewsContext);
  159. const {views, tempView} = state;
  160. const [editingTabKey, setEditingTabKey] = useState<string | null>(null);
  161. // TODO(msun): Use the location from useLocation instead of props router in the future
  162. const {cursor: _cursor, page: _page, ...queryParams} = router?.location.query;
  163. const {query, sort, viewId, project, environment} = queryParams;
  164. const queryParamsWithPageFilters = useMemo(() => {
  165. return {
  166. ...queryParams,
  167. project: project ?? pageFilters.selection.projects,
  168. environment: environment ?? pageFilters.selection.environments,
  169. ...normalizeDateTimeParams(pageFilters.selection.datetime),
  170. };
  171. }, [
  172. environment,
  173. pageFilters.selection.datetime,
  174. pageFilters.selection.environments,
  175. pageFilters.selection.projects,
  176. project,
  177. queryParams,
  178. ]);
  179. // This insane useEffect ensures that the correct tab is selected when the url updates
  180. useEffect(() => {
  181. // If no query, sort, or viewId is present, set the first tab as the selected tab, update query accordingly
  182. if (!query && !sort && !viewId) {
  183. navigate(
  184. normalizeUrl({
  185. ...location,
  186. query: {
  187. ...queryParamsWithPageFilters,
  188. query: views[0]!.query,
  189. sort: views[0]!.querySort,
  190. viewId: views[0]!.id,
  191. },
  192. }),
  193. {replace: true}
  194. );
  195. tabListState?.setSelectedKey(views[0]!.key);
  196. return;
  197. }
  198. // if a viewId is present, check if it exists in the existing views.
  199. if (viewId) {
  200. const selectedTab = views.find(tab => tab.id === viewId);
  201. if (selectedTab) {
  202. const issueSortOption = Object.values(IssueSortOptions).includes(sort)
  203. ? sort
  204. : IssueSortOptions.DATE;
  205. const newUnsavedChanges: [string, IssueSortOptions] | undefined =
  206. query === selectedTab.query && sort === selectedTab.querySort
  207. ? undefined
  208. : [query ?? selectedTab.query, issueSortOption];
  209. if (
  210. (newUnsavedChanges && !selectedTab.unsavedChanges) ||
  211. selectedTab.unsavedChanges?.[0] !== newUnsavedChanges?.[0] ||
  212. selectedTab.unsavedChanges?.[1] !== newUnsavedChanges?.[1]
  213. ) {
  214. // If there were no unsaved changes before, or the existing unsaved changes
  215. // don't match the new query and/or sort, update the unsaved changes
  216. dispatch({
  217. type: 'UPDATE_UNSAVED_CHANGES',
  218. unsavedChanges: newUnsavedChanges,
  219. });
  220. } else if (!newUnsavedChanges && selectedTab.unsavedChanges) {
  221. // If there are no longer unsaved changes but there were before, remove them
  222. dispatch({type: 'UPDATE_UNSAVED_CHANGES', unsavedChanges: undefined});
  223. }
  224. if (!tabListState?.selectionManager.isSelected(selectedTab.key)) {
  225. navigate(
  226. normalizeUrl({
  227. ...location,
  228. query: {
  229. ...queryParamsWithPageFilters,
  230. query: newUnsavedChanges ? newUnsavedChanges[0] : selectedTab.query,
  231. sort: newUnsavedChanges ? newUnsavedChanges[1] : selectedTab.querySort,
  232. viewId: selectedTab.id,
  233. },
  234. }),
  235. {replace: true}
  236. );
  237. tabListState?.setSelectedKey(selectedTab.key);
  238. }
  239. } else {
  240. // if a viewId does not exist, remove it from the query
  241. tabListState?.setSelectedKey(TEMPORARY_TAB_KEY);
  242. navigate(
  243. normalizeUrl({
  244. ...location,
  245. query: {
  246. ...queryParamsWithPageFilters,
  247. viewId: undefined,
  248. },
  249. }),
  250. {replace: true}
  251. );
  252. trackAnalytics('issue_views.shared_view_opened', {
  253. organization,
  254. query,
  255. });
  256. }
  257. return;
  258. }
  259. if (query) {
  260. if (!tabListState?.selectionManager.isSelected(TEMPORARY_TAB_KEY)) {
  261. dispatch({type: 'SET_TEMP_VIEW', query, sort});
  262. navigate(
  263. normalizeUrl({
  264. ...location,
  265. query: {
  266. ...queryParamsWithPageFilters,
  267. viewId: undefined,
  268. },
  269. }),
  270. {replace: true}
  271. );
  272. tabListState?.setSelectedKey(TEMPORARY_TAB_KEY);
  273. return;
  274. }
  275. }
  276. }, [
  277. navigate,
  278. organization.slug,
  279. query,
  280. sort,
  281. viewId,
  282. tabListState,
  283. location,
  284. queryParamsWithPageFilters,
  285. views,
  286. organization,
  287. dispatch,
  288. ]);
  289. // This useEffect ensures the "new view" page is displayed/hidden correctly
  290. useEffect(() => {
  291. if (viewId?.startsWith('_')) {
  292. if (views.find(tab => tab.id === viewId)?.isCommitted) {
  293. return;
  294. }
  295. // If the user types in query manually while the new view flow is showing,
  296. // then replace the add view flow with the issue stream with the query loaded,
  297. // and persist the query
  298. if (newViewActive && query !== '') {
  299. setNewViewActive(false);
  300. dispatch({
  301. type: 'UPDATE_UNSAVED_CHANGES',
  302. unsavedChanges: [query, sort ?? IssueSortOptions.DATE],
  303. isCommitted: true,
  304. syncViews: true,
  305. });
  306. trackAnalytics('issue_views.add_view.custom_query_saved', {
  307. organization,
  308. query,
  309. });
  310. } else {
  311. setNewViewActive(true);
  312. }
  313. } else {
  314. setNewViewActive(false);
  315. }
  316. // eslint-disable-next-line react-hooks/exhaustive-deps
  317. }, [viewId, query]);
  318. useHotkeys(
  319. [
  320. {
  321. match: ['command+s', 'ctrl+s'],
  322. includeInputs: true,
  323. callback: () => {
  324. if (views.find(tab => tab.key === tabListState?.selectedKey)?.unsavedChanges) {
  325. dispatch({type: 'SAVE_CHANGES', syncViews: true});
  326. addSuccessMessage(t('Changes saved to view'));
  327. }
  328. },
  329. },
  330. ],
  331. [dispatch, tabListState?.selectedKey, views]
  332. );
  333. const handleCreateNewView = () => {
  334. const tempId = generateTempViewId();
  335. dispatch({type: 'CREATE_NEW_VIEW', tempId});
  336. tabListState?.setSelectedKey(tempId);
  337. navigate({
  338. ...location,
  339. query: {
  340. ...queryParams,
  341. query: '',
  342. viewId: tempId,
  343. },
  344. });
  345. };
  346. const allTabs = tempView ? [...views, tempView] : views;
  347. const initialTabKey =
  348. viewId && views.find(tab => tab.id === viewId)
  349. ? views.find(tab => tab.id === viewId)!.key
  350. : query
  351. ? TEMPORARY_TAB_KEY
  352. : views[0]!.key;
  353. return (
  354. <DraggableTabList
  355. onReorder={(newOrder: Node<DraggableTabListItemProps>[]) =>
  356. dispatch({
  357. type: 'REORDER_TABS',
  358. newKeyOrder: newOrder.map(node => node.key.toString()),
  359. })
  360. }
  361. onReorderComplete={() => dispatch({type: 'SYNC_VIEWS_TO_BACKEND'})}
  362. defaultSelectedKey={initialTabKey}
  363. onAddView={handleCreateNewView}
  364. orientation="horizontal"
  365. editingTabKey={editingTabKey ?? undefined}
  366. hideBorder
  367. >
  368. {allTabs.map(view => (
  369. <DraggableTabList.Item
  370. textValue={view.label}
  371. key={view.key}
  372. to={normalizeUrl({
  373. query: {
  374. ...queryParams,
  375. query: view.unsavedChanges?.[0] ?? view.query,
  376. sort: view.unsavedChanges?.[1] ?? view.querySort,
  377. viewId: view.id !== TEMPORARY_TAB_KEY ? view.id : undefined,
  378. },
  379. pathname: `/organizations/${organization.slug}/issues/`,
  380. })}
  381. disabled={view.key === editingTabKey}
  382. >
  383. <IssueViewTab
  384. key={view.key}
  385. view={view}
  386. initialTabKey={initialTabKey}
  387. router={router}
  388. editingTabKey={editingTabKey}
  389. setEditingTabKey={setEditingTabKey}
  390. />
  391. </DraggableTabList.Item>
  392. ))}
  393. </DraggableTabList>
  394. );
  395. }
  396. export default IssueViewsIssueListHeader;
  397. const StyledIssueViews = styled(IssueViews)`
  398. grid-column: 1 / -1;
  399. `;
  400. const StyledGlobalEventProcessingAlert = styled(GlobalEventProcessingAlert)`
  401. grid-column: 1/-1;
  402. margin-top: ${space(1)};
  403. margin-bottom: ${space(1)};
  404. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  405. margin-top: ${space(2)};
  406. margin-bottom: 0;
  407. }
  408. `;