groupDetails.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850
  1. import {
  2. cloneElement,
  3. Fragment,
  4. isValidElement,
  5. useCallback,
  6. useEffect,
  7. useState,
  8. } from 'react';
  9. import styled from '@emotion/styled';
  10. import * as Sentry from '@sentry/react';
  11. import * as qs from 'query-string';
  12. import FloatingFeedbackWidget from 'sentry/components/feedback/widget/floatingFeedbackWidget';
  13. import useDrawer from 'sentry/components/globalDrawer';
  14. import LoadingError from 'sentry/components/loadingError';
  15. import LoadingIndicator from 'sentry/components/loadingIndicator';
  16. import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
  17. import MissingProjectMembership from 'sentry/components/projects/missingProjectMembership';
  18. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  19. import {TabPanels, Tabs} from 'sentry/components/tabs';
  20. import {t} from 'sentry/locale';
  21. import GroupStore from 'sentry/stores/groupStore';
  22. import {space} from 'sentry/styles/space';
  23. import type {Event} from 'sentry/types/event';
  24. import type {Group} from 'sentry/types/group';
  25. import {GroupStatus, IssueCategory, IssueType} from 'sentry/types/group';
  26. import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
  27. import type {Organization} from 'sentry/types/organization';
  28. import type {Project} from 'sentry/types/project';
  29. import {defined} from 'sentry/utils';
  30. import {trackAnalytics} from 'sentry/utils/analytics';
  31. import {browserHistory} from 'sentry/utils/browserHistory';
  32. import {getUtcDateString} from 'sentry/utils/dates';
  33. import {
  34. getAnalyticsDataForEvent,
  35. getAnalyticsDataForGroup,
  36. getMessage,
  37. getTitle,
  38. } from 'sentry/utils/events';
  39. import {getConfigForIssueType} from 'sentry/utils/issueTypeConfig';
  40. import {getAnalyicsDataForProject} from 'sentry/utils/projects';
  41. import {setApiQueryData, useApiQuery, useQueryClient} from 'sentry/utils/queryClient';
  42. import recreateRoute from 'sentry/utils/recreateRoute';
  43. import useDisableRouteAnalytics from 'sentry/utils/routeAnalytics/useDisableRouteAnalytics';
  44. import useRouteAnalyticsEventNames from 'sentry/utils/routeAnalytics/useRouteAnalyticsEventNames';
  45. import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
  46. import useApi from 'sentry/utils/useApi';
  47. import {useDetailedProject} from 'sentry/utils/useDetailedProject';
  48. import {useLocation} from 'sentry/utils/useLocation';
  49. import {useMemoWithPrevious} from 'sentry/utils/useMemoWithPrevious';
  50. import {useNavigate} from 'sentry/utils/useNavigate';
  51. import useOrganization from 'sentry/utils/useOrganization';
  52. import {useParams} from 'sentry/utils/useParams';
  53. import useProjects from 'sentry/utils/useProjects';
  54. import useRouter from 'sentry/utils/useRouter';
  55. import {useUser} from 'sentry/utils/useUser';
  56. import GroupHeader from 'sentry/views/issueDetails//header';
  57. import {ERROR_TYPES} from 'sentry/views/issueDetails/constants';
  58. import {useGroupTagsDrawer} from 'sentry/views/issueDetails/groupTags/useGroupTagsDrawer';
  59. import SampleEventAlert from 'sentry/views/issueDetails/sampleEventAlert';
  60. import {GroupDetailsLayout} from 'sentry/views/issueDetails/streamline/groupDetailsLayout';
  61. import {useMergedIssuesDrawer} from 'sentry/views/issueDetails/streamline/useMergedIssuesDrawer';
  62. import {useSimilarIssuesDrawer} from 'sentry/views/issueDetails/streamline/useSimilarIssuesDrawer';
  63. import {Tab} from 'sentry/views/issueDetails/types';
  64. import {makeFetchGroupQueryKey, useGroup} from 'sentry/views/issueDetails/useGroup';
  65. import {useGroupDetailsRoute} from 'sentry/views/issueDetails/useGroupDetailsRoute';
  66. import {
  67. getGroupEventQueryKey,
  68. getGroupReprocessingStatus,
  69. markEventSeen,
  70. ReprocessingStatus,
  71. useDefaultIssueEvent,
  72. useEnvironmentsFromUrl,
  73. useHasStreamlinedUI,
  74. useIsSampleEvent,
  75. } from 'sentry/views/issueDetails/utils';
  76. type Error = (typeof ERROR_TYPES)[keyof typeof ERROR_TYPES] | null;
  77. type RouterParams = {groupId: string; eventId?: string};
  78. type RouteProps = RouteComponentProps<RouterParams, {}>;
  79. interface GroupDetailsProps extends RouteComponentProps<{groupId: string}, {}> {
  80. children: React.ReactNode;
  81. }
  82. type FetchGroupDetailsState = {
  83. error: boolean;
  84. errorType: Error;
  85. event: Event | null;
  86. eventError: boolean;
  87. group: Group | null;
  88. loadingEvent: boolean;
  89. loadingGroup: boolean;
  90. refetchData: () => void;
  91. refetchGroup: () => void;
  92. };
  93. interface GroupDetailsContentProps extends GroupDetailsProps, FetchGroupDetailsState {
  94. group: Group;
  95. project: Project;
  96. }
  97. function getFetchDataRequestErrorType(status?: number | null): Error {
  98. if (!status) {
  99. return null;
  100. }
  101. if (status === 404) {
  102. return ERROR_TYPES.GROUP_NOT_FOUND;
  103. }
  104. if (status === 403) {
  105. return ERROR_TYPES.MISSING_MEMBERSHIP;
  106. }
  107. return null;
  108. }
  109. function getReprocessingNewRoute({
  110. group,
  111. currentTab,
  112. router,
  113. baseUrl,
  114. }: {
  115. baseUrl: string;
  116. currentTab: Tab;
  117. group: Group;
  118. router: RouteProps['router'];
  119. }) {
  120. const {routes, params, location} = router;
  121. const {groupId} = params;
  122. const {id: nextGroupId} = group;
  123. const reprocessingStatus = getGroupReprocessingStatus(group);
  124. if (groupId !== nextGroupId) {
  125. // Redirects to the Activities tab
  126. if (
  127. reprocessingStatus === ReprocessingStatus.REPROCESSED_AND_HASNT_EVENT &&
  128. currentTab !== Tab.ACTIVITY
  129. ) {
  130. return {
  131. pathname: `${baseUrl}${Tab.ACTIVITY}/`,
  132. query: {...params, groupId: nextGroupId},
  133. };
  134. }
  135. return recreateRoute('', {
  136. routes,
  137. location,
  138. params: {...params, groupId: nextGroupId},
  139. });
  140. }
  141. if (
  142. reprocessingStatus === ReprocessingStatus.REPROCESSING &&
  143. currentTab !== Tab.DETAILS
  144. ) {
  145. return {
  146. pathname: baseUrl,
  147. query: params,
  148. };
  149. }
  150. if (
  151. reprocessingStatus === ReprocessingStatus.REPROCESSED_AND_HASNT_EVENT &&
  152. currentTab !== Tab.ACTIVITY &&
  153. currentTab !== Tab.USER_FEEDBACK
  154. ) {
  155. return {
  156. pathname: `${baseUrl}${Tab.ACTIVITY}/`,
  157. query: params,
  158. };
  159. }
  160. return undefined;
  161. }
  162. function useRefetchGroupForReprocessing({
  163. refetchGroup,
  164. }: Pick<FetchGroupDetailsState, 'refetchGroup'>) {
  165. useEffect(() => {
  166. const refetchInterval = window.setInterval(refetchGroup, 30000);
  167. return () => {
  168. window.clearInterval(refetchInterval);
  169. };
  170. }, [refetchGroup]);
  171. }
  172. function useEventApiQuery({
  173. groupId,
  174. eventId,
  175. environments,
  176. }: {
  177. environments: string[];
  178. groupId: string;
  179. eventId?: string;
  180. }) {
  181. const organization = useOrganization();
  182. const location = useLocation<{query?: string}>();
  183. const navigate = useNavigate();
  184. const defaultIssueEvent = useDefaultIssueEvent();
  185. const eventIdUrl = eventId ?? defaultIssueEvent;
  186. const recommendedEventQuery =
  187. typeof location.query.query === 'string' ? location.query.query : undefined;
  188. const isLatestOrRecommendedEvent =
  189. eventIdUrl === 'latest' || eventIdUrl === 'recommended';
  190. const queryKey = getGroupEventQueryKey({
  191. orgSlug: organization.slug,
  192. groupId,
  193. eventId: eventIdUrl,
  194. environments,
  195. recommendedEventQuery: isLatestOrRecommendedEvent ? recommendedEventQuery : undefined,
  196. });
  197. const eventQuery = useApiQuery<Event>(queryKey, {
  198. // Latest/recommended event will change over time, so only cache for 30 seconds
  199. // Oldest/specific events will never change
  200. staleTime: isLatestOrRecommendedEvent ? 30000 : Infinity,
  201. retry: false,
  202. });
  203. useEffect(() => {
  204. if (isLatestOrRecommendedEvent && eventQuery.isError && location.query.query) {
  205. // If we get an error from the helpful event endpoint, it probably means
  206. // the query failed validation. We should remove the query to try again.
  207. navigate(
  208. {
  209. ...location,
  210. query: {
  211. ...location.query,
  212. query: undefined,
  213. },
  214. },
  215. {replace: true}
  216. );
  217. }
  218. }, [isLatestOrRecommendedEvent, eventQuery.isError, navigate, location]);
  219. return eventQuery;
  220. }
  221. /**
  222. * This is a temporary measure to ensure that the GroupStore and query cache
  223. * are both up to date while we are still using both in the issue details page.
  224. * Once we remove all references to GroupStore in the issue details page we
  225. * should remove this.
  226. */
  227. function useSyncGroupStore(groupId: string, incomingEnvs: string[]) {
  228. const queryClient = useQueryClient();
  229. const organization = useOrganization();
  230. // It's possible the overview page is still unloading the store
  231. useEffect(() => {
  232. return GroupStore.listen(() => {
  233. const [storeGroup] = GroupStore.getState();
  234. if (
  235. defined(storeGroup) &&
  236. storeGroup.id === groupId &&
  237. // Check for properties that are only set after the group has been loaded
  238. defined(storeGroup.participants) &&
  239. defined(storeGroup.activity)
  240. ) {
  241. setApiQueryData(
  242. queryClient,
  243. makeFetchGroupQueryKey({
  244. groupId: storeGroup.id,
  245. organizationSlug: organization.slug,
  246. environments: incomingEnvs,
  247. }),
  248. storeGroup
  249. );
  250. }
  251. }, undefined) as () => void;
  252. }, [groupId, incomingEnvs, organization.slug, queryClient]);
  253. }
  254. function useFetchGroupDetails(): FetchGroupDetailsState {
  255. const api = useApi();
  256. const organization = useOrganization();
  257. const router = useRouter();
  258. const params = router.params;
  259. const [allProjectChanged, setAllProjectChanged] = useState<boolean>(false);
  260. const {currentTab, baseUrl} = useGroupDetailsRoute();
  261. const environments = useEnvironmentsFromUrl();
  262. const groupId = params.groupId;
  263. const {
  264. data: event,
  265. isPending: loadingEvent,
  266. isError,
  267. refetch: refetchEvent,
  268. } = useEventApiQuery({
  269. groupId,
  270. eventId: params.eventId,
  271. environments,
  272. });
  273. const {
  274. data: groupData,
  275. isPending: loadingGroup,
  276. isError: isGroupError,
  277. error: groupError,
  278. refetch: refetchGroupCall,
  279. } = useGroup({groupId});
  280. /**
  281. * Allows the GroupEventHeader to display the previous event while the new event is loading.
  282. * This is not closer to the GroupEventHeader because it is unmounted
  283. * between route changes like latest event => eventId
  284. */
  285. const previousEvent = useMemoWithPrevious<typeof event | null>(
  286. previousInstance => {
  287. if (event) {
  288. return event;
  289. }
  290. return previousInstance;
  291. },
  292. [event]
  293. );
  294. const group = groupData ?? null;
  295. useEffect(() => {
  296. if (defined(group)) {
  297. GroupStore.loadInitialData([group]);
  298. }
  299. }, [groupId, group]);
  300. useSyncGroupStore(groupId, environments);
  301. useEffect(() => {
  302. if (group && event) {
  303. const reprocessingNewRoute = getReprocessingNewRoute({
  304. group,
  305. currentTab,
  306. router,
  307. baseUrl,
  308. });
  309. if (reprocessingNewRoute) {
  310. browserHistory.push(reprocessingNewRoute);
  311. }
  312. }
  313. }, [group, event, router, currentTab, baseUrl]);
  314. useEffect(() => {
  315. const matchingProjectSlug = group?.project?.slug;
  316. if (!matchingProjectSlug) {
  317. return;
  318. }
  319. if (!group.hasSeen) {
  320. markEventSeen(api, organization.slug, matchingProjectSlug, params.groupId);
  321. }
  322. }, [
  323. api,
  324. group?.hasSeen,
  325. group?.project?.id,
  326. group?.project?.slug,
  327. organization.slug,
  328. params.groupId,
  329. ]);
  330. const allProjectsFlag = router.location.query._allp;
  331. useEffect(() => {
  332. const locationQuery = qs.parse(window.location.search) || {};
  333. // We use _allp as a temporary measure to know they came from the
  334. // issue list page with no project selected (all projects included in
  335. // filter).
  336. //
  337. // If it is not defined, we add the locked project id to the URL
  338. // (this is because if someone navigates directly to an issue on
  339. // single-project priveleges, then goes back - they were getting
  340. // assigned to the first project).
  341. //
  342. // If it is defined, we do not so that our back button will bring us
  343. // to the issue list page with no project selected instead of the
  344. // locked project.
  345. if (
  346. locationQuery.project === undefined &&
  347. !allProjectsFlag &&
  348. !allProjectChanged &&
  349. group?.project.id
  350. ) {
  351. locationQuery.project = group?.project.id;
  352. browserHistory.replace({...window.location, query: locationQuery});
  353. }
  354. if (allProjectsFlag && !allProjectChanged) {
  355. delete locationQuery.project;
  356. // We delete _allp from the URL to keep the hack a bit cleaner, but
  357. // this is not an ideal solution and will ultimately be replaced with
  358. // something smarter.
  359. delete locationQuery._allp;
  360. browserHistory.replace({...window.location, query: locationQuery});
  361. setAllProjectChanged(true);
  362. }
  363. }, [allProjectsFlag, group?.project.id, allProjectChanged]);
  364. const errorType = groupError ? getFetchDataRequestErrorType(groupError.status) : null;
  365. useEffect(() => {
  366. if (isGroupError) {
  367. Sentry.captureException(groupError);
  368. }
  369. }, [isGroupError, groupError]);
  370. const refetchGroup = useCallback(() => {
  371. if (group?.status !== GroupStatus.REPROCESSING || loadingGroup || loadingEvent) {
  372. return;
  373. }
  374. refetchGroupCall();
  375. }, [group, loadingGroup, loadingEvent, refetchGroupCall]);
  376. const refetchData = useCallback(() => {
  377. refetchEvent();
  378. refetchGroup();
  379. }, [refetchGroup, refetchEvent]);
  380. // Refetch when group is stale
  381. useEffect(() => {
  382. if (group && (group as Group & {stale?: boolean}).stale) {
  383. refetchGroup();
  384. }
  385. }, [refetchGroup, group]);
  386. useRefetchGroupForReprocessing({refetchGroup});
  387. useEffect(() => {
  388. return () => {
  389. GroupStore.reset();
  390. };
  391. }, []);
  392. return {
  393. loadingGroup,
  394. loadingEvent,
  395. group,
  396. // Allow previous event to be displayed while new event is loading
  397. event: (loadingEvent ? event ?? previousEvent : event) ?? null,
  398. errorType,
  399. error: isGroupError,
  400. eventError: isError,
  401. refetchData,
  402. refetchGroup,
  403. };
  404. }
  405. function useLoadedEventType() {
  406. const params = useParams<{eventId?: string}>();
  407. const defaultIssueEvent = useDefaultIssueEvent();
  408. switch (params.eventId) {
  409. case undefined:
  410. return defaultIssueEvent;
  411. case 'latest':
  412. case 'oldest':
  413. return params.eventId;
  414. default:
  415. return 'event_id';
  416. }
  417. }
  418. function useTrackView({
  419. group,
  420. event,
  421. project,
  422. tab,
  423. }: {
  424. event: Event | null;
  425. group: Group | null;
  426. tab: Tab;
  427. project?: Project;
  428. }) {
  429. const location = useLocation();
  430. const {alert_date, alert_rule_id, alert_type, ref_fallback, stream_index, query} =
  431. location.query;
  432. const groupEventType = useLoadedEventType();
  433. const user = useUser();
  434. useRouteAnalyticsEventNames('issue_details.viewed', 'Issue Details: Viewed');
  435. useRouteAnalyticsParams({
  436. ...getAnalyticsDataForGroup(group),
  437. ...getAnalyticsDataForEvent(event),
  438. ...getAnalyicsDataForProject(project),
  439. tab,
  440. stream_index: typeof stream_index === 'string' ? Number(stream_index) : undefined,
  441. query: typeof query === 'string' ? query : undefined,
  442. // Alert properties track if the user came from email/slack alerts
  443. alert_date:
  444. typeof alert_date === 'string' ? getUtcDateString(Number(alert_date)) : undefined,
  445. alert_rule_id: typeof alert_rule_id === 'string' ? alert_rule_id : undefined,
  446. alert_type: typeof alert_type === 'string' ? alert_type : undefined,
  447. ref_fallback,
  448. group_event_type: groupEventType,
  449. prefers_streamlined_ui: user?.options?.prefersIssueDetailsStreamlinedUI ?? false,
  450. });
  451. // Set default values for properties that may be updated in subcomponents.
  452. // Must be separate from the above values, otherwise the actual values filled in
  453. // by subcomponents may be overwritten when the above values change.
  454. useRouteAnalyticsParams({
  455. // Will be updated by StacktraceLink if there is a stacktrace link
  456. stacktrace_link_viewed: false,
  457. // Will be updated by IssueQuickTrace if there is a trace
  458. trace_status: 'none',
  459. // Will be updated in GroupDetailsHeader if there are replays
  460. group_has_replay: false,
  461. // Will be updated in ReplayPreview if there is a replay
  462. event_replay_status: 'none',
  463. // Will be updated in SuspectCommits if there are suspect commits
  464. num_suspect_commits: 0,
  465. suspect_commit_calculation: 'no suspect commit',
  466. // Will be updated in Autofix if enabled
  467. autofix_status: 'none',
  468. });
  469. useDisableRouteAnalytics(!group || !event || !project);
  470. }
  471. const trackTabChanged = ({
  472. organization,
  473. project,
  474. group,
  475. event,
  476. tab,
  477. }: {
  478. event: Event | null;
  479. group: Group;
  480. organization: Organization;
  481. project: Project;
  482. tab: Tab;
  483. }) => {
  484. if (!project || !group) {
  485. return;
  486. }
  487. trackAnalytics('issue_details.tab_changed', {
  488. organization,
  489. project_id: parseInt(project.id, 10),
  490. tab,
  491. ...getAnalyticsDataForGroup(group),
  492. });
  493. if (group.issueCategory !== IssueCategory.ERROR) {
  494. return;
  495. }
  496. const analyticsData = event
  497. ? event.tags
  498. .filter(({key}) => ['device', 'os', 'browser'].includes(key))
  499. .reduce((acc, {key, value}) => {
  500. acc[key] = value;
  501. return acc;
  502. }, {})
  503. : {};
  504. trackAnalytics('issue_group_details.tab.clicked', {
  505. organization,
  506. tab,
  507. platform: project.platform,
  508. ...analyticsData,
  509. });
  510. };
  511. function GroupDetailsContentError({
  512. errorType,
  513. onRetry,
  514. }: {
  515. errorType: Error;
  516. onRetry: () => void;
  517. }) {
  518. const organization = useOrganization();
  519. const location = useLocation();
  520. const projectId = location.query.project;
  521. const {projects} = useProjects();
  522. const project = projects.find(proj => proj.id === projectId);
  523. switch (errorType) {
  524. case ERROR_TYPES.GROUP_NOT_FOUND:
  525. return (
  526. <StyledLoadingError
  527. message={t('The issue you were looking for was not found.')}
  528. />
  529. );
  530. case ERROR_TYPES.MISSING_MEMBERSHIP:
  531. return <MissingProjectMembership organization={organization} project={project} />;
  532. default:
  533. return <StyledLoadingError onRetry={onRetry} />;
  534. }
  535. }
  536. function GroupDetailsContent({
  537. children,
  538. group,
  539. project,
  540. loadingEvent,
  541. eventError,
  542. event,
  543. refetchData,
  544. }: GroupDetailsContentProps) {
  545. const organization = useOrganization();
  546. const {openTagsDrawer} = useGroupTagsDrawer({group});
  547. const {openSimilarIssuesDrawer} = useSimilarIssuesDrawer({group, project});
  548. const {openMergedIssuesDrawer} = useMergedIssuesDrawer({group, project});
  549. const {isDrawerOpen} = useDrawer();
  550. const {currentTab, baseUrl} = useGroupDetailsRoute();
  551. const groupReprocessingStatus = getGroupReprocessingStatus(group);
  552. const environments = useEnvironmentsFromUrl();
  553. const hasStreamlinedUI = useHasStreamlinedUI();
  554. useEffect(() => {
  555. if (!hasStreamlinedUI || isDrawerOpen) {
  556. return;
  557. }
  558. if (currentTab === Tab.TAGS) {
  559. openTagsDrawer();
  560. } else if (currentTab === Tab.SIMILAR_ISSUES) {
  561. openSimilarIssuesDrawer();
  562. } else if (currentTab === Tab.MERGED) {
  563. openMergedIssuesDrawer();
  564. }
  565. }, [
  566. currentTab,
  567. hasStreamlinedUI,
  568. isDrawerOpen,
  569. openTagsDrawer,
  570. openSimilarIssuesDrawer,
  571. openMergedIssuesDrawer,
  572. ]);
  573. useTrackView({group, event, project, tab: currentTab});
  574. const childProps = {
  575. environments,
  576. group,
  577. project,
  578. event,
  579. loadingEvent,
  580. eventError,
  581. groupReprocessingStatus,
  582. onRetry: refetchData,
  583. baseUrl,
  584. };
  585. return hasStreamlinedUI ? (
  586. <GroupDetailsLayout
  587. group={group}
  588. event={event ?? undefined}
  589. project={project}
  590. groupReprocessingStatus={groupReprocessingStatus}
  591. >
  592. {isValidElement(children) ? cloneElement(children, childProps) : children}
  593. </GroupDetailsLayout>
  594. ) : (
  595. <Tabs
  596. value={currentTab}
  597. onChange={tab => trackTabChanged({tab, group, project, event, organization})}
  598. >
  599. <GroupHeader
  600. organization={organization}
  601. groupReprocessingStatus={groupReprocessingStatus}
  602. event={event}
  603. group={group}
  604. baseUrl={baseUrl}
  605. project={project as Project}
  606. />
  607. <GroupTabPanels>
  608. <TabPanels.Item key={currentTab}>
  609. {isValidElement(children) ? cloneElement(children, childProps) : children}
  610. </TabPanels.Item>
  611. </GroupTabPanels>
  612. </Tabs>
  613. );
  614. }
  615. function GroupDetailsPageContent(props: GroupDetailsProps & FetchGroupDetailsState) {
  616. const projectSlug = props.group?.project?.slug;
  617. const api = useApi();
  618. const organization = useOrganization();
  619. const [injectedEvent, setInjectedEvent] = useState(null);
  620. const {
  621. projects,
  622. initiallyLoaded: projectsLoaded,
  623. fetchError: errorFetchingProjects,
  624. } = useProjects({slugs: projectSlug ? [projectSlug] : []});
  625. // Preload detailed project data for highlighted data section
  626. useDetailedProject(
  627. {
  628. orgSlug: organization.slug,
  629. projectSlug: projectSlug ?? '',
  630. },
  631. {enabled: !!projectSlug}
  632. );
  633. const project = projects.find(({slug}) => slug === projectSlug);
  634. const projectWithFallback = project ?? projects[0];
  635. const isRegressionIssue =
  636. props.group?.issueType === IssueType.PERFORMANCE_DURATION_REGRESSION ||
  637. props.group?.issueType === IssueType.PERFORMANCE_ENDPOINT_REGRESSION;
  638. useEffect(() => {
  639. if (props.group && projectsLoaded && !project) {
  640. Sentry.withScope(scope => {
  641. const projectIds = projects.map(item => item.id);
  642. scope.setContext('missingProject', {
  643. projectId: props.group?.project.id,
  644. availableProjects: projectIds,
  645. });
  646. scope.setFingerprint(['group-details-project-not-found']);
  647. Sentry.captureException(new Error('Project not found'));
  648. });
  649. }
  650. }, [props.group, project, projects, projectsLoaded]);
  651. useEffect(() => {
  652. const fetchLatestEvent = async () => {
  653. const event = await api.requestPromise(
  654. `/organizations/${organization.slug}/issues/${props.group?.id}/events/latest/`
  655. );
  656. setInjectedEvent(event);
  657. };
  658. if (isRegressionIssue && !defined(props.event)) {
  659. fetchLatestEvent();
  660. }
  661. }, [
  662. api,
  663. organization.slug,
  664. props.event,
  665. props.group,
  666. props.group?.id,
  667. isRegressionIssue,
  668. ]);
  669. if (props.error) {
  670. return (
  671. <GroupDetailsContentError errorType={props.errorType} onRetry={props.refetchData} />
  672. );
  673. }
  674. if (errorFetchingProjects) {
  675. return <StyledLoadingError message={t('Error loading the specified project')} />;
  676. }
  677. if (projectSlug && !errorFetchingProjects && projectsLoaded && !projectWithFallback) {
  678. return (
  679. <StyledLoadingError message={t('The project %s does not exist', projectSlug)} />
  680. );
  681. }
  682. const regressionIssueLoaded = defined(injectedEvent ?? props.event);
  683. if (
  684. !projectsLoaded ||
  685. !projectWithFallback ||
  686. !props.group ||
  687. (isRegressionIssue && !regressionIssueLoaded)
  688. ) {
  689. return <LoadingIndicator />;
  690. }
  691. return (
  692. <GroupDetailsContent
  693. {...props}
  694. project={projectWithFallback}
  695. group={props.group}
  696. event={props.event ?? injectedEvent}
  697. />
  698. );
  699. }
  700. function GroupDetails(props: GroupDetailsProps) {
  701. const organization = useOrganization();
  702. const {group, ...fetchGroupDetailsProps} = useFetchGroupDetails();
  703. const isSampleError = useIsSampleEvent();
  704. const getGroupDetailsTitle = () => {
  705. const defaultTitle = 'Sentry';
  706. if (!group) {
  707. return defaultTitle;
  708. }
  709. const {title} = getTitle(group);
  710. const message = getMessage(group);
  711. const eventDetails = `${organization.slug} — ${group.project.slug}`;
  712. if (title && message) {
  713. return `${title}: ${message} — ${eventDetails}`;
  714. }
  715. return `${title || message || defaultTitle} — ${eventDetails}`;
  716. };
  717. const config = group && getConfigForIssueType(group, group.project);
  718. return (
  719. <Fragment>
  720. {isSampleError && group && (
  721. <SampleEventAlert project={group.project} organization={organization} />
  722. )}
  723. <SentryDocumentTitle noSuffix title={getGroupDetailsTitle()}>
  724. <PageFiltersContainer
  725. skipLoadLastUsed
  726. forceProject={group?.project}
  727. shouldForceProject
  728. >
  729. {config?.showFeedbackWidget && <FloatingFeedbackWidget />}
  730. <GroupDetailsPageContent
  731. {...props}
  732. {...{
  733. group,
  734. ...fetchGroupDetailsProps,
  735. }}
  736. />
  737. </PageFiltersContainer>
  738. </SentryDocumentTitle>
  739. </Fragment>
  740. );
  741. }
  742. export default Sentry.withProfiler(GroupDetails);
  743. const StyledLoadingError = styled(LoadingError)`
  744. margin: ${space(2)};
  745. `;
  746. const GroupTabPanels = styled(TabPanels)`
  747. flex-grow: 1;
  748. display: flex;
  749. flex-direction: column;
  750. justify-content: stretch;
  751. `;