customViewsHeader.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  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. navigate(
  317. normalizeUrl({
  318. ...location,
  319. query: {
  320. ...queryParamsWithPageFilters,
  321. viewId: undefined,
  322. },
  323. }),
  324. {replace: true}
  325. );
  326. return;
  327. }
  328. }
  329. }, [
  330. navigate,
  331. organization.slug,
  332. query,
  333. sort,
  334. viewId,
  335. tabListState,
  336. location,
  337. queryParamsWithPageFilters,
  338. draggableTabs,
  339. organization,
  340. ]);
  341. // Update local tabs when new views are received from mutation request
  342. useEffectAfterFirstRender(() => {
  343. const newlyCreatedViews = views.filter(
  344. view => !draggableTabs.find(tab => tab.id === view.id)
  345. );
  346. const currentView = draggableTabs.find(tab => tab.id === viewId);
  347. setDraggableTabs(oldDraggableTabs => {
  348. const assignedIds = new Set();
  349. return oldDraggableTabs.map(tab => {
  350. // Temp viewIds are prefixed with '_'
  351. if (tab.id && tab.id[0] === '_') {
  352. const matchingView = newlyCreatedViews.find(
  353. view =>
  354. view.id &&
  355. !assignedIds.has(view.id) &&
  356. tab.query === view.query &&
  357. tab.querySort === view.querySort &&
  358. tab.label === view.name
  359. );
  360. if (matchingView?.id) {
  361. assignedIds.add(matchingView.id);
  362. return {
  363. ...tab,
  364. id: matchingView.id,
  365. };
  366. }
  367. }
  368. return tab;
  369. });
  370. });
  371. if (viewId?.startsWith('_') && currentView) {
  372. const matchingView = newlyCreatedViews.find(
  373. view =>
  374. view.id &&
  375. currentView.query === view.query &&
  376. currentView.querySort === view.querySort &&
  377. currentView.label === view.name
  378. );
  379. if (matchingView?.id) {
  380. navigate(
  381. normalizeUrl({
  382. ...location,
  383. query: {
  384. ...queryParamsWithPageFilters,
  385. viewId: matchingView.id,
  386. },
  387. }),
  388. {replace: true}
  389. );
  390. }
  391. }
  392. // eslint-disable-next-line react-hooks/exhaustive-deps
  393. }, [views]);
  394. useEffect(() => {
  395. if (viewId?.startsWith('_')) {
  396. if (draggableTabs.find(tab => tab.id === viewId)?.isCommitted) {
  397. return;
  398. }
  399. // If the user types in query manually while the new view flow is showing,
  400. // then replace the add view flow with the issue stream with the query loaded,
  401. // and persist the query
  402. if (newViewActive && query !== '') {
  403. setNewViewActive(false);
  404. const updatedTabs: Tab[] = draggableTabs.map(tab =>
  405. tab.id === viewId
  406. ? {
  407. ...tab,
  408. unsavedChanges: [query, sort ?? IssueSortOptions.DATE],
  409. isCommitted: true,
  410. }
  411. : tab
  412. );
  413. setDraggableTabs(updatedTabs);
  414. debounceUpdateViews(updatedTabs);
  415. trackAnalytics('issue_views.add_view.custom_query_saved', {
  416. organization,
  417. query,
  418. });
  419. } else {
  420. setNewViewActive(true);
  421. }
  422. } else {
  423. setNewViewActive(false);
  424. }
  425. // eslint-disable-next-line react-hooks/exhaustive-deps
  426. }, [viewId, query]);
  427. return (
  428. <DraggableTabBar
  429. initialTabKey={getInitialTabKey()}
  430. tabs={draggableTabs}
  431. setTabs={setDraggableTabs}
  432. tempTab={tempTab}
  433. setTempTab={setTempTab}
  434. orgSlug={organization.slug}
  435. onReorder={debounceUpdateViews}
  436. onAddView={debounceUpdateViews}
  437. onDelete={debounceUpdateViews}
  438. onDuplicate={debounceUpdateViews}
  439. onTabRenamed={newTabs => debounceUpdateViews(newTabs)}
  440. onSave={debounceUpdateViews}
  441. onSaveTempView={debounceUpdateViews}
  442. router={router}
  443. />
  444. );
  445. }
  446. export default CustomViewsIssueListHeader;
  447. const StyledTabs = styled(Tabs)`
  448. grid-column: 1 / -1;
  449. `;
  450. const StyledGlobalEventProcessingAlert = styled(GlobalEventProcessingAlert)`
  451. grid-column: 1/-1;
  452. margin-top: ${space(1)};
  453. margin-bottom: ${space(1)};
  454. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  455. margin-top: ${space(2)};
  456. margin-bottom: 0;
  457. }
  458. `;