issues.tsx 15 KB

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