customViewsHeader.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. import {useContext, useEffect, useMemo, useState} from 'react';
  2. import type {InjectedRouter} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import debounce from 'lodash/debounce';
  5. import GlobalEventProcessingAlert from 'sentry/components/globalEventProcessingAlert';
  6. import * as Layout from 'sentry/components/layouts/thirds';
  7. import {PageHeadingQuestionTooltip} from 'sentry/components/pageHeadingQuestionTooltip';
  8. import {Tabs, TabsContext} from 'sentry/components/tabs';
  9. import {t} from 'sentry/locale';
  10. import {space} from 'sentry/styles/space';
  11. import type {Organization} from 'sentry/types/organization';
  12. import {useNavigate} from 'sentry/utils/useNavigate';
  13. import useProjects from 'sentry/utils/useProjects';
  14. import {
  15. DraggableTabBar,
  16. type Tab,
  17. } from 'sentry/views/issueList/groupSearchViewTabs/draggableTabBar';
  18. import {useUpdateGroupSearchViews} from 'sentry/views/issueList/mutations/useUpdateGroupSearchViews';
  19. import {useFetchGroupSearchViews} from 'sentry/views/issueList/queries/useFetchGroupSearchViews';
  20. import type {UpdateGroupSearchViewPayload} from 'sentry/views/issueList/types';
  21. import {IssueSortOptions, type QueryCounts} from './utils';
  22. type CustomViewsIssueListHeaderProps = {
  23. organization: Organization;
  24. queryCounts: QueryCounts;
  25. router: InjectedRouter;
  26. selectedProjectIds: number[];
  27. };
  28. type CustomViewsIssueListHeaderTabsContentProps = {
  29. organization: Organization;
  30. queryCounts: QueryCounts;
  31. router: InjectedRouter;
  32. views: UpdateGroupSearchViewPayload[];
  33. };
  34. function CustomViewsIssueListHeader({
  35. selectedProjectIds,
  36. ...props
  37. }: CustomViewsIssueListHeaderProps) {
  38. const {projects} = useProjects();
  39. const selectedProjects = projects.filter(({id}) =>
  40. selectedProjectIds.includes(Number(id))
  41. );
  42. const {data: groupSearchViews} = useFetchGroupSearchViews({
  43. orgSlug: props.organization.slug,
  44. });
  45. return (
  46. <Layout.Header
  47. noActionWrap
  48. // No viewId in the URL query means that a temp view is selected, which has a dashed border
  49. borderStyle={
  50. groupSearchViews && !props.router?.location.query.viewId ? 'dashed' : 'solid'
  51. }
  52. >
  53. <Layout.HeaderContent>
  54. <Layout.Title>
  55. {t('Issues')}
  56. <PageHeadingQuestionTooltip
  57. docsUrl="https://docs.sentry.io/product/issues/"
  58. title={t(
  59. 'Detailed views of errors and performance problems in your application grouped by events with a similar set of characteristics.'
  60. )}
  61. />
  62. </Layout.Title>
  63. </Layout.HeaderContent>
  64. <Layout.HeaderActions />
  65. <StyledGlobalEventProcessingAlert projects={selectedProjects} />
  66. {groupSearchViews ? (
  67. <Tabs>
  68. <CustomViewsIssueListHeaderTabsContent {...props} views={groupSearchViews} />
  69. </Tabs>
  70. ) : (
  71. <div style={{height: 33}} />
  72. )}
  73. </Layout.Header>
  74. );
  75. }
  76. function CustomViewsIssueListHeaderTabsContent({
  77. organization,
  78. queryCounts,
  79. router,
  80. views,
  81. }: CustomViewsIssueListHeaderTabsContentProps) {
  82. // Remove cursor and page when switching tabs
  83. const navigate = useNavigate();
  84. // TODO: Replace this with useLocation
  85. const {cursor: _cursor, page: _page, ...queryParams} = router?.location.query;
  86. const {query, sort, viewId} = queryParams;
  87. const viewsToTabs = views.map(
  88. ({id, name, query: viewQuery, querySort: viewQuerySort}, index): Tab => {
  89. const tabId = id ?? `default${index.toString()}`;
  90. return {
  91. id: tabId,
  92. key: tabId,
  93. label: name,
  94. query: viewQuery,
  95. querySort: viewQuerySort,
  96. queryCount: queryCounts[viewQuery]?.count ?? undefined,
  97. };
  98. }
  99. );
  100. const [draggableTabs, setDraggableTabs] = useState<Tab[]>(viewsToTabs);
  101. const {tabListState} = useContext(TabsContext);
  102. const getInitialTabKey = () => {
  103. if (draggableTabs[0].key.startsWith('default')) {
  104. return draggableTabs[0].key;
  105. }
  106. if (!query && !sort && !viewId) {
  107. return draggableTabs[0].key;
  108. }
  109. if (viewId && draggableTabs.find(tab => tab.id === viewId)) {
  110. return draggableTabs.find(tab => tab.id === viewId)!.key;
  111. }
  112. if (query) {
  113. return 'temporary-tab';
  114. }
  115. return draggableTabs[0].key;
  116. };
  117. // TODO: Try to remove this state if possible
  118. const [tempTab, setTempTab] = useState<Tab | undefined>(
  119. getInitialTabKey() === 'temporary-tab' && query
  120. ? {
  121. id: 'temporary-tab',
  122. key: 'temporary-tab',
  123. label: t('Unsaved'),
  124. query: query,
  125. querySort: sort ?? IssueSortOptions.DATE,
  126. }
  127. : undefined
  128. );
  129. const {mutate: updateViews} = useUpdateGroupSearchViews();
  130. const debounceUpdateViews = useMemo(
  131. () =>
  132. debounce((newTabs: Tab[]) => {
  133. if (newTabs) {
  134. updateViews({
  135. orgSlug: organization.slug,
  136. groupSearchViews: newTabs.map(tab => ({
  137. // Do not send over an ID if it's a temporary or default tab so that
  138. // the backend will save these and generate permanent Ids for them
  139. ...(tab.id[0] !== '_' && !tab.id.startsWith('default') ? {id: tab.id} : {}),
  140. name: tab.label,
  141. query: tab.query,
  142. querySort: tab.querySort,
  143. })),
  144. });
  145. }
  146. }, 500),
  147. [organization.slug, updateViews]
  148. );
  149. // This insane useEffect ensures that the correct tab is selected when the url updates
  150. useEffect(() => {
  151. // If no query, sort, or viewId is present, set the first tab as the selected tab, update query accordingly
  152. if (!query && !sort && !viewId) {
  153. navigate({
  154. query: {
  155. ...queryParams,
  156. query: draggableTabs[0].query,
  157. sort: draggableTabs[0].querySort,
  158. viewId: draggableTabs[0].id,
  159. },
  160. pathname: `/organizations/${organization.slug}/issues/`,
  161. });
  162. tabListState?.setSelectedKey(draggableTabs[0].key);
  163. return;
  164. }
  165. // if a viewId is present, check if it exists in the existing views.
  166. if (viewId) {
  167. const selectedTab = draggableTabs.find(tab => tab.id === viewId);
  168. if (selectedTab && query && sort) {
  169. // if a viewId exists but the query and sort are not what we expected, set them as unsaved changes
  170. const isCurrentQuerySortDifferentFromExistingUnsavedChanges =
  171. selectedTab.unsavedChanges &&
  172. (selectedTab.unsavedChanges[0] !== query ||
  173. selectedTab.unsavedChanges[1] !== sort);
  174. const isCurrentQuerySortDifferentFromSelectedTabQuerySort =
  175. query !== selectedTab.query || sort !== selectedTab.querySort;
  176. if (
  177. isCurrentQuerySortDifferentFromExistingUnsavedChanges ||
  178. isCurrentQuerySortDifferentFromSelectedTabQuerySort
  179. ) {
  180. setDraggableTabs(
  181. draggableTabs.map(tab =>
  182. tab.key === selectedTab!.key
  183. ? {
  184. ...tab,
  185. unsavedChanges: [query, sort],
  186. }
  187. : tab
  188. )
  189. );
  190. }
  191. tabListState?.setSelectedKey(selectedTab.key);
  192. return;
  193. }
  194. if (selectedTab && query === undefined) {
  195. navigate({
  196. query: {
  197. ...queryParams,
  198. query: selectedTab.query,
  199. sort: selectedTab.querySort,
  200. viewId: selectedTab.id,
  201. },
  202. pathname: `/organizations/${organization.slug}/issues/`,
  203. });
  204. tabListState?.setSelectedKey(selectedTab.key);
  205. return;
  206. }
  207. if (!selectedTab) {
  208. // if a viewId does not exist, remove it from the query
  209. tabListState?.setSelectedKey('temporary-tab');
  210. navigate({
  211. query: {
  212. ...queryParams,
  213. viewId: undefined,
  214. },
  215. pathname: `/organizations/${organization.slug}/issues/`,
  216. });
  217. return;
  218. }
  219. return;
  220. }
  221. if (query) {
  222. tabListState?.setSelectedKey('temporary-tab');
  223. return;
  224. }
  225. // eslint-disable-next-line react-hooks/exhaustive-deps
  226. }, [navigate, organization.slug, query, sort, viewId]);
  227. // Update local tabs when new views are received from mutation request
  228. useEffect(() => {
  229. setDraggableTabs(
  230. draggableTabs.map(tab => {
  231. if (tab.id && tab.id[0] === '_') {
  232. // Temp viewIds are prefixed with '_'
  233. views.forEach(view => {
  234. if (
  235. view.id &&
  236. tab.query === view.query &&
  237. tab.querySort === view.querySort &&
  238. tab.label === view.name
  239. ) {
  240. tab.id = view.id;
  241. }
  242. });
  243. navigate({
  244. query: {
  245. ...queryParams,
  246. viewId: tab.id,
  247. },
  248. pathname: `/organizations/${organization.slug}/issues/`,
  249. });
  250. }
  251. return tab;
  252. })
  253. );
  254. // eslint-disable-next-line react-hooks/exhaustive-deps
  255. }, [views]);
  256. // Loads query counts when they are available
  257. // TODO: fetch these dynamically instead of getting them from overview.tsx
  258. useEffect(() => {
  259. setDraggableTabs(
  260. draggableTabs?.map(tab => {
  261. if (tab.query && queryCounts[tab.query]) {
  262. tab.queryCount = queryCounts[tab.query]?.count ?? 0; // TODO: Confirm null = 0 is correct
  263. }
  264. return tab;
  265. })
  266. );
  267. // eslint-disable-next-line react-hooks/exhaustive-deps
  268. }, [queryCounts]);
  269. return (
  270. <DraggableTabBar
  271. initialTabKey={getInitialTabKey()}
  272. tabs={draggableTabs}
  273. setTabs={setDraggableTabs}
  274. tempTab={tempTab}
  275. setTempTab={setTempTab}
  276. orgSlug={organization.slug}
  277. onReorder={debounceUpdateViews}
  278. onAddView={debounceUpdateViews}
  279. onDelete={debounceUpdateViews}
  280. onDuplicate={debounceUpdateViews}
  281. onTabRenamed={newTabs => debounceUpdateViews(newTabs)}
  282. onSave={debounceUpdateViews}
  283. onSaveTempView={debounceUpdateViews}
  284. router={router}
  285. />
  286. );
  287. }
  288. export default CustomViewsIssueListHeader;
  289. const StyledGlobalEventProcessingAlert = styled(GlobalEventProcessingAlert)`
  290. grid-column: 1/-1;
  291. margin-top: ${space(1)};
  292. margin-bottom: ${space(1)};
  293. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  294. margin-top: ${space(2)};
  295. margin-bottom: 0;
  296. }
  297. `;