issueViewsHeader.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  1. import {useContext, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {Node} from '@react-types/shared';
  4. import isEqual from 'lodash/isEqual';
  5. import {addSuccessMessage} from 'sentry/actionCreators/indicator';
  6. import DisableInDemoMode from 'sentry/components/acl/demoModeDisabled';
  7. import {Button} from 'sentry/components/button';
  8. import ButtonBar from 'sentry/components/buttonBar';
  9. import {DraggableTabList} from 'sentry/components/draggableTabs/draggableTabList';
  10. import type {DraggableTabListItemProps} from 'sentry/components/draggableTabs/item';
  11. import GlobalEventProcessingAlert from 'sentry/components/globalEventProcessingAlert';
  12. import * as Layout from 'sentry/components/layouts/thirds';
  13. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  14. import {PageHeadingQuestionTooltip} from 'sentry/components/pageHeadingQuestionTooltip';
  15. import {IconMegaphone, IconPause, IconPlay} from 'sentry/icons';
  16. import {t} from 'sentry/locale';
  17. import {space} from 'sentry/styles/space';
  18. import type {InjectedRouter} from 'sentry/types/legacyReactRouter';
  19. import {trackAnalytics} from 'sentry/utils/analytics';
  20. import normalizeUrl from 'sentry/utils/url/normalizeUrl';
  21. import {useFeedbackForm} from 'sentry/utils/useFeedbackForm';
  22. import {useHotkeys} from 'sentry/utils/useHotkeys';
  23. import {useLocation} from 'sentry/utils/useLocation';
  24. import {useNavigate} from 'sentry/utils/useNavigate';
  25. import useOrganization from 'sentry/utils/useOrganization';
  26. import useProjects from 'sentry/utils/useProjects';
  27. import {
  28. DEFAULT_ENVIRONMENTS,
  29. DEFAULT_TIME_FILTERS,
  30. generateTempViewId,
  31. type IssueView,
  32. type IssueViewParams,
  33. IssueViews,
  34. IssueViewsContext,
  35. TEMPORARY_TAB_KEY,
  36. } from 'sentry/views/issueList/issueViews/issueViews';
  37. import {IssueViewTab} from 'sentry/views/issueList/issueViews/issueViewTab';
  38. import {useFetchGroupSearchViews} from 'sentry/views/issueList/queries/useFetchGroupSearchViews';
  39. import {NewTabContext} from 'sentry/views/issueList/utils/newTabContext';
  40. import {IssueSortOptions} from './utils';
  41. type IssueViewsIssueListHeaderProps = {
  42. onRealtimeChange: (realtime: boolean) => void;
  43. realtimeActive: boolean;
  44. router: InjectedRouter;
  45. selectedProjectIds: number[];
  46. };
  47. type IssueViewsIssueListHeaderTabsContentProps = {
  48. router: InjectedRouter;
  49. };
  50. function IssueViewsIssueListHeader({
  51. selectedProjectIds,
  52. realtimeActive,
  53. onRealtimeChange,
  54. router,
  55. }: IssueViewsIssueListHeaderProps) {
  56. const organization = useOrganization();
  57. const {projects} = useProjects();
  58. const selectedProjects = projects.filter(({id}) =>
  59. selectedProjectIds.includes(Number(id))
  60. );
  61. const {newViewActive} = useContext(NewTabContext);
  62. const {data: groupSearchViews} = useFetchGroupSearchViews({
  63. orgSlug: organization.slug,
  64. });
  65. const realtimeTitle = realtimeActive
  66. ? t('Pause real-time updates')
  67. : t('Enable real-time updates');
  68. const openForm = useFeedbackForm();
  69. const hasNewLayout = organization.features.includes('issue-stream-table-layout');
  70. return (
  71. <Layout.Header
  72. noActionWrap
  73. // No viewId in the URL query means that a temp view is selected, which has a dashed border
  74. borderStyle={
  75. groupSearchViews && !router?.location.query.viewId ? 'dashed' : 'solid'
  76. }
  77. >
  78. <Layout.HeaderContent>
  79. <Layout.Title>
  80. {t('Issues')}
  81. <PageHeadingQuestionTooltip
  82. docsUrl="https://docs.sentry.io/product/issues/"
  83. title={t(
  84. 'Detailed views of errors and performance problems in your application grouped by events with a similar set of characteristics.'
  85. )}
  86. />
  87. </Layout.Title>
  88. </Layout.HeaderContent>
  89. <Layout.HeaderActions>
  90. <ButtonBar gap={1}>
  91. {openForm && hasNewLayout && (
  92. <Button
  93. size="sm"
  94. aria-label="issue-stream-feedback"
  95. icon={<IconMegaphone />}
  96. onClick={() =>
  97. openForm({
  98. messagePlaceholder: t(
  99. 'How can we make the issue stream better for you?'
  100. ),
  101. tags: {
  102. ['feedback.source']: 'new_issue_stream_layout',
  103. ['feedback.owner']: 'issues',
  104. },
  105. })
  106. }
  107. >
  108. {t('Give Feedback')}
  109. </Button>
  110. )}
  111. {!newViewActive && (
  112. <DisableInDemoMode>
  113. <Button
  114. size="sm"
  115. data-test-id="real-time"
  116. title={realtimeTitle}
  117. aria-label={realtimeTitle}
  118. icon={realtimeActive ? <IconPause /> : <IconPlay />}
  119. onClick={() => onRealtimeChange(!realtimeActive)}
  120. />
  121. </DisableInDemoMode>
  122. )}
  123. </ButtonBar>
  124. </Layout.HeaderActions>
  125. <StyledGlobalEventProcessingAlert projects={selectedProjects} />
  126. {groupSearchViews ? (
  127. <StyledIssueViews
  128. router={router}
  129. initialViews={groupSearchViews.map(
  130. (
  131. {
  132. id,
  133. name,
  134. query: viewQuery,
  135. querySort: viewQuerySort,
  136. environments: viewEnvironments,
  137. projects: viewProjects,
  138. timeFilters: viewTimeFilters,
  139. isAllProjects,
  140. },
  141. index
  142. ): IssueView => {
  143. const tabId = id ?? `default${index.toString()}`;
  144. return {
  145. id: tabId,
  146. key: tabId,
  147. label: name,
  148. query: viewQuery,
  149. querySort: viewQuerySort,
  150. environments: viewEnvironments,
  151. projects: isAllProjects ? [-1] : viewProjects,
  152. timeFilters: viewTimeFilters,
  153. isCommitted: true,
  154. };
  155. }
  156. )}
  157. >
  158. <IssueViewsIssueListHeaderTabsContent router={router} />
  159. </StyledIssueViews>
  160. ) : (
  161. <div style={{height: 33}} />
  162. )}
  163. </Layout.Header>
  164. );
  165. }
  166. function IssueViewsIssueListHeaderTabsContent({
  167. router,
  168. }: IssueViewsIssueListHeaderTabsContentProps) {
  169. const organization = useOrganization();
  170. const navigate = useNavigate();
  171. const location = useLocation();
  172. const {newViewActive, setNewViewActive} = useContext(NewTabContext);
  173. const {tabListState, state, dispatch, defaultProject} = useContext(IssueViewsContext);
  174. const {views, tempView} = state;
  175. const [editingTabKey, setEditingTabKey] = useState<string | null>(null);
  176. // TODO(msun): Use the location from useLocation instead of props router in the future
  177. const {query, sort, viewId} = router.location.query;
  178. // This insane useEffect ensures that the correct tab is selected when the url updates
  179. useEffect(() => {
  180. const {
  181. project,
  182. environment: env,
  183. start,
  184. end,
  185. statsPeriod,
  186. utc,
  187. } = router.location.query;
  188. const {queryEnvs, queryProjects} = normalizeProjectsEnvironments(project, env);
  189. const queryTimeFilters =
  190. start || end || statsPeriod || utc
  191. ? {
  192. start: statsPeriod ? null : start,
  193. end: statsPeriod ? null : end,
  194. period: statsPeriod,
  195. utc: statsPeriod ? null : utc,
  196. }
  197. : undefined;
  198. if (!viewId) {
  199. const {
  200. id,
  201. query: viewQuery,
  202. querySort,
  203. projects,
  204. environments,
  205. timeFilters,
  206. } = views[0]!;
  207. if (!query && !sort) {
  208. if (queryProjects || queryTimeFilters) {
  209. navigate(
  210. normalizeUrl({
  211. ...location,
  212. query: {
  213. ...router.location.query,
  214. viewId: id,
  215. query: query ?? viewQuery,
  216. sort: sort ?? querySort,
  217. project: queryProjects ?? projects,
  218. environment: queryEnvs ?? environments,
  219. ...normalizeDateTimeParams(queryTimeFilters ?? timeFilters),
  220. },
  221. }),
  222. {replace: true}
  223. );
  224. } else {
  225. navigate(
  226. normalizeUrl({
  227. ...location,
  228. query: {
  229. ...router.location.query,
  230. viewId: id,
  231. query: viewQuery,
  232. sort: querySort,
  233. project: projects,
  234. environment: environments,
  235. ...normalizeDateTimeParams(timeFilters),
  236. },
  237. }),
  238. {replace: true}
  239. );
  240. }
  241. tabListState?.setSelectedKey(views[0]!.key);
  242. return;
  243. }
  244. }
  245. // if a viewId is present, check the frontend is aware of a view with that id.
  246. if (viewId) {
  247. const selectedView = views.find(tab => tab.id === viewId);
  248. if (selectedView) {
  249. // If the frontend is aware of a view with that id, check if the query/sort is different
  250. // from the view's original query/sort.
  251. const {
  252. query: originalQuery,
  253. querySort: originalSort,
  254. projects: originalProjects,
  255. environments: originalEnvironments,
  256. timeFilters: originalTimeFilters,
  257. } = selectedView;
  258. const issueSortOption = Object.values(IssueSortOptions).includes(sort)
  259. ? (sort as IssueSortOptions)
  260. : undefined;
  261. const newUnsavedChanges: Partial<IssueViewParams> = {
  262. query: query === originalQuery ? undefined : query,
  263. querySort: sort === originalSort ? undefined : issueSortOption,
  264. projects: isEqual(queryProjects?.sort(), originalProjects.sort())
  265. ? undefined
  266. : queryProjects,
  267. environments: isEqual(queryEnvs?.sort(), originalEnvironments.sort())
  268. ? undefined
  269. : queryEnvs,
  270. timeFilters:
  271. queryTimeFilters &&
  272. isEqual(
  273. normalizeDateTimeParams(originalTimeFilters),
  274. normalizeDateTimeParams(queryTimeFilters)
  275. )
  276. ? undefined
  277. : queryTimeFilters,
  278. };
  279. const hasNoChanges = Object.values(newUnsavedChanges).every(
  280. value => value === undefined
  281. );
  282. if (!hasNoChanges && !isEqual(selectedView.unsavedChanges, newUnsavedChanges)) {
  283. // If there were no unsaved changes before, or the existing unsaved changes
  284. // don't match the new query and/or sort, update the unsaved changes
  285. dispatch({
  286. type: 'UPDATE_UNSAVED_CHANGES',
  287. unsavedChanges: newUnsavedChanges,
  288. });
  289. } else if (hasNoChanges && selectedView.unsavedChanges) {
  290. // If all view params are the same as the original view params, remove any unsaved changes
  291. dispatch({type: 'UPDATE_UNSAVED_CHANGES', unsavedChanges: undefined});
  292. }
  293. if (!tabListState?.selectionManager.isSelected(selectedView.key)) {
  294. navigate(
  295. normalizeUrl({
  296. ...location,
  297. query: {
  298. ...router.location.query,
  299. viewId: selectedView.id,
  300. query: newUnsavedChanges.query ?? selectedView.query,
  301. sort: newUnsavedChanges.querySort ?? selectedView.querySort,
  302. project: newUnsavedChanges.projects ?? selectedView.projects,
  303. environment: newUnsavedChanges.environments ?? selectedView.environments,
  304. ...normalizeDateTimeParams(
  305. newUnsavedChanges.timeFilters ?? selectedView.timeFilters
  306. ),
  307. },
  308. }),
  309. {replace: true}
  310. );
  311. tabListState?.setSelectedKey(selectedView.key);
  312. }
  313. } else {
  314. // if the viewId isn't found in this user's views, remove it from the query
  315. tabListState?.setSelectedKey(TEMPORARY_TAB_KEY);
  316. navigate(
  317. normalizeUrl({
  318. ...location,
  319. query: {
  320. ...router.location.query,
  321. viewId: undefined,
  322. project: project ?? defaultProject,
  323. environment: env ?? DEFAULT_ENVIRONMENTS,
  324. ...normalizeDateTimeParams(queryTimeFilters ?? DEFAULT_TIME_FILTERS),
  325. },
  326. }),
  327. {replace: true}
  328. );
  329. trackAnalytics('issue_views.shared_view_opened', {
  330. organization,
  331. query,
  332. });
  333. }
  334. return;
  335. }
  336. if (query) {
  337. if (!tabListState?.selectionManager.isSelected(TEMPORARY_TAB_KEY)) {
  338. dispatch({type: 'SET_TEMP_VIEW', query, sort});
  339. navigate(
  340. normalizeUrl({
  341. ...location,
  342. query: {
  343. ...router.location.query,
  344. viewId: undefined,
  345. },
  346. }),
  347. {replace: true}
  348. );
  349. tabListState?.setSelectedKey(TEMPORARY_TAB_KEY);
  350. return;
  351. }
  352. }
  353. }, [
  354. defaultProject,
  355. dispatch,
  356. location,
  357. navigate,
  358. organization,
  359. query,
  360. router.location.query,
  361. sort,
  362. tabListState,
  363. viewId,
  364. views,
  365. ]);
  366. // This useEffect ensures the "new view" page is displayed/hidden correctly
  367. useEffect(() => {
  368. if (viewId?.startsWith('_')) {
  369. if (views.find(tab => tab.id === viewId)?.isCommitted) {
  370. return;
  371. }
  372. // If the user types in query manually while the new view flow is showing,
  373. // then replace the add view flow with the issue stream with the query loaded,
  374. // and persist the query
  375. if (newViewActive && query !== '') {
  376. setNewViewActive(false);
  377. dispatch({
  378. type: 'UPDATE_UNSAVED_CHANGES',
  379. unsavedChanges: {query, querySort: sort ?? IssueSortOptions.DATE},
  380. isCommitted: true,
  381. syncViews: true,
  382. });
  383. trackAnalytics('issue_views.add_view.custom_query_saved', {
  384. organization,
  385. query,
  386. });
  387. } else {
  388. setNewViewActive(true);
  389. }
  390. } else {
  391. setNewViewActive(false);
  392. }
  393. // eslint-disable-next-line react-hooks/exhaustive-deps
  394. }, [viewId, query]);
  395. const issuesViewSaveHotkeys = useMemo(() => {
  396. return [
  397. {
  398. match: ['command+s', 'ctrl+s'],
  399. includeInputs: true,
  400. callback: () => {
  401. if (views.find(tab => tab.key === tabListState?.selectedKey)?.unsavedChanges) {
  402. dispatch({type: 'SAVE_CHANGES', syncViews: true});
  403. addSuccessMessage(t('Changes saved to view'));
  404. }
  405. },
  406. },
  407. ];
  408. }, [dispatch, tabListState?.selectedKey, views]);
  409. useHotkeys(issuesViewSaveHotkeys);
  410. const handleCreateNewView = () => {
  411. const tempId = generateTempViewId();
  412. dispatch({type: 'CREATE_NEW_VIEW', tempId});
  413. tabListState?.setSelectedKey(tempId);
  414. navigate({
  415. ...location,
  416. query: {
  417. ...router.location.query,
  418. query: '',
  419. viewId: tempId,
  420. project: defaultProject,
  421. environment: DEFAULT_ENVIRONMENTS,
  422. ...normalizeDateTimeParams(DEFAULT_TIME_FILTERS),
  423. },
  424. });
  425. };
  426. const allTabs = tempView ? [...views, tempView] : views;
  427. const initialTabKey =
  428. viewId && views.find(tab => tab.id === viewId)
  429. ? views.find(tab => tab.id === viewId)!.key
  430. : query
  431. ? TEMPORARY_TAB_KEY
  432. : views[0]!.key;
  433. return (
  434. <DraggableTabList
  435. onReorder={(newOrder: Array<Node<DraggableTabListItemProps>>) =>
  436. dispatch({
  437. type: 'REORDER_TABS',
  438. newKeyOrder: newOrder.map(node => node.key.toString()),
  439. })
  440. }
  441. onReorderComplete={() => dispatch({type: 'SYNC_VIEWS_TO_BACKEND'})}
  442. defaultSelectedKey={initialTabKey}
  443. onAddView={handleCreateNewView}
  444. orientation="horizontal"
  445. editingTabKey={editingTabKey ?? undefined}
  446. hideBorder
  447. >
  448. {allTabs.map(view => (
  449. <DraggableTabList.Item
  450. textValue={view.label}
  451. key={view.key}
  452. to={normalizeUrl({
  453. query: {
  454. ...router.location.query,
  455. query: view.unsavedChanges?.query ?? view.query,
  456. sort: view.unsavedChanges?.querySort ?? view.querySort,
  457. viewId: view.id !== TEMPORARY_TAB_KEY ? view.id : undefined,
  458. project: view.unsavedChanges?.projects ?? view.projects,
  459. environment: view.unsavedChanges?.environments ?? view.environments,
  460. ...normalizeDateTimeParams(
  461. view.unsavedChanges?.timeFilters ?? view.timeFilters
  462. ),
  463. cursor: undefined,
  464. page: undefined,
  465. },
  466. pathname: `/organizations/${organization.slug}/issues/`,
  467. })}
  468. disabled={view.key === editingTabKey}
  469. >
  470. <IssueViewTab
  471. key={view.key}
  472. view={view}
  473. initialTabKey={initialTabKey}
  474. router={router}
  475. editingTabKey={editingTabKey}
  476. setEditingTabKey={setEditingTabKey}
  477. />
  478. </DraggableTabList.Item>
  479. ))}
  480. </DraggableTabList>
  481. );
  482. }
  483. export default IssueViewsIssueListHeader;
  484. /**
  485. * Normalizes the project and environment query params to arrays of strings and numbers.
  486. * If project/environemnts is undefined, it equates to an empty array. If it is a single value,
  487. * it is converted to single element array.
  488. */
  489. export const normalizeProjectsEnvironments = (
  490. project: string[] | string | undefined,
  491. env: string[] | string | undefined
  492. ): {queryEnvs: string[] | undefined; queryProjects: number[] | undefined} => {
  493. let queryProjects: number[] | undefined = undefined;
  494. if (Array.isArray(project)) {
  495. queryProjects = project.map(p => parseInt(p, 10)).filter(p => !isNaN(p));
  496. } else if (project) {
  497. const parsed = parseInt(project, 10);
  498. if (!isNaN(parsed)) {
  499. queryProjects = [parsed];
  500. }
  501. }
  502. let queryEnvs: string[] | undefined = undefined;
  503. if (Array.isArray(env)) {
  504. queryEnvs = env;
  505. } else if (env) {
  506. queryEnvs = [env];
  507. }
  508. return {queryEnvs, queryProjects};
  509. };
  510. const StyledIssueViews = styled(IssueViews)`
  511. grid-column: 1 / -1;
  512. `;
  513. const StyledGlobalEventProcessingAlert = styled(GlobalEventProcessingAlert)`
  514. grid-column: 1/-1;
  515. margin-top: ${space(1)};
  516. margin-bottom: ${space(1)};
  517. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  518. margin-top: ${space(2)};
  519. margin-bottom: 0;
  520. }
  521. `;