@@ -0,0 +1,65 @@ | |||
/* | |||
* 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 type { State } from './components/App'; | |||
export const enableLocationsNavigator = (state: State) => ({ | |||
locationsNavigator: true, | |||
selectedLocationIndex: state.selectedLocationIndex || 0 | |||
}); | |||
export const disableLocationsNavigator = () => ({ | |||
locationsNavigator: false | |||
}); | |||
export const selectLocation = (nextIndex: ?number) => (state: State) => { | |||
const { selectedLocationIndex: index, openIssue } = state; | |||
if (openIssue) { | |||
if (!state.locationsNavigator) { | |||
if (nextIndex != null) { | |||
return { locationsNavigator: true, selectedLocationIndex: nextIndex }; | |||
} | |||
} else if (index != null) { | |||
// disable locations when selecting (clicking) the same location | |||
return { | |||
locationsNavigator: nextIndex !== index, | |||
selectedLocationIndex: nextIndex | |||
}; | |||
} | |||
} | |||
}; | |||
export const selectNextLocation = (state: State) => { | |||
const { selectedLocationIndex: index, openIssue } = state; | |||
if (openIssue) { | |||
return { | |||
selectedLocationIndex: index != null && openIssue.secondaryLocations.length > index + 1 | |||
? index + 1 | |||
: index | |||
}; | |||
} | |||
}; | |||
export const selectPreviousLocation = (state: State) => { | |||
const { selectedLocationIndex: index, openIssue } = state; | |||
if (openIssue) { | |||
return { selectedLocationIndex: index != null && index > 0 ? index - 1 : index }; | |||
} | |||
}; |
@@ -32,6 +32,7 @@ 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, | |||
@@ -62,7 +63,7 @@ import { scrollToElement } from '../../../helpers/scrolling'; | |||
import type { Issue } from '../../../components/issue/types'; | |||
import '../styles.css'; | |||
type Props = { | |||
export type Props = { | |||
component?: Component, | |||
currentUser: CurrentUser, | |||
fetchIssues: () => Promise<*>, | |||
@@ -71,21 +72,24 @@ type Props = { | |||
router: { push: () => void, replace: () => void } | |||
}; | |||
type State = { | |||
export type State = { | |||
bulkChange: 'all' | 'selected' | null, | |||
checked: Array<string>, | |||
facets: { [string]: Facet }, | |||
issues: Array<Issue>, | |||
loading: boolean, | |||
locationsNavigator: boolean, | |||
myIssues: boolean, | |||
openFacets: { [string]: boolean }, | |||
openIssue: ?Issue, | |||
paging?: Paging, | |||
query: Query, | |||
referencedComponents: { [string]: ReferencedComponent }, | |||
referencedLanguages: { [string]: ReferencedLanguage }, | |||
referencedRules: { [string]: { name: string } }, | |||
referencedUsers: { [string]: ReferencedUser }, | |||
selected?: string | |||
selected?: string, | |||
selectedLocationIndex: ?number | |||
}; | |||
const DEFAULT_QUERY = { resolved: 'false' }; | |||
@@ -103,14 +107,17 @@ export default class App extends React.PureComponent { | |||
facets: {}, | |||
issues: [], | |||
loading: true, | |||
locationsNavigator: false, | |||
myIssues: areMyIssuesSelected(props.location.query), | |||
openFacets: { resolutions: true, types: true }, | |||
openIssue: null, | |||
query: parseQuery(props.location.query), | |||
referencedComponents: {}, | |||
referencedLanguages: {}, | |||
referencedRules: {}, | |||
referencedUsers: {}, | |||
selected: getOpen(props.location.query) | |||
selected: getOpen(props.location.query), | |||
selectedLocationIndex: null | |||
}; | |||
} | |||
@@ -127,12 +134,19 @@ export default class App extends React.PureComponent { | |||
} | |||
componentWillReceiveProps(nextProps: Props) { | |||
const open = getOpen(nextProps.location.query); | |||
if (open != null && open !== this.state.selected) { | |||
this.setState({ selected: open }); | |||
const openIssue = this.getOpenIssue(nextProps, this.state.issues); | |||
if (openIssue != null && openIssue.key !== this.state.selected) { | |||
this.setState({ selected: openIssue.key, selectedLocationIndex: null }); | |||
} | |||
if (openIssue == null) { | |||
this.setState({ selectedLocationIndex: null }); | |||
} | |||
this.setState({ | |||
myIssues: areMyIssuesSelected(nextProps.location.query), | |||
openIssue, | |||
query: parseQuery(nextProps.location.query) | |||
}); | |||
} | |||
@@ -146,8 +160,7 @@ export default class App extends React.PureComponent { | |||
) { | |||
this.fetchFirstIssues(); | |||
} else if (prevState.selected !== this.state.selected) { | |||
const open = getOpen(query); | |||
if (!open) { | |||
if (!this.state.openIssue) { | |||
this.scrollToSelectedIssue(); | |||
} | |||
} | |||
@@ -182,26 +195,64 @@ export default class App extends React.PureComponent { | |||
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 + up | |||
event.preventDefault(); | |||
this.selectNextLocation(); | |||
} else if (event.keyCode === 38 && event.altKey) { | |||
// alt + down | |||
event.preventDefault(); | |||
this.selectPreviousLocation(); | |||
} | |||
}; | |||
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<Issue>): ?Issue => { | |||
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 (getOpen(this.props.location.query)) { | |||
if (this.state.openIssue) { | |||
this.openIssue(issues[selectedIndex + 1].key); | |||
} else { | |||
this.setState({ selected: issues[selectedIndex + 1].key }); | |||
this.setState({ selected: issues[selectedIndex + 1].key, selectedLocationIndex: null }); | |||
} | |||
} | |||
}; | |||
@@ -210,10 +261,10 @@ export default class App extends React.PureComponent { | |||
const { issues } = this.state; | |||
const selectedIndex = this.getSelectedIndex(); | |||
if (issues != null && selectedIndex != null && selectedIndex > 0) { | |||
if (getOpen(this.props.location.query)) { | |||
if (this.state.openIssue) { | |||
this.openIssue(issues[selectedIndex - 1].key); | |||
} else { | |||
this.setState({ selected: issues[selectedIndex - 1].key }); | |||
this.setState({ selected: issues[selectedIndex - 1].key, selectedLocationIndex: null }); | |||
} | |||
} | |||
}; | |||
@@ -228,8 +279,7 @@ export default class App extends React.PureComponent { | |||
open: issue | |||
} | |||
}; | |||
const open = getOpen(this.props.location.query); | |||
if (open) { | |||
if (this.state.openIssue) { | |||
this.props.router.replace(path); | |||
} else { | |||
this.props.router.push(path); | |||
@@ -308,19 +358,21 @@ export default class App extends React.PureComponent { | |||
this.setState({ loading: true }); | |||
return this.fetchIssues({}, true).then(({ facets, issues, paging, ...other }) => { | |||
if (this.mounted) { | |||
const open = getOpen(this.props.location.query); | |||
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 | |||
? issues.find(issue => issue.key === open) != null ? open : issues[0].key | |||
: undefined | |||
? openIssue != null ? openIssue.key : issues[0].key | |||
: undefined, | |||
selectedLocationIndex: null | |||
}); | |||
} | |||
return issues; | |||
@@ -368,10 +420,7 @@ export default class App extends React.PureComponent { | |||
}; | |||
fetchIssuesForComponent = (): Promise<Array<Issue>> => { | |||
const { issues, paging } = this.state; | |||
const open = getOpen(this.props.location.query); | |||
const openIssue = issues.find(issue => issue.key === open); | |||
const { issues, openIssue, paging } = this.state; | |||
if (!openIssue || !paging) { | |||
return Promise.reject(); | |||
@@ -508,7 +557,11 @@ export default class App extends React.PureComponent { | |||
}); | |||
}; | |||
renderBulkChange(openIssue?: Issue) { | |||
selectLocation = (index: ?number) => this.setState(actions.selectLocation(index)); | |||
selectNextLocation = () => this.setState(actions.selectNextLocation); | |||
selectPreviousLocation = () => this.setState(actions.selectPreviousLocation); | |||
renderBulkChange(openIssue: ?Issue) { | |||
const { component, currentUser } = this.props; | |||
const { bulkChange, checked, paging } = this.state; | |||
@@ -597,7 +650,9 @@ export default class App extends React.PureComponent { | |||
<ConciseIssuesList | |||
issues={issues} | |||
onIssueSelect={this.openIssue} | |||
onLocationSelect={this.selectLocation} | |||
selected={this.state.selected} | |||
selectedLocationIndex={this.state.selectedLocationIndex} | |||
/> | |||
{paging != null && | |||
paging.total > 0 && | |||
@@ -606,7 +661,7 @@ export default class App extends React.PureComponent { | |||
); | |||
} | |||
renderSide(openIssue?: Issue) { | |||
renderSide(openIssue: ?Issue) { | |||
const top = this.props.component ? 95 : 30; | |||
return ( | |||
@@ -616,7 +671,7 @@ export default class App extends React.PureComponent { | |||
); | |||
} | |||
renderList(openIssue?: Issue) { | |||
renderList(openIssue: ?Issue) { | |||
const { component, currentUser } = this.props; | |||
const { issues, paging } = this.state; | |||
const selectedIndex = this.getSelectedIndex(); | |||
@@ -648,12 +703,21 @@ export default class App extends React.PureComponent { | |||
); | |||
} | |||
renderShortcutsForLocations() { | |||
return ( | |||
<div className="pull-right note"> | |||
<span className="shortcut-button little-spacer-right">alt</span> | |||
<span className="little-spacer-right">{'+'}</span> | |||
<span className="shortcut-button little-spacer-right">↑</span> | |||
<span className="shortcut-button little-spacer-right">↓</span> | |||
{translate('issues.to_navigate_issue_locations')} | |||
</div> | |||
); | |||
} | |||
render() { | |||
const { component } = this.props; | |||
const { issues, paging } = this.state; | |||
const open = getOpen(this.props.location.query); | |||
const openIssue = issues.find(issue => issue.key === open); | |||
const { openIssue, paging } = this.state; | |||
const selectedIndex = this.getSelectedIndex(); | |||
@@ -677,6 +741,7 @@ export default class App extends React.PureComponent { | |||
paging={paging} | |||
selectedIndex={selectedIndex} | |||
/>} | |||
{openIssue != null && this.renderShortcutsForLocations()} | |||
</PageMainInner> | |||
</div> | |||
</div> | |||
@@ -689,6 +754,10 @@ export default class App extends React.PureComponent { | |||
loadIssues={this.fetchIssuesForComponent} | |||
onIssueChange={this.handleIssueChange} | |||
onIssueSelect={this.openIssue} | |||
onLocationSelect={this.selectLocation} | |||
selectedLocationIndex={ | |||
this.state.locationsNavigator ? this.state.selectedLocationIndex : null | |||
} | |||
/>} | |||
{this.renderList(openIssue)} |
@@ -27,7 +27,9 @@ type Props = {| | |||
loadIssues: () => Promise<*>, | |||
onIssueChange: Issue => void, | |||
onIssueSelect: string => void, | |||
openIssue: Issue | |||
onLocationSelect: number => void, | |||
openIssue: Issue, | |||
selectedLocationIndex: ?number | |||
|}; | |||
export default class IssuesSourceViewer extends React.PureComponent { | |||
@@ -35,7 +37,10 @@ export default class IssuesSourceViewer extends React.PureComponent { | |||
props: Props; | |||
componentDidUpdate(prevProps: Props) { | |||
if (prevProps.openIssue.component === this.props.openIssue.component) { | |||
if ( | |||
prevProps.openIssue !== this.props.openIssue && | |||
prevProps.openIssue.component === this.props.openIssue.component | |||
) { | |||
this.scrollToIssue(); | |||
} | |||
} | |||
@@ -43,12 +48,25 @@ export default class IssuesSourceViewer extends React.PureComponent { | |||
scrollToIssue = () => { | |||
const element = this.node.querySelector(`[data-issue="${this.props.openIssue.key}"]`); | |||
if (element) { | |||
scrollToElement(element, 100, 100); | |||
this.handleScroll(element); | |||
} | |||
}; | |||
handleScroll = (element: HTMLElement) => { | |||
const offset = window.innerHeight / 2; | |||
scrollToElement(element, offset - 100, offset); | |||
}; | |||
render() { | |||
const { openIssue } = this.props; | |||
const { openIssue, selectedLocationIndex } = this.props; | |||
const locations = openIssue.secondaryLocations; | |||
const locationMessage = locations != null && | |||
selectedLocationIndex != null && | |||
locations.length >= selectedLocationIndex | |||
? { index: selectedLocationIndex, text: locations[selectedLocationIndex].msg } | |||
: undefined; | |||
return ( | |||
<div ref={node => (this.node = node)}> | |||
@@ -56,10 +74,14 @@ export default class IssuesSourceViewer extends React.PureComponent { | |||
aroundLine={openIssue.line} | |||
component={openIssue.component} | |||
displayAllIssues={true} | |||
highlightedLocations={locations} | |||
highlightedLocationMessage={locationMessage} | |||
loadIssues={this.props.loadIssues} | |||
onLoaded={this.scrollToIssue} | |||
onLocationSelect={this.props.onLocationSelect} | |||
onIssueChange={this.props.onIssueChange} | |||
onIssueSelect={this.props.onIssueSelect} | |||
scroll={this.handleScroll} | |||
selectedIssue={openIssue.key} | |||
/> | |||
</div> |
@@ -26,6 +26,7 @@ import { translate, translateWithParameters } from '../../../helpers/l10n'; | |||
type Option = { label: string, value: string }; | |||
type Props = {| | |||
autofocus: boolean, | |||
minimumQueryLength: number, | |||
onSearch: (query: string) => Promise<Array<Option>>, | |||
onSelect: (value: string) => void, | |||
@@ -46,6 +47,7 @@ export default class SearchSelect extends React.PureComponent { | |||
state: State; | |||
static defaultProps = { | |||
autofocus: true, | |||
minimumQueryLength: 2, | |||
resetOnBlur: true | |||
}; | |||
@@ -95,7 +97,7 @@ export default class SearchSelect extends React.PureComponent { | |||
render() { | |||
return ( | |||
<Select | |||
autofocus={true} | |||
autofocus={this.props.autofocus} | |||
cache={false} | |||
className="input-super-large" | |||
clearable={false} |
@@ -24,11 +24,13 @@ import ConciseIssueComponent from './ConciseIssueComponent'; | |||
import type { Issue } from '../../../components/issue/types'; | |||
type Props = {| | |||
innerRef: HTMLElement => void, | |||
issue: Issue, | |||
onLocationSelect: number => void, | |||
onSelect: string => void, | |||
previousIssue: ?Issue, | |||
selected: boolean | |||
scroll: HTMLElement => void, | |||
selected: boolean, | |||
selectedLocationIndex: ?number | |||
|}; | |||
export default class ConciseIssue extends React.PureComponent { | |||
@@ -40,9 +42,16 @@ export default class ConciseIssue extends React.PureComponent { | |||
const displayComponent = previousIssue == null || previousIssue.component !== issue.component; | |||
return ( | |||
<div ref={this.props.innerRef}> | |||
<div> | |||
{displayComponent && <ConciseIssueComponent path={issue.componentLongName} />} | |||
<ConciseIssueBox issue={issue} onClick={this.props.onSelect} selected={selected} /> | |||
<ConciseIssueBox | |||
issue={issue} | |||
onClick={this.props.onSelect} | |||
onLocationSelect={this.props.onLocationSelect} | |||
scroll={this.props.scroll} | |||
selected={selected} | |||
selectedLocationIndex={selected ? this.props.selectedLocationIndex : null} | |||
/> | |||
</div> | |||
); | |||
} |
@@ -21,6 +21,7 @@ | |||
import React from 'react'; | |||
import classNames from 'classnames'; | |||
import ConciseIssueLocations from './ConciseIssueLocations'; | |||
import ConciseIssueLocationsNavigator from './ConciseIssueLocationsNavigator'; | |||
import SeverityHelper from '../../../components/shared/SeverityHelper'; | |||
import TypeHelper from '../../../components/shared/TypeHelper'; | |||
import type { Issue } from '../../../components/issue/types'; | |||
@@ -28,27 +29,60 @@ import type { Issue } from '../../../components/issue/types'; | |||
type Props = {| | |||
issue: Issue, | |||
onClick: string => void, | |||
selected: boolean | |||
onLocationSelect: number => void, | |||
scroll: HTMLElement => void, | |||
selected: boolean, | |||
selectedLocationIndex: ?number | |||
|}; | |||
export default function ConciseIssueBox(props: Props) { | |||
const { issue, selected } = props; | |||
export default class ConciseIssueBox extends React.PureComponent { | |||
node: HTMLElement; | |||
props: Props; | |||
const handleClick = (event: Event) => { | |||
componentDidMount() { | |||
// scroll to the message element and not to the root element, | |||
// because the root element can be huge and exceed the window height | |||
if (this.props.selected) { | |||
this.props.scroll(this.node); | |||
} | |||
} | |||
componentDidUpdate(prevProps: Props) { | |||
if (this.props.selected && prevProps.selected !== this.props.selected) { | |||
this.props.scroll(this.node); | |||
} | |||
} | |||
handleClick = (event: Event) => { | |||
event.preventDefault(); | |||
props.onClick(issue.key); | |||
this.props.onClick(this.props.issue.key); | |||
}; | |||
const clickAttributes = selected ? {} : { onClick: handleClick, role: 'listitem', tabIndex: 0 }; | |||
render() { | |||
const { issue, selected } = this.props; | |||
const clickAttributes = selected | |||
? {} | |||
: { onClick: this.handleClick, role: 'listitem', tabIndex: 0 }; | |||
return ( | |||
<div className={classNames('concise-issue-box', { selected })} {...clickAttributes}> | |||
<div className="concise-issue-box-message">{issue.message}</div> | |||
<div className="concise-issue-box-attributes"> | |||
<TypeHelper type={issue.type} /> | |||
<SeverityHelper className="big-spacer-left" severity={issue.severity} /> | |||
<ConciseIssueLocations flows={issue.flows} /> | |||
return ( | |||
<div className={classNames('concise-issue-box', { selected })} {...clickAttributes}> | |||
<div className="concise-issue-box-message" ref={node => (this.node = node)}> | |||
{issue.message} | |||
</div> | |||
<div className="concise-issue-box-attributes"> | |||
<TypeHelper type={issue.type} /> | |||
<SeverityHelper className="big-spacer-left" severity={issue.severity} /> | |||
<ConciseIssueLocations issue={issue} /> | |||
</div> | |||
{selected && | |||
<ConciseIssueLocationsNavigator | |||
issue={issue} | |||
onLocationSelect={this.props.onLocationSelect} | |||
scroll={this.props.scroll} | |||
selectedLocationIndex={this.props.selectedLocationIndex} | |||
/>} | |||
</div> | |||
</div> | |||
); | |||
); | |||
} | |||
} |
@@ -20,6 +20,7 @@ | |||
// @flow | |||
import React from 'react'; | |||
import Tooltip from '../../../components/controls/Tooltip'; | |||
import LocationIndex from '../../../components/common/LocationIndex'; | |||
import { translateWithParameters } from '../../../helpers/l10n'; | |||
import { formatMeasure } from '../../../helpers/measures'; | |||
@@ -34,9 +35,9 @@ export default function ConciseIssueLocationBadge(props: Props) { | |||
'issue.this_issue_involves_x_code_locations', | |||
formatMeasure(props.count) | |||
)}> | |||
<div className="concise-issue-location-badge"> | |||
<LocationIndex> | |||
{'+'}{props.count} | |||
</div> | |||
</LocationIndex> | |||
</Tooltip> | |||
); | |||
} |
@@ -20,37 +20,24 @@ | |||
// @flow | |||
import React from 'react'; | |||
import ConciseIssueLocationBadge from './ConciseIssueLocationBadge'; | |||
import type { FlowLocation } from '../../../components/issue/types'; | |||
import type { Issue } from '../../../components/issue/types'; | |||
type Props = {| | |||
flows: Array<{ | |||
locations?: Array<FlowLocation> | |||
}> | |||
issue: Issue | |||
|}; | |||
export default class ConciseIssueLocations extends React.PureComponent { | |||
props: Props; | |||
render() { | |||
const { flows } = this.props; | |||
const secondaryLocations = flows.filter( | |||
flow => flow.locations != null && flow.locations.length === 1 | |||
).length; | |||
const realFlows = flows.filter(flow => flow.locations != null && flow.locations.length > 1); | |||
const { secondaryLocations, flows } = this.props.issue; | |||
return ( | |||
<div className="pull-right"> | |||
{secondaryLocations > 0 && <ConciseIssueLocationBadge count={secondaryLocations} />} | |||
{secondaryLocations.length > 0 && | |||
<ConciseIssueLocationBadge count={secondaryLocations.length} />} | |||
{realFlows.map((flow, index) => ( | |||
<ConciseIssueLocationBadge | |||
// $FlowFixMe locations are not null | |||
count={flow.locations.length} | |||
key={index} | |||
/> | |||
))} | |||
{flows.map((flow, index) => <ConciseIssueLocationBadge key={index} count={flow.length} />)} | |||
</div> | |||
); | |||
} |
@@ -0,0 +1,63 @@ | |||
/* | |||
* 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 ConciseIssueLocationsNavigatorLocation from './ConciseIssueLocationsNavigatorLocation'; | |||
import type { Issue } from '../../../components/issue/types'; | |||
type Props = {| | |||
issue: Issue, | |||
onLocationSelect: number => void, | |||
scroll: HTMLElement => void, | |||
selectedLocationIndex: ?number | |||
|}; | |||
export default class ConciseIssueLocationsNavigator extends React.PureComponent { | |||
props: Props; | |||
handleClick = (index: number) => (event: Event) => { | |||
event.preventDefault(); | |||
this.props.onLocationSelect(index); | |||
}; | |||
render() { | |||
const { selectedLocationIndex } = this.props; | |||
const { secondaryLocations } = this.props.issue; | |||
if (secondaryLocations.length === 0) { | |||
return null; | |||
} | |||
return ( | |||
<div className="spacer-top"> | |||
{secondaryLocations.map((location, index) => ( | |||
<ConciseIssueLocationsNavigatorLocation | |||
key={index} | |||
index={index} | |||
message={location.msg} | |||
onClick={this.props.onLocationSelect} | |||
scroll={this.props.scroll} | |||
selected={index === selectedLocationIndex} | |||
/> | |||
))} | |||
</div> | |||
); | |||
} | |||
} |
@@ -0,0 +1,68 @@ | |||
/* | |||
* 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 LocationIndex from '../../../components/common/LocationIndex'; | |||
import LocationMessage from '../../../components/common/LocationMessage'; | |||
type Props = { | |||
index: number, | |||
message: string, | |||
onClick: number => void, | |||
scroll: HTMLElement => void, | |||
selected: boolean | |||
}; | |||
export default class ConciseIssueLocationsNavigatorLocation extends React.PureComponent { | |||
node: HTMLElement; | |||
props: Props; | |||
componentDidMount() { | |||
if (this.props.selected) { | |||
this.props.scroll(this.node); | |||
} | |||
} | |||
componentDidUpdate(prevProps: Props) { | |||
if (this.props.selected && prevProps.selected !== this.props.selected) { | |||
this.props.scroll(this.node); | |||
} | |||
} | |||
handleClick = (event: Event) => { | |||
event.preventDefault(); | |||
this.props.onClick(this.props.index); | |||
}; | |||
render() { | |||
return ( | |||
<div className="little-spacer-top" ref={node => (this.node = node)}> | |||
<a className="link-no-underline" href="#" onClick={this.handleClick}> | |||
<LocationIndex selected={this.props.selected}> | |||
{this.props.index + 1} | |||
</LocationIndex> | |||
<LocationMessage selected={this.props.selected}> | |||
{this.props.message} | |||
</LocationMessage> | |||
</a> | |||
</div> | |||
); | |||
} | |||
} |
@@ -26,43 +26,19 @@ import type { Issue } from '../../../components/issue/types'; | |||
type Props = {| | |||
issues: Array<Issue>, | |||
onIssueSelect: string => void, | |||
selected?: string | |||
onLocationSelect: number => void, | |||
selected?: string, | |||
selectedLocationIndex: ?number | |||
|}; | |||
export default class ConciseIssuesList extends React.PureComponent { | |||
nodes: { [string]: HTMLElement }; | |||
props: Props; | |||
constructor(props: Props) { | |||
super(props); | |||
this.nodes = {}; | |||
} | |||
componentDidMount() { | |||
if (this.props.selected) { | |||
this.ensureSelectedVisible(); | |||
} | |||
} | |||
componentDidUpdate(prevProps: Props) { | |||
if (this.props.selected && prevProps.selected !== this.props.selected) { | |||
this.ensureSelectedVisible(); | |||
handleScroll = (element: HTMLElement) => { | |||
const scrollableElement = document.querySelector('.layout-page-side'); | |||
if (element && scrollableElement) { | |||
scrollToElement(element, 150, 100, scrollableElement); | |||
} | |||
} | |||
ensureSelectedVisible() { | |||
const { selected } = this.props; | |||
if (selected) { | |||
const scrollableElement = document.querySelector('.layout-page-side'); | |||
const element = this.nodes[selected]; | |||
if (element && scrollableElement) { | |||
scrollToElement(element, 150, 100, scrollableElement); | |||
} | |||
} | |||
} | |||
innerRef = (issue: string) => (node: HTMLElement) => { | |||
this.nodes[issue] = node; | |||
}; | |||
render() { | |||
@@ -71,11 +47,13 @@ export default class ConciseIssuesList extends React.PureComponent { | |||
{this.props.issues.map((issue, index) => ( | |||
<ConciseIssue | |||
key={issue.key} | |||
innerRef={this.innerRef(issue.key)} | |||
issue={issue} | |||
onLocationSelect={this.props.onLocationSelect} | |||
onSelect={this.props.onIssueSelect} | |||
previousIssue={index > 0 ? this.props.issues[index - 1] : null} | |||
scroll={this.handleScroll} | |||
selected={issue.key === this.props.selected} | |||
selectedLocationIndex={this.props.selectedLocationIndex} | |||
/> | |||
))} | |||
</div> |
@@ -17,34 +17,36 @@ | |||
* 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 { shallow } from 'enzyme'; | |||
import ConciseIssueLocations from '../ConciseIssueLocations'; | |||
const textRange = { startLine: 1, startOffset: 1, endLine: 1, endOffset: 1 }; | |||
it('should render only secondary locations', () => { | |||
const flows = [ | |||
{ locations: [{ msg: '', textRange }] }, | |||
{ locations: [{ msg: '', textRange }] }, | |||
{ locations: [{ msg: '', textRange }] } | |||
]; | |||
expect(shallow(<ConciseIssueLocations flows={flows} />)).toMatchSnapshot(); | |||
it('should render secondary locations', () => { | |||
const issue = { | |||
secondaryLocations: [{ msg: '', textRange }, { msg: '', textRange }, { msg: '', textRange }], | |||
flows: [] | |||
}; | |||
expect(shallow(<ConciseIssueLocations issue={issue} />)).toMatchSnapshot(); | |||
}); | |||
it('should render one flow', () => { | |||
const flows = [ | |||
{ locations: [{ msg: '', textRange }, { msg: '', textRange }, { msg: '', textRange }] } | |||
]; | |||
expect(shallow(<ConciseIssueLocations flows={flows} />)).toMatchSnapshot(); | |||
const issue = { | |||
secondaryLocations: [], | |||
flows: [[{ msg: '', textRange }, { msg: '', textRange }, { msg: '', textRange }]] | |||
}; | |||
expect(shallow(<ConciseIssueLocations issue={issue} />)).toMatchSnapshot(); | |||
}); | |||
it('should render several flows', () => { | |||
const flows = [ | |||
{ locations: [{ msg: '', textRange }, { msg: '', textRange }, { msg: '', textRange }] }, | |||
{ locations: [{ msg: '', textRange }, { msg: '', textRange }] }, | |||
{ locations: [{ msg: '', textRange }, { msg: '', textRange }, { msg: '', textRange }] } | |||
]; | |||
expect(shallow(<ConciseIssueLocations flows={flows} />)).toMatchSnapshot(); | |||
const issue = { | |||
secondaryLocations: [], | |||
flows: [ | |||
[{ msg: '', textRange }, { msg: '', textRange }, { msg: '', textRange }], | |||
[{ msg: '', textRange }, { msg: '', textRange }], | |||
[{ msg: '', textRange }, { msg: '', textRange }, { msg: '', textRange }] | |||
] | |||
}; | |||
expect(shallow(<ConciseIssueLocations issue={issue} />)).toMatchSnapshot(); | |||
}); |
@@ -4,6 +4,7 @@ exports[`test should render 1`] = ` | |||
<ConciseIssueBox | |||
issue={Object {}} | |||
onClick={[Function]} | |||
selected={false} /> | |||
selected={false} | |||
selectedLocationIndex={null} /> | |||
</div> | |||
`; |
@@ -2,10 +2,10 @@ exports[`test should render 1`] = ` | |||
<Tooltip | |||
overlay="issue.this_issue_involves_x_code_locations.7" | |||
placement="bottom"> | |||
<div | |||
className="concise-issue-location-badge"> | |||
<LocationIndex | |||
selected={false}> | |||
+ | |||
7 | |||
</div> | |||
</LocationIndex> | |||
</Tooltip> | |||
`; |
@@ -6,7 +6,7 @@ exports[`test should render one flow 1`] = ` | |||
</div> | |||
`; | |||
exports[`test should render only secondary locations 1`] = ` | |||
exports[`test should render secondary locations 1`] = ` | |||
<div | |||
className="pull-right"> | |||
<ConciseIssueLocationBadge |
@@ -1,16 +1,15 @@ | |||
exports[`test should render 1`] = ` | |||
<div> | |||
<ConciseIssue | |||
innerRef={[Function]} | |||
issue={ | |||
Object { | |||
"key": "foo", | |||
} | |||
} | |||
previousIssue={null} | |||
scroll={[Function]} | |||
selected={false} /> | |||
<ConciseIssue | |||
innerRef={[Function]} | |||
issue={ | |||
Object { | |||
"key": "bar", | |||
@@ -21,6 +20,7 @@ exports[`test should render 1`] = ` | |||
"key": "foo", | |||
} | |||
} | |||
scroll={[Function]} | |||
selected={false} /> | |||
</div> | |||
`; |
@@ -47,7 +47,6 @@ class LanguageFacetFooter extends React.PureComponent { | |||
return ( | |||
<div className="search-navigator-facet-footer"> | |||
<Select | |||
autofocus={true} | |||
className="input-super-large" | |||
clearable={false} | |||
noResultsText={translate('select2.noMatches')} |
@@ -36,7 +36,7 @@ export default class FacetFooter extends React.PureComponent { | |||
render() { | |||
return ( | |||
<div className="search-navigator-facet-footer"> | |||
<SearchSelect {...this.props} /> | |||
<SearchSelect autofocus={false} {...this.props} /> | |||
</div> | |||
); | |||
} |
@@ -2,6 +2,7 @@ exports[`test should render 1`] = ` | |||
<div | |||
className="search-navigator-facet-footer"> | |||
<SearchSelect | |||
autofocus={false} | |||
minimumQueryLength={2} | |||
onSearch={[Function]} | |||
onSelect={[Function]} |
@@ -111,16 +111,6 @@ | |||
font-size: 12px; | |||
} | |||
.concise-issue-location-badge { | |||
display: inline-block; | |||
padding-left: 4px; | |||
padding-right: 4px; | |||
border-radius: 2px; | |||
.concise-issue-box:not(.selected) .location-index { | |||
background-color: #ccc; | |||
color: #fff; | |||
transition: background-color 0.3s ease; | |||
} | |||
.concise-issue-box.selected .concise-issue-location-badge { | |||
background-color: #d18582; | |||
} |
@@ -23,7 +23,6 @@ import classNames from 'classnames'; | |||
import { intersection, uniqBy } from 'lodash'; | |||
import SourceViewerHeader from './SourceViewerHeader'; | |||
import SourceViewerCode from './SourceViewerCode'; | |||
import SourceViewerIssueLocations from './SourceViewerIssueLocations'; | |||
import CoveragePopupView from './popups/coverage-popup'; | |||
import DuplicationPopupView from './popups/duplication-popup'; | |||
import LineActionsPopupView from './popups/line-actions-popup'; | |||
@@ -34,18 +33,10 @@ import getCoverageStatus from './helpers/getCoverageStatus'; | |||
import { | |||
issuesByLine, | |||
locationsByLine, | |||
locationsByIssueAndLine, | |||
locationMessagesByIssueAndLine, | |||
duplicationsByLine, | |||
symbolsByLine, | |||
findLocationByIndex | |||
} from './helpers/indexing'; | |||
import type { | |||
LinearIssueLocation, | |||
IndexedIssueLocation, | |||
IndexedIssueLocationsByIssueAndLine, | |||
IndexedIssueLocationMessagesByIssueAndLine | |||
symbolsByLine | |||
} from './helpers/indexing'; | |||
import type { LinearIssueLocation } from './helpers/indexing'; | |||
import { | |||
getComponentForSourceViewer, | |||
getSources, | |||
@@ -55,7 +46,7 @@ import { | |||
import { translate } from '../../helpers/l10n'; | |||
import { scrollToElement } from '../../helpers/scrolling'; | |||
import type { SourceLine } from './types'; | |||
import type { Issue } from '../issue/types'; | |||
import type { Issue, FlowLocation } from '../issue/types'; | |||
import './styles.css'; | |||
// TODO react-virtualized | |||
@@ -66,14 +57,18 @@ type Props = { | |||
displayAllIssues: boolean, | |||
filterLine?: (line: SourceLine) => boolean, | |||
highlightedLine?: number, | |||
highlightedLocations?: Array<FlowLocation>, | |||
highlightedLocationMessage?: { index: number, text: string }, | |||
loadComponent: string => Promise<*>, | |||
loadIssues: (string, number, number) => Promise<*>, | |||
loadSources: (string, number, number) => Promise<*>, | |||
onLoaded?: (component: Object, sources: Array<*>, issues: Array<*>) => void, | |||
onLocationSelect?: number => void, | |||
onIssueChange?: Issue => void, | |||
onIssueSelect?: string => void, | |||
onIssueUnselect?: () => void, | |||
onReceiveComponent: ({ canMarkAsFavorite: boolean, fav: boolean, key: string }) => void, | |||
scroll?: HTMLElement => void, | |||
selectedIssue?: string | |||
}; | |||
@@ -95,26 +90,19 @@ type State = { | |||
issues?: Array<Issue>, | |||
issuesByLine: { [number]: Array<Issue> }, | |||
issueLocationsByLine: { [number]: Array<LinearIssueLocation> }, | |||
issueSecondaryLocationsByIssueByLine: IndexedIssueLocationsByIssueAndLine, | |||
issueSecondaryLocationMessagesByIssueByLine: IndexedIssueLocationMessagesByIssueAndLine, | |||
loading: boolean, | |||
loadingSourcesAfter: boolean, | |||
loadingSourcesBefore: boolean, | |||
locationsPanelHeight: number, | |||
notAccessible: boolean, | |||
notExist: boolean, | |||
openIssuesByLine: { [number]: boolean }, | |||
selectedIssue?: string, | |||
selectedIssueLocation: IndexedIssueLocation | null, | |||
sources?: Array<SourceLine>, | |||
symbolsByLine: { [number]: Array<string> } | |||
}; | |||
const LINES = 500; | |||
const LOCATIONS_PANEL_DEFAULT_HEIGHT = 200; | |||
const LOCATIONS_PANEL_HEIGHT_LOCAL_STORAGE_KEY = 'sonarqube.locations.height'; | |||
const loadComponent = (key: string): Promise<*> => { | |||
return getComponentForSourceViewer(key); | |||
}; | |||
@@ -151,7 +139,6 @@ export default class SourceViewerBase extends React.PureComponent { | |||
loading: true, | |||
loadingSourcesAfter: false, | |||
loadingSourcesBefore: false, | |||
locationsPanelHeight: this.getInitialLocationsPanelHeight(), | |||
notAccessible: false, | |||
notExist: false, | |||
openIssuesByLine: {}, | |||
@@ -168,11 +155,11 @@ export default class SourceViewerBase extends React.PureComponent { | |||
componentWillReceiveProps(nextProps: Props) { | |||
if (nextProps.onIssueSelect != null && nextProps.selectedIssue !== this.props.selectedIssue) { | |||
this.setState({ selectedIssue: nextProps.selectedIssue, selectedIssueLocation: null }); | |||
this.setState({ selectedIssue: nextProps.selectedIssue }); | |||
} | |||
} | |||
componentDidUpdate(prevProps: Props, prevState: State) { | |||
componentDidUpdate(prevProps: Props) { | |||
if (prevProps.component !== this.props.component) { | |||
this.fetchComponent(); | |||
} else if ( | |||
@@ -182,13 +169,6 @@ export default class SourceViewerBase extends React.PureComponent { | |||
) { | |||
this.fetchSources(); | |||
} | |||
if ( | |||
prevState.selectedIssueLocation !== this.state.selectedIssueLocation && | |||
this.state.selectedIssueLocation != null | |||
) { | |||
this.scrollToLine(this.state.selectedIssueLocation.line); | |||
} | |||
} | |||
componentWillUnmount() { | |||
@@ -200,7 +180,7 @@ export default class SourceViewerBase extends React.PureComponent { | |||
`.source-line-code[data-line-number="${line}"] .source-line-issue-locations` | |||
); | |||
if (lineElement) { | |||
scrollToElement(lineElement, 125, this.state.locationsPanelHeight + 75); | |||
scrollToElement(lineElement, 125, 75); | |||
} | |||
} | |||
@@ -231,8 +211,6 @@ export default class SourceViewerBase extends React.PureComponent { | |||
issues, | |||
issuesByLine: issuesByLine(issues), | |||
issueLocationsByLine: locationsByLine(issues), | |||
issueSecondaryLocationsByIssueByLine: locationsByIssueAndLine(issues), | |||
issueSecondaryLocationMessagesByIssueByLine: locationMessagesByIssueAndLine(issues), | |||
loading: false, | |||
hasSourcesAfter: sources.length > LINES, | |||
sources: this.computeCoverageStatus(finalSources), | |||
@@ -309,8 +287,14 @@ export default class SourceViewerBase extends React.PureComponent { | |||
}; | |||
const from = this.props.aroundLine ? Math.max(1, this.props.aroundLine - LINES / 2 + 1) : 1; | |||
let to = this.props.aroundLine ? this.props.aroundLine + LINES / 2 + 1 : LINES + 1; | |||
// make sure we try to download `LINES` lines | |||
if (from === 1 && to < LINES) { | |||
to = LINES; | |||
} | |||
// request one additional line to define `hasSourcesAfter` | |||
const to = this.props.aroundLine ? this.props.aroundLine + LINES / 2 + 1 : LINES + 1; | |||
to++; | |||
return this.props | |||
.loadSources(this.props.component, from, to) | |||
@@ -384,23 +368,6 @@ export default class SourceViewerBase extends React.PureComponent { | |||
}); | |||
}; | |||
getInitialLocationsPanelHeight() { | |||
try { | |||
const rawValue = window.localStorage.getItem(LOCATIONS_PANEL_HEIGHT_LOCAL_STORAGE_KEY); | |||
if (!rawValue) { | |||
return LOCATIONS_PANEL_DEFAULT_HEIGHT; | |||
} | |||
const intValue = Number(rawValue); | |||
return !isNaN(intValue) ? intValue : LOCATIONS_PANEL_DEFAULT_HEIGHT; | |||
} catch (e) { | |||
return LOCATIONS_PANEL_DEFAULT_HEIGHT; | |||
} | |||
} | |||
storeLocationsPanelHeight(height: number) { | |||
window.localStorage.setItem(LOCATIONS_PANEL_HEIGHT_LOCAL_STORAGE_KEY, height); | |||
} | |||
openNewWindow = () => { | |||
const { component } = this.state; | |||
if (component != null) { | |||
@@ -486,27 +453,11 @@ export default class SourceViewerBase extends React.PureComponent { | |||
popup.render(); | |||
}; | |||
handleSelectIssueLocation = (flowIndex: number, locationIndex: number) => { | |||
this.setState(prevState => { | |||
const selectedIssueLocation = findLocationByIndex( | |||
prevState.issueSecondaryLocationsByIssueByLine, | |||
flowIndex, | |||
locationIndex | |||
); | |||
return { selectedIssueLocation }; | |||
}); | |||
}; | |||
handleLocationsPanelResize = (height: number) => { | |||
this.setState({ locationsPanelHeight: height }); | |||
this.storeLocationsPanelHeight(height); | |||
}; | |||
handleIssueSelect = (issue: string) => { | |||
if (this.props.onIssueSelect) { | |||
this.props.onIssueSelect(issue); | |||
} else { | |||
this.setState({ selectedIssue: issue, selectedIssueLocation: null }); | |||
this.setState({ selectedIssue: issue }); | |||
} | |||
}; | |||
@@ -514,7 +465,7 @@ export default class SourceViewerBase extends React.PureComponent { | |||
if (this.props.onIssueUnselect) { | |||
this.props.onIssueUnselect(); | |||
} else { | |||
this.setState({ selectedIssue: undefined, selectedIssueLocation: null }); | |||
this.setState({ selectedIssue: undefined }); | |||
} | |||
}; | |||
@@ -554,14 +505,12 @@ export default class SourceViewerBase extends React.PureComponent { | |||
hasSourcesAfter={this.state.hasSourcesAfter} | |||
filterLine={this.props.filterLine} | |||
highlightedLine={this.state.highlightedLine} | |||
highlightedLocations={this.props.highlightedLocations} | |||
highlightedLocationMessage={this.props.highlightedLocationMessage} | |||
highlightedSymbols={this.state.highlightedSymbols} | |||
issues={this.state.issues} | |||
issuesByLine={this.state.issuesByLine} | |||
issueLocationsByLine={this.state.issueLocationsByLine} | |||
issueSecondaryLocationsByIssueByLine={this.state.issueSecondaryLocationsByIssueByLine} | |||
issueSecondaryLocationMessagesByIssueByLine={ | |||
this.state.issueSecondaryLocationMessagesByIssueByLine | |||
} | |||
loadDuplications={this.loadDuplications} | |||
loadSourcesAfter={this.loadSourcesAfter} | |||
loadSourcesBefore={this.loadSourcesBefore} | |||
@@ -575,12 +524,12 @@ export default class SourceViewerBase extends React.PureComponent { | |||
onIssuesOpen={this.handleOpenIssues} | |||
onIssuesClose={this.handleCloseIssues} | |||
onLineClick={this.handleLineClick} | |||
onLocationSelect={this.props.onLocationSelect} | |||
onSCMClick={this.handleSCMClick} | |||
onLocationSelect={this.handleSelectIssueLocation} | |||
onSymbolClick={this.handleSymbolClick} | |||
openIssuesByLine={this.state.openIssuesByLine} | |||
scroll={this.props.scroll} | |||
selectedIssue={this.state.selectedIssue} | |||
selectedIssueLocation={this.state.selectedIssueLocation} | |||
sources={sources} | |||
symbolsByLine={this.state.symbolsByLine} | |||
/> | |||
@@ -610,10 +559,6 @@ export default class SourceViewerBase extends React.PureComponent { | |||
'source-duplications-expanded': this.state.displayDuplications | |||
}); | |||
const selectedIssueObj = this.state.selectedIssue && this.state.issues != null | |||
? this.state.issues.find(issue => issue.key === this.state.selectedIssue) | |||
: null; | |||
return ( | |||
<div className={className} ref={node => (this.node = node)}> | |||
<SourceViewerHeader | |||
@@ -626,15 +571,6 @@ export default class SourceViewerBase extends React.PureComponent { | |||
{translate('code_viewer.no_source_code_displayed_due_to_security')} | |||
</div>} | |||
{this.state.sources != null && this.renderCode(this.state.sources)} | |||
{selectedIssueObj != null && | |||
selectedIssueObj.flows.length > 0 && | |||
<SourceViewerIssueLocations | |||
height={this.state.locationsPanelHeight} | |||
issue={selectedIssueObj} | |||
onResize={this.handleLocationsPanelResize} | |||
onSelectLocation={this.handleSelectIssueLocation} | |||
selectedLocation={this.state.selectedIssueLocation} | |||
/>} | |||
</div> | |||
); | |||
} |
@@ -22,14 +22,10 @@ import React from 'react'; | |||
import { intersection } from 'lodash'; | |||
import Line from './components/Line'; | |||
import { translate } from '../../helpers/l10n'; | |||
import { getLinearLocations } from './helpers/issueLocations'; | |||
import type { Duplication, SourceLine } from './types'; | |||
import type { Issue } from '../issue/types'; | |||
import type { | |||
LinearIssueLocation, | |||
IndexedIssueLocation, | |||
IndexedIssueLocationsByIssueAndLine, | |||
IndexedIssueLocationMessagesByIssueAndLine | |||
} from './helpers/indexing'; | |||
import type { Issue, FlowLocation } from '../issue/types'; | |||
import type { LinearIssueLocation } from './helpers/indexing'; | |||
const EMPTY_ARRAY = []; | |||
@@ -49,12 +45,12 @@ export default class SourceViewerCode extends React.PureComponent { | |||
hasSourcesAfter: boolean, | |||
hasSourcesBefore: boolean, | |||
highlightedLine: number | null, | |||
highlightedLocations?: Array<FlowLocation>, | |||
highlightedLocationMessage?: { index: number, text: string }, | |||
highlightedSymbols: Array<string>, | |||
issues: Array<Issue>, | |||
issuesByLine: { [number]: Array<Issue> }, | |||
issueLocationsByLine: { [number]: Array<LinearIssueLocation> }, | |||
issueSecondaryLocationsByIssueByLine: IndexedIssueLocationsByIssueAndLine, | |||
issueSecondaryLocationMessagesByIssueByLine: IndexedIssueLocationMessagesByIssueAndLine, | |||
loadDuplications: (SourceLine, HTMLElement) => void, | |||
loadSourcesAfter: () => void, | |||
loadSourcesBefore: () => void, | |||
@@ -68,12 +64,12 @@ export default class SourceViewerCode extends React.PureComponent { | |||
onIssuesOpen: SourceLine => void, | |||
onIssuesClose: SourceLine => void, | |||
onLineClick: (SourceLine, HTMLElement) => void, | |||
onLocationSelect?: number => void, | |||
onSCMClick: (SourceLine, HTMLElement) => void, | |||
onLocationSelect: (flowIndex: number, locationIndex: number) => void, | |||
onSymbolClick: Array<string> => void, | |||
openIssuesByLine: { [number]: boolean }, | |||
scroll?: HTMLElement => void, | |||
selectedIssue: string | null, | |||
selectedIssueLocation: IndexedIssueLocation | null, | |||
sources: Array<SourceLine>, | |||
symbolsByLine: { [number]: Array<string> } | |||
|}; | |||
@@ -90,20 +86,19 @@ export default class SourceViewerCode extends React.PureComponent { | |||
return this.props.issueLocationsByLine[line.line] || EMPTY_ARRAY; | |||
} | |||
getSecondaryIssueLocationsForLine(line: SourceLine, issueKey: string) { | |||
const index = this.props.issueSecondaryLocationsByIssueByLine; | |||
if (index[issueKey] == null) { | |||
getSecondaryIssueLocationsForLine( | |||
line: SourceLine | |||
): Array<{ from: number, to: number, line: number, index: number, startLine: number }> { | |||
const { highlightedLocations } = this.props; | |||
if (!highlightedLocations) { | |||
return EMPTY_ARRAY; | |||
} | |||
return index[issueKey][line.line] || EMPTY_ARRAY; | |||
} | |||
getSecondaryIssueLocationMessagesForLine(line: SourceLine, issueKey: string) { | |||
const index = this.props.issueSecondaryLocationMessagesByIssueByLine; | |||
if (index[issueKey] == null) { | |||
return EMPTY_ARRAY; | |||
} | |||
return index[issueKey][line.line] || EMPTY_ARRAY; | |||
return highlightedLocations.reduce((locations, location, index) => { | |||
const linearLocations = getLinearLocations(location.textRange) | |||
.filter(l => l.line === line.line) | |||
.map(l => ({ ...l, startLine: location.textRange.startLine, index })); | |||
return [...locations, ...linearLocations]; | |||
}, []); | |||
} | |||
renderLine = ( | |||
@@ -114,14 +109,10 @@ export default class SourceViewerCode extends React.PureComponent { | |||
displayFiltered: boolean, | |||
displayIssues: boolean | |||
) => { | |||
const { filterLine, selectedIssue, sources } = this.props; | |||
const { filterLine, highlightedLocationMessage, selectedIssue, sources } = this.props; | |||
const filtered = filterLine ? filterLine(line) : null; | |||
const secondaryIssueLocations = selectedIssue | |||
? this.getSecondaryIssueLocationsForLine(line, selectedIssue) | |||
: EMPTY_ARRAY; | |||
const secondaryIssueLocationMessages = selectedIssue | |||
? this.getSecondaryIssueLocationMessagesForLine(line, selectedIssue) | |||
: EMPTY_ARRAY; | |||
const secondaryIssueLocations = this.getSecondaryIssueLocationsForLine(line); | |||
const duplicationsCount = this.props.duplications ? this.props.duplications.length : 0; | |||
@@ -132,7 +123,7 @@ export default class SourceViewerCode extends React.PureComponent { | |||
const { highlightedSymbols } = this.props; | |||
let optimizedHighlightedSymbols = intersection(symbolsForLine, highlightedSymbols); | |||
if (!optimizedHighlightedSymbols.length) { | |||
optimizedHighlightedSymbols = EMPTY_ARRAY; | |||
optimizedHighlightedSymbols = undefined; | |||
} | |||
const optimizedSelectedIssue = selectedIssue != null && | |||
@@ -140,15 +131,16 @@ export default class SourceViewerCode extends React.PureComponent { | |||
? selectedIssue | |||
: null; | |||
const { selectedIssueLocation } = this.props; | |||
const optimizedSelectedIssueLocation = selectedIssueLocation != null && | |||
secondaryIssueLocations.some( | |||
location => | |||
location.flowIndex === selectedIssueLocation.flowIndex && | |||
location.locationIndex === selectedIssueLocation.locationIndex | |||
const optimizedSecondaryIssueLocations = secondaryIssueLocations.length > 0 | |||
? secondaryIssueLocations | |||
: EMPTY_ARRAY; | |||
const optimizedLocationMessage = highlightedLocationMessage != null && | |||
optimizedSecondaryIssueLocations.some( | |||
location => location.index === highlightedLocationMessage.index | |||
) | |||
? selectedIssueLocation | |||
: null; | |||
? highlightedLocationMessage | |||
: undefined; | |||
return ( | |||
<Line | |||
@@ -161,6 +153,7 @@ export default class SourceViewerCode extends React.PureComponent { | |||
duplicationsCount={duplicationsCount} | |||
filtered={filtered} | |||
highlighted={line.line === this.props.highlightedLine} | |||
highlightedLocationMessage={optimizedLocationMessage} | |||
highlightedSymbols={optimizedHighlightedSymbols} | |||
issueLocations={this.getIssueLocationsForLine(line)} | |||
issues={issuesForLine} | |||
@@ -175,15 +168,14 @@ export default class SourceViewerCode extends React.PureComponent { | |||
onIssueUnselect={this.props.onIssueUnselect} | |||
onIssuesOpen={this.props.onIssuesOpen} | |||
onIssuesClose={this.props.onIssuesClose} | |||
onSCMClick={this.props.onSCMClick} | |||
onLocationSelect={this.props.onLocationSelect} | |||
onSCMClick={this.props.onSCMClick} | |||
onSymbolClick={this.props.onSymbolClick} | |||
openIssues={this.props.openIssuesByLine[line.line] || false} | |||
previousLine={index > 0 ? sources[index - 1] : undefined} | |||
secondaryIssueLocations={secondaryIssueLocations} | |||
secondaryIssueLocationMessages={secondaryIssueLocationMessages} | |||
scroll={this.props.scroll} | |||
secondaryIssueLocations={optimizedSecondaryIssueLocations} | |||
selectedIssue={optimizedSelectedIssue} | |||
selectedIssueLocation={optimizedSelectedIssueLocation} | |||
/> | |||
); | |||
}; |
@@ -1,312 +0,0 @@ | |||
/* | |||
* 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 AutoSizer from 'react-virtualized/dist/commonjs/AutoSizer'; | |||
import { DraggableCore } from 'react-draggable'; | |||
import classNames from 'classnames'; | |||
import { throttle } from 'lodash'; | |||
import { scrollToElement } from '../../helpers/scrolling'; | |||
import { translate } from '../../helpers/l10n'; | |||
import type { Issue, FlowLocation } from '../issue/types'; | |||
import type { IndexedIssueLocation } from './helpers/indexing'; | |||
type Props = { | |||
height: number, | |||
issue: Issue, | |||
onResize: (height: number) => void, | |||
onSelectLocation: (flowIndex: number, locationIndex: number) => void, | |||
selectedLocation: IndexedIssueLocation | null | |||
}; | |||
type State = { | |||
fixed: boolean, | |||
locationBlink: boolean | |||
}; | |||
export default class SourceViewerIssueLocations extends React.PureComponent { | |||
fixedNode: HTMLElement; | |||
locations: { [string]: HTMLElement }; | |||
node: HTMLElement; | |||
props: Props; | |||
rootNode: HTMLElement; | |||
state: State; | |||
constructor(props: Props) { | |||
super(props); | |||
this.state = { fixed: true, locationBlink: false }; | |||
this.locations = {}; | |||
this.handleScroll = throttle(this.handleScroll, 50); | |||
} | |||
componentDidMount() { | |||
this.bindShortcuts(); | |||
this.listenScroll(); | |||
} | |||
componentWillReceiveProps(nextProps: Props) { | |||
if (nextProps.selectedLocation !== this.props.selectedLocation) { | |||
this.setState({ locationBlink: false }); | |||
} | |||
} | |||
componentDidUpdate(prevProps: Props) { | |||
if ( | |||
prevProps.selectedLocation !== this.props.selectedLocation && | |||
this.props.selectedLocation != null | |||
) { | |||
this.scrollToLocation(); | |||
} | |||
} | |||
componentWillUnmount() { | |||
this.unbindShortcuts(); | |||
this.unlistenScroll(); | |||
} | |||
bindShortcuts() { | |||
document.addEventListener('keydown', this.handleKeyPress); | |||
} | |||
unbindShortcuts() { | |||
document.removeEventListener('keydown', this.handleKeyPress); | |||
} | |||
listenScroll() { | |||
window.addEventListener('scroll', this.handleScroll); | |||
} | |||
unlistenScroll() { | |||
window.removeEventListener('scroll', this.handleScroll); | |||
} | |||
blinkLocation = () => { | |||
this.setState({ locationBlink: true }); | |||
setTimeout(() => this.setState({ locationBlink: false }), 1000); | |||
}; | |||
handleScroll = () => { | |||
const rootNodeTop = this.rootNode.getBoundingClientRect().top; | |||
const fixedNodeRect = this.fixedNode.getBoundingClientRect(); | |||
const fixedNodeTop = fixedNodeRect.top; | |||
const fixedNodeBottom = fixedNodeRect.bottom; | |||
this.setState((state: State) => { | |||
if (state.fixed) { | |||
if (rootNodeTop <= fixedNodeTop) { | |||
return { fixed: false }; | |||
} | |||
} else if (fixedNodeBottom >= window.innerHeight) { | |||
return { fixed: true }; | |||
} | |||
}); | |||
}; | |||
handleDrag = (e: Event, data: { deltaY: number }) => { | |||
let height = this.props.height - data.deltaY; | |||
if (height < 100) { | |||
height = 100; | |||
} | |||
if (height > window.innerHeight / 2) { | |||
height = window.innerHeight / 2; | |||
} | |||
this.props.onResize(height); | |||
}; | |||
scrollToLocation() { | |||
const { selectedLocation } = this.props; | |||
if (selectedLocation != null) { | |||
const key = `${selectedLocation.flowIndex}-${selectedLocation.locationIndex}`; | |||
const locationElement = this.locations[key]; | |||
if (locationElement) { | |||
scrollToElement(locationElement, 15, 15, this.node); | |||
} | |||
} | |||
} | |||
handleSelectPrev() { | |||
const { issue, selectedLocation } = this.props; | |||
if (!selectedLocation) { | |||
if (issue.flows.length > 0) { | |||
// move to the first location of the first flow | |||
this.props.onSelectLocation(0, 0); | |||
} | |||
} else { | |||
const currentFlow = issue.flows[selectedLocation.flowIndex]; | |||
if ( | |||
currentFlow.locations != null && | |||
currentFlow.locations.length > selectedLocation.locationIndex + 1 | |||
) { | |||
// move to the next location for the same flow | |||
this.props.onSelectLocation(selectedLocation.flowIndex, selectedLocation.locationIndex + 1); | |||
} else if (selectedLocation.flowIndex > 0) { | |||
// move to the first location of the previous flow | |||
this.props.onSelectLocation(selectedLocation.flowIndex - 1, 0); | |||
} else { | |||
this.blinkLocation(); | |||
} | |||
} | |||
} | |||
handleSelectNext() { | |||
const { issue, selectedLocation } = this.props; | |||
if (!selectedLocation) { | |||
if (issue.flows.length > 0) { | |||
// move to the last location of the first flow | |||
const firstFlow = issue.flows[0]; | |||
if (firstFlow.locations != null) { | |||
this.props.onSelectLocation(0, firstFlow.locations.length - 1); | |||
} | |||
} | |||
} else if (selectedLocation.locationIndex > 0) { | |||
// move to the previous location for the same flow | |||
this.props.onSelectLocation(selectedLocation.flowIndex, selectedLocation.locationIndex - 1); | |||
} else if (issue.flows.length > selectedLocation.flowIndex + 1) { | |||
// move to the last location of the next flow | |||
const nextFlow = issue.flows[selectedLocation.flowIndex + 1]; | |||
if (nextFlow.locations) { | |||
this.props.onSelectLocation(selectedLocation.flowIndex + 1, nextFlow.locations.length - 1); | |||
} | |||
} else { | |||
this.blinkLocation(); | |||
} | |||
} | |||
handleKeyPress = (e: Object) => { | |||
const tagName = e.target.tagName.toUpperCase(); | |||
const shouldHandle = tagName !== 'INPUT' && tagName !== 'TEXTAREA' && tagName !== 'BUTTON'; | |||
if (shouldHandle) { | |||
const selectNext = e.keyCode === 40 && e.altKey; | |||
const selectPrev = e.keyCode === 38 && e.altKey; | |||
if (selectNext) { | |||
e.preventDefault(); | |||
this.handleSelectNext(); | |||
} | |||
if (selectPrev) { | |||
e.preventDefault(); | |||
this.handleSelectPrev(); | |||
} | |||
} | |||
}; | |||
reverseLocations(locations: Array<*>) { | |||
return [...locations].reverse(); | |||
} | |||
isLocationSelected(flowIndex: number, locationIndex: number) { | |||
const { selectedLocation } = this.props; | |||
if (selectedLocation == null) { | |||
return false; | |||
} else { | |||
return ( | |||
selectedLocation.flowIndex === flowIndex && selectedLocation.locationIndex === locationIndex | |||
); | |||
} | |||
} | |||
handleLocationClick(flowIndex: number, locationIndex: number, e: SyntheticInputEvent) { | |||
e.preventDefault(); | |||
this.props.onSelectLocation(flowIndex, locationIndex); | |||
} | |||
renderLocation = ( | |||
location: FlowLocation, | |||
flowIndex: number, | |||
locationIndex: number, | |||
locations: Array<*> | |||
) => { | |||
const displayIndex = locations.length > 1; | |||
const line = location.textRange ? location.textRange.startLine : null; | |||
const key = `${flowIndex}-${locationIndex}`; | |||
// note that locations order is reversed | |||
const selected = this.isLocationSelected(flowIndex, locations.length - locationIndex - 1); | |||
return ( | |||
<li key={key} ref={node => (this.locations[key] = node)} className="spacer-bottom"> | |||
{line != null && <code className="source-issue-locations-line">L{line}</code>} | |||
<a | |||
className={classNames('issue-location-message', 'flash', 'flash-heavy', { | |||
selected, | |||
in: selected && this.state.locationBlink | |||
})} | |||
href="#" | |||
onClick={this.handleLocationClick.bind( | |||
this, | |||
flowIndex, | |||
locations.length - locationIndex - 1 | |||
)}> | |||
{displayIndex && <strong>{locationIndex + 1}: </strong>} | |||
{location.msg} | |||
</a> | |||
</li> | |||
); | |||
}; | |||
render() { | |||
const { flows } = this.props.issue; | |||
const { height } = this.props; | |||
const className = classNames('source-issue-locations-panel', { fixed: this.state.fixed }); | |||
return ( | |||
<AutoSizer disableHeight={true}> | |||
{({ width }) => ( | |||
<div | |||
ref={node => (this.rootNode = node)} | |||
className="source-issue-locations" | |||
style={{ width, height }}> | |||
<div | |||
ref={node => (this.fixedNode = node)} | |||
className={className} | |||
style={{ width, height }}> | |||
<header className="source-issue-locations-header" /> | |||
<div className="source-issue-locations-shortcuts"> | |||
<span className="shortcut-button">Alt</span> | |||
{' + '} | |||
<span className="shortcut-button">↑</span> | |||
{' '} | |||
<span className="shortcut-button">↓</span> | |||
{' '} | |||
{translate('source_viewer.to_navigate_issue_locations')} | |||
</div> | |||
<ul | |||
ref={node => (this.node = node)} | |||
className="source-issue-locations-list" | |||
style={{ height: height - 15 }}> | |||
{flows.map( | |||
(flow, flowIndex) => | |||
flow.locations != null && | |||
this.reverseLocations(flow.locations).map((location, locationIndex) => | |||
this.renderLocation(location, flowIndex, locationIndex, flow.locations || []) | |||
) | |||
)} | |||
</ul> | |||
<DraggableCore axis="y" onDrag={this.handleDrag} offsetParent={document.body}> | |||
<div className="workspace-viewer-resize" /> | |||
</DraggableCore> | |||
</div> | |||
</div> | |||
)} | |||
</AutoSizer> | |||
); | |||
} | |||
} |
@@ -30,11 +30,7 @@ import LineIssuesIndicator from './LineIssuesIndicator'; | |||
import LineCode from './LineCode'; | |||
import { TooltipsContainer } from '../../mixins/tooltips-mixin'; | |||
import type { SourceLine } from '../types'; | |||
import type { | |||
LinearIssueLocation, | |||
IndexedIssueLocation, | |||
IndexedIssueLocationMessage | |||
} from '../helpers/indexing'; | |||
import type { LinearIssueLocation } from '../helpers/indexing'; | |||
import type { Issue } from '../../issue/types'; | |||
type Props = {| | |||
@@ -47,7 +43,8 @@ type Props = {| | |||
duplicationsCount: number, | |||
filtered: boolean | null, | |||
highlighted: boolean, | |||
highlightedSymbols: Array<string>, | |||
highlightedLocationMessage?: { index: number, text: string }, | |||
highlightedSymbols?: Array<string>, | |||
issueLocations: Array<LinearIssueLocation>, | |||
issues: Array<Issue>, | |||
line: SourceLine, | |||
@@ -60,16 +57,20 @@ type Props = {| | |||
onIssueUnselect: () => void, | |||
onIssuesOpen: SourceLine => void, | |||
onIssuesClose: SourceLine => void, | |||
onLocationSelect?: number => void, | |||
onSCMClick: (SourceLine, HTMLElement) => void, | |||
onLocationSelect: (flowIndex: number, locationIndex: number) => void, | |||
onSymbolClick: Array<string> => void, | |||
openIssues: boolean, | |||
previousLine?: SourceLine, | |||
selectedIssue: string | null, | |||
secondaryIssueLocations: Array<IndexedIssueLocation>, | |||
// $FlowFixMe | |||
secondaryIssueLocationMessages: Array<IndexedIssueLocationMessage>, | |||
selectedIssueLocation: IndexedIssueLocation | null | |||
scroll?: HTMLElement => void, | |||
secondaryIssueLocations: Array<{ | |||
from: number, | |||
to: number, | |||
line: number, | |||
index: number, | |||
startLine: number | |||
}>, | |||
selectedIssue: string | null | |||
|}; | |||
export default class Line extends React.PureComponent { | |||
@@ -138,6 +139,7 @@ export default class Line extends React.PureComponent { | |||
</td>} | |||
<LineCode | |||
highlightedLocationMessage={this.props.highlightedLocationMessage} | |||
highlightedSymbols={this.props.highlightedSymbols} | |||
issues={this.props.issues} | |||
issueLocations={this.props.issueLocations} | |||
@@ -146,10 +148,9 @@ export default class Line extends React.PureComponent { | |||
onIssueSelect={this.props.onIssueSelect} | |||
onLocationSelect={this.props.onLocationSelect} | |||
onSymbolClick={this.props.onSymbolClick} | |||
secondaryIssueLocationMessages={this.props.secondaryIssueLocationMessages} | |||
scroll={this.props.scroll} | |||
secondaryIssueLocations={this.props.secondaryIssueLocations} | |||
selectedIssue={this.props.selectedIssue} | |||
selectedIssueLocation={this.props.selectedIssueLocation} | |||
showIssues={this.props.openIssues || this.props.displayAllIssues} | |||
/> | |||
</tr> |
@@ -21,35 +21,33 @@ | |||
import React from 'react'; | |||
import classNames from 'classnames'; | |||
import LineIssuesList from './LineIssuesList'; | |||
import { | |||
splitByTokens, | |||
highlightSymbol, | |||
highlightIssueLocations, | |||
generateHTML | |||
} from '../helpers/highlight'; | |||
import LocationIndex from '../../common/LocationIndex'; | |||
import LocationMessage from '../../common/LocationMessage'; | |||
import { splitByTokens, highlightSymbol, highlightIssueLocations } from '../helpers/highlight'; | |||
import type { Tokens } from '../helpers/highlight'; | |||
import type { SourceLine } from '../types'; | |||
import type { | |||
LinearIssueLocation, | |||
IndexedIssueLocation, | |||
IndexedIssueLocationMessage | |||
} from '../helpers/indexing'; | |||
import type { LinearIssueLocation } from '../helpers/indexing'; | |||
import type { Issue } from '../../issue/types'; | |||
type Props = {| | |||
highlightedSymbols: Array<string>, | |||
highlightedLocationMessage?: { index: number, text: string }, | |||
highlightedSymbols?: Array<string>, | |||
issues: Array<Issue>, | |||
issueLocations: Array<LinearIssueLocation>, | |||
line: SourceLine, | |||
onIssueChange: Issue => void, | |||
onIssueSelect: (issueKey: string) => void, | |||
onLocationSelect: (flowIndex: number, locationIndex: number) => void, | |||
onLocationSelect?: number => void, | |||
onSymbolClick: Array<string> => void, | |||
// $FlowFixMe | |||
secondaryIssueLocations: Array<IndexedIssueLocation>, | |||
secondaryIssueLocationMessages: Array<IndexedIssueLocationMessage>, | |||
scroll?: HTMLElement => void, | |||
secondaryIssueLocations: Array<{ | |||
from: number, | |||
to: number, | |||
line: number, | |||
index: number, | |||
startLine: number | |||
}>, | |||
selectedIssue: string | null, | |||
selectedIssueLocation: IndexedIssueLocation | null, | |||
showIssues: boolean | |||
|}; | |||
@@ -58,6 +56,7 @@ type State = { | |||
}; | |||
export default class LineCode extends React.PureComponent { | |||
activeMarkerNode: ?HTMLElement; | |||
codeNode: HTMLElement; | |||
props: Props; | |||
state: State; | |||
@@ -72,6 +71,9 @@ export default class LineCode extends React.PureComponent { | |||
componentDidMount() { | |||
this.attachEvents(); | |||
if (this.props.highlightedLocationMessage && this.activeMarkerNode && this.props.scroll) { | |||
this.props.scroll(this.activeMarkerNode); | |||
} | |||
} | |||
componentWillReceiveProps(nextProps: Props) { | |||
@@ -86,8 +88,16 @@ export default class LineCode extends React.PureComponent { | |||
this.detachEvents(); | |||
} | |||
componentDidUpdate() { | |||
componentDidUpdate(prevProps: Props) { | |||
this.attachEvents(); | |||
if ( | |||
this.props.highlightedLocationMessage && | |||
prevProps.highlightedLocationMessage !== this.props.highlightedLocationMessage && | |||
this.activeMarkerNode && | |||
this.props.scroll | |||
) { | |||
this.props.scroll(this.activeMarkerNode); | |||
} | |||
} | |||
componentWillUnmount() { | |||
@@ -117,75 +127,38 @@ export default class LineCode extends React.PureComponent { | |||
} | |||
}; | |||
handleLocationMessageClick = ( | |||
e: SyntheticInputEvent, | |||
flowIndex: number, | |||
locationIndex: number | |||
) => { | |||
e.preventDefault(); | |||
this.props.onLocationSelect(flowIndex, locationIndex); | |||
}; | |||
isSecondaryIssueLocationSelected(location: IndexedIssueLocation | IndexedIssueLocationMessage) { | |||
const { selectedIssueLocation } = this.props; | |||
if (selectedIssueLocation == null) { | |||
return false; | |||
} else { | |||
return ( | |||
selectedIssueLocation.flowIndex === location.flowIndex && | |||
selectedIssueLocation.locationIndex === location.locationIndex | |||
); | |||
} | |||
} | |||
renderSecondaryIssueLocationMessage = (location: IndexedIssueLocationMessage) => { | |||
const className = classNames('source-viewer-issue-location', 'issue-location-message', { | |||
selected: this.isSecondaryIssueLocationSelected(location) | |||
}); | |||
const limitString = (str: string) => (str.length > 30 ? str.substr(0, 30) + '...' : str); | |||
renderMarker(index: number, message: ?string) { | |||
const { onLocationSelect } = this.props; | |||
const onClick = onLocationSelect ? () => onLocationSelect(index) : undefined; | |||
const ref = message != null ? node => (this.activeMarkerNode = node) : undefined; | |||
return ( | |||
<a | |||
key={`${location.flowIndex}-${location.locationIndex}`} | |||
href="#" | |||
className={className} | |||
title={location.msg} | |||
onClick={e => | |||
this.handleLocationMessageClick(e, location.flowIndex, location.locationIndex)}> | |||
{location.index && <strong>{location.index}: </strong>} | |||
{location.msg ? limitString(location.msg) : ''} | |||
</a> | |||
); | |||
}; | |||
renderSecondaryIssueLocationMessages(locations: Array<IndexedIssueLocationMessage>) { | |||
return ( | |||
<div className="source-line-issue-locations"> | |||
{locations.map(this.renderSecondaryIssueLocationMessage)} | |||
</div> | |||
<LocationIndex key={`marker-${index}`} onClick={onClick} selected={message != null}> | |||
<span href="#" ref={ref}>{index + 1}</span> | |||
{message != null && <LocationMessage selected={true}>{message}</LocationMessage>} | |||
</LocationIndex> | |||
); | |||
} | |||
render() { | |||
const { | |||
highlightedLocationMessage, | |||
highlightedSymbols, | |||
issues, | |||
issueLocations, | |||
line, | |||
onIssueSelect, | |||
secondaryIssueLocationMessages, | |||
secondaryIssueLocations, | |||
selectedIssue, | |||
selectedIssueLocation, | |||
showIssues | |||
} = this.props; | |||
let tokens = [...this.state.tokens]; | |||
highlightedSymbols.forEach(symbol => { | |||
tokens = highlightSymbol(tokens, symbol); | |||
}); | |||
if (highlightedSymbols) { | |||
highlightedSymbols.forEach(symbol => { | |||
tokens = highlightSymbol(tokens, symbol); | |||
}); | |||
} | |||
if (issueLocations.length > 0) { | |||
tokens = highlightIssueLocations(tokens, issueLocations); | |||
@@ -193,32 +166,39 @@ export default class LineCode extends React.PureComponent { | |||
if (secondaryIssueLocations) { | |||
tokens = highlightIssueLocations(tokens, secondaryIssueLocations, 'issue-location'); | |||
if (selectedIssueLocation != null) { | |||
const x = secondaryIssueLocations.find(location => | |||
this.isSecondaryIssueLocationSelected(location) | |||
if (highlightedLocationMessage) { | |||
const location = secondaryIssueLocations.find( | |||
location => location.index === highlightedLocationMessage.index | |||
); | |||
if (x) { | |||
tokens = highlightIssueLocations(tokens, [x], 'selected'); | |||
if (location) { | |||
tokens = highlightIssueLocations(tokens, [location], 'selected'); | |||
} | |||
} | |||
} | |||
const finalCode = generateHTML(tokens); | |||
const className = classNames('source-line-code', 'code', { | |||
'has-issues': issues.length > 0 | |||
}); | |||
const renderedTokens = []; | |||
tokens.forEach((token, index) => { | |||
if (token.markers.length > 0) { | |||
token.markers.forEach(marker => { | |||
const message = highlightedLocationMessage != null && | |||
highlightedLocationMessage.index === marker | |||
? highlightedLocationMessage.text | |||
: null; | |||
renderedTokens.push(this.renderMarker(marker, message)); | |||
}); | |||
} | |||
renderedTokens.push(<span className={token.className} key={index}>{token.text}</span>); | |||
}); | |||
return ( | |||
<td className={className} data-line-number={line.line}> | |||
<div className="source-line-code-inner"> | |||
<pre | |||
ref={node => (this.codeNode = node)} | |||
dangerouslySetInnerHTML={{ __html: finalCode }} | |||
/> | |||
{secondaryIssueLocationMessages != null && | |||
secondaryIssueLocationMessages.length > 0 && | |||
this.renderSecondaryIssueLocationMessages(secondaryIssueLocationMessages)} | |||
<pre ref={node => (this.codeNode = node)}>{renderedTokens}</pre> | |||
</div> | |||
{showIssues && | |||
issues.length > 0 && |
@@ -19,7 +19,6 @@ | |||
*/ | |||
import React from 'react'; | |||
import { shallow } from 'enzyme'; | |||
// import { click } from '../../../../helpers/testUtils'; | |||
import LineCode from '../LineCode'; | |||
it('render code', () => { | |||
@@ -28,9 +27,6 @@ it('render code', () => { | |||
code: '<span class="k">class</span> <span class="sym sym-1">Foo</span> {' | |||
}; | |||
const issueLocations = [{ from: 0, to: 5, line: 3 }]; | |||
const secondaryIssueLocations = [{ from: 6, to: 9, line: 3 }]; | |||
const secondaryIssueLocationMessages = [{ msg: 'Fix that', flowIndex: 0, locationIndex: 0 }]; | |||
const selectedIssueLocation = { from: 6, to: 9, line: 3, flowIndex: 0, locationIndex: 0 }; | |||
const wrapper = shallow( | |||
<LineCode | |||
highlightedSymbols={['sym1']} | |||
@@ -40,40 +36,9 @@ it('render code', () => { | |||
onIssueSelect={jest.fn()} | |||
onSelectLocation={jest.fn()} | |||
onSymbolClick={jest.fn()} | |||
secondaryIssueLocations={secondaryIssueLocations} | |||
secondaryIssueLocationMessages={secondaryIssueLocationMessages} | |||
selectedIssue="issue-1" | |||
selectedIssueLocation={selectedIssueLocation} | |||
showIssues={true} | |||
/> | |||
); | |||
expect(wrapper).toMatchSnapshot(); | |||
}); | |||
it('should handle empty location message', () => { | |||
const line = { | |||
line: 3, | |||
code: '<span class="k">class</span>' | |||
}; | |||
const issueLocations = [{ from: 0, to: 5, line: 3 }]; | |||
const secondaryIssueLocations = [{ from: 6, to: 9, line: 3 }]; | |||
const secondaryIssueLocationMessages = [{ flowIndex: 0, locationIndex: 0 }]; | |||
const selectedIssueLocation = { from: 6, to: 9, line: 3, flowIndex: 0, locationIndex: 0 }; | |||
const wrapper = shallow( | |||
<LineCode | |||
highlightedSymbols={['sym1']} | |||
issues={[{ key: 'issue-1' }, { key: 'issue-2' }]} | |||
issueLocations={issueLocations} | |||
line={line} | |||
onIssueSelect={jest.fn()} | |||
onSelectLocation={jest.fn()} | |||
onSymbolClick={jest.fn()} | |||
secondaryIssueLocations={secondaryIssueLocations} | |||
secondaryIssueLocationMessages={secondaryIssueLocationMessages} | |||
selectedIssue="issue-1" | |||
selectedIssueLocation={selectedIssueLocation} | |||
showIssues={true} | |||
/> | |||
); | |||
expect(wrapper.find('.source-line-issue-locations')).toMatchSnapshot(); | |||
}); |
@@ -4,22 +4,24 @@ exports[`test render code 1`] = ` | |||
data-line-number={3}> | |||
<div | |||
className="source-line-code-inner"> | |||
<pre | |||
dangerouslySetInnerHTML={ | |||
Object { | |||
"__html": "<span class=\"k source-line-code-issue\">class</span><span class=\"\"> </span><span class=\"sym sym-1 issue-location\">Foo</span><span class=\"\"> {</span>", | |||
} | |||
} /> | |||
<div | |||
className="source-line-issue-locations"> | |||
<a | |||
className="source-viewer-issue-location issue-location-message selected" | |||
href="#" | |||
onClick={[Function]} | |||
title="Fix that"> | |||
Fix that | |||
</a> | |||
</div> | |||
<pre> | |||
<span | |||
className="k source-line-code-issue"> | |||
class | |||
</span> | |||
<span | |||
className=""> | |||
</span> | |||
<span | |||
className="sym sym-1"> | |||
Foo | |||
</span> | |||
<span | |||
className=""> | |||
{ | |||
</span> | |||
</pre> | |||
</div> | |||
<LineIssuesList | |||
issues={ | |||
@@ -36,13 +38,3 @@ exports[`test render code 1`] = ` | |||
selectedIssue="issue-1" /> | |||
</td> | |||
`; | |||
exports[`test should handle empty location message 1`] = ` | |||
<div | |||
className="source-line-issue-locations"> | |||
<a | |||
className="source-viewer-issue-location issue-location-message selected" | |||
href="#" | |||
onClick={[Function]} /> | |||
</div> | |||
`; |
@@ -24,33 +24,33 @@ describe('highlightSymbol', () => { | |||
it('should not highlight symbols with similar beginning', () => { | |||
// test all positions of sym-X in the string: beginning, middle and ending | |||
const tokens = [ | |||
{ className: 'sym-18 b', text: 'foo' }, | |||
{ className: 'a sym-18', text: 'foo' }, | |||
{ className: 'a sym-18 b', text: 'foo' }, | |||
{ className: 'sym-1 d', text: 'bar' }, | |||
{ className: 'c sym-1', text: 'bar' }, | |||
{ className: 'c sym-1 d', text: 'bar' } | |||
{ className: 'sym-18 b', markers: [], text: 'foo' }, | |||
{ className: 'a sym-18', markers: [], text: 'foo' }, | |||
{ className: 'a sym-18 b', markers: [], text: 'foo' }, | |||
{ className: 'sym-1 d', markers: [], text: 'bar' }, | |||
{ className: 'c sym-1', markers: [], text: 'bar' }, | |||
{ className: 'c sym-1 d', markers: [], text: 'bar' } | |||
]; | |||
expect(highlightSymbol(tokens, 'sym-1')).toEqual([ | |||
{ className: 'sym-18 b', text: 'foo' }, | |||
{ className: 'a sym-18', text: 'foo' }, | |||
{ className: 'a sym-18 b', text: 'foo' }, | |||
{ className: 'sym-1 d highlighted', text: 'bar' }, | |||
{ className: 'c sym-1 highlighted', text: 'bar' }, | |||
{ className: 'c sym-1 d highlighted', text: 'bar' } | |||
{ className: 'sym-18 b', markers: [], text: 'foo' }, | |||
{ className: 'a sym-18', markers: [], text: 'foo' }, | |||
{ className: 'a sym-18 b', markers: [], text: 'foo' }, | |||
{ className: 'sym-1 d highlighted', markers: [], text: 'bar' }, | |||
{ className: 'c sym-1 highlighted', markers: [], text: 'bar' }, | |||
{ className: 'c sym-1 d highlighted', markers: [], text: 'bar' } | |||
]); | |||
}); | |||
it('should highlight symbols marked twice', () => { | |||
const tokens = [ | |||
{ className: 'sym sym-1 sym sym-2', text: 'foo' }, | |||
{ className: 'sym sym-1', text: 'bar' }, | |||
{ className: 'sym sym-2', text: 'qux' } | |||
{ className: 'sym sym-1 sym sym-2', markers: [], text: 'foo' }, | |||
{ className: 'sym sym-1', markers: [], text: 'bar' }, | |||
{ className: 'sym sym-2', markers: [], text: 'qux' } | |||
]; | |||
expect(highlightSymbol(tokens, 'sym-1')).toEqual([ | |||
{ className: 'sym sym-1 sym sym-2 highlighted', text: 'foo' }, | |||
{ className: 'sym sym-1 highlighted', text: 'bar' }, | |||
{ className: 'sym sym-2', text: 'qux' } | |||
{ className: 'sym sym-1 sym sym-2 highlighted', markers: [], text: 'foo' }, | |||
{ className: 'sym sym-1 highlighted', markers: [], text: 'bar' }, | |||
{ className: 'sym sym-2', markers: [], text: 'qux' } | |||
]); | |||
}); | |||
}); |
@@ -19,9 +19,9 @@ | |||
*/ | |||
// @flow | |||
import escapeHtml from 'escape-html'; | |||
import type { LinearIssueLocation } from './indexing'; | |||
import { uniq } from 'lodash'; | |||
export type Token = { className: string, text: string }; | |||
export type Token = { className: string, markers: Array<number>, text: string }; | |||
export type Tokens = Array<Token>; | |||
const ISSUE_LOCATION_CLASS = 'source-line-code-issue'; | |||
@@ -39,7 +39,7 @@ export const splitByTokens = (code: string, rootClassName: string = ''): Tokens | |||
} | |||
if (node.nodeType === 3) { | |||
// TEXT NODE | |||
tokens.push({ className: rootClassName, text: node.nodeValue }); | |||
tokens.push({ className: rootClassName, markers: [], text: node.nodeValue }); | |||
} | |||
}); | |||
return tokens; | |||
@@ -88,28 +88,36 @@ const part = (str: string, from: number, to: number, acc: number): string => { | |||
*/ | |||
export const highlightIssueLocations = ( | |||
tokens: Tokens, | |||
issueLocations: Array<LinearIssueLocation>, | |||
issueLocations: Array<*>, | |||
rootClassName: string = ISSUE_LOCATION_CLASS | |||
): Tokens => { | |||
issueLocations.forEach(location => { | |||
const nextTokens = []; | |||
let acc = 0; | |||
let markerAdded = location.line !== location.startLine; | |||
tokens.forEach(token => { | |||
const x = intersect(acc, acc + token.text.length, location.from, location.to); | |||
const p1 = part(token.text, acc, x.from, acc); | |||
const p2 = part(token.text, x.from, x.to, acc); | |||
const p3 = part(token.text, x.to, acc + token.text.length, acc); | |||
if (p1.length) { | |||
nextTokens.push({ className: token.className, text: p1 }); | |||
nextTokens.push({ ...token, text: p1 }); | |||
} | |||
if (p2.length) { | |||
const newClassName = token.className.indexOf(rootClassName) === -1 | |||
? `${token.className} ${rootClassName}` | |||
: token.className; | |||
nextTokens.push({ className: newClassName, text: p2 }); | |||
nextTokens.push({ | |||
className: newClassName, | |||
markers: !markerAdded && location.index != null | |||
? uniq([...token.markers, location.index]) | |||
: token.markers, | |||
text: p2 | |||
}); | |||
markerAdded = true; | |||
} | |||
if (p3.length) { | |||
nextTokens.push({ className: token.className, text: p3 }); | |||
nextTokens.push({ ...token, text: p3 }); | |||
} | |||
acc += token.text.length; | |||
}); |
@@ -20,21 +20,20 @@ | |||
// @flow | |||
import { flatten } from 'lodash'; | |||
import { splitByTokens } from './highlight'; | |||
import { getLinearLocations, getIssueLocations } from './issueLocations'; | |||
import { getLinearLocations } from './issueLocations'; | |||
import type { Issue } from '../../issue/types'; | |||
import type { SourceLine } from '../types'; | |||
export type LinearIssueLocation = { | |||
from: number, | |||
line: number, | |||
to: number | |||
to: number, | |||
index?: number | |||
}; | |||
export type IndexedIssueLocation = { | |||
flowIndex: number, | |||
from: number, | |||
line: number, | |||
locationIndex: number, | |||
to: number | |||
}; | |||
@@ -44,19 +43,6 @@ export type IndexedIssueLocationMessage = { | |||
msg?: string | |||
}; | |||
export type IndexedIssueLocationsByIssueAndLine = { | |||
[issueKey: string]: { | |||
// $FlowFixMe | |||
[lineNumber: number]: Array<IndexedIssueLocation> | |||
} | |||
}; | |||
export type IndexedIssueLocationMessagesByIssueAndLine = { | |||
[issueKey: string]: { | |||
[lineNumber: number]: Array<IndexedIssueLocationMessage> | |||
} | |||
}; | |||
export const issuesByLine = (issues: Array<Issue>) => { | |||
const index = {}; | |||
issues.forEach(issue => { | |||
@@ -82,47 +68,6 @@ export const locationsByLine = (issues: Array<Issue>): { [number]: Array<LinearI | |||
return index; | |||
}; | |||
export const locationsByIssueAndLine = ( | |||
issues: Array<Issue> | |||
): IndexedIssueLocationsByIssueAndLine => { | |||
const index = {}; | |||
issues.forEach(issue => { | |||
const byLine = {}; | |||
getIssueLocations(issue).forEach(location => { | |||
getLinearLocations(location.textRange).forEach(linearLocation => { | |||
if (!(linearLocation.line in byLine)) { | |||
byLine[linearLocation.line] = []; | |||
} | |||
byLine[linearLocation.line].push({ | |||
...linearLocation, | |||
flowIndex: location.flowIndex, | |||
locationIndex: location.locationIndex | |||
}); | |||
}); | |||
}); | |||
index[issue.key] = byLine; | |||
}); | |||
return index; | |||
}; | |||
export const locationMessagesByIssueAndLine = ( | |||
issues: Array<Issue> | |||
): IndexedIssueLocationMessagesByIssueAndLine => { | |||
const index = {}; | |||
issues.forEach(issue => { | |||
const byLine = {}; | |||
getIssueLocations(issue).forEach(location => { | |||
const line = location.textRange ? location.textRange.startLine : 0; | |||
if (!(line in byLine)) { | |||
byLine[line] = []; | |||
} | |||
byLine[line].push(location); | |||
}); | |||
index[issue.key] = byLine; | |||
}); | |||
return index; | |||
}; | |||
export const duplicationsByLine = (duplications: Array<*> | null) => { | |||
if (duplications == null) { | |||
return {}; | |||
@@ -160,24 +105,3 @@ export const symbolsByLine = (sources: Array<SourceLine>) => { | |||
}); | |||
return index; | |||
}; | |||
export const findLocationByIndex = ( | |||
locations: IndexedIssueLocationsByIssueAndLine, | |||
flowIndex: number, | |||
locationIndex: number | |||
) => { | |||
const issueKeys = Object.keys(locations); | |||
for (const issueKey of issueKeys) { | |||
const lineNumbers = Object.keys(locations[issueKey]); | |||
for (let lineIndex = 0; lineIndex < lineNumbers.length; lineIndex++) { | |||
for (let i = 0; i < locations[issueKey][lineNumbers[lineIndex]].length; i++) { | |||
const location = locations[issueKey][lineNumbers[lineIndex]][i]; | |||
if (location.flowIndex === flowIndex && location.locationIndex === locationIndex) { | |||
return location; | |||
} | |||
} | |||
} | |||
} | |||
return null; | |||
}; |
@@ -48,7 +48,7 @@ export const getIssueLocations = ( | |||
index?: number | |||
}> => { | |||
const allLocations = []; | |||
issue.flows.forEach(({ locations }, flowIndex) => { | |||
issue.flows.forEach((locations, flowIndex) => { | |||
if (locations) { | |||
const locationsCount = locations.length; | |||
locations.forEach((location, index) => { |
@@ -1,66 +1,11 @@ | |||
.source-issue-locations { | |||
position: relative; | |||
} | |||
.source-issue-locations-panel { | |||
background-color: #fff; | |||
box-shadow: 0 -6px 12px rgba(0, 0, 0, .175); | |||
} | |||
.source-issue-locations-panel.fixed { | |||
position: fixed; | |||
bottom: 0; | |||
margin-left: -1px; | |||
border-left: 1px solid #e6e6e6; | |||
border-right: 1px solid #e6e6e6; | |||
} | |||
.source-issue-locations-header { | |||
height: 15px; | |||
padding: 0 15px; | |||
box-sizing: border-box; | |||
background-color: #404040; | |||
color: #fff; | |||
} | |||
.source-issue-locations-shortcuts { | |||
position: absolute; | |||
top: 18px; | |||
right: 18px; | |||
padding: 6px; | |||
background-color: #fff; | |||
color: #777; | |||
font-size: 11px; | |||
} | |||
.source-issue-locations-list { | |||
height: 185px; | |||
padding: 15px; | |||
box-sizing: border-box; | |||
overflow: auto; | |||
} | |||
.source-issue-locations-line { | |||
display: inline-block; | |||
min-width: 25px; | |||
margin-right: 15px; | |||
color: #777; | |||
font-size: 12px; | |||
text-align: right; | |||
} | |||
.issue-location, | |||
.issue-location-message { | |||
.issue-location { | |||
display: inline-block; | |||
vertical-align: top; | |||
line-height: 18px; | |||
height: 18px; | |||
box-sizing: border-box; | |||
background-color: #ffeaea; | |||
} | |||
.issue-location { | |||
/* nothing so far */ | |||
transition: background-color 0.3s ease; | |||
} | |||
.issue-location.highlighted { | |||
@@ -71,22 +16,4 @@ | |||
.issue-location.selected { | |||
border-color: #f4b1b0; | |||
background-color: #f4b1b0; | |||
} | |||
.issue-location-message { | |||
padding: 0 10px; | |||
border: 1px solid #ffeaea; | |||
color: #444 !important; | |||
font-size: 12px; | |||
white-space: nowrap; | |||
transition: all 0.3s ease; | |||
} | |||
.issue-location-message:hover { | |||
border-color: #f4b1b0; | |||
background-color: #f4b1b0; | |||
} | |||
.issue-location-message.selected { | |||
border-color: #dd4040; | |||
} |
@@ -0,0 +1,45 @@ | |||
.location-index { | |||
position: relative; | |||
display: inline-block; | |||
vertical-align: top; | |||
line-height: 16px; | |||
padding-left: 6px; | |||
padding-right: 6px; | |||
border-radius: 2px; | |||
background-color: #d18582; | |||
color: #fff; | |||
font-family: 'Helvetica Neue', 'Segoe UI', Helvetica, Arial, sans-serif; | |||
font-size: 12px; | |||
transition: background-color 0.3s ease; | |||
} | |||
.location-index.selected { | |||
background-color: #bc5e5e; | |||
} | |||
.location-index.muted { | |||
background-color: #ccc; | |||
} | |||
.location-index[tabindex] { | |||
cursor: pointer; | |||
} | |||
.location-index[tabindex]:hover { | |||
background-color: #bc5e5e; | |||
} | |||
.location-index[tabindex]:focus { | |||
outline: none; | |||
} | |||
.source-line-code .location-index { | |||
line-height: 16px; | |||
margin: 1px; | |||
margin-left: 4px; | |||
margin-right: 4px; | |||
} | |||
.source-line-code .location-index + .location-index { | |||
margin-left: 0; | |||
} |
@@ -0,0 +1,51 @@ | |||
/* | |||
* 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 classNames from 'classnames'; | |||
import './LocationIndex.css'; | |||
type Props = { | |||
children?: React.Element<*>, | |||
onClick?: () => void, | |||
selected?: boolean | |||
}; | |||
export default function LocationIndex(props: Props) { | |||
const clickAttributes = props.onClick | |||
? { | |||
onClick: props.onClick, | |||
role: 'button', | |||
tabIndex: 0 | |||
} | |||
: {}; | |||
return ( | |||
<div | |||
className={classNames('location-index', { selected: props.selected })} | |||
{...clickAttributes}> | |||
{props.children} | |||
</div> | |||
); | |||
} | |||
LocationIndex.defaultProps = { | |||
selected: false | |||
}; |
@@ -0,0 +1,43 @@ | |||
.location-message { | |||
display: inline-block; | |||
vertical-align: top; | |||
line-height: 16px; | |||
padding: 0 6px; | |||
border-radius: 2px; | |||
background-color: #9e9e9e; | |||
color: #fff; | |||
font-family: 'Helvetica Neue', 'Segoe UI', Helvetica, Arial, sans-serif; | |||
font-size: 12px; | |||
transition: background-color 0.3s ease; | |||
} | |||
.location-message.selected { | |||
background-color: #475760; | |||
} | |||
.location-index + .location-message { | |||
margin-left: 4px; | |||
} | |||
.location-index > .location-message { | |||
position: absolute; | |||
bottom: calc(100% + 4px); | |||
left: 0; | |||
} | |||
.location-index > .location-message::after { | |||
position: absolute; | |||
bottom: -5px; | |||
left: 4px; | |||
width: 0; | |||
height: 0; | |||
border-top: 5px solid #475760; | |||
border-left: 5px solid transparent; | |||
border-right: 5px solid transparent; | |||
content: ""; | |||
} | |||
.source-line-code .location-message { | |||
padding-top: 2px; | |||
padding-bottom: 2px; | |||
} |
@@ -0,0 +1,36 @@ | |||
/* | |||
* 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 classNames from 'classnames'; | |||
import './LocationMessage.css'; | |||
type Props = { | |||
children?: React.Element<*>, | |||
selected: boolean | |||
}; | |||
export default function LocationMessage(props: Props) { | |||
return ( | |||
<div className={classNames('location-message', { selected: props.selected })}> | |||
{props.children} | |||
</div> | |||
); | |||
} |
@@ -27,7 +27,7 @@ export type TextRange = { | |||
export type FlowLocation = { | |||
msg: string, | |||
textRange?: TextRange | |||
textRange: TextRange | |||
}; | |||
export type IssueComment = { | |||
@@ -59,9 +59,7 @@ export type Issue = { | |||
creationDate: string, | |||
effort?: string, | |||
key: string, | |||
flows: Array<{ | |||
locations?: Array<FlowLocation> | |||
}>, | |||
flows: Array<Array<FlowLocation>>, | |||
line?: number, | |||
message: string, | |||
organization: string, | |||
@@ -72,6 +70,7 @@ export type Issue = { | |||
resolution?: string, | |||
rule: string, | |||
ruleName: string, | |||
secondaryLocations: Array<FlowLocation>, | |||
severity: string, | |||
status: string, | |||
subProject?: string, |
@@ -41,21 +41,19 @@ it('should populate comments data', () => { | |||
} | |||
] | |||
}; | |||
expect(parseIssueFromResponse(issue, undefined, users, undefined)).toEqual({ | |||
comments: [ | |||
{ | |||
author: 'admin', | |||
authorActive: true, | |||
authorAvatar: 'c1244e6857f7be3dc4549d9e9d51c631', | |||
authorLogin: 'admin', | |||
authorName: 'Admin Admin', | |||
createdAt: '2017-04-11T10:38:09+0200', | |||
htmlText: 'comment!', | |||
key: 'AVtcKbZkQmGLa7yW8J71', | |||
login: undefined, | |||
markdown: 'comment!', | |||
updatable: true | |||
} | |||
] | |||
}); | |||
expect(parseIssueFromResponse(issue, undefined, users, undefined).comments).toEqual([ | |||
{ | |||
author: 'admin', | |||
authorActive: true, | |||
authorAvatar: 'c1244e6857f7be3dc4549d9e9d51c631', | |||
authorLogin: 'admin', | |||
authorName: 'Admin Admin', | |||
createdAt: '2017-04-11T10:38:09+0200', | |||
htmlText: 'comment!', | |||
key: 'AVtcKbZkQmGLa7yW8J71', | |||
login: undefined, | |||
markdown: 'comment!', | |||
updatable: true | |||
} | |||
]); | |||
}); |
@@ -18,9 +18,9 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
// @flow | |||
import { sortBy } from 'lodash'; | |||
import { flatten, sortBy } from 'lodash'; | |||
import { SEVERITIES } from './constants'; | |||
import type { Issue } from '../components/issue/types'; | |||
import type { Issue, FlowLocation } from '../components/issue/types'; | |||
type TextRange = { | |||
startLine: number, | |||
@@ -42,11 +42,8 @@ type RawIssue = { | |||
author: string, | |||
comments?: Array<Comment>, | |||
component: string, | |||
flows: Array<{ | |||
locations: Array<{ | |||
msg: string, | |||
textRange: TextRange | |||
}> | |||
flows?: Array<{ | |||
locations?: Array<{ msg: string, textRange?: TextRange }> | |||
}>, | |||
key: string, | |||
line?: number, | |||
@@ -111,12 +108,29 @@ const ensureTextRange = (issue: RawIssue) => { | |||
: {}; | |||
}; | |||
const splitFlows = ( | |||
issue: RawIssue | |||
// $FlowFixMe textRange is not null | |||
): { secondaryLocations: Array<FlowLocation>, flows: Array<Array<FlowLocation>> } => { | |||
const parsedFlows = (issue.flows || []) | |||
.filter(flow => flow.locations != null) | |||
// $FlowFixMe flow.locations is not null | |||
.map(flow => flow.locations.filter(location => location.textRange != null)); | |||
const onlySecondaryLocations = parsedFlows.every(flow => flow.length === 1); | |||
return onlySecondaryLocations | |||
? { secondaryLocations: flatten(parsedFlows), flows: [] } | |||
: { secondaryLocations: [], flows: parsedFlows }; | |||
}; | |||
export const parseIssueFromResponse = ( | |||
issue: Object, | |||
components?: Array<*>, | |||
users?: Array<*>, | |||
rules?: Array<*> | |||
): Issue => { | |||
const { secondaryLocations, flows } = splitFlows(issue); | |||
return { | |||
...issue, | |||
...injectRelational(issue, components, 'component', 'key'), | |||
@@ -126,6 +140,8 @@ export const parseIssueFromResponse = ( | |||
...injectRelational(issue, users, 'assignee', 'login'), | |||
...injectCommentsRelational(issue, users), | |||
...prepareClosed(issue), | |||
...ensureTextRange(issue) | |||
...ensureTextRange(issue), | |||
secondaryLocations, | |||
flows | |||
}; | |||
}; |
@@ -694,6 +694,7 @@ issues.by_creation_date=by creation date | |||
issues.issues=issues | |||
issues.to_select_issues=to select issues | |||
issues.to_navigate=to navigate | |||
issues.to_navigate_issue_locations=to navigate issue locations | |||
issues.leak_period=Leak Period | |||
issues.my_issues=My Issues | |||
@@ -2551,7 +2552,6 @@ source_viewer.tooltip.new_code=New {0}. | |||
source_viewer.load_more_code=Load More Code | |||
source_viewer.loading_more_code=Loading More Code... | |||
source_viewer.to_navigate_issue_locations=to quicky navigate issue locations | |||