You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

App.tsx 34KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2021 SonarSource SA
  4. * mailto:info AT sonarsource DOT com
  5. *
  6. * This program is free software; you can redistribute it and/or
  7. * modify it under the terms of the GNU Lesser General Public
  8. * License as published by the Free Software Foundation; either
  9. * version 3 of the License, or (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  14. * Lesser General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Lesser General Public License
  17. * along with this program; if not, write to the Free Software Foundation,
  18. * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  19. */
  20. import * as key from 'keymaster';
  21. import { debounce, keyBy, omit, without } from 'lodash';
  22. import * as React from 'react';
  23. import { Helmet } from 'react-helmet-async';
  24. import { FormattedMessage } from 'react-intl';
  25. import A11ySkipTarget from '../../../app/components/a11y/A11ySkipTarget';
  26. import Suggestions from '../../../app/components/embed-docs-modal/Suggestions';
  27. import EmptySearch from '../../../components/common/EmptySearch';
  28. import FiltersHeader from '../../../components/common/FiltersHeader';
  29. import ScreenPositionHelper from '../../../components/common/ScreenPositionHelper';
  30. import { Button } from '../../../components/controls/buttons';
  31. import Checkbox from '../../../components/controls/Checkbox';
  32. import ListFooter from '../../../components/controls/ListFooter';
  33. import { Location, Router } from '../../../components/hoc/withRouter';
  34. import '../../../components/search-navigator.css';
  35. import { Alert } from '../../../components/ui/Alert';
  36. import DeferredSpinner from '../../../components/ui/DeferredSpinner';
  37. import {
  38. fillBranchLike,
  39. getBranchLikeQuery,
  40. isPullRequest,
  41. isSameBranchLike
  42. } from '../../../helpers/branch-like';
  43. import handleRequiredAuthentication from '../../../helpers/handleRequiredAuthentication';
  44. import { translate, translateWithParameters } from '../../../helpers/l10n';
  45. import {
  46. addSideBarClass,
  47. addWhitePageClass,
  48. removeSideBarClass,
  49. removeWhitePageClass
  50. } from '../../../helpers/pages';
  51. import { serializeDate } from '../../../helpers/query';
  52. import { isSonarCloud } from '../../../helpers/system';
  53. import { BranchLike } from '../../../types/branch-like';
  54. import {
  55. Facet,
  56. FetchIssuesPromise,
  57. ReferencedComponent,
  58. ReferencedLanguage,
  59. ReferencedRule
  60. } from '../../../types/issues';
  61. import { SecurityStandard } from '../../../types/security';
  62. import * as actions from '../actions';
  63. import ConciseIssuesList from '../conciseIssuesList/ConciseIssuesList';
  64. import ConciseIssuesListHeader from '../conciseIssuesList/ConciseIssuesListHeader';
  65. import Sidebar from '../sidebar/Sidebar';
  66. import '../styles.css';
  67. import {
  68. areMyIssuesSelected,
  69. areQueriesEqual,
  70. getOpen,
  71. parseFacets,
  72. parseQuery,
  73. Query,
  74. saveMyIssues,
  75. scrollToIssue,
  76. serializeQuery,
  77. shouldOpenSonarSourceSecurityFacet,
  78. shouldOpenStandardsChildFacet,
  79. shouldOpenStandardsFacet,
  80. STANDARDS
  81. } from '../utils';
  82. import BulkChangeModal, { MAX_PAGE_SIZE } from './BulkChangeModal';
  83. import IssuesList from './IssuesList';
  84. import IssuesSourceViewer from './IssuesSourceViewer';
  85. import MyIssuesFilter from './MyIssuesFilter';
  86. import NoIssues from './NoIssues';
  87. import NoMyIssues from './NoMyIssues';
  88. import PageActions from './PageActions';
  89. interface Props {
  90. branchLike?: BranchLike;
  91. component?: T.Component;
  92. currentUser: T.CurrentUser;
  93. fetchBranchStatus: (branchLike: BranchLike, projectKey: string) => void;
  94. fetchIssues: (query: T.RawQuery) => Promise<FetchIssuesPromise>;
  95. location: Location;
  96. onBranchesChange?: () => void;
  97. router: Pick<Router, 'push' | 'replace'>;
  98. }
  99. export interface State {
  100. bulkChangeModal: boolean;
  101. cannotShowOpenIssue?: boolean;
  102. checkAll?: boolean;
  103. checked: string[];
  104. effortTotal?: number;
  105. facets: T.Dict<Facet>;
  106. issues: T.Issue[];
  107. loading: boolean;
  108. loadingFacets: T.Dict<boolean>;
  109. loadingMore: boolean;
  110. locationsNavigator: boolean;
  111. myIssues: boolean;
  112. openFacets: T.Dict<boolean>;
  113. openIssue?: T.Issue;
  114. openPopup?: { issue: string; name: string };
  115. paging?: T.Paging;
  116. query: Query;
  117. referencedComponentsById: T.Dict<ReferencedComponent>;
  118. referencedComponentsByKey: T.Dict<ReferencedComponent>;
  119. referencedLanguages: T.Dict<ReferencedLanguage>;
  120. referencedRules: T.Dict<ReferencedRule>;
  121. referencedUsers: T.Dict<T.UserBase>;
  122. selected?: string;
  123. selectedFlowIndex?: number;
  124. selectedLocationIndex?: number;
  125. }
  126. const DEFAULT_QUERY = { resolved: 'false' };
  127. const MAX_INITAL_FETCH = 1000;
  128. export default class App extends React.PureComponent<Props, State> {
  129. mounted = false;
  130. constructor(props: Props) {
  131. super(props);
  132. const query = parseQuery(props.location.query);
  133. this.state = {
  134. bulkChangeModal: false,
  135. checked: [],
  136. facets: {},
  137. issues: [],
  138. loading: true,
  139. loadingFacets: {},
  140. loadingMore: false,
  141. locationsNavigator: false,
  142. myIssues: areMyIssuesSelected(props.location.query),
  143. openFacets: {
  144. owaspTop10: shouldOpenStandardsChildFacet({}, query, SecurityStandard.OWASP_TOP10),
  145. sansTop25: shouldOpenStandardsChildFacet({}, query, SecurityStandard.SANS_TOP25),
  146. severities: true,
  147. sonarsourceSecurity: shouldOpenSonarSourceSecurityFacet({}, query),
  148. standards: shouldOpenStandardsFacet({}, query),
  149. types: true
  150. },
  151. query,
  152. referencedComponentsById: {},
  153. referencedComponentsByKey: {},
  154. referencedLanguages: {},
  155. referencedRules: {},
  156. referencedUsers: {},
  157. selected: getOpen(props.location.query)
  158. };
  159. this.refreshBranchStatus = debounce(this.refreshBranchStatus, 1000);
  160. }
  161. componentDidMount() {
  162. this.mounted = true;
  163. if (this.state.myIssues && !this.props.currentUser.isLoggedIn) {
  164. handleRequiredAuthentication();
  165. return;
  166. }
  167. addWhitePageClass();
  168. addSideBarClass();
  169. this.attachShortcuts();
  170. this.fetchFirstIssues();
  171. }
  172. componentWillReceiveProps(nextProps: Props) {
  173. const { issues, selected } = this.state;
  174. const openIssue = this.getOpenIssue(nextProps, issues);
  175. if (openIssue && openIssue.key !== selected) {
  176. this.setState({
  177. locationsNavigator: false,
  178. selected: openIssue.key,
  179. selectedFlowIndex: undefined,
  180. selectedLocationIndex: undefined
  181. });
  182. }
  183. if (!openIssue) {
  184. this.setState({ selectedFlowIndex: undefined, selectedLocationIndex: undefined });
  185. }
  186. this.setState({
  187. myIssues: areMyIssuesSelected(nextProps.location.query),
  188. openIssue,
  189. query: parseQuery(nextProps.location.query)
  190. });
  191. }
  192. componentDidUpdate(prevProps: Props, prevState: State) {
  193. const { query } = this.props.location;
  194. const { query: prevQuery } = prevProps.location;
  195. if (
  196. prevProps.component !== this.props.component ||
  197. !isSameBranchLike(prevProps.branchLike, this.props.branchLike) ||
  198. !areQueriesEqual(prevQuery, query) ||
  199. areMyIssuesSelected(prevQuery) !== areMyIssuesSelected(query)
  200. ) {
  201. this.fetchFirstIssues();
  202. this.setState({ checkAll: false });
  203. } else if (
  204. !this.state.openIssue &&
  205. (prevState.selected !== this.state.selected || prevState.openIssue)
  206. ) {
  207. // if user simply selected another issue
  208. // or if user went from the source code back to the list of issues
  209. this.scrollToSelectedIssue();
  210. }
  211. }
  212. componentWillUnmount() {
  213. this.detachShortcuts();
  214. this.mounted = false;
  215. removeWhitePageClass();
  216. removeSideBarClass();
  217. }
  218. attachShortcuts() {
  219. key.setScope('issues');
  220. key('up', 'issues', () => {
  221. this.selectPreviousIssue();
  222. return false;
  223. });
  224. key('down', 'issues', () => {
  225. this.selectNextIssue();
  226. return false;
  227. });
  228. key('right', 'issues', () => {
  229. this.openSelectedIssue();
  230. return false;
  231. });
  232. key('left', 'issues', () => {
  233. if (this.state.query.issues.length !== 1) {
  234. this.closeIssue();
  235. }
  236. return false;
  237. });
  238. window.addEventListener('keydown', this.handleKeyDown);
  239. window.addEventListener('keyup', this.handleKeyUp);
  240. }
  241. detachShortcuts() {
  242. key.deleteScope('issues');
  243. window.removeEventListener('keydown', this.handleKeyDown);
  244. window.removeEventListener('keyup', this.handleKeyUp);
  245. }
  246. handleKeyDown = (event: KeyboardEvent) => {
  247. if (key.getScope() !== 'issues') {
  248. return;
  249. }
  250. if (event.keyCode === 18) {
  251. // alt
  252. event.preventDefault();
  253. this.setState(actions.enableLocationsNavigator);
  254. } else if (event.keyCode === 40 && event.altKey) {
  255. // alt + down
  256. event.preventDefault();
  257. this.selectNextLocation();
  258. } else if (event.keyCode === 38 && event.altKey) {
  259. // alt + up
  260. event.preventDefault();
  261. this.selectPreviousLocation();
  262. } else if (event.keyCode === 37 && event.altKey) {
  263. // alt + left
  264. event.preventDefault();
  265. this.selectPreviousFlow();
  266. } else if (event.keyCode === 39 && event.altKey) {
  267. // alt + right
  268. event.preventDefault();
  269. this.selectNextFlow();
  270. }
  271. };
  272. handleKeyUp = (event: KeyboardEvent) => {
  273. if (key.getScope() !== 'issues') {
  274. return;
  275. }
  276. if (event.keyCode === 18) {
  277. // alt
  278. this.setState(actions.disableLocationsNavigator);
  279. }
  280. };
  281. getSelectedIndex() {
  282. const { issues = [], selected } = this.state;
  283. const index = issues.findIndex(issue => issue.key === selected);
  284. return index !== -1 ? index : undefined;
  285. }
  286. getOpenIssue = (props: Props, issues: T.Issue[]) => {
  287. const open = getOpen(props.location.query);
  288. return open ? issues.find(issue => issue.key === open) : undefined;
  289. };
  290. selectNextIssue = () => {
  291. const { issues } = this.state;
  292. const selectedIndex = this.getSelectedIndex();
  293. if (selectedIndex !== undefined && selectedIndex < issues.length - 1) {
  294. if (this.state.openIssue) {
  295. this.openIssue(issues[selectedIndex + 1].key);
  296. } else {
  297. this.setState({
  298. selected: issues[selectedIndex + 1].key,
  299. selectedFlowIndex: undefined,
  300. selectedLocationIndex: undefined
  301. });
  302. }
  303. }
  304. };
  305. selectPreviousIssue = () => {
  306. const { issues } = this.state;
  307. const selectedIndex = this.getSelectedIndex();
  308. if (selectedIndex !== undefined && selectedIndex > 0) {
  309. if (this.state.openIssue) {
  310. this.openIssue(issues[selectedIndex - 1].key);
  311. } else {
  312. this.setState({
  313. selected: issues[selectedIndex - 1].key,
  314. selectedFlowIndex: undefined,
  315. selectedLocationIndex: undefined
  316. });
  317. }
  318. }
  319. };
  320. openIssue = (issueKey: string) => {
  321. const path = {
  322. pathname: this.props.location.pathname,
  323. query: {
  324. ...serializeQuery(this.state.query),
  325. ...getBranchLikeQuery(this.props.branchLike),
  326. id: this.props.component && this.props.component.key,
  327. myIssues: this.state.myIssues ? 'true' : undefined,
  328. open: issueKey
  329. }
  330. };
  331. if (this.state.openIssue) {
  332. if (path.query.open && path.query.open === this.state.openIssue.key) {
  333. this.setState(
  334. {
  335. locationsNavigator: false,
  336. selectedFlowIndex: undefined,
  337. selectedLocationIndex: undefined
  338. },
  339. this.scrollToSelectedIssue
  340. );
  341. } else {
  342. this.props.router.replace(path);
  343. }
  344. } else {
  345. this.props.router.push(path);
  346. }
  347. };
  348. closeIssue = () => {
  349. if (this.state.query) {
  350. this.props.router.push({
  351. pathname: this.props.location.pathname,
  352. query: {
  353. ...serializeQuery(this.state.query),
  354. ...getBranchLikeQuery(this.props.branchLike),
  355. id: this.props.component && this.props.component.key,
  356. myIssues: this.state.myIssues ? 'true' : undefined,
  357. open: undefined
  358. }
  359. });
  360. }
  361. };
  362. openSelectedIssue = () => {
  363. const { selected } = this.state;
  364. if (selected) {
  365. this.openIssue(selected);
  366. }
  367. };
  368. scrollToSelectedIssue = (smooth = true) => {
  369. const { selected } = this.state;
  370. if (selected) {
  371. scrollToIssue(selected, smooth);
  372. }
  373. };
  374. createdAfterIncludesTime = () => Boolean(this.props.location.query.createdAfter?.includes('T'));
  375. fetchIssues = (additional: T.RawQuery, requestFacets = false): Promise<FetchIssuesPromise> => {
  376. const { component } = this.props;
  377. const { myIssues, openFacets, query } = this.state;
  378. const facets = requestFacets
  379. ? Object.keys(openFacets)
  380. .filter(facet => facet !== STANDARDS)
  381. .join(',')
  382. : undefined;
  383. const parameters: T.Dict<string | undefined> = {
  384. ...getBranchLikeQuery(this.props.branchLike),
  385. componentKeys: component && component.key,
  386. s: 'FILE_LINE',
  387. ...serializeQuery(query),
  388. ps: '100',
  389. facets,
  390. ...additional
  391. };
  392. if (query.createdAfter !== undefined && this.createdAfterIncludesTime()) {
  393. parameters.createdAfter = serializeDate(query.createdAfter);
  394. }
  395. // only sorting by CREATION_DATE is allowed, so let's sort DESC
  396. if (query.sort) {
  397. Object.assign(parameters, { asc: 'false' });
  398. }
  399. if (myIssues) {
  400. Object.assign(parameters, { assignees: '__me__' });
  401. }
  402. return this.props.fetchIssues(parameters);
  403. };
  404. fetchFirstIssues() {
  405. const prevQuery = this.props.location.query;
  406. const openIssueKey = getOpen(this.props.location.query);
  407. let fetchPromise;
  408. this.setState({ checked: [], loading: true });
  409. if (openIssueKey !== undefined) {
  410. fetchPromise = this.fetchIssuesUntil(1, (pageIssues: T.Issue[], paging: T.Paging) => {
  411. if (
  412. paging.total <= paging.pageIndex * paging.pageSize ||
  413. paging.pageIndex * paging.pageSize >= MAX_INITAL_FETCH
  414. ) {
  415. return true;
  416. }
  417. return pageIssues.some(issue => issue.key === openIssueKey);
  418. });
  419. } else {
  420. fetchPromise = this.fetchIssues({}, true);
  421. }
  422. return fetchPromise.then(
  423. ({ effortTotal, facets, issues, paging, ...other }) => {
  424. if (this.mounted && areQueriesEqual(prevQuery, this.props.location.query)) {
  425. const openIssue = this.getOpenIssue(this.props, issues);
  426. let selected: string | undefined = undefined;
  427. if (issues.length > 0) {
  428. selected = openIssue ? openIssue.key : issues[0].key;
  429. }
  430. this.setState(state => ({
  431. cannotShowOpenIssue: Boolean(openIssueKey && !openIssue),
  432. effortTotal,
  433. facets: { ...state.facets, ...parseFacets(facets) },
  434. loading: false,
  435. issues,
  436. openIssue,
  437. paging,
  438. referencedComponentsById: keyBy(other.components, 'uuid'),
  439. referencedComponentsByKey: keyBy(other.components, 'key'),
  440. referencedLanguages: keyBy(other.languages, 'key'),
  441. referencedRules: keyBy(other.rules, 'key'),
  442. referencedUsers: keyBy(other.users, 'login'),
  443. selected,
  444. selectedFlowIndex: undefined,
  445. selectedLocationIndex: undefined
  446. }));
  447. }
  448. return issues;
  449. },
  450. () => {
  451. if (this.mounted && areQueriesEqual(prevQuery, this.props.location.query)) {
  452. this.setState({ loading: false });
  453. }
  454. return [];
  455. }
  456. );
  457. }
  458. fetchIssuesPage = (p: number) => {
  459. return this.fetchIssues({ p });
  460. };
  461. fetchIssuesUntil = (
  462. p: number,
  463. done: (pageIssues: T.Issue[], paging: T.Paging) => boolean
  464. ): Promise<FetchIssuesPromise> => {
  465. const recursiveFetch = (p: number, prevIssues: T.Issue[]): Promise<FetchIssuesPromise> => {
  466. return this.fetchIssuesPage(p).then(({ issues: pageIssues, paging, ...other }) => {
  467. const issues = [...prevIssues, ...pageIssues];
  468. return done(pageIssues, paging)
  469. ? { issues, paging, ...other }
  470. : recursiveFetch(p + 1, issues);
  471. });
  472. };
  473. return recursiveFetch(p, []);
  474. };
  475. fetchMoreIssues = () => {
  476. const { paging } = this.state;
  477. if (!paging) {
  478. return Promise.reject();
  479. }
  480. const p = paging.pageIndex + 1;
  481. this.setState({ checkAll: false, loadingMore: true });
  482. return this.fetchIssuesPage(p).then(
  483. response => {
  484. if (this.mounted) {
  485. this.setState(state => ({
  486. loadingMore: false,
  487. issues: [...state.issues, ...response.issues],
  488. paging: response.paging
  489. }));
  490. }
  491. },
  492. () => {
  493. if (this.mounted) {
  494. this.setState({ loadingMore: false });
  495. }
  496. }
  497. );
  498. };
  499. fetchIssuesForComponent = (_component: string, _from: number, to: number) => {
  500. const { issues, openIssue, paging } = this.state;
  501. if (!openIssue || !paging) {
  502. return Promise.reject(undefined);
  503. }
  504. const isSameComponent = (issue: T.Issue) => issue.component === openIssue.component;
  505. const done = (pageIssues: T.Issue[], paging: T.Paging) => {
  506. const lastIssue = pageIssues[pageIssues.length - 1];
  507. if (paging.total <= paging.pageIndex * paging.pageSize) {
  508. return true;
  509. }
  510. if (lastIssue.component !== openIssue.component) {
  511. return true;
  512. }
  513. return lastIssue.textRange !== undefined && lastIssue.textRange.endLine > to;
  514. };
  515. if (done(issues, paging)) {
  516. return Promise.resolve(issues.filter(isSameComponent));
  517. }
  518. this.setState({ loading: true });
  519. return this.fetchIssuesUntil(paging.pageIndex + 1, done).then(
  520. response => {
  521. const nextIssues = [...issues, ...response.issues];
  522. if (this.mounted) {
  523. this.setState({
  524. issues: nextIssues,
  525. loading: false,
  526. paging: response.paging
  527. });
  528. }
  529. return nextIssues.filter(isSameComponent);
  530. },
  531. () => {
  532. if (this.mounted) {
  533. this.setState({ loading: false });
  534. }
  535. return [];
  536. }
  537. );
  538. };
  539. fetchFacet = (facet: string) => {
  540. return this.fetchIssues({ ps: 1, facets: facet }, false).then(
  541. ({ facets, ...other }) => {
  542. if (this.mounted) {
  543. this.setState(state => ({
  544. facets: { ...state.facets, ...parseFacets(facets) },
  545. loadingFacets: omit(state.loadingFacets, facet),
  546. referencedComponentsById: {
  547. ...state.referencedComponentsById,
  548. ...keyBy(other.components, 'uuid')
  549. },
  550. referencedComponentsByKey: {
  551. ...state.referencedComponentsByKey,
  552. ...keyBy(other.components, 'key')
  553. },
  554. referencedLanguages: {
  555. ...state.referencedLanguages,
  556. ...keyBy(other.languages, 'key')
  557. },
  558. referencedRules: { ...state.referencedRules, ...keyBy(other.rules, 'key') },
  559. referencedUsers: { ...state.referencedUsers, ...keyBy(other.users, 'login') }
  560. }));
  561. }
  562. },
  563. () => {
  564. /* Do nothing */
  565. }
  566. );
  567. };
  568. isFiltered = () => {
  569. const serialized = serializeQuery(this.state.query);
  570. return !areQueriesEqual(serialized, DEFAULT_QUERY);
  571. };
  572. getCheckedIssues = () => {
  573. const issues = this.state.checked
  574. .map(checked => this.state.issues.find(issue => issue.key === checked))
  575. .filter((issue): issue is T.Issue => issue !== undefined);
  576. const paging = { pageIndex: 1, pageSize: issues.length, total: issues.length };
  577. return Promise.resolve({ issues, paging });
  578. };
  579. getButtonLabel = (checked: string[], checkAll?: boolean, paging?: T.Paging) => {
  580. if (checked.length > 0) {
  581. let count;
  582. if (checkAll && paging) {
  583. count = paging.total > MAX_PAGE_SIZE ? MAX_PAGE_SIZE : paging.total;
  584. } else {
  585. count = Math.min(checked.length, MAX_PAGE_SIZE);
  586. }
  587. return translateWithParameters('issues.bulk_change_X_issues', count);
  588. } else {
  589. return translate('bulk_change');
  590. }
  591. };
  592. handleFilterChange = (changes: Partial<Query>) => {
  593. this.props.router.push({
  594. pathname: this.props.location.pathname,
  595. query: {
  596. ...serializeQuery({ ...this.state.query, ...changes }),
  597. ...getBranchLikeQuery(this.props.branchLike),
  598. id: this.props.component && this.props.component.key,
  599. myIssues: this.state.myIssues ? 'true' : undefined
  600. }
  601. });
  602. this.setState(({ openFacets }) => ({
  603. openFacets: {
  604. ...openFacets,
  605. sonarsourceSecurity: shouldOpenSonarSourceSecurityFacet(openFacets, changes),
  606. standards: shouldOpenStandardsFacet(openFacets, changes)
  607. }
  608. }));
  609. };
  610. handleMyIssuesChange = (myIssues: boolean) => {
  611. this.closeFacet('assignees');
  612. if (!this.props.component) {
  613. saveMyIssues(myIssues);
  614. }
  615. this.props.router.push({
  616. pathname: this.props.location.pathname,
  617. query: {
  618. ...serializeQuery({ ...this.state.query, assigned: true, assignees: [] }),
  619. ...getBranchLikeQuery(this.props.branchLike),
  620. id: this.props.component && this.props.component.key,
  621. myIssues: myIssues ? 'true' : undefined
  622. }
  623. });
  624. };
  625. loadSearchResultCount = (property: string, changes: Partial<Query>) => {
  626. const { component } = this.props;
  627. const { myIssues, query } = this.state;
  628. const parameters = {
  629. ...getBranchLikeQuery(this.props.branchLike),
  630. componentKeys: component && component.key,
  631. facets: property,
  632. s: 'FILE_LINE',
  633. ...serializeQuery({ ...query, ...changes }),
  634. ps: 1
  635. };
  636. if (myIssues) {
  637. Object.assign(parameters, { assignees: '__me__' });
  638. }
  639. return this.props.fetchIssues(parameters).then(({ facets }) => parseFacets(facets)[property]);
  640. };
  641. closeFacet = (property: string) => {
  642. this.setState(state => ({
  643. openFacets: { ...state.openFacets, [property]: false }
  644. }));
  645. };
  646. handleFacetToggle = (property: string) => {
  647. this.setState(state => {
  648. const willOpenProperty = !state.openFacets[property];
  649. const newState = {
  650. loadingFacets: state.loadingFacets,
  651. openFacets: { ...state.openFacets, [property]: willOpenProperty }
  652. };
  653. // Try to open sonarsource security "subfacet" by default if the standard facet is open
  654. if (willOpenProperty && property === STANDARDS) {
  655. newState.openFacets.sonarsourceSecurity = shouldOpenSonarSourceSecurityFacet(
  656. newState.openFacets,
  657. state.query
  658. );
  659. // Force loading of sonarsource security facet data
  660. property = newState.openFacets.sonarsourceSecurity ? 'sonarsourceSecurity' : property;
  661. }
  662. // No need to load facets data for standard facet
  663. if (property !== STANDARDS && !state.facets[property]) {
  664. newState.loadingFacets[property] = true;
  665. this.fetchFacet(property);
  666. }
  667. return newState;
  668. });
  669. };
  670. handleReset = () => {
  671. this.props.router.push({
  672. pathname: this.props.location.pathname,
  673. query: {
  674. ...DEFAULT_QUERY,
  675. ...getBranchLikeQuery(this.props.branchLike),
  676. id: this.props.component && this.props.component.key,
  677. myIssues: this.state.myIssues ? 'true' : undefined
  678. }
  679. });
  680. };
  681. handlePopupToggle = (issue: string, popupName: string, open?: boolean) => {
  682. this.setState((state: State) => {
  683. const { openPopup } = state;
  684. const samePopup = openPopup && openPopup.name === popupName && openPopup.issue === issue;
  685. if (open !== false && !samePopup) {
  686. return { ...state, openPopup: { issue, name: popupName } };
  687. } else if (open !== true && samePopup) {
  688. return { ...state, openPopup: undefined };
  689. }
  690. return state;
  691. });
  692. };
  693. handleIssueCheck = (issue: string) => {
  694. this.setState(state => ({
  695. checkAll: false,
  696. checked: state.checked.includes(issue)
  697. ? without(state.checked, issue)
  698. : [...state.checked, issue]
  699. }));
  700. };
  701. handleIssueChange = (issue: T.Issue) => {
  702. this.refreshBranchStatus();
  703. this.setState(state => ({
  704. issues: state.issues.map(candidate => (candidate.key === issue.key ? issue : candidate)),
  705. openIssue: state.openIssue && state.openIssue.key === issue.key ? issue : state.openIssue
  706. }));
  707. };
  708. handleOpenBulkChange = () => {
  709. key.setScope('issues-bulk-change');
  710. this.setState({ bulkChangeModal: true });
  711. };
  712. handleCloseBulkChange = () => {
  713. key.setScope('issues');
  714. this.setState({ bulkChangeModal: false });
  715. };
  716. handleBulkChangeDone = () => {
  717. this.setState({ checkAll: false });
  718. this.refreshBranchStatus();
  719. this.fetchFirstIssues();
  720. this.handleCloseBulkChange();
  721. };
  722. selectLocation = (index: number) => {
  723. this.setState(actions.selectLocation(index));
  724. };
  725. selectNextLocation = () => {
  726. this.setState(actions.selectNextLocation);
  727. };
  728. selectPreviousLocation = () => {
  729. this.setState(actions.selectPreviousLocation);
  730. };
  731. handleCheckAll = (checked: boolean) => {
  732. if (checked) {
  733. this.setState(state => ({
  734. checkAll: true,
  735. checked: state.issues.map(issue => issue.key)
  736. }));
  737. } else {
  738. this.setState({ checkAll: false, checked: [] });
  739. }
  740. };
  741. selectFlow = (index?: number) => {
  742. this.setState(actions.selectFlow(index));
  743. };
  744. selectNextFlow = () => {
  745. this.setState(actions.selectNextFlow);
  746. };
  747. selectPreviousFlow = () => {
  748. this.setState(actions.selectPreviousFlow);
  749. };
  750. refreshBranchStatus = () => {
  751. const { branchLike, component } = this.props;
  752. if (branchLike && component && isPullRequest(branchLike)) {
  753. this.props.fetchBranchStatus(branchLike, component.key);
  754. }
  755. };
  756. renderBulkChange() {
  757. const { component, currentUser } = this.props;
  758. const { checkAll, bulkChangeModal, checked, issues, paging } = this.state;
  759. const isAllChecked = checked.length > 0 && issues.length === checked.length;
  760. const thirdState = checked.length > 0 && !isAllChecked;
  761. const isChecked = isAllChecked || thirdState;
  762. if (!currentUser.isLoggedIn) {
  763. return null;
  764. }
  765. return (
  766. <div className="pull-left">
  767. <Checkbox
  768. checked={isChecked}
  769. className="spacer-right text-middle"
  770. disabled={issues.length === 0}
  771. id="issues-selection"
  772. onCheck={this.handleCheckAll}
  773. thirdState={thirdState}
  774. title={translate('issues.select_all_issues')}
  775. />
  776. <Button
  777. disabled={checked.length === 0}
  778. id="issues-bulk-change"
  779. onClick={this.handleOpenBulkChange}>
  780. {this.getButtonLabel(checked, checkAll, paging)}
  781. </Button>
  782. {bulkChangeModal && (
  783. <BulkChangeModal
  784. component={component}
  785. currentUser={currentUser}
  786. fetchIssues={checkAll ? this.fetchIssues : this.getCheckedIssues}
  787. onClose={this.handleCloseBulkChange}
  788. onDone={this.handleBulkChangeDone}
  789. />
  790. )}
  791. </div>
  792. );
  793. }
  794. renderFacets() {
  795. const { component, currentUser, branchLike } = this.props;
  796. const { query } = this.state;
  797. return (
  798. <div className="layout-page-filters">
  799. {currentUser.isLoggedIn && !isSonarCloud() && (
  800. <MyIssuesFilter
  801. myIssues={this.state.myIssues}
  802. onMyIssuesChange={this.handleMyIssuesChange}
  803. />
  804. )}
  805. <FiltersHeader displayReset={this.isFiltered()} onReset={this.handleReset} />
  806. <Sidebar
  807. branchLike={branchLike}
  808. component={component}
  809. createdAfterIncludesTime={this.createdAfterIncludesTime()}
  810. facets={this.state.facets}
  811. loadSearchResultCount={this.loadSearchResultCount}
  812. loadingFacets={this.state.loadingFacets}
  813. myIssues={this.state.myIssues}
  814. onFacetToggle={this.handleFacetToggle}
  815. onFilterChange={this.handleFilterChange}
  816. openFacets={this.state.openFacets}
  817. query={query}
  818. referencedComponentsById={this.state.referencedComponentsById}
  819. referencedComponentsByKey={this.state.referencedComponentsByKey}
  820. referencedLanguages={this.state.referencedLanguages}
  821. referencedRules={this.state.referencedRules}
  822. referencedUsers={this.state.referencedUsers}
  823. />
  824. </div>
  825. );
  826. }
  827. renderConciseIssuesList() {
  828. const { issues, loadingMore, paging, query } = this.state;
  829. return (
  830. <div className="layout-page-filters">
  831. <ConciseIssuesListHeader
  832. displayBackButton={query.issues.length !== 1}
  833. loading={this.state.loading}
  834. onBackClick={this.closeIssue}
  835. />
  836. <ConciseIssuesList
  837. issues={issues}
  838. onFlowSelect={this.selectFlow}
  839. onIssueSelect={this.openIssue}
  840. onLocationSelect={this.selectLocation}
  841. selected={this.state.selected}
  842. selectedFlowIndex={this.state.selectedFlowIndex}
  843. selectedLocationIndex={this.state.selectedLocationIndex}
  844. />
  845. {paging && paging.total > 0 && (
  846. <ListFooter
  847. count={issues.length}
  848. loadMore={this.fetchMoreIssues}
  849. loading={loadingMore}
  850. total={paging.total}
  851. />
  852. )}
  853. </div>
  854. );
  855. }
  856. renderSide(openIssue: T.Issue | undefined) {
  857. return (
  858. <ScreenPositionHelper className="layout-page-side-outer">
  859. {({ top }) => (
  860. <section
  861. aria-label={openIssue ? translate('list_of_issues') : translate('filters')}
  862. className="layout-page-side"
  863. style={{ top }}>
  864. <div className="layout-page-side-inner">
  865. <A11ySkipTarget
  866. anchor="issues_sidebar"
  867. label={
  868. openIssue ? translate('issues.skip_to_list') : translate('issues.skip_to_filters')
  869. }
  870. weight={10}
  871. />
  872. {openIssue ? this.renderConciseIssuesList() : this.renderFacets()}
  873. </div>
  874. </section>
  875. )}
  876. </ScreenPositionHelper>
  877. );
  878. }
  879. renderList() {
  880. const { branchLike, component, currentUser } = this.props;
  881. const { issues, loading, loadingMore, openIssue, paging } = this.state;
  882. const selectedIndex = this.getSelectedIndex();
  883. const selectedIssue = selectedIndex !== undefined ? issues[selectedIndex] : undefined;
  884. if (!paging || openIssue) {
  885. return null;
  886. }
  887. let noIssuesMessage = null;
  888. if (paging.total === 0 && !loading) {
  889. if (this.isFiltered()) {
  890. noIssuesMessage = <EmptySearch />;
  891. } else if (this.state.myIssues) {
  892. noIssuesMessage = <NoMyIssues />;
  893. } else {
  894. noIssuesMessage = <NoIssues />;
  895. }
  896. }
  897. return (
  898. <div>
  899. <h2 className="a11y-hidden">{translate('list_of_issues')}</h2>
  900. {paging.total > 0 && (
  901. <IssuesList
  902. branchLike={branchLike}
  903. checked={this.state.checked}
  904. component={component}
  905. issues={issues}
  906. onFilterChange={this.handleFilterChange}
  907. onIssueChange={this.handleIssueChange}
  908. onIssueCheck={currentUser.isLoggedIn ? this.handleIssueCheck : undefined}
  909. onIssueClick={this.openIssue}
  910. onPopupToggle={this.handlePopupToggle}
  911. openPopup={this.state.openPopup}
  912. selectedIssue={selectedIssue}
  913. />
  914. )}
  915. {paging.total > 0 && (
  916. <ListFooter
  917. count={issues.length}
  918. loadMore={this.fetchMoreIssues}
  919. loading={loadingMore}
  920. total={paging.total}
  921. />
  922. )}
  923. {noIssuesMessage}
  924. </div>
  925. );
  926. }
  927. renderHeader({
  928. openIssue,
  929. paging,
  930. selectedIndex
  931. }: {
  932. openIssue: T.Issue | undefined;
  933. paging: T.Paging | undefined;
  934. selectedIndex: number | undefined;
  935. }) {
  936. return openIssue ? (
  937. <A11ySkipTarget anchor="issues_main" />
  938. ) : (
  939. <div className="layout-page-header-panel layout-page-main-header issues-main-header">
  940. <div className="layout-page-header-panel-inner layout-page-main-header-inner">
  941. <div className="layout-page-main-inner">
  942. <A11ySkipTarget anchor="issues_main" />
  943. {this.renderBulkChange()}
  944. <PageActions
  945. canSetHome={!this.props.component}
  946. effortTotal={this.state.effortTotal}
  947. paging={paging}
  948. selectedIndex={selectedIndex}
  949. />
  950. </div>
  951. </div>
  952. </div>
  953. );
  954. }
  955. renderPage() {
  956. const { cannotShowOpenIssue, checkAll, issues, loading, openIssue, paging } = this.state;
  957. return (
  958. <div className="layout-page-main-inner">
  959. {openIssue ? (
  960. <IssuesSourceViewer
  961. branchLike={fillBranchLike(openIssue.branch, openIssue.pullRequest)}
  962. issues={issues}
  963. loadIssues={this.fetchIssuesForComponent}
  964. locationsNavigator={this.state.locationsNavigator}
  965. onIssueChange={this.handleIssueChange}
  966. onIssueSelect={this.openIssue}
  967. onLocationSelect={this.selectLocation}
  968. openIssue={openIssue}
  969. selectedFlowIndex={this.state.selectedFlowIndex}
  970. selectedLocationIndex={this.state.selectedLocationIndex}
  971. />
  972. ) : (
  973. <DeferredSpinner loading={loading}>
  974. {checkAll && paging && paging.total > MAX_PAGE_SIZE && (
  975. <Alert className="big-spacer-bottom" variant="warning">
  976. <FormattedMessage
  977. defaultMessage={translate('issue_bulk_change.max_issues_reached')}
  978. id="issue_bulk_change.max_issues_reached"
  979. values={{ max: <strong>{MAX_PAGE_SIZE}</strong> }}
  980. />
  981. </Alert>
  982. )}
  983. {cannotShowOpenIssue && (!paging || paging.total > 0) && (
  984. <Alert className="big-spacer-bottom" variant="warning">
  985. {translateWithParameters(
  986. 'issues.cannot_open_issue_max_initial_X_fetched',
  987. MAX_INITAL_FETCH
  988. )}
  989. </Alert>
  990. )}
  991. {this.renderList()}
  992. </DeferredSpinner>
  993. )}
  994. </div>
  995. );
  996. }
  997. render() {
  998. const { openIssue, paging } = this.state;
  999. const selectedIndex = this.getSelectedIndex();
  1000. return (
  1001. <div className="layout-page issues" id="issues-page">
  1002. <Suggestions suggestions="issues" />
  1003. <Helmet defer={false} title={openIssue ? openIssue.message : translate('issues.page')} />
  1004. <h1 className="a11y-hidden">{translate('issues.page')}</h1>
  1005. {this.renderSide(openIssue)}
  1006. <div role="main" className="layout-page-main">
  1007. {this.renderHeader({ openIssue, paging, selectedIndex })}
  1008. {this.renderPage()}
  1009. </div>
  1010. </div>
  1011. );
  1012. }
  1013. }