releaseIssues.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. import {Component, Fragment} from 'react';
  2. import {browserHistory} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import {Location} from 'history';
  5. import isEqual from 'lodash/isEqual';
  6. import * as qs from 'query-string';
  7. import {Client} from 'sentry/api';
  8. import GuideAnchor from 'sentry/components/assistant/guideAnchor';
  9. import Button, {ButtonLabel} from 'sentry/components/button';
  10. import ButtonBar, {ButtonGrid} from 'sentry/components/buttonBar';
  11. import GroupList from 'sentry/components/issues/groupList';
  12. import Pagination from 'sentry/components/pagination';
  13. import QueryCount from 'sentry/components/queryCount';
  14. import {DEFAULT_RELATIVE_PERIODS} from 'sentry/constants';
  15. import {t, tct} from 'sentry/locale';
  16. import space from 'sentry/styles/space';
  17. import {Organization, PageFilters} from 'sentry/types';
  18. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  19. import withApi from 'sentry/utils/withApi';
  20. import withOrganization from 'sentry/utils/withOrganization';
  21. import {IssueSortOptions} from 'sentry/views/issueList/utils';
  22. import {getReleaseParams, ReleaseBounds} from '../../utils';
  23. import EmptyState from '../commitsAndFiles/emptyState';
  24. enum IssuesType {
  25. NEW = 'new',
  26. UNHANDLED = 'unhandled',
  27. REGRESSED = 'regressed',
  28. RESOLVED = 'resolved',
  29. ALL = 'all',
  30. }
  31. enum IssuesQuery {
  32. NEW = 'first-release',
  33. UNHANDLED = 'error.handled:0',
  34. REGRESSED = 'regressed_in_release',
  35. RESOLVED = 'is:resolved',
  36. ALL = 'release',
  37. }
  38. type IssuesQueryParams = {
  39. limit: number;
  40. query: string;
  41. sort: string;
  42. };
  43. const defaultProps = {
  44. withChart: false,
  45. };
  46. type Props = {
  47. api: Client;
  48. location: Location;
  49. organization: Organization;
  50. releaseBounds: ReleaseBounds;
  51. selection: PageFilters;
  52. version: string;
  53. queryFilterDescription?: string;
  54. } & Partial<typeof defaultProps>;
  55. type State = {
  56. count: {
  57. all: number | null;
  58. new: number | null;
  59. regressed: number | null;
  60. resolved: number | null;
  61. unhandled: number | null;
  62. };
  63. issuesType: IssuesType;
  64. onCursor?: () => void;
  65. pageLinks?: string;
  66. };
  67. class ReleaseIssues extends Component<Props, State> {
  68. static defaultProps = defaultProps;
  69. state: State = this.getInitialState();
  70. getInitialState() {
  71. const {location} = this.props;
  72. const query = location.query ? location.query.issuesType : null;
  73. const issuesTypeState = !query
  74. ? IssuesType.NEW
  75. : query.includes(IssuesType.NEW)
  76. ? IssuesType.NEW
  77. : query.includes(IssuesType.UNHANDLED)
  78. ? IssuesType.REGRESSED
  79. : query.includes(IssuesType.REGRESSED)
  80. ? IssuesType.UNHANDLED
  81. : query.includes(IssuesType.RESOLVED)
  82. ? IssuesType.RESOLVED
  83. : query.includes(IssuesType.ALL)
  84. ? IssuesType.ALL
  85. : IssuesType.ALL;
  86. return {
  87. issuesType: issuesTypeState,
  88. count: {
  89. new: null,
  90. all: null,
  91. resolved: null,
  92. unhandled: null,
  93. regressed: null,
  94. },
  95. };
  96. }
  97. componentDidMount() {
  98. this.fetchIssuesCount();
  99. }
  100. componentDidUpdate(prevProps: Props) {
  101. if (
  102. !isEqual(
  103. getReleaseParams({
  104. location: this.props.location,
  105. releaseBounds: this.props.releaseBounds,
  106. }),
  107. getReleaseParams({
  108. location: prevProps.location,
  109. releaseBounds: prevProps.releaseBounds,
  110. })
  111. )
  112. ) {
  113. this.fetchIssuesCount();
  114. }
  115. }
  116. getIssuesUrl() {
  117. const {version, organization} = this.props;
  118. const {issuesType} = this.state;
  119. const {queryParams} = this.getIssuesEndpoint();
  120. const query = new MutableSearch([]);
  121. switch (issuesType) {
  122. case IssuesType.NEW:
  123. query.setFilterValues('firstRelease', [version]);
  124. break;
  125. case IssuesType.UNHANDLED:
  126. query.setFilterValues('release', [version]);
  127. query.setFilterValues('error.handled', ['0']);
  128. break;
  129. case IssuesType.REGRESSED:
  130. query.setFilterValues('regressed_in_release', [version]);
  131. break;
  132. case IssuesType.RESOLVED:
  133. case IssuesType.ALL:
  134. default:
  135. query.setFilterValues('release', [version]);
  136. }
  137. return {
  138. pathname: `/organizations/${organization.slug}/issues/`,
  139. query: {
  140. ...queryParams,
  141. limit: undefined,
  142. cursor: undefined,
  143. query: query.formatString(),
  144. },
  145. };
  146. }
  147. getIssuesEndpoint(): {path: string; queryParams: IssuesQueryParams} {
  148. const {version, organization, location, releaseBounds} = this.props;
  149. const {issuesType} = this.state;
  150. const queryParams = {
  151. ...getReleaseParams({
  152. location,
  153. releaseBounds,
  154. }),
  155. limit: 10,
  156. sort: IssueSortOptions.FREQ,
  157. groupStatsPeriod: 'auto',
  158. };
  159. switch (issuesType) {
  160. case IssuesType.ALL:
  161. return {
  162. path: `/organizations/${organization.slug}/issues/`,
  163. queryParams: {
  164. ...queryParams,
  165. query: new MutableSearch([
  166. `${IssuesQuery.ALL}:${version}`,
  167. 'is:unresolved',
  168. ]).formatString(),
  169. },
  170. };
  171. case IssuesType.RESOLVED:
  172. return {
  173. path: `/organizations/${organization.slug}/releases/${encodeURIComponent(
  174. version
  175. )}/resolved/`,
  176. queryParams: {...queryParams, query: ''},
  177. };
  178. case IssuesType.UNHANDLED:
  179. return {
  180. path: `/organizations/${organization.slug}/issues/`,
  181. queryParams: {
  182. ...queryParams,
  183. query: new MutableSearch([
  184. `${IssuesQuery.ALL}:${version}`,
  185. IssuesQuery.UNHANDLED,
  186. 'is:unresolved',
  187. ]).formatString(),
  188. },
  189. };
  190. case IssuesType.REGRESSED:
  191. return {
  192. path: `/organizations/${organization.slug}/issues/`,
  193. queryParams: {
  194. ...queryParams,
  195. query: new MutableSearch([
  196. `${IssuesQuery.REGRESSED}:${version}`,
  197. ]).formatString(),
  198. },
  199. };
  200. case IssuesType.NEW:
  201. default:
  202. return {
  203. path: `/organizations/${organization.slug}/issues/`,
  204. queryParams: {
  205. ...queryParams,
  206. query: new MutableSearch([
  207. `${IssuesQuery.NEW}:${version}`,
  208. 'is:unresolved',
  209. ]).formatString(),
  210. },
  211. };
  212. }
  213. }
  214. async fetchIssuesCount() {
  215. const {api, organization, version} = this.props;
  216. const issueCountEndpoint = this.getIssueCountEndpoint();
  217. const resolvedEndpoint = `/organizations/${
  218. organization.slug
  219. }/releases/${encodeURIComponent(version)}/resolved/`;
  220. try {
  221. await Promise.all([
  222. api.requestPromise(issueCountEndpoint),
  223. api.requestPromise(resolvedEndpoint),
  224. ]).then(([issueResponse, resolvedResponse]) => {
  225. this.setState({
  226. count: {
  227. all: issueResponse[`${IssuesQuery.ALL}:"${version}" is:unresolved`] || 0,
  228. new: issueResponse[`${IssuesQuery.NEW}:"${version}" is:unresolved`] || 0,
  229. resolved: resolvedResponse.length,
  230. unhandled:
  231. issueResponse[
  232. `${IssuesQuery.UNHANDLED} ${IssuesQuery.ALL}:"${version}" is:unresolved`
  233. ] || 0,
  234. regressed: issueResponse[`${IssuesQuery.REGRESSED}:"${version}"`] || 0,
  235. },
  236. });
  237. });
  238. } catch {
  239. // do nothing
  240. }
  241. }
  242. getIssueCountEndpoint() {
  243. const {organization, version, location, releaseBounds} = this.props;
  244. const issuesCountPath = `/organizations/${organization.slug}/issues-count/`;
  245. const params = [
  246. `${IssuesQuery.NEW}:"${version}" is:unresolved`,
  247. `${IssuesQuery.ALL}:"${version}" is:unresolved`,
  248. `${IssuesQuery.UNHANDLED} ${IssuesQuery.ALL}:"${version}" is:unresolved`,
  249. `${IssuesQuery.REGRESSED}:"${version}"`,
  250. ];
  251. const queryParams = params.map(param => param);
  252. const queryParameters = {
  253. ...getReleaseParams({
  254. location,
  255. releaseBounds,
  256. }),
  257. query: queryParams,
  258. };
  259. return `${issuesCountPath}?${qs.stringify(queryParameters)}`;
  260. }
  261. handleIssuesTypeSelection = (issuesType: IssuesType) => {
  262. const {location} = this.props;
  263. const issuesTypeQuery =
  264. issuesType === IssuesType.ALL
  265. ? IssuesType.ALL
  266. : issuesType === IssuesType.NEW
  267. ? IssuesType.NEW
  268. : issuesType === IssuesType.RESOLVED
  269. ? IssuesType.RESOLVED
  270. : issuesType === IssuesType.UNHANDLED
  271. ? IssuesType.UNHANDLED
  272. : issuesType === IssuesType.REGRESSED
  273. ? IssuesType.REGRESSED
  274. : '';
  275. const to = {
  276. ...location,
  277. query: {
  278. ...location.query,
  279. issuesType: issuesTypeQuery,
  280. },
  281. };
  282. browserHistory.replace(to);
  283. this.setState({issuesType});
  284. };
  285. handleFetchSuccess = (groupListState, onCursor) => {
  286. this.setState({pageLinks: groupListState.pageLinks, onCursor});
  287. };
  288. renderEmptyMessage = () => {
  289. const {location, releaseBounds} = this.props;
  290. const {issuesType} = this.state;
  291. const isEntireReleasePeriod =
  292. !location.query.pageStatsPeriod && !location.query.pageStart;
  293. const {statsPeriod} = getReleaseParams({
  294. location,
  295. releaseBounds,
  296. });
  297. const selectedTimePeriod = statsPeriod ? DEFAULT_RELATIVE_PERIODS[statsPeriod] : null;
  298. const displayedPeriod = selectedTimePeriod
  299. ? selectedTimePeriod.toLowerCase()
  300. : t('given timeframe');
  301. return (
  302. <EmptyState>
  303. {issuesType === IssuesType.NEW
  304. ? isEntireReleasePeriod
  305. ? t('No new issues in this release.')
  306. : tct('No new issues for the [timePeriod].', {
  307. timePeriod: displayedPeriod,
  308. })
  309. : null}
  310. {issuesType === IssuesType.UNHANDLED
  311. ? isEntireReleasePeriod
  312. ? t('No unhandled issues in this release.')
  313. : tct('No unhandled issues for the [timePeriod].', {
  314. timePeriod: displayedPeriod,
  315. })
  316. : null}
  317. {issuesType === IssuesType.REGRESSED
  318. ? isEntireReleasePeriod
  319. ? t('No regressed issues in this release.')
  320. : tct('No regressed issues for the [timePeriod].', {
  321. timePeriod: displayedPeriod,
  322. })
  323. : null}
  324. {issuesType === IssuesType.RESOLVED && t('No resolved issues in this release.')}
  325. {issuesType === IssuesType.ALL
  326. ? isEntireReleasePeriod
  327. ? t('No issues in this release')
  328. : tct('No issues for the [timePeriod].', {
  329. timePeriod: displayedPeriod,
  330. })
  331. : null}
  332. </EmptyState>
  333. );
  334. };
  335. render() {
  336. const {issuesType, count, pageLinks, onCursor} = this.state;
  337. const {organization, queryFilterDescription, withChart} = this.props;
  338. const {path, queryParams} = this.getIssuesEndpoint();
  339. const issuesTypes = [
  340. {value: IssuesType.ALL, label: t('All Issues'), issueCount: count.all},
  341. {value: IssuesType.NEW, label: t('New Issues'), issueCount: count.new},
  342. {
  343. value: IssuesType.UNHANDLED,
  344. label: t('Unhandled'),
  345. issueCount: count.unhandled,
  346. },
  347. {
  348. value: IssuesType.REGRESSED,
  349. label: t('Regressed'),
  350. issueCount: count.regressed,
  351. },
  352. {
  353. value: IssuesType.RESOLVED,
  354. label: t('Resolved'),
  355. issueCount: count.resolved,
  356. },
  357. ];
  358. return (
  359. <Fragment>
  360. <ControlsWrapper>
  361. <GuideAnchor target="release_states">
  362. <StyledButtonBar active={issuesType} merged>
  363. {issuesTypes.map(({value, label, issueCount}) => (
  364. <Button
  365. key={value}
  366. barId={value}
  367. size="xs"
  368. onClick={() => this.handleIssuesTypeSelection(value)}
  369. >
  370. {label}
  371. <QueryCount
  372. count={issueCount}
  373. max={99}
  374. hideParens
  375. hideIfEmpty={false}
  376. />
  377. </Button>
  378. ))}
  379. </StyledButtonBar>
  380. </GuideAnchor>
  381. <OpenInButtonBar gap={1}>
  382. <Button to={this.getIssuesUrl()} size="xs">
  383. {t('Open in Issues')}
  384. </Button>
  385. <StyledPagination pageLinks={pageLinks} onCursor={onCursor} size="xs" />
  386. </OpenInButtonBar>
  387. </ControlsWrapper>
  388. <div data-test-id="release-wrapper">
  389. <GroupList
  390. orgId={organization.slug}
  391. endpointPath={path}
  392. queryParams={queryParams}
  393. query=""
  394. canSelectGroups={false}
  395. queryFilterDescription={queryFilterDescription}
  396. withChart={withChart}
  397. narrowGroups
  398. renderEmptyMessage={this.renderEmptyMessage}
  399. withPagination={false}
  400. onFetchSuccess={this.handleFetchSuccess}
  401. source="release"
  402. />
  403. </div>
  404. </Fragment>
  405. );
  406. }
  407. }
  408. const ControlsWrapper = styled('div')`
  409. display: flex;
  410. flex-wrap: wrap;
  411. align-items: center;
  412. justify-content: space-between;
  413. @media (max-width: ${p => p.theme.breakpoints.small}) {
  414. display: block;
  415. ${ButtonGrid} {
  416. overflow: auto;
  417. }
  418. }
  419. `;
  420. const OpenInButtonBar = styled(ButtonBar)`
  421. margin: ${space(1)} 0;
  422. `;
  423. const StyledButtonBar = styled(ButtonBar)`
  424. grid-template-columns: repeat(4, 1fr);
  425. ${ButtonLabel} {
  426. white-space: nowrap;
  427. gap: ${space(0.5)};
  428. span:last-child {
  429. color: ${p => p.theme.buttonCount};
  430. }
  431. }
  432. .active {
  433. ${ButtonLabel} {
  434. span:last-child {
  435. color: ${p => p.theme.buttonCountActive};
  436. }
  437. }
  438. }
  439. `;
  440. const StyledPagination = styled(Pagination)`
  441. margin: 0;
  442. `;
  443. export default withApi(withOrganization(ReleaseIssues));