groupDetails.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  1. import * as React from 'react';
  2. import DocumentTitle from 'react-document-title';
  3. import * as ReactRouter from 'react-router';
  4. import * as Sentry from '@sentry/react';
  5. import PropTypes from 'prop-types';
  6. import {Client} from 'app/api';
  7. import LoadingError from 'app/components/loadingError';
  8. import LoadingIndicator from 'app/components/loadingIndicator';
  9. import GlobalSelectionHeader from 'app/components/organizations/globalSelectionHeader';
  10. import MissingProjectMembership from 'app/components/projects/missingProjectMembership';
  11. import {t} from 'app/locale';
  12. import SentryTypes from 'app/sentryTypes';
  13. import GroupStore from 'app/stores/groupStore';
  14. import {PageContent} from 'app/styles/organization';
  15. import {AvatarProject, Group, Organization, Project} from 'app/types';
  16. import {Event} from 'app/types/event';
  17. import {callIfFunction} from 'app/utils/callIfFunction';
  18. import {getMessage, getTitle} from 'app/utils/events';
  19. import Projects from 'app/utils/projects';
  20. import recreateRoute from 'app/utils/recreateRoute';
  21. import withApi from 'app/utils/withApi';
  22. import {ERROR_TYPES} from './constants';
  23. import GroupHeader, {TAB} from './header';
  24. import {
  25. fetchGroupEvent,
  26. getGroupReprocessingStatus,
  27. markEventSeen,
  28. ReprocessingStatus,
  29. } from './utils';
  30. type Error = typeof ERROR_TYPES[keyof typeof ERROR_TYPES] | null;
  31. type Props = {
  32. api: Client;
  33. organization: Organization;
  34. environments: string[];
  35. children: React.ReactNode;
  36. isGlobalSelectionReady: boolean;
  37. } & ReactRouter.RouteComponentProps<
  38. {orgId: string; groupId: string; eventId?: string},
  39. {}
  40. >;
  41. type State = {
  42. group: Group | null;
  43. loading: boolean;
  44. loadingEvent: boolean;
  45. loadingGroup: boolean;
  46. error: boolean;
  47. eventError: boolean;
  48. errorType: Error;
  49. project: null | (Pick<Project, 'id' | 'slug'> & Partial<Pick<Project, 'platform'>>);
  50. event?: Event;
  51. };
  52. class GroupDetails extends React.Component<Props, State> {
  53. static childContextTypes = {
  54. group: SentryTypes.Group,
  55. location: PropTypes.object,
  56. };
  57. state = this.initialState;
  58. getChildContext() {
  59. return {
  60. group: this.state.group,
  61. location: this.props.location,
  62. };
  63. }
  64. componentDidMount() {
  65. this.fetchData();
  66. this.updateReprocessingProgress();
  67. }
  68. componentDidUpdate(prevProps: Props, prevState: State) {
  69. if (
  70. prevProps.isGlobalSelectionReady !== this.props.isGlobalSelectionReady ||
  71. prevProps.location.pathname !== this.props.location.pathname
  72. ) {
  73. this.fetchData();
  74. }
  75. if (
  76. (!this.canLoadEventEarly(prevProps) && !prevState?.group && this.state.group) ||
  77. (prevProps.params?.eventId !== this.props.params?.eventId && this.state.group)
  78. ) {
  79. this.getEvent(this.state.group);
  80. }
  81. }
  82. componentWillUnmount() {
  83. GroupStore.reset();
  84. callIfFunction(this.listener);
  85. if (this.interval) {
  86. clearInterval(this.interval);
  87. }
  88. }
  89. get initialState(): State {
  90. return {
  91. group: null,
  92. loading: true,
  93. loadingEvent: true,
  94. loadingGroup: true,
  95. error: false,
  96. eventError: false,
  97. errorType: null,
  98. project: null,
  99. };
  100. }
  101. remountComponent = () => {
  102. this.setState(this.initialState);
  103. this.fetchData();
  104. };
  105. canLoadEventEarly(props: Props) {
  106. return !props.params.eventId || ['oldest', 'latest'].includes(props.params.eventId);
  107. }
  108. get groupDetailsEndpoint() {
  109. return `/issues/${this.props.params.groupId}/`;
  110. }
  111. get groupReleaseEndpoint() {
  112. return `/issues/${this.props.params.groupId}/first-last-release/`;
  113. }
  114. async getEvent(group?: Group) {
  115. if (group) {
  116. this.setState({loadingEvent: true, eventError: false});
  117. }
  118. const {params, environments, api} = this.props;
  119. const orgSlug = params.orgId;
  120. const groupId = params.groupId;
  121. const eventId = params?.eventId || 'latest';
  122. const projectId = group?.project?.slug;
  123. try {
  124. const event = await fetchGroupEvent(
  125. api,
  126. orgSlug,
  127. groupId,
  128. eventId,
  129. environments,
  130. projectId
  131. );
  132. this.setState({event, loading: false, eventError: false, loadingEvent: false});
  133. } catch (err) {
  134. // This is an expected error, capture to Sentry so that it is not considered as an unhandled error
  135. Sentry.captureException(err);
  136. this.setState({eventError: true, loading: false, loadingEvent: false});
  137. }
  138. }
  139. getCurrentRouteInfo(group: Group): {currentTab: keyof typeof TAB; baseUrl: string} {
  140. const {routes, organization} = this.props;
  141. const {event} = this.state;
  142. // All the routes under /organizations/:orgId/issues/:groupId have a defined props
  143. const {currentTab, isEventRoute} = routes[routes.length - 1].props as {
  144. currentTab: keyof typeof TAB;
  145. isEventRoute: boolean;
  146. };
  147. const baseUrl =
  148. isEventRoute && event
  149. ? `/organizations/${organization.slug}/issues/${group.id}/events/${event.id}/`
  150. : `/organizations/${organization.slug}/issues/${group.id}/`;
  151. return {currentTab, baseUrl};
  152. }
  153. updateReprocessingProgress() {
  154. const hasReprocessingV2Feature = this.hasReprocessingV2Feature();
  155. if (!hasReprocessingV2Feature) {
  156. return;
  157. }
  158. this.interval = setInterval(this.refetchGroup, 30000);
  159. }
  160. hasReprocessingV2Feature() {
  161. const {organization} = this.props;
  162. return organization.features?.includes('reprocessing-v2');
  163. }
  164. getReprocessingNewRoute(data: Group) {
  165. const {routes, location, params} = this.props;
  166. const {groupId} = params;
  167. const {id: nextGroupId} = data;
  168. const hasReprocessingV2Feature = this.hasReprocessingV2Feature();
  169. const reprocessingStatus = getGroupReprocessingStatus(data);
  170. const {currentTab, baseUrl} = this.getCurrentRouteInfo(data);
  171. if (groupId !== nextGroupId) {
  172. if (hasReprocessingV2Feature) {
  173. // Redirects to the Activities tab
  174. if (
  175. reprocessingStatus === ReprocessingStatus.REPROCESSED_AND_HASNT_EVENT &&
  176. currentTab !== TAB.ACTIVITY
  177. ) {
  178. return {
  179. pathname: `${baseUrl}${TAB.ACTIVITY}/`,
  180. query: {...params, groupId: nextGroupId},
  181. };
  182. }
  183. }
  184. return recreateRoute('', {
  185. routes,
  186. location,
  187. params: {...params, groupId: nextGroupId},
  188. });
  189. }
  190. if (hasReprocessingV2Feature) {
  191. if (
  192. reprocessingStatus === ReprocessingStatus.REPROCESSING &&
  193. currentTab !== TAB.DETAILS
  194. ) {
  195. return {
  196. pathname: baseUrl,
  197. query: params,
  198. };
  199. }
  200. if (
  201. reprocessingStatus === ReprocessingStatus.REPROCESSED_AND_HASNT_EVENT &&
  202. currentTab !== TAB.ACTIVITY &&
  203. currentTab !== TAB.USER_FEEDBACK
  204. ) {
  205. return {
  206. pathname: `${baseUrl}${TAB.ACTIVITY}/`,
  207. query: params,
  208. };
  209. }
  210. }
  211. return undefined;
  212. }
  213. getGroupQuery(): Record<string, string | string[]> {
  214. const {environments} = this.props;
  215. // Note, we do not want to include the environment key at all if there are no environments
  216. const query: Record<string, string | string[]> = {
  217. ...(environments ? {environment: environments} : {}),
  218. expand: 'inbox',
  219. collapse: 'release',
  220. };
  221. return query;
  222. }
  223. getFetchDataRequestErrorType(status: any): Error {
  224. if (!status) {
  225. return null;
  226. }
  227. if (status === 404) {
  228. return ERROR_TYPES.GROUP_NOT_FOUND;
  229. }
  230. if (status === 403) {
  231. return ERROR_TYPES.MISSING_MEMBERSHIP;
  232. }
  233. return null;
  234. }
  235. handleRequestError(error: any) {
  236. Sentry.captureException(error);
  237. const errorType = this.getFetchDataRequestErrorType(error?.status);
  238. this.setState({
  239. loadingGroup: false,
  240. loading: false,
  241. error: true,
  242. errorType,
  243. });
  244. }
  245. refetchGroup = async () => {
  246. const {loadingGroup, loading, loadingEvent, group} = this.state;
  247. if (
  248. group?.status !== ReprocessingStatus.REPROCESSING ||
  249. loadingGroup ||
  250. loading ||
  251. loadingEvent
  252. ) {
  253. return;
  254. }
  255. const {api} = this.props;
  256. this.setState({loadingGroup: true});
  257. try {
  258. const updatedGroup = await api.requestPromise(this.groupDetailsEndpoint, {
  259. query: this.getGroupQuery(),
  260. });
  261. const reprocessingNewRoute = this.getReprocessingNewRoute(updatedGroup);
  262. if (reprocessingNewRoute) {
  263. ReactRouter.browserHistory.push(reprocessingNewRoute);
  264. return;
  265. }
  266. this.setState({group: updatedGroup, loadingGroup: false});
  267. } catch (error) {
  268. this.handleRequestError(error);
  269. }
  270. };
  271. async fetchGroupReleases() {
  272. const {api} = this.props;
  273. const releases = await api.requestPromise(this.groupReleaseEndpoint);
  274. GroupStore.onPopulateReleases(this.props.params.groupId, releases);
  275. }
  276. async fetchData() {
  277. const {api, isGlobalSelectionReady, params} = this.props;
  278. // Need to wait for global selection store to be ready before making request
  279. if (!isGlobalSelectionReady) {
  280. return;
  281. }
  282. try {
  283. const eventPromise = this.canLoadEventEarly(this.props)
  284. ? this.getEvent()
  285. : undefined;
  286. const groupPromise = await api.requestPromise(this.groupDetailsEndpoint, {
  287. query: this.getGroupQuery(),
  288. });
  289. const [data] = await Promise.all([groupPromise, eventPromise]);
  290. this.fetchGroupReleases();
  291. const reprocessingNewRoute = this.getReprocessingNewRoute(data);
  292. if (reprocessingNewRoute) {
  293. ReactRouter.browserHistory.push(reprocessingNewRoute);
  294. return;
  295. }
  296. const project = data.project;
  297. markEventSeen(api, params.orgId, project.slug, params.groupId);
  298. if (!project) {
  299. Sentry.withScope(() => {
  300. Sentry.captureException(new Error('Project not found'));
  301. });
  302. } else {
  303. const locationWithProject = {...this.props.location};
  304. if (
  305. locationWithProject.query.project === undefined &&
  306. locationWithProject.query._allp === undefined
  307. ) {
  308. // We use _allp as a temporary measure to know they came from the
  309. // issue list page with no project selected (all projects included in
  310. // filter).
  311. //
  312. // If it is not defined, we add the locked project id to the URL
  313. // (this is because if someone navigates directly to an issue on
  314. // single-project priveleges, then goes back - they were getting
  315. // assigned to the first project).
  316. //
  317. // If it is defined, we do not so that our back button will bring us
  318. // to the issue list page with no project selected instead of the
  319. // locked project.
  320. locationWithProject.query.project = project.id;
  321. }
  322. // We delete _allp from the URL to keep the hack a bit cleaner, but
  323. // this is not an ideal solution and will ultimately be replaced with
  324. // something smarter.
  325. delete locationWithProject.query._allp;
  326. ReactRouter.browserHistory.replace(locationWithProject);
  327. }
  328. this.setState({project, loadingGroup: false});
  329. GroupStore.loadInitialData([data]);
  330. } catch (error) {
  331. this.handleRequestError(error);
  332. }
  333. }
  334. listener = GroupStore.listen(itemIds => this.onGroupChange(itemIds), undefined);
  335. interval: ReturnType<typeof setInterval> | undefined = undefined;
  336. onGroupChange(itemIds: Set<string>) {
  337. const id = this.props.params.groupId;
  338. if (itemIds.has(id)) {
  339. const group = GroupStore.get(id) as Group;
  340. if (group) {
  341. // TODO(ts) This needs a better approach. issueActions is splicing attributes onto
  342. // group objects to cheat here.
  343. if ((group as Group & {stale?: boolean}).stale) {
  344. this.fetchData();
  345. return;
  346. }
  347. this.setState({
  348. group,
  349. });
  350. }
  351. }
  352. }
  353. getTitle() {
  354. const {organization} = this.props;
  355. const {group} = this.state;
  356. const defaultTitle = 'Sentry';
  357. if (!group) {
  358. return defaultTitle;
  359. }
  360. const {title} = getTitle(group, organization?.features);
  361. const message = getMessage(group);
  362. const {project} = group;
  363. const eventDetails = `${organization.slug} - ${project.slug}`;
  364. if (title && message) {
  365. return `${title}: ${message} - ${eventDetails}`;
  366. }
  367. return `${title || message || defaultTitle} - ${eventDetails}`;
  368. }
  369. renderError() {
  370. const {organization, location} = this.props;
  371. const projects = organization.projects ?? [];
  372. const projectId = location.query.project;
  373. const projectSlug = projects.find(proj => proj.id === projectId)?.slug;
  374. switch (this.state.errorType) {
  375. case ERROR_TYPES.GROUP_NOT_FOUND:
  376. return (
  377. <LoadingError message={t('The issue you were looking for was not found.')} />
  378. );
  379. case ERROR_TYPES.MISSING_MEMBERSHIP:
  380. return (
  381. <MissingProjectMembership
  382. organization={this.props.organization}
  383. projectSlug={projectSlug}
  384. />
  385. );
  386. default:
  387. return <LoadingError onRetry={this.remountComponent} />;
  388. }
  389. }
  390. renderContent(project: AvatarProject, group: Group) {
  391. const {children, environments} = this.props;
  392. const {loadingEvent, eventError, event} = this.state;
  393. const {currentTab, baseUrl} = this.getCurrentRouteInfo(group);
  394. const groupReprocessingStatus = getGroupReprocessingStatus(group);
  395. let childProps: Record<string, any> = {
  396. environments,
  397. group,
  398. project,
  399. };
  400. if (currentTab === TAB.DETAILS) {
  401. childProps = {
  402. ...childProps,
  403. event,
  404. loadingEvent,
  405. eventError,
  406. groupReprocessingStatus,
  407. onRetry: () => this.remountComponent(),
  408. };
  409. }
  410. if (currentTab === TAB.TAGS) {
  411. childProps = {...childProps, event, baseUrl};
  412. }
  413. return (
  414. <React.Fragment>
  415. <GroupHeader
  416. groupReprocessingStatus={groupReprocessingStatus}
  417. project={project as Project}
  418. event={event}
  419. group={group}
  420. currentTab={currentTab}
  421. baseUrl={baseUrl}
  422. />
  423. {React.isValidElement(children)
  424. ? React.cloneElement(children, childProps)
  425. : children}
  426. </React.Fragment>
  427. );
  428. }
  429. renderPageContent() {
  430. const {error: isError, group, project, loading} = this.state;
  431. const isLoading = loading || (!group && !isError);
  432. if (isLoading) {
  433. return <LoadingIndicator />;
  434. }
  435. if (isError) {
  436. return this.renderError();
  437. }
  438. const {organization} = this.props;
  439. return (
  440. <Projects
  441. orgId={organization.slug}
  442. slugs={[project?.slug ?? '']}
  443. data-test-id="group-projects-container"
  444. >
  445. {({projects, initiallyLoaded, fetchError}) =>
  446. initiallyLoaded ? (
  447. fetchError ? (
  448. <LoadingError message={t('Error loading the specified project')} />
  449. ) : (
  450. // TODO(ts): Update renderContent function to deal with empty group
  451. this.renderContent(projects[0], group!)
  452. )
  453. ) : (
  454. <LoadingIndicator />
  455. )
  456. }
  457. </Projects>
  458. );
  459. }
  460. render() {
  461. const {project} = this.state;
  462. return (
  463. <DocumentTitle title={this.getTitle()}>
  464. <GlobalSelectionHeader
  465. skipLoadLastUsed
  466. forceProject={project}
  467. showDateSelector={false}
  468. shouldForceProject
  469. lockedMessageSubject={t('issue')}
  470. showIssueStreamLink
  471. showProjectSettingsLink
  472. >
  473. <PageContent>{this.renderPageContent()}</PageContent>
  474. </GlobalSelectionHeader>
  475. </DocumentTitle>
  476. );
  477. }
  478. }
  479. export default withApi(Sentry.withProfiler(GroupDetails));