/* * SonarQube * Copyright (C) 2009-2017 SonarSource SA * mailto:info AT sonarsource DOT com * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3 of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ // @flow import React from 'react'; import Helmet from 'react-helmet'; import key from 'keymaster'; import { keyBy, without } from 'lodash'; import PageActions from './PageActions'; import FiltersHeader from './FiltersHeader'; import MyIssuesFilter from './MyIssuesFilter'; import Sidebar from '../sidebar/Sidebar'; import IssuesList from './IssuesList'; import ComponentBreadcrumbs from './ComponentBreadcrumbs'; import IssuesSourceViewer from './IssuesSourceViewer'; import BulkChangeModal from './BulkChangeModal'; import ConciseIssuesList from '../conciseIssuesList/ConciseIssuesList'; import ConciseIssuesListHeader from '../conciseIssuesList/ConciseIssuesListHeader'; import * as actions from '../actions'; import { parseQuery, areMyIssuesSelected, areQueriesEqual, getOpen, serializeQuery, parseFacets, mapFacet, saveMyIssues } from '../utils'; /*:: import type { Query, Paging, Facet, ReferencedComponent, ReferencedUser, ReferencedLanguage, Component, CurrentUser } from '../utils'; */ import ListFooter from '../../../components/controls/ListFooter'; import EmptySearch from '../../../components/common/EmptySearch'; import ScreenPositionHelper from '../../../components/common/ScreenPositionHelper'; import { getBranchName } from '../../../helpers/branches'; import { translate, translateWithParameters } from '../../../helpers/l10n'; import { scrollToElement } from '../../../helpers/scrolling'; /*:: import type { Issue } from '../../../components/issue/types'; */ /*:: import type { RawQuery } from '../../../helpers/query'; */ import '../styles.css'; /*:: export type Props = { branch?: { name: string }, component?: Component, currentUser: CurrentUser, fetchIssues: (query: RawQuery) => Promise<*>, location: { pathname: string, query: RawQuery }, organization?: { key: string }, router: { push: ({ pathname: string, query?: RawQuery }) => void, replace: ({ pathname: string, query?: RawQuery }) => void } }; */ /*:: export type State = { bulkChange: 'all' | 'selected' | null, checked: Array, facets: { [string]: Facet }, issues: Array, loading: boolean, locationsNavigator: boolean, myIssues: boolean, openFacets: { [string]: boolean }, openIssue: ?Issue, openPopup: ?{ issue: string, name: string }, paging?: Paging, query: Query, referencedComponents: { [string]: ReferencedComponent }, referencedLanguages: { [string]: ReferencedLanguage }, referencedRules: { [string]: { name: string } }, referencedUsers: { [string]: ReferencedUser }, selected?: string, selectedFlowIndex: ?number, selectedLocationIndex: ?number }; */ const DEFAULT_QUERY = { resolved: 'false' }; export default class App extends React.PureComponent { /*:: mounted: boolean; */ /*:: props: Props; */ /*:: state: State; */ constructor(props /*: Props */) { super(props); this.state = { bulkChange: null, checked: [], facets: {}, issues: [], loading: true, locationsNavigator: false, myIssues: areMyIssuesSelected(props.location.query), openFacets: { resolutions: true, types: true }, openIssue: null, openPopup: null, query: parseQuery(props.location.query), referencedComponents: {}, referencedLanguages: {}, referencedRules: {}, referencedUsers: {}, selected: getOpen(props.location.query), selectedFlowIndex: null, selectedLocationIndex: null }; } componentDidMount() { this.mounted = true; const footer = document.getElementById('footer'); if (footer) { footer.classList.add('page-footer-with-sidebar'); } this.attachShortcuts(); this.fetchFirstIssues(); } componentWillReceiveProps(nextProps /*: Props */) { const openIssue = this.getOpenIssue(nextProps, this.state.issues); if (openIssue != null && openIssue.key !== this.state.selected) { this.setState({ locationsNavigator: false, selected: openIssue.key, selectedFlowIndex: null, selectedLocationIndex: null }); } if (openIssue == null) { this.setState({ selectedFlowIndex: null, selectedLocationIndex: null }); } this.setState({ myIssues: areMyIssuesSelected(nextProps.location.query), openIssue, query: parseQuery(nextProps.location.query) }); } componentDidUpdate(prevProps /*: Props */, prevState /*: State */) { const { query } = this.props.location; const { query: prevQuery } = prevProps.location; if ( prevProps.component !== this.props.component || prevProps.branch !== this.props.branch || !areQueriesEqual(prevQuery, query) || areMyIssuesSelected(prevQuery) !== areMyIssuesSelected(query) ) { this.fetchFirstIssues(); } else if (prevState.selected !== this.state.selected) { if (!this.state.openIssue) { this.scrollToSelectedIssue(); } } } componentWillUnmount() { this.detachShortcuts(); const footer = document.getElementById('footer'); if (footer) { footer.classList.remove('page-footer-with-sidebar'); } this.mounted = false; } attachShortcuts() { key.setScope('issues'); key('up', 'issues', () => { this.selectPreviousIssue(); return false; }); key('down', 'issues', () => { this.selectNextIssue(); return false; }); key('right', 'issues', () => { this.openSelectedIssue(); return false; }); key('left', 'issues', () => { this.closeIssue(); return false; }); window.addEventListener('keydown', this.handleKeyDown); window.addEventListener('keyup', this.handleKeyUp); } detachShortcuts() { key.deleteScope('issues'); window.removeEventListener('keydown', this.handleKeyDown); window.removeEventListener('keyup', this.handleKeyUp); } handleKeyDown = (event /*: KeyboardEvent */) => { if (key.getScope() !== 'issues') { return; } if (event.keyCode === 18) { // alt event.preventDefault(); this.setState(actions.enableLocationsNavigator); } else if (event.keyCode === 40 && event.altKey) { // alt + down event.preventDefault(); this.selectNextLocation(); } else if (event.keyCode === 38 && event.altKey) { // alt + up event.preventDefault(); this.selectPreviousLocation(); } else if (event.keyCode === 37 && event.altKey) { // alt + left event.preventDefault(); this.selectPreviousFlow(); } else if (event.keyCode === 39 && event.altKey) { // alt + right event.preventDefault(); this.selectNextFlow(); } }; handleKeyUp = (event /*: KeyboardEvent */) => { if (key.getScope() !== 'issues') { return; } if (event.keyCode === 18) { // alt this.setState(actions.disableLocationsNavigator); } }; getSelectedIndex() /*: ?number */ { const { issues, selected } = this.state; const index = issues.findIndex(issue => issue.key === selected); return index !== -1 ? index : null; } getOpenIssue = (props /*: Props */, issues /*: Array */) => { const open = getOpen(props.location.query); return open ? issues.find(issue => issue.key === open) : null; }; selectNextIssue = () => { const { issues } = this.state; const selectedIndex = this.getSelectedIndex(); if (issues != null && selectedIndex != null && selectedIndex < issues.length - 1) { if (this.state.openIssue) { this.openIssue(issues[selectedIndex + 1].key); } else { this.setState({ selected: issues[selectedIndex + 1].key, selectedFlowIndex: null, selectedLocationIndex: null }); } } }; selectPreviousIssue = () => { const { issues } = this.state; const selectedIndex = this.getSelectedIndex(); if (issues != null && selectedIndex != null && selectedIndex > 0) { if (this.state.openIssue) { this.openIssue(issues[selectedIndex - 1].key); } else { this.setState({ selected: issues[selectedIndex - 1].key, selectedFlowIndex: null, selectedLocationIndex: null }); } } }; openIssue = (issue /*: string */) => { const path = { pathname: this.props.location.pathname, query: { ...serializeQuery(this.state.query), branch: this.props.branch && getBranchName(this.props.branch), id: this.props.component && this.props.component.key, myIssues: this.state.myIssues ? 'true' : undefined, open: issue } }; if (this.state.openIssue) { this.props.router.replace(path); } else { this.props.router.push(path); } }; closeIssue = () => { if (this.state.query) { this.props.router.push({ pathname: this.props.location.pathname, query: { ...serializeQuery(this.state.query), branch: this.props.branch && getBranchName(this.props.branch), id: this.props.component && this.props.component.key, myIssues: this.state.myIssues ? 'true' : undefined, open: undefined } }); this.scrollToSelectedIssue(false); } }; openSelectedIssue = () => { const { selected } = this.state; if (selected) { this.openIssue(selected); } }; scrollToSelectedIssue = (smooth /*: boolean */ = true) => { const { selected } = this.state; if (selected) { const element = document.querySelector(`[data-issue="${selected}"]`); if (element) { scrollToElement(element, { topOffset: 150, bottomOffset: 100, smooth }); } } }; fetchIssues = (additional /*: ?{} */, requestFacets /*: ?boolean */ = false) => { const { component, organization } = this.props; const { myIssues, openFacets, query } = this.state; const facets = requestFacets ? Object.keys(openFacets) .filter(facet => openFacets[facet]) .map(mapFacet) .join(',') : undefined; const parameters = { branch: this.props.branch && getBranchName(this.props.branch), componentKeys: component && component.key, s: 'FILE_LINE', ...serializeQuery(query), ps: '100', organization: organization && organization.key, facets, ...additional }; // only sorting by CREATION_DATE is allowed, so let's sort DESC if (query.sort) { Object.assign(parameters, { asc: 'false' }); } if (myIssues) { Object.assign(parameters, { assignees: '__me__' }); } return this.props.fetchIssues(parameters); }; fetchFirstIssues() { this.setState({ checked: [], loading: true }); return this.fetchIssues({}, true).then( ({ facets, issues, paging, ...other }) => { if (this.mounted) { const openIssue = this.getOpenIssue(this.props, issues); this.setState({ facets: parseFacets(facets), loading: false, issues, openIssue, paging, referencedComponents: keyBy(other.components, 'uuid'), referencedLanguages: keyBy(other.languages, 'key'), referencedRules: keyBy(other.rules, 'key'), referencedUsers: keyBy(other.users, 'login'), selected: issues.length > 0 ? (openIssue != null ? openIssue.key : issues[0].key) : undefined, selectedFlowIndex: null, selectedLocationIndex: null }); } return issues; }, () => { if (this.mounted) { this.setState({ loading: false }); } return Promise.reject(); } ); } fetchIssuesPage = (p /*: number */) => { return this.fetchIssues({ p }); }; fetchIssuesUntil = (p /*: number */, done /*: (Array, Paging) => boolean */) => { return this.fetchIssuesPage(p).then(response => { const { issues, paging } = response; return done(issues, paging) ? { issues, paging } : this.fetchIssuesUntil(p + 1, done).then(nextResponse => { return { issues: [...issues, ...nextResponse.issues], paging: nextResponse.paging }; }); }); }; fetchMoreIssues = () => { const { paging } = this.state; if (!paging) { return; } const p = paging.pageIndex + 1; this.setState({ loading: true }); this.fetchIssuesPage(p).then( response => { if (this.mounted) { this.setState(state => ({ loading: false, issues: [...state.issues, ...response.issues], paging: response.paging })); } }, () => { if (this.mounted) { this.setState({ loading: false }); } } ); }; fetchIssuesForComponent = (component /*: string */, from /*: number */, to /*: number */) => { const { issues, openIssue, paging } = this.state; if (!openIssue || !paging) { return Promise.reject(); } const isSameComponent = (issue /*: Issue */) => issue.component === openIssue.component; const done = (issues /*: Array */, paging /*: Paging */) => { if (paging.total <= paging.pageIndex * paging.pageSize) { return true; } const lastIssue = issues[issues.length - 1]; if (lastIssue.component !== openIssue.component) { return true; } return lastIssue.textRange != null && lastIssue.textRange.endLine > to; }; if (done(issues, paging)) { return Promise.resolve(issues.filter(isSameComponent)); } this.setState({ loading: true }); return this.fetchIssuesUntil(paging.pageIndex + 1, done).then( response => { const nextIssues = [...issues, ...response.issues]; if (this.mounted) { this.setState({ issues: nextIssues, loading: false, paging: response.paging }); } return nextIssues.filter(isSameComponent); }, () => { if (this.mounted) { this.setState({ loading: false }); } } ); }; fetchFacet = (facet /*: string */) => { return this.fetchIssues({ ps: 1, facets: mapFacet(facet) }).then(({ facets, ...other }) => { if (this.mounted) { this.setState(state => ({ facets: { ...state.facets, ...parseFacets(facets) }, referencedComponents: { ...state.referencedComponents, ...keyBy(other.components, 'uuid') }, referencedLanguages: { ...state.referencedLanguages, ...keyBy(other.languages, 'key') }, referencedRules: { ...state.referencedRules, ...keyBy(other.rules, 'key') }, referencedUsers: { ...state.referencedUsers, ...keyBy(other.users, 'login') } })); } }); }; isFiltered = () => { const serialized = serializeQuery(this.state.query); return !areQueriesEqual(serialized, DEFAULT_QUERY); }; getCheckedIssues = () => { const issues = this.state.checked.map(checked => this.state.issues.find(issue => issue.key === checked) ); const paging = { pageIndex: 1, pageSize: issues.length, total: issues.length }; return Promise.resolve({ issues, paging }); }; handleFilterChange = (changes /*: {} */) => { this.props.router.push({ pathname: this.props.location.pathname, query: { ...serializeQuery({ ...this.state.query, ...changes }), branch: this.props.branch && getBranchName(this.props.branch), id: this.props.component && this.props.component.key, myIssues: this.state.myIssues ? 'true' : undefined } }); }; handleMyIssuesChange = (myIssues /*: boolean */) => { this.closeFacet('assignees'); if (!this.props.component) { saveMyIssues(myIssues); } this.props.router.push({ pathname: this.props.location.pathname, query: { ...serializeQuery({ ...this.state.query, assigned: true, assignees: [] }), branch: this.props.branch && getBranchName(this.props.branch), id: this.props.component && this.props.component.key, myIssues: myIssues ? 'true' : undefined } }); }; closeFacet = (property /*: string */) => { this.setState(state => ({ openFacets: { ...state.openFacets, [property]: false } })); }; handleFacetToggle = (property /*: string */) => { this.setState(state => ({ openFacets: { ...state.openFacets, [property]: !state.openFacets[property] } })); if (!this.state.facets[property]) { this.fetchFacet(property); } }; handleReset = () => { this.props.router.push({ pathname: this.props.location.pathname, query: { ...DEFAULT_QUERY, branch: this.props.branch && getBranchName(this.props.branch), id: this.props.component && this.props.component.key, myIssues: this.state.myIssues ? 'true' : undefined } }); }; handlePopupToggle = (issue /*: string */, popupName /*: string */, open /*: ?boolean */) => { this.setState((state /*: State */) => { const samePopup = state.openPopup && state.openPopup.name === popupName && state.openPopup.issue === issue; if (open !== false && !samePopup) { return { openPopup: { issue, name: popupName } }; } else if (open !== true && samePopup) { return { openPopup: null }; } return state; }); }; handleIssueCheck = (issue /*: string */) => { this.setState(state => ({ checked: state.checked.includes(issue) ? without(state.checked, issue) : [...state.checked, issue] })); }; handleIssueChange = (issue /*: Issue */) => { this.setState(state => ({ issues: state.issues.map(candidate => (candidate.key === issue.key ? issue : candidate)) })); }; openBulkChange = (mode /*: 'all' | 'selected' */) => { this.setState({ bulkChange: mode }); key.setScope('issues-bulk-change'); }; closeBulkChange = () => { key.setScope('issues'); this.setState({ bulkChange: null }); }; handleBulkChangeClick = (e /*: Event & { target: HTMLElement } */) => { e.preventDefault(); e.target.blur(); this.openBulkChange('all'); }; handleBulkChangeSelectedClick = (e /*: Event & { target: HTMLElement } */) => { e.preventDefault(); e.target.blur(); this.openBulkChange('selected'); }; handleBulkChangeDone = () => { this.fetchFirstIssues(); this.closeBulkChange(); }; handleReload = () => { this.fetchFirstIssues(); }; handleReloadAndOpenFirst = () => { this.fetchFirstIssues().then(issues => { if (issues.length > 0) { this.openIssue(issues[0].key); } }); }; selectLocation = (index /*: ?number */) => this.setState(actions.selectLocation(index)); selectNextLocation = () => this.setState(actions.selectNextLocation); selectPreviousLocation = () => this.setState(actions.selectPreviousLocation); selectFlow = (index /*: ?number */) => this.setState(actions.selectFlow(index)); selectNextFlow = () => this.setState(actions.selectNextFlow); selectPreviousFlow = () => this.setState(actions.selectPreviousFlow); renderBulkChange(openIssue /*: ?Issue */) { const { component, currentUser } = this.props; const { bulkChange, checked, paging } = this.state; if (!currentUser.isLoggedIn || openIssue != null) { return null; } return (
{checked.length > 0 ? ( ) : ( )} {bulkChange != null && ( )}
); } renderFacets() { const { component, currentUser } = this.props; const { query } = this.state; return (
{currentUser.isLoggedIn && ( )}
); } renderConciseIssuesList() { const { issues, paging } = this.state; return (
{paging != null && paging.total > 0 && ( )}
); } renderSide(openIssue /*: ?Issue */) { return ( {({ top }) => (
{openIssue == null ? this.renderFacets() : this.renderConciseIssuesList()}
)}
); } renderList() { const { branch, component, currentUser, organization } = this.props; const { issues, openIssue, paging } = this.state; const selectedIndex = this.getSelectedIndex(); const selectedIssue = selectedIndex != null ? issues[selectedIndex] : null; if (paging == null || openIssue != null) { return null; } return (
{paging.total > 0 && ( )} {paging.total > 0 && ( )} {paging.total === 0 && }
); } renderShortcutsForLocations() { const { openIssue } = this.state; if (openIssue == null || (!openIssue.secondaryLocations.length && !openIssue.flows.length)) { return null; } const hasSeveralFlows = openIssue.flows.length > 1; return (
alt {'+'} {hasSeveralFlows && ( )} {translate('issues.to_navigate_issue_locations')}
); } render() { const { component } = this.props; const { openIssue, paging } = this.state; const selectedIndex = this.getSelectedIndex(); return (
{this.renderSide(openIssue)}
{this.renderBulkChange(openIssue)} {openIssue != null ? (
) : ( )} {this.renderShortcutsForLocations()}
{openIssue ? ( ) : ( this.renderList() )}
); } }