customViewsHeader.tsx 9.3 KB

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