diff options
Diffstat (limited to 'server/sonar-web/src/main/js/components')
75 files changed, 904 insertions, 2345 deletions
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.js b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.js index 0acd2dc123d..41517e76d50 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.js +++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.js @@ -21,7 +21,6 @@ import { connect } from 'react-redux'; import SourceViewerBase from './SourceViewerBase'; import { receiveFavorites } from '../../store/favorites/duck'; -import { receiveIssues } from '../../store/issues/duck'; const mapStateToProps = null; @@ -39,11 +38,6 @@ const onReceiveComponent = (component: { key: string, canMarkAsFavorite: boolean } }; -const onReceiveIssues = (issues: Array<*>) => - dispatch => { - dispatch(receiveIssues(issues)); - }; - -const mapDispatchToProps = { onReceiveComponent, onReceiveIssues }; +const mapDispatchToProps = { onReceiveComponent }; export default connect(mapStateToProps, mapDispatchToProps)(SourceViewerBase); diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.js b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.js index c0cae1208f8..2e66388c3a1 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.js +++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.js @@ -70,10 +70,10 @@ type Props = { loadIssues: (string, number, number) => Promise<*>, loadSources: (string, number, number) => Promise<*>, onLoaded?: (component: Object, sources: Array<*>, issues: Array<*>) => void, + onIssueChange?: (Issue) => void, onIssueSelect?: (string) => void, onIssueUnselect?: () => void, onReceiveComponent: ({ canMarkAsFavorite: boolean, fav: boolean, key: string }) => void, - onReceiveIssues: (issues: Array<*>) => void, selectedIssue?: string }; @@ -93,7 +93,7 @@ type State = { highlightedLine: number | null, highlightedSymbols: Array<string>, issues?: Array<Issue>, - issuesByLine: { [number]: Array<string> }, + issuesByLine: { [number]: Array<Issue> }, issueLocationsByLine: { [number]: Array<LinearIssueLocation> }, issueSecondaryLocationsByIssueByLine: IndexedIssueLocationsByIssueAndLine, issueSecondaryLocationMessagesByIssueByLine: IndexedIssueLocationMessagesByIssueAndLine, @@ -221,10 +221,8 @@ export default class SourceViewerBase extends React.Component { fetchComponent() { this.setState({ loading: true }); - const loadIssues = (component, sources) => { this.props.loadIssues(this.props.component, 1, LINES).then(issues => { - this.props.onReceiveIssues(issues); if (this.mounted) { const finalSources = sources.slice(0, LINES); this.setState( @@ -329,7 +327,6 @@ export default class SourceViewerBase extends React.Component { const from = Math.max(1, firstSourceLine.line - LINES); this.props.loadSources(this.props.component, from, firstSourceLine.line - 1).then(sources => { this.props.loadIssues(this.props.component, from, firstSourceLine.line - 1).then(issues => { - this.props.onReceiveIssues(issues); if (this.mounted) { this.setState(prevState => ({ issues: uniqBy([...issues, ...prevState.issues], issue => issue.key), @@ -353,7 +350,6 @@ export default class SourceViewerBase extends React.Component { const toLine = lastSourceLine.line + LINES + 1; this.props.loadSources(this.props.component, fromLine, toLine).then(sources => { this.props.loadIssues(this.props.component, fromLine, toLine).then(issues => { - this.props.onReceiveIssues(issues); if (this.mounted) { this.setState(prevState => ({ issues: uniqBy([...prevState.issues, ...issues], issue => issue.key), @@ -534,6 +530,16 @@ export default class SourceViewerBase extends React.Component { })); }; + handleIssueChange = (issue: Issue) => { + this.setState(state => { + const issues = state.issues.map(candidate => candidate.key === issue.key ? issue : candidate); + return { issues, issuesByLine: issuesByLine(issues) }; + }); + if (this.props.onIssueChange) { + this.props.onIssueChange(issue); + } + }; + renderCode(sources: Array<SourceLine>) { const hasSourcesBefore = sources.length > 0 && sources[0].line > 1; return ( @@ -561,6 +567,7 @@ export default class SourceViewerBase extends React.Component { loadingSourcesBefore={this.state.loadingSourcesBefore} onCoverageClick={this.handleCoverageClick} onDuplicationClick={this.handleDuplicationClick} + onIssueChange={this.handleIssueChange} onIssueSelect={this.handleIssueSelect} onIssueUnselect={this.handleIssueUnselect} onIssuesOpen={this.handleOpenIssues} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.js b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.js index 8b9cfb46bd5..64aeedd5ba6 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.js +++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.js @@ -40,7 +40,7 @@ const ZERO_LINE = { }; export default class SourceViewerCode extends React.PureComponent { - props: { + props: {| displayAllIssues: boolean, duplications?: Array<Duplication>, duplicationsByLine: { [number]: Array<number> }, @@ -51,7 +51,7 @@ export default class SourceViewerCode extends React.PureComponent { highlightedLine: number | null, highlightedSymbols: Array<string>, issues: Array<Issue>, - issuesByLine: { [number]: Array<string> }, + issuesByLine: { [number]: Array<Issue> }, issueLocationsByLine: { [number]: Array<LinearIssueLocation> }, issueSecondaryLocationsByIssueByLine: IndexedIssueLocationsByIssueAndLine, issueSecondaryLocationMessagesByIssueByLine: IndexedIssueLocationMessagesByIssueAndLine, @@ -62,6 +62,7 @@ export default class SourceViewerCode extends React.PureComponent { loadingSourcesBefore: boolean, onCoverageClick: (SourceLine, HTMLElement) => void, onDuplicationClick: (number, number) => void, + onIssueChange: (Issue) => void, onIssueSelect: (string) => void, onIssueUnselect: () => void, onIssuesOpen: (SourceLine) => void, @@ -75,13 +76,13 @@ export default class SourceViewerCode extends React.PureComponent { selectedIssueLocation: IndexedIssueLocation | null, sources: Array<SourceLine>, symbolsByLine: { [number]: Array<string> } - }; + |}; getDuplicationsForLine(line: SourceLine) { return this.props.duplicationsByLine[line.line] || EMPTY_ARRAY; } - getIssuesForLine(line: SourceLine): Array<string> { + getIssuesForLine(line: SourceLine): Array<Issue> { return this.props.issuesByLine[line.line] || EMPTY_ARRAY; } @@ -98,8 +99,11 @@ export default class SourceViewerCode extends React.PureComponent { } getSecondaryIssueLocationMessagesForLine(line: SourceLine, issueKey: string) { - return this.props.issueSecondaryLocationMessagesByIssueByLine[issueKey][line.line] || - EMPTY_ARRAY; + const index = this.props.issueSecondaryLocationMessagesByIssueByLine; + if (index[issueKey] == null) { + return EMPTY_ARRAY; + } + return index[issueKey][line.line] || EMPTY_ARRAY; } renderLine = ( @@ -131,7 +135,8 @@ export default class SourceViewerCode extends React.PureComponent { optimizedHighlightedSymbols = EMPTY_ARRAY; } - const optimizedSelectedIssue = selectedIssue != null && issuesForLine.includes(selectedIssue) + const optimizedSelectedIssue = selectedIssue != null && + issuesForLine.find(issue => issue.key === selectedIssue) ? selectedIssue : null; @@ -165,6 +170,7 @@ export default class SourceViewerCode extends React.PureComponent { onClick={this.props.onLineClick} onCoverageClick={this.props.onCoverageClick} onDuplicationClick={this.props.onDuplicationClick} + onIssueChange={this.props.onIssueChange} onIssueSelect={this.props.onIssueSelect} onIssueUnselect={this.props.onIssueUnselect} onIssuesOpen={this.props.onIssuesOpen} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeader.js b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeader.js index 4b65cd32ede..523ceeb192f 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeader.js +++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeader.js @@ -20,7 +20,7 @@ // @flow import React from 'react'; import { Link } from 'react-router'; -import QualifierIcon from '../shared/qualifier-icon'; +import QualifierIcon from '../shared/QualifierIcon'; import FavoriteContainer from '../controls/FavoriteContainer'; import { getProjectUrl, getIssuesUrl } from '../../helpers/urls'; import { collapsedDirFromPath, fileFromPath } from '../../helpers/path'; @@ -44,7 +44,8 @@ export default class SourceViewerHeader extends React.PureComponent { projectName: string, q: string, subProject?: string, - subProjectName?: string + subProjectName?: string, + uuid: string }, openNewWindow: () => void, showMeasures: () => void @@ -76,7 +77,8 @@ export default class SourceViewerHeader extends React.PureComponent { projectName, q, subProject, - subProjectName + subProjectName, + uuid } = this.props.component; const isUnitTest = q === 'UTS'; // TODO check if source viewer is displayed inside workspace @@ -169,7 +171,7 @@ export default class SourceViewerHeader extends React.PureComponent { <div className="source-viewer-header-measure"> <span className="source-viewer-header-measure-value"> <Link - to={getIssuesUrl({ resolved: 'false', componentKeys: key })} + to={getIssuesUrl({ resolved: 'false', fileUuids: uuid })} className="source-viewer-header-external-link" target="_blank"> {measures.issues != null ? formatMeasure(measures.issues, 'SHORT_INT') : 0} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/Line.js b/server/sonar-web/src/main/js/components/SourceViewer/components/Line.js index 74f3dabbfae..b1d051389a5 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/Line.js +++ b/server/sonar-web/src/main/js/components/SourceViewer/components/Line.js @@ -26,7 +26,7 @@ import LineSCM from './LineSCM'; import LineCoverage from './LineCoverage'; import LineDuplications from './LineDuplications'; import LineDuplicationBlock from './LineDuplicationBlock'; -import LineIssuesIndicatorContainer from './LineIssuesIndicatorContainer'; +import LineIssuesIndicator from './LineIssuesIndicator'; import LineCode from './LineCode'; import { TooltipsContainer } from '../../mixins/tooltips-mixin'; import type { SourceLine } from '../types'; @@ -35,8 +35,9 @@ import type { IndexedIssueLocation, IndexedIssueLocationMessage } from '../helpers/indexing'; +import type { Issue } from '../../issue/types'; -type Props = { +type Props = {| displayAllIssues: boolean, displayCoverage: boolean, displayDuplications: boolean, @@ -48,12 +49,13 @@ type Props = { highlighted: boolean, highlightedSymbols: Array<string>, issueLocations: Array<LinearIssueLocation>, - issues: Array<string>, + issues: Array<Issue>, line: SourceLine, loadDuplications: (SourceLine, HTMLElement) => void, onClick: (SourceLine, HTMLElement) => void, onCoverageClick: (SourceLine, HTMLElement) => void, onDuplicationClick: (number, number) => void, + onIssueChange: (Issue) => void, onIssueSelect: (string) => void, onIssueUnselect: () => void, onIssuesOpen: (SourceLine) => void, @@ -68,7 +70,7 @@ type Props = { // $FlowFixMe secondaryIssueLocationMessages: Array<IndexedIssueLocationMessage>, selectedIssueLocation: IndexedIssueLocation | null -}; +|}; export default class Line extends React.PureComponent { props: Props; @@ -82,7 +84,7 @@ export default class Line extends React.PureComponent { const { issues } = this.props; if (issues.length > 0) { - this.props.onIssueSelect(issues[0]); + this.props.onIssueSelect(issues[0].key); } } }; @@ -124,8 +126,8 @@ export default class Line extends React.PureComponent { {this.props.displayIssues && !this.props.displayAllIssues && - <LineIssuesIndicatorContainer - issueKeys={this.props.issues} + <LineIssuesIndicator + issues={this.props.issues} line={line} onClick={this.handleIssuesIndicatorClick} />} @@ -137,9 +139,10 @@ export default class Line extends React.PureComponent { <LineCode highlightedSymbols={this.props.highlightedSymbols} - issueKeys={this.props.issues} + issues={this.props.issues} issueLocations={this.props.issueLocations} line={line} + onIssueChange={this.props.onIssueChange} onIssueSelect={this.props.onIssueSelect} onLocationSelect={this.props.onLocationSelect} onSymbolClick={this.props.onSymbolClick} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/LineCode.js b/server/sonar-web/src/main/js/components/SourceViewer/components/LineCode.js index b5813f76365..6b18e06791b 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/LineCode.js +++ b/server/sonar-web/src/main/js/components/SourceViewer/components/LineCode.js @@ -34,12 +34,14 @@ import type { IndexedIssueLocation, IndexedIssueLocationMessage } from '../helpers/indexing'; +import type { Issue } from '../../issue/types'; -type Props = { +type Props = {| highlightedSymbols: Array<string>, - issueKeys: Array<string>, + issues: Array<Issue>, issueLocations: Array<LinearIssueLocation>, line: SourceLine, + onIssueChange: (Issue) => void, onIssueSelect: (issueKey: string) => void, onLocationSelect: (flowIndex: number, locationIndex: number) => void, onSymbolClick: (Array<string>) => void, @@ -49,7 +51,7 @@ type Props = { selectedIssue: string | null, selectedIssueLocation: IndexedIssueLocation | null, showIssues: boolean -}; +|}; type State = { tokens: Tokens @@ -166,7 +168,7 @@ export default class LineCode extends React.PureComponent { render() { const { highlightedSymbols, - issueKeys, + issues, issueLocations, line, onIssueSelect, @@ -201,7 +203,7 @@ export default class LineCode extends React.PureComponent { const finalCode = generateHTML(tokens); const className = classNames('source-line-code', 'code', { - 'has-issues': issueKeys.length > 0 + 'has-issues': issues.length > 0 }); return ( @@ -213,9 +215,10 @@ export default class LineCode extends React.PureComponent { this.renderSecondaryIssueLocationMessages(secondaryIssueLocationMessages)} </div> {showIssues && - issueKeys.length > 0 && + issues.length > 0 && <LineIssuesList - issueKeys={issueKeys} + issues={issues} + onIssueChange={this.props.onIssueChange} onIssueClick={onIssueSelect} selectedIssue={selectedIssue} />} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicator.js b/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicator.js index daf1785ffd2..b7f1c2a176e 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicator.js +++ b/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicator.js @@ -23,9 +23,10 @@ import classNames from 'classnames'; import SeverityIcon from '../../shared/SeverityIcon'; import { sortBySeverity } from '../../../helpers/issues'; import type { SourceLine } from '../types'; +import type { Issue } from '../../issue/types'; type Props = { - issues: Array<{ severity: string }>, + issues: Array<Issue>, line: SourceLine, onClick: () => void }; diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesList.js b/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesList.js index ca89ab51fae..bff245af97c 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesList.js +++ b/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesList.js @@ -19,10 +19,12 @@ */ // @flow import React from 'react'; -import ConnectedIssue from '../../issue/ConnectedIssue'; +import Issue from '../../issue/Issue'; +import type { Issue as IssueType } from '../../issue/types'; type Props = { - issueKeys: Array<string>, + issues: Array<IssueType>, + onIssueChange: (IssueType) => void, onIssueClick: (issueKey: string) => void, selectedIssue: string | null }; @@ -31,16 +33,17 @@ export default class LineIssuesList extends React.PureComponent { props: Props; render() { - const { issueKeys, onIssueClick, selectedIssue } = this.props; + const { issues, onIssueClick, selectedIssue } = this.props; return ( <div className="issue-list"> - {issueKeys.map(issueKey => ( - <ConnectedIssue - issueKey={issueKey} - key={issueKey} + {issues.map(issue => ( + <Issue + issue={issue} + key={issue.key} + onChange={this.props.onIssueChange} onClick={onIssueClick} - selected={selectedIssue === issueKey} + selected={selectedIssue === issue.key} /> ))} </div> diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineCode-test.js b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineCode-test.js index 3cc6793b214..5cd841a2a5a 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineCode-test.js +++ b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineCode-test.js @@ -34,7 +34,7 @@ it('render code', () => { const wrapper = shallow( <LineCode highlightedSymbols={['sym1']} - issueKeys={['issue-1', 'issue-2']} + issues={[{ key: 'issue-1' }, { key: 'issue-2' }]} issueLocations={issueLocations} line={line} onIssueSelect={jest.fn()} @@ -62,7 +62,7 @@ it('should handle empty location message', () => { const wrapper = shallow( <LineCode highlightedSymbols={['sym1']} - issueKeys={['issue-1', 'issue-2']} + issues={[{ key: 'issue-1' }, { key: 'issue-2' }]} issueLocations={issueLocations} line={line} onIssueSelect={jest.fn()} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineIssuesList-test.js b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineIssuesList-test.js index ede9d50241c..a9a0e0763b9 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineIssuesList-test.js +++ b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineIssuesList-test.js @@ -23,15 +23,10 @@ import LineIssuesList from '../LineIssuesList'; it('render issues list', () => { const line = { line: 3 }; - const issueKeys = ['foo', 'bar']; + const issues = [{ key: 'foo' }, { key: 'bar' }]; const onIssueClick = jest.fn(); const wrapper = shallow( - <LineIssuesList - issueKeys={issueKeys} - line={line} - onIssueClick={onIssueClick} - selectedIssue="foo" - /> + <LineIssuesList issues={issues} line={line} onIssueClick={onIssueClick} selectedIssue="foo" /> ); expect(wrapper).toMatchSnapshot(); }); diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineCode-test.js.snap b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineCode-test.js.snap index 3e4499bb1bf..ca94ecedbf7 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineCode-test.js.snap +++ b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineCode-test.js.snap @@ -22,10 +22,14 @@ exports[`test render code 1`] = ` </div> </div> <LineIssuesList - issueKeys={ + issues={ Array [ - "issue-1", - "issue-2", + Object { + "key": "issue-1", + }, + Object { + "key": "issue-2", + }, ] } onIssueClick={[Function]} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineIssuesList-test.js.snap b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineIssuesList-test.js.snap index 30bbfaa7779..090d0852df8 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineIssuesList-test.js.snap +++ b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineIssuesList-test.js.snap @@ -1,12 +1,20 @@ exports[`test render issues list 1`] = ` <div className="issue-list"> - <Connect(BaseIssue) - issueKey="foo" + <BaseIssue + issue={ + Object { + "key": "foo", + } + } onClick={[Function]} selected={true} /> - <Connect(BaseIssue) - issueKey="bar" + <BaseIssue + issue={ + Object { + "key": "bar", + } + } onClick={[Function]} selected={false} /> </div> diff --git a/server/sonar-web/src/main/js/components/SourceViewer/helpers/indexing.js b/server/sonar-web/src/main/js/components/SourceViewer/helpers/indexing.js index 36bf7e73b3a..d39351c52dc 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/helpers/indexing.js +++ b/server/sonar-web/src/main/js/components/SourceViewer/helpers/indexing.js @@ -64,7 +64,7 @@ export const issuesByLine = (issues: Array<Issue>) => { if (!(line in index)) { index[line] = []; } - index[line].push(issue.key); + index[line].push(issue); }); return index; }; diff --git a/server/sonar-web/src/main/js/components/__tests__/issue-test.js b/server/sonar-web/src/main/js/components/__tests__/issue-test.js deleted file mode 100644 index b0483b8cc21..00000000000 --- a/server/sonar-web/src/main/js/components/__tests__/issue-test.js +++ /dev/null @@ -1,197 +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. - */ -import Issue from '../issue/models/issue'; - -describe('Model', () => { - it('should have correct urlRoot', () => { - const issue = new Issue(); - expect(issue.urlRoot()).toBe('/api/issues'); - }); - - it('should parse response without root issue object', () => { - const issue = new Issue(); - const example = { a: 1 }; - expect(issue.parse(example)).toEqual(example); - }); - - it('should parse response with the root issue object', () => { - const issue = new Issue(); - const example = { a: 1 }; - expect(issue.parse({ issue: example })).toEqual(example); - }); - - it('should reset attributes (no attributes initially)', () => { - const issue = new Issue(); - const example = { a: 1 }; - issue.reset(example); - expect(issue.toJSON()).toEqual(example); - }); - - it('should reset attributes (override attribute)', () => { - const issue = new Issue({ a: 2 }); - const example = { a: 1 }; - issue.reset(example); - expect(issue.toJSON()).toEqual(example); - }); - - it('should reset attributes (different attributes)', () => { - const issue = new Issue({ a: 2 }); - const example = { b: 1 }; - issue.reset(example); - expect(issue.toJSON()).toEqual(example); - }); - - it('should unset `textRange` of a closed issue', () => { - const issue = new Issue(); - const result = issue.parse({ issue: { status: 'CLOSED', textRange: { startLine: 5 } } }); - expect(result.textRange).toBeFalsy(); - }); - - it('should unset `flows` of a closed issue', () => { - const issue = new Issue(); - const result = issue.parse({ issue: { status: 'CLOSED', flows: [1, 2, 3] } }); - expect(result.flows).toEqual([]); - }); - - describe('Actions', () => { - it('should assign', () => { - const issue = new Issue({ key: 'issue-key' }); - const spy = jest.fn(); - issue._action = spy; - issue.assign('admin'); - expect(spy).toBeCalledWith({ - data: { assignee: 'admin', issue: 'issue-key' }, - url: '/api/issues/assign' - }); - }); - - it('should unassign', () => { - const issue = new Issue({ key: 'issue-key' }); - const spy = jest.fn(); - issue._action = spy; - issue.assign(); - expect(spy).toBeCalledWith({ - data: { assignee: undefined, issue: 'issue-key' }, - url: '/api/issues/assign' - }); - }); - - it('should plan', () => { - const issue = new Issue({ key: 'issue-key' }); - const spy = jest.fn(); - issue._action = spy; - issue.plan('plan'); - expect(spy).toBeCalledWith({ - data: { plan: 'plan', issue: 'issue-key' }, - url: '/api/issues/plan' - }); - }); - - it('should unplan', () => { - const issue = new Issue({ key: 'issue-key' }); - const spy = jest.fn(); - issue._action = spy; - issue.plan(); - expect(spy).toBeCalledWith({ - data: { plan: undefined, issue: 'issue-key' }, - url: '/api/issues/plan' - }); - }); - - it('should set severity', () => { - const issue = new Issue({ key: 'issue-key' }); - const spy = jest.fn(); - issue._action = spy; - issue.setSeverity('BLOCKER'); - expect(spy).toBeCalledWith({ - data: { severity: 'BLOCKER', issue: 'issue-key' }, - url: '/api/issues/set_severity' - }); - }); - }); - - describe('#getLinearLocations', () => { - it('should return single line location', () => { - const issue = new Issue({ - textRange: { startLine: 1, endLine: 1, startOffset: 0, endOffset: 10 } - }); - const locations = issue.getLinearLocations(); - expect(locations.length).toBe(1); - - expect(locations[0].line).toBe(1); - expect(locations[0].from).toBe(0); - expect(locations[0].to).toBe(10); - }); - - it('should return location not from 0', () => { - const issue = new Issue({ - textRange: { startLine: 1, endLine: 1, startOffset: 5, endOffset: 10 } - }); - const locations = issue.getLinearLocations(); - expect(locations.length).toBe(1); - - expect(locations[0].line).toBe(1); - expect(locations[0].from).toBe(5); - expect(locations[0].to).toBe(10); - }); - - it('should return 2-lines location', () => { - const issue = new Issue({ - textRange: { startLine: 2, endLine: 3, startOffset: 5, endOffset: 10 } - }); - const locations = issue.getLinearLocations(); - expect(locations.length).toBe(2); - - expect(locations[0].line).toBe(2); - expect(locations[0].from).toBe(5); - expect(locations[0].to).toBe(999999); - - expect(locations[1].line).toBe(3); - expect(locations[1].from).toBe(0); - expect(locations[1].to).toBe(10); - }); - - it('should return 3-lines location', () => { - const issue = new Issue({ - textRange: { startLine: 4, endLine: 6, startOffset: 5, endOffset: 10 } - }); - const locations = issue.getLinearLocations(); - expect(locations.length).toBe(3); - - expect(locations[0].line).toBe(4); - expect(locations[0].from).toBe(5); - expect(locations[0].to).toBe(999999); - - expect(locations[1].line).toBe(5); - expect(locations[1].from).toBe(0); - expect(locations[1].to).toBe(999999); - - expect(locations[2].line).toBe(6); - expect(locations[2].from).toBe(0); - expect(locations[2].to).toBe(10); - }); - - it('should return [] when no location', () => { - const issue = new Issue(); - const locations = issue.getLinearLocations(); - expect(locations.length).toBe(0); - }); - }); -}); diff --git a/server/sonar-web/src/main/js/components/charts/bar-chart.js b/server/sonar-web/src/main/js/components/charts/bar-chart.js index dc4f0082f27..4e1d336dc0d 100644 --- a/server/sonar-web/src/main/js/components/charts/bar-chart.js +++ b/server/sonar-web/src/main/js/components/charts/bar-chart.js @@ -21,7 +21,7 @@ import React from 'react'; import { max } from 'd3-array'; import { scaleLinear, scaleBand } from 'd3-scale'; import { ResizeMixin } from './../mixins/resize-mixin'; -import { TooltipsMixin } from './../mixins/tooltips-mixin'; +import { TooltipsContainer } from './../mixins/tooltips-mixin'; export const BarChart = React.createClass({ propTypes: { @@ -34,7 +34,7 @@ export const BarChart = React.createClass({ onBarClick: React.PropTypes.func }, - mixins: [ResizeMixin, TooltipsMixin], + mixins: [ResizeMixin], getDefaultProps() { return { @@ -162,13 +162,15 @@ export const BarChart = React.createClass({ const yScale = scaleLinear().domain([0, maxY]).range([availableHeight, 0]); return ( - <svg className="bar-chart" width={this.state.width} height={this.state.height}> - <g transform={`translate(${this.props.padding[3]}, ${this.props.padding[0]})`}> - {this.renderXTicks(xScale, yScale)} - {this.renderXValues(xScale, yScale)} - {this.renderBars(xScale, yScale)} - </g> - </svg> + <TooltipsContainer> + <svg className="bar-chart" width={this.state.width} height={this.state.height}> + <g transform={`translate(${this.props.padding[3]}, ${this.props.padding[0]})`}> + {this.renderXTicks(xScale, yScale)} + {this.renderXValues(xScale, yScale)} + {this.renderBars(xScale, yScale)} + </g> + </svg> + </TooltipsContainer> ); } }); diff --git a/server/sonar-web/src/main/js/components/charts/treemap-breadcrumbs.js b/server/sonar-web/src/main/js/components/charts/treemap-breadcrumbs.js index d91207af777..7b06317b39e 100644 --- a/server/sonar-web/src/main/js/components/charts/treemap-breadcrumbs.js +++ b/server/sonar-web/src/main/js/components/charts/treemap-breadcrumbs.js @@ -18,7 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import React from 'react'; -import QualifierIcon from '../shared/qualifier-icon'; +import QualifierIcon from '../shared/QualifierIcon'; export const TreemapBreadcrumbs = React.createClass({ propTypes: { diff --git a/server/sonar-web/src/main/js/components/common/EmptySearch.js b/server/sonar-web/src/main/js/components/common/EmptySearch.js new file mode 100644 index 00000000000..904a6b2cbad --- /dev/null +++ b/server/sonar-web/src/main/js/components/common/EmptySearch.js @@ -0,0 +1,39 @@ +/* + * 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 { css } from 'glamor'; +import { translate } from '../../helpers/l10n'; + +const EmptySearch = () => ( + <div + className={css({ + padding: '60px 0', + border: '1px solid #e6e6e6', + borderRadius: 2, + textAlign: 'center', + color: '#777' + })}> + <h3>{translate('no_results_search')}</h3> + <p className="big-spacer-top">{translate('no_results_search.2')}</p> + </div> +); + +export default EmptySearch; diff --git a/server/sonar-web/src/main/js/components/common/MarkdownTips.js b/server/sonar-web/src/main/js/components/common/MarkdownTips.js index 2d83b6aeb24..8c5db3a8fe1 100644 --- a/server/sonar-web/src/main/js/components/common/MarkdownTips.js +++ b/server/sonar-web/src/main/js/components/common/MarkdownTips.js @@ -25,7 +25,7 @@ import { translate } from '../../helpers/l10n'; export default class MarkdownTips extends React.PureComponent { handleClick(evt: MouseEvent) { evt.preventDefault(); - window.open(getMarkdownHelpUrl(), 'height=300,width=600,scrollbars=1,resizable=1'); + window.open(getMarkdownHelpUrl(), 'Markdown', 'height=300,width=600,scrollbars=1,resizable=1'); } render() { diff --git a/server/sonar-web/src/main/js/components/common/SelectList.js b/server/sonar-web/src/main/js/components/common/SelectList.js index ba2f82b34b7..bec5c2e6712 100644 --- a/server/sonar-web/src/main/js/components/common/SelectList.js +++ b/server/sonar-web/src/main/js/components/common/SelectList.js @@ -19,6 +19,8 @@ */ // @flow import React from 'react'; +import key from 'keymaster'; +import { uniqueId } from 'lodash'; import SelectListItem from './SelectListItem'; type Props = { @@ -33,7 +35,8 @@ type State = { }; export default class SelectList extends React.PureComponent { - list: HTMLElement; + currentKeyScope: string; + previousKeyScope: string; props: Props; state: State; @@ -45,7 +48,7 @@ export default class SelectList extends React.PureComponent { } componentDidMount() { - this.list.focus(); + this.attachShortcuts(); } componentWillReceiveProps(nextProps: Props) { @@ -57,24 +60,36 @@ export default class SelectList extends React.PureComponent { } } - handleKeyboard = (evt: KeyboardEvent) => { - switch (evt.keyCode) { - case 40: // down - this.setState(this.selectNextElement); - break; - case 38: // up - this.setState(this.selectPreviousElement); - break; - case 13: // return - if (this.state.active) { - this.handleSelect(this.state.active); - } - break; - default: - return; - } - evt.preventDefault(); - evt.stopPropagation(); + componentWillUnmount() { + this.detachShortcuts(); + } + + attachShortcuts = () => { + this.previousKeyScope = key.getScope(); + this.currentKeyScope = uniqueId('key-scope'); + key.setScope(this.currentKeyScope); + + key('down', this.currentKeyScope, () => { + this.setState(this.selectNextElement); + return false; + }); + + key('up', this.currentKeyScope, () => { + this.setState(this.selectPreviousElement); + return false; + }); + + key('return', this.currentKeyScope, () => { + if (this.state.active) { + this.handleSelect(this.state.active); + } + return false; + }); + }; + + detachShortcuts = () => { + key.setScope(this.previousKeyScope); + key.deleteScope(this.currentKeyScope); }; handleSelect = (item: string) => { @@ -105,18 +120,18 @@ export default class SelectList extends React.PureComponent { const { children } = this.props; const hasChildren = React.Children.count(children) > 0; return ( - <ul - className="menu" - onKeyDown={this.handleKeyboard} - ref={list => this.list = list} - tabIndex={0}> + <ul className="menu"> {hasChildren && - React.Children.map(children, child => - React.cloneElement(child, { - active: this.state.active, - onHover: this.handleHover, - onSelect: this.handleSelect - }))} + React.Children.map( + children, + child => + child != null && + React.cloneElement(child, { + active: this.state.active, + onHover: this.handleHover, + onSelect: this.handleSelect + }) + )} {!hasChildren && this.props.items.map(item => ( <SelectListItem diff --git a/server/sonar-web/src/main/js/components/common/__tests__/SelectList-test.js b/server/sonar-web/src/main/js/components/common/__tests__/SelectList-test.js index 9c0e88e6aa3..58afbecad26 100644 --- a/server/sonar-web/src/main/js/components/common/__tests__/SelectList-test.js +++ b/server/sonar-web/src/main/js/components/common/__tests__/SelectList-test.js @@ -64,11 +64,11 @@ it('should correclty handle user actions', () => { ))} </SelectList> ); - keydown(list.find('ul'), 40); + keydown(40); expect(list.state()).toMatchSnapshot(); - keydown(list.find('ul'), 40); + keydown(40); expect(list.state()).toMatchSnapshot(); - keydown(list.find('ul'), 38); + keydown(38); expect(list.state()).toMatchSnapshot(); click(list.childAt(2).find('a')); expect(onSelect.mock.calls).toMatchSnapshot(); // eslint-disable-linelist diff --git a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/SelectList-test.js.snap b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/SelectList-test.js.snap index 4cf15f469cb..b2d9388c7ef 100644 --- a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/SelectList-test.js.snap +++ b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/SelectList-test.js.snap @@ -26,9 +26,7 @@ Array [ exports[`test should render correctly with children 1`] = ` <ul - className="menu" - onKeyDown={[Function]} - tabIndex={0}> + className="menu"> <SelectListItem active="seconditem" item="item" @@ -61,9 +59,7 @@ exports[`test should render correctly with children 1`] = ` exports[`test should render correctly without children 1`] = ` <ul - className="menu" - onKeyDown={[Function]} - tabIndex={0}> + className="menu"> <SelectListItem active="seconditem" item="item" diff --git a/server/sonar-web/src/main/js/components/common/action-options-view.js b/server/sonar-web/src/main/js/components/common/action-options-view.js index 976f88eb081..a538e22b88e 100644 --- a/server/sonar-web/src/main/js/components/common/action-options-view.js +++ b/server/sonar-web/src/main/js/components/common/action-options-view.js @@ -18,6 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import $ from 'jquery'; +import key from 'keymaster'; import PopupView from './popup'; export default PopupView.extend({ diff --git a/server/sonar-web/src/main/js/components/common/modals.js b/server/sonar-web/src/main/js/components/common/modals.js index 5a1343bd511..a86a262d40c 100644 --- a/server/sonar-web/src/main/js/components/common/modals.js +++ b/server/sonar-web/src/main/js/components/common/modals.js @@ -19,6 +19,7 @@ */ import $ from 'jquery'; import Marionette from 'backbone.marionette'; +import key from 'keymaster'; const EVENT_SCOPE = 'modal'; diff --git a/server/sonar-web/src/main/js/components/common/popup.js b/server/sonar-web/src/main/js/components/common/popup.js index 532380f71b4..bce61b72938 100644 --- a/server/sonar-web/src/main/js/components/common/popup.js +++ b/server/sonar-web/src/main/js/components/common/popup.js @@ -19,6 +19,7 @@ */ import $ from 'jquery'; import Marionette from 'backbone.marionette'; +import key from 'keymaster'; export default Marionette.ItemView.extend({ className: 'bubble-popup', diff --git a/server/sonar-web/src/main/js/components/controls/Checkbox.js b/server/sonar-web/src/main/js/components/controls/Checkbox.js index f5e7289dd45..c81d49b8d8c 100644 --- a/server/sonar-web/src/main/js/components/controls/Checkbox.js +++ b/server/sonar-web/src/main/js/components/controls/Checkbox.js @@ -26,7 +26,7 @@ export default class Checkbox extends React.Component { onCheck: React.PropTypes.func.isRequired, checked: React.PropTypes.bool.isRequired, thirdState: React.PropTypes.bool, - className: React.PropTypes.string + className: React.PropTypes.any }; static defaultProps = { @@ -44,7 +44,9 @@ export default class Checkbox extends React.Component { } render() { - const className = classNames(this.props.className, 'icon-checkbox', { + const className = classNames('icon-checkbox', { + // trick to work with glamor + [this.props.className]: true, 'icon-checkbox-checked': this.props.checked, 'icon-checkbox-single': this.props.thirdState }); diff --git a/server/sonar-web/src/main/js/components/controls/DateInput.js b/server/sonar-web/src/main/js/components/controls/DateInput.js index 52b47cbe189..f4070c12119 100644 --- a/server/sonar-web/src/main/js/components/controls/DateInput.js +++ b/server/sonar-web/src/main/js/components/controls/DateInput.js @@ -19,11 +19,13 @@ */ import $ from 'jquery'; import React from 'react'; +import classNames from 'classnames'; import { pick } from 'lodash'; import './styles.css'; export default class DateInput extends React.Component { static propTypes = { + className: React.PropTypes.string, value: React.PropTypes.string, format: React.PropTypes.string, name: React.PropTypes.string, @@ -67,12 +69,12 @@ export default class DateInput extends React.Component { /* eslint max-len: 0 */ return ( - <span className="date-input-control"> + <span className={classNames('date-input-control', this.props.className)}> <input className="date-input-control-input" ref="input" type="text" - initialValue={this.props.value} + defaultValue={this.props.value} readOnly={true} {...inputProps} /> diff --git a/server/sonar-web/src/main/js/components/issue/BaseIssue.js b/server/sonar-web/src/main/js/components/issue/BaseIssue.js deleted file mode 100644 index d4ada02869b..00000000000 --- a/server/sonar-web/src/main/js/components/issue/BaseIssue.js +++ /dev/null @@ -1,153 +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 IssueView from './IssueView'; -import { setIssueAssignee } from '../../api/issues'; -import type { Issue } from './types'; - -type Props = { - checked?: boolean, - issue: Issue, - onCheck?: () => void, - onClick: (string) => void, - onFail: (Error) => void, - onFilterClick?: () => void, - onIssueChange: (Promise<*>, oldIssue?: Issue, newIssue?: Issue) => void, - selected: boolean -}; - -type State = { - currentPopup: string -}; - -export default class BaseIssue extends React.PureComponent { - mounted: boolean; - props: Props; - state: State; - - static defaultProps = { - selected: false - }; - - constructor(props: Props) { - super(props); - this.state = { - currentPopup: '' - }; - } - - componentDidMount() { - this.mounted = true; - if (this.props.selected) { - this.bindShortcuts(); - } - } - - componentWillUpdate(nextProps: Props) { - if (!nextProps.selected && this.props.selected) { - this.unbindShortcuts(); - } - } - - componentDidUpdate(prevProps: Props) { - if (!prevProps.selected && this.props.selected) { - this.bindShortcuts(); - } - } - - componentWillUnmount() { - this.mounted = false; - if (this.props.selected) { - this.unbindShortcuts(); - } - } - - bindShortcuts() { - document.addEventListener('keypress', this.handleKeyPress); - } - - unbindShortcuts() { - document.removeEventListener('keypress', this.handleKeyPress); - } - - togglePopup = (popupName: string, open?: boolean) => { - if (this.mounted) { - this.setState((prevState: State) => { - if (prevState.currentPopup !== popupName && open !== false) { - return { currentPopup: popupName }; - } else if (prevState.currentPopup === popupName && open !== true) { - return { currentPopup: '' }; - } - return prevState; - }); - } - }; - - handleAssignement = (login: string) => { - const { issue } = this.props; - if (issue.assignee !== login) { - this.props.onIssueChange(setIssueAssignee({ issue: issue.key, assignee: login })); - } - this.togglePopup('assign', false); - }; - - handleKeyPress = (e: Object) => { - const tagName = e.target.tagName.toUpperCase(); - const shouldHandle = tagName !== 'INPUT' && tagName !== 'TEXTAREA' && tagName !== 'BUTTON'; - - if (shouldHandle) { - switch (e.key) { - case 'f': - return this.togglePopup('transition'); - case 'a': - return this.togglePopup('assign'); - case 'm': - return this.props.issue.actions.includes('assign_to_me') && this.handleAssignement('_me'); - case 'p': - return this.togglePopup('plan'); - case 'i': - return this.togglePopup('set-severity'); - case 'c': - return this.togglePopup('comment'); - case 't': - return this.togglePopup('edit-tags'); - } - } - }; - - render() { - return ( - <IssueView - issue={this.props.issue} - checked={this.props.checked} - onAssign={this.handleAssignement} - onCheck={this.props.onCheck} - onClick={this.props.onClick} - onFail={this.props.onFail} - onFilterClick={this.props.onFilterClick} - onIssueChange={this.props.onIssueChange} - togglePopup={this.togglePopup} - currentPopup={this.state.currentPopup} - selected={this.props.selected} - /> - ); - } -} diff --git a/server/sonar-web/src/main/js/components/issue/ConnectedIssue.js b/server/sonar-web/src/main/js/components/issue/ConnectedIssue.js deleted file mode 100644 index 67d71fe37cc..00000000000 --- a/server/sonar-web/src/main/js/components/issue/ConnectedIssue.js +++ /dev/null @@ -1,36 +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 { connect } from 'react-redux'; -import BaseIssue from './BaseIssue'; -import { getIssueByKey } from '../../store/rootReducer'; -import { onFail } from '../../store/rootActions'; -import { updateIssue } from './actions'; - -const mapStateToProps = (state, ownProps) => ({ - issue: getIssueByKey(state, ownProps.issueKey) -}); - -const mapDispatchToProps = { - onIssueChange: updateIssue, - onFail: error => dispatch => onFail(dispatch)(error) -}; - -export default connect(mapStateToProps, mapDispatchToProps)(BaseIssue); diff --git a/server/sonar-web/src/main/js/components/issue/Issue.js b/server/sonar-web/src/main/js/components/issue/Issue.js index a121bf738d0..471a62e04f0 100644 --- a/server/sonar-web/src/main/js/components/issue/Issue.js +++ b/server/sonar-web/src/main/js/components/issue/Issue.js @@ -18,14 +18,145 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ // @flow -import { connect } from 'react-redux'; -import BaseIssue from './BaseIssue'; -import { onFail } from '../../store/rootActions'; +import React from 'react'; +import IssueView from './IssueView'; import { updateIssue } from './actions'; +import { setIssueAssignee } from '../../api/issues'; +import { onFail } from '../../store/rootActions'; +import type { Issue } from './types'; + +type Props = {| + checked?: boolean, + issue: Issue, + onChange: (Issue) => void, + onCheck?: (string) => void, + onClick: (string) => void, + onFilter?: (property: string, issue: Issue) => void, + selected: boolean +|}; -const mapDispatchToProps = { - onIssueChange: updateIssue, - onFail: error => dispatch => onFail(dispatch)(error) +type State = { + currentPopup: string }; -export default connect(null, mapDispatchToProps)(BaseIssue); +export default class BaseIssue extends React.PureComponent { + mounted: boolean; + props: Props; + state: State; + + static contextTypes = { + store: React.PropTypes.object + }; + + static defaultProps = { + selected: false + }; + + constructor(props: Props) { + super(props); + this.state = { + currentPopup: '' + }; + } + + componentDidMount() { + this.mounted = true; + if (this.props.selected) { + this.bindShortcuts(); + } + } + + componentWillUpdate(nextProps: Props) { + if (!nextProps.selected && this.props.selected) { + this.unbindShortcuts(); + } + } + + componentDidUpdate(prevProps: Props) { + if (!prevProps.selected && this.props.selected) { + this.bindShortcuts(); + } + } + + componentWillUnmount() { + this.mounted = false; + if (this.props.selected) { + this.unbindShortcuts(); + } + } + + bindShortcuts() { + document.addEventListener('keypress', this.handleKeyPress); + } + + unbindShortcuts() { + document.removeEventListener('keypress', this.handleKeyPress); + } + + togglePopup = (popupName: string, open?: boolean) => { + if (this.mounted) { + this.setState((prevState: State) => { + if (prevState.currentPopup !== popupName && open !== false) { + return { currentPopup: popupName }; + } else if (prevState.currentPopup === popupName && open !== true) { + return { currentPopup: '' }; + } + return prevState; + }); + } + }; + + handleAssignement = (login: string) => { + const { issue } = this.props; + if (issue.assignee !== login) { + updateIssue(this.props.onChange, setIssueAssignee({ issue: issue.key, assignee: login })); + } + this.togglePopup('assign', false); + }; + + handleFail = (error: Error) => { + onFail(this.context.store.dispatch)(error); + }; + + handleKeyPress = (e: Object) => { + const tagName = e.target.tagName.toUpperCase(); + const shouldHandle = tagName !== 'INPUT' && tagName !== 'TEXTAREA' && tagName !== 'BUTTON'; + + if (shouldHandle) { + switch (e.key) { + case 'f': + return this.togglePopup('transition'); + case 'a': + return this.togglePopup('assign'); + case 'm': + return this.props.issue.actions.includes('assign_to_me') && this.handleAssignement('_me'); + case 'p': + return this.togglePopup('plan'); + case 'i': + return this.togglePopup('set-severity'); + case 'c': + return this.togglePopup('comment'); + case 't': + return this.togglePopup('edit-tags'); + } + } + }; + + render() { + return ( + <IssueView + issue={this.props.issue} + checked={this.props.checked} + onAssign={this.handleAssignement} + onCheck={this.props.onCheck} + onClick={this.props.onClick} + onFail={this.handleFail} + onFilter={this.props.onFilter} + onChange={this.props.onChange} + togglePopup={this.togglePopup} + currentPopup={this.state.currentPopup} + selected={this.props.selected} + /> + ); + } +} diff --git a/server/sonar-web/src/main/js/components/issue/IssueView.js b/server/sonar-web/src/main/js/components/issue/IssueView.js index 52ee7e95280..3d959f26573 100644 --- a/server/sonar-web/src/main/js/components/issue/IssueView.js +++ b/server/sonar-web/src/main/js/components/issue/IssueView.js @@ -20,43 +20,51 @@ // @flow import React from 'react'; import classNames from 'classnames'; -import Checkbox from '../../components/controls/Checkbox'; import IssueTitleBar from './components/IssueTitleBar'; import IssueActionsBar from './components/IssueActionsBar'; import IssueCommentLine from './components/IssueCommentLine'; +import { updateIssue } from './actions'; import { deleteIssueComment, editIssueComment } from '../../api/issues'; import type { Issue } from './types'; -type Props = { +type Props = {| checked?: boolean, currentPopup: string, issue: Issue, onAssign: (string) => void, - onCheck?: () => void, + onChange: (Issue) => void, + onCheck?: (string) => void, onClick: (string) => void, onFail: (Error) => void, - onFilterClick?: () => void, - onIssueChange: (Promise<*>, oldIssue?: Issue, newIssue?: Issue) => void, + onFilter?: (property: string, issue: Issue) => void, selected: boolean, togglePopup: (string) => void -}; +|}; export default class IssueView extends React.PureComponent { props: Props; - handleClick = (evt: MouseEvent) => { - evt.preventDefault(); + handleCheck = (event: Event) => { + event.preventDefault(); + event.stopPropagation(); + if (this.props.onCheck) { + this.props.onCheck(this.props.issue.key); + } + }; + + handleClick = (event: Event & { target: HTMLElement }) => { + event.preventDefault(); if (this.props.onClick) { this.props.onClick(this.props.issue.key); } }; editComment = (comment: string, text: string) => { - this.props.onIssueChange(editIssueComment({ comment, text })); + updateIssue(this.props.onChange, editIssueComment({ comment, text })); }; deleteComment = (comment: string) => { - this.props.onIssueChange(deleteIssueComment({ comment })); + updateIssue(this.props.onChange, deleteIssueComment({ comment })); }; render() { @@ -74,13 +82,13 @@ export default class IssueView extends React.PureComponent { className={issueClass} data-issue={issue.key} onClick={this.handleClick} - tabIndex={0} - role="listitem"> + role="listitem" + tabIndex={0}> <IssueTitleBar issue={issue} currentPopup={this.props.currentPopup} onFail={this.props.onFail} - onFilterClick={this.props.onFilterClick} + onFilter={this.props.onFilter} togglePopup={this.props.togglePopup} /> <IssueActionsBar @@ -89,7 +97,7 @@ export default class IssueView extends React.PureComponent { onAssign={this.props.onAssign} onFail={this.props.onFail} togglePopup={this.props.togglePopup} - onIssueChange={this.props.onIssueChange} + onChange={this.props.onChange} /> {issue.comments && issue.comments.length > 0 && @@ -108,13 +116,13 @@ export default class IssueView extends React.PureComponent { <i className="issue-navigate-to-right icon-chevron-right" /> </a> {hasCheckbox && - <div className="js-toggle issue-checkbox-container"> - <Checkbox - className="issue-checkbox" - onCheck={this.props.onCheck} - checked={this.props.checked} + <a className="js-toggle issue-checkbox-container" href="#" onClick={this.handleCheck}> + <i + className={classNames('issue-checkbox', 'icon-checkbox', { + 'icon-checkbox-checked': this.props.checked + })} /> - </div>} + </a>} </div> ); } diff --git a/server/sonar-web/src/main/js/components/issue/actions.js b/server/sonar-web/src/main/js/components/issue/actions.js index a0631c17001..a44430520bd 100644 --- a/server/sonar-web/src/main/js/components/issue/actions.js +++ b/server/sonar-web/src/main/js/components/issue/actions.js @@ -18,35 +18,41 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ // @flow -import type { Dispatch } from 'redux'; -import type { Issue } from './types'; import { onFail } from '../../store/rootActions'; -import { receiveIssues } from '../../store/issues/duck'; import { parseIssueFromResponse } from '../../helpers/issues'; +import type { Issue } from './types'; -export const updateIssue = (resultPromise: Promise<*>, oldIssue?: Issue, newIssue?: Issue) => - (dispatch: Dispatch<*>) => { - if (oldIssue && newIssue) { - dispatch(receiveIssues([newIssue])); - } - resultPromise.then( - response => { - dispatch( - receiveIssues([ - parseIssueFromResponse( - response.issue, - response.components, - response.users, - response.rules - ) - ]) +export const updateIssue = ( + onChange: (Issue) => void, + resultPromise: Promise<*>, + oldIssue?: Issue, + newIssue?: Issue +) => { + const optimisticUpdate = oldIssue != null && newIssue != null; + + if (optimisticUpdate) { + // $FlowFixMe `newIssue` is not null, because `optimisticUpdate` is true + onChange(newIssue); + } + + resultPromise.then( + response => { + if (!optimisticUpdate) { + const issue = parseIssueFromResponse( + response.issue, + response.components, + response.users, + response.rules ); - }, - error => { - onFail(dispatch)(error); - if (oldIssue && newIssue) { - dispatch(receiveIssues([oldIssue])); - } + onChange(issue); + } + }, + error => { + onFail(error); + if (optimisticUpdate) { + // $FlowFixMe `oldIssue` is not null, because `optimisticUpdate` is true + onChange(oldIssue); } - ); - }; + } + ); +}; diff --git a/server/sonar-web/src/main/js/components/issue/collections/issues.js b/server/sonar-web/src/main/js/components/issue/collections/issues.js deleted file mode 100644 index 69ac37b1beb..00000000000 --- a/server/sonar-web/src/main/js/components/issue/collections/issues.js +++ /dev/null @@ -1,101 +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. - */ -import Backbone from 'backbone'; -import Issue from '../models/issue'; - -export default Backbone.Collection.extend({ - model: Issue, - - url() { - return window.baseUrl + '/api/issues/search'; - }, - - _injectRelational(issue, source, baseField, lookupField) { - const baseValue = issue[baseField]; - if (baseValue != null && Array.isArray(source) && source.length > 0) { - const lookupValue = source.find(candidate => candidate[lookupField] === baseValue); - if (lookupValue != null) { - Object.keys(lookupValue).forEach(key => { - const newKey = baseField + key.charAt(0).toUpperCase() + key.slice(1); - issue[newKey] = lookupValue[key]; - }); - } - } - return issue; - }, - - _injectCommentsRelational(issue, users) { - if (issue.comments) { - const that = this; - const newComments = issue.comments.map(comment => { - let newComment = { ...comment, author: comment.login }; - delete newComment.login; - newComment = that._injectRelational(newComment, users, 'author', 'login'); - return newComment; - }); - issue = { ...issue, comments: newComments }; - } - return issue; - }, - - _prepareClosed(issue) { - if (issue.status === 'CLOSED') { - issue.flows = []; - delete issue.textRange; - } - return issue; - }, - - ensureTextRange(issue) { - if (issue.line && !issue.textRange) { - // FIXME 999999 - issue.textRange = { - startLine: issue.line, - endLine: issue.line, - startOffset: 0, - endOffset: 999999 - }; - } - return issue; - }, - - parse(r) { - const that = this; - - this.paging = { - p: r.p, - ps: r.ps, - total: r.total, - maxResultsReached: r.p * r.ps >= r.total - }; - - return r.issues.map(issue => { - issue = that._injectRelational(issue, r.components, 'component', 'key'); - issue = that._injectRelational(issue, r.components, 'project', 'key'); - issue = that._injectRelational(issue, r.components, 'subProject', 'key'); - issue = that._injectRelational(issue, r.rules, 'rule', 'key'); - issue = that._injectRelational(issue, r.users, 'assignee', 'login'); - issue = that._injectCommentsRelational(issue, r.users); - issue = that._prepareClosed(issue); - issue = that.ensureTextRange(issue); - return issue; - }); - } -}); diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueActionsBar.js b/server/sonar-web/src/main/js/components/issue/components/IssueActionsBar.js index e60bc87c991..9006cd9a19e 100644 --- a/server/sonar-web/src/main/js/components/issue/components/IssueActionsBar.js +++ b/server/sonar-web/src/main/js/components/issue/components/IssueActionsBar.js @@ -25,6 +25,7 @@ import IssueSeverity from './IssueSeverity'; import IssueTags from './IssueTags'; import IssueTransition from './IssueTransition'; import IssueType from './IssueType'; +import { updateIssue } from '../actions'; import { translate, translateWithParameters } from '../../../helpers/l10n'; import type { Issue } from '../types'; @@ -32,8 +33,8 @@ type Props = { issue: Issue, currentPopup: string, onAssign: (string) => void, + onChange: (Issue) => void, onFail: (Error) => void, - onIssueChange: (Promise<*>, oldIssue?: Issue, newIssue?: Issue) => void, togglePopup: (string) => void }; @@ -63,15 +64,18 @@ export default class IssueActionsBar extends React.PureComponent { const { issue } = this.props; if (issue[property] !== value) { const newIssue = { ...issue, [property]: value }; - this.props.onIssueChange(apiCall({ issue: issue.key, [property]: value }), issue, newIssue); + updateIssue( + this.props.onChange, + apiCall({ issue: issue.key, [property]: value }), + issue, + newIssue + ); } this.props.togglePopup(popup, false); }; toggleComment = (open?: boolean, placeholder?: string) => { - this.setState({ - commentPlaceholder: placeholder || '' - }); + this.setState({ commentPlaceholder: placeholder || '' }); this.props.togglePopup('comment', open); }; @@ -112,8 +116,8 @@ export default class IssueActionsBar extends React.PureComponent { isOpen={this.props.currentPopup === 'transition' && hasTransitions} issue={issue} hasTransitions={hasTransitions} + onChange={this.props.onChange} togglePopup={this.props.togglePopup} - setIssueProperty={this.setIssueProperty} /> </li> <li className="issue-meta"> @@ -134,10 +138,10 @@ export default class IssueActionsBar extends React.PureComponent { </li>} {canComment && <IssueCommentAction - issueKey={issue.key} commentPlaceholder={this.state.commentPlaceholder} currentPopup={this.props.currentPopup} - onIssueChange={this.props.onIssueChange} + issueKey={issue.key} + onChange={this.props.onChange} toggleComment={this.toggleComment} />} </ul> @@ -149,8 +153,8 @@ export default class IssueActionsBar extends React.PureComponent { isOpen={this.props.currentPopup === 'edit-tags' && canSetTags} canSetTags={canSetTags} issue={issue} + onChange={this.props.onChange} onFail={this.props.onFail} - onIssueChange={this.props.onIssueChange} togglePopup={this.props.togglePopup} /> </li> diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueCommentAction.js b/server/sonar-web/src/main/js/components/issue/components/IssueCommentAction.js index f5f6bf5b8d3..8111815e942 100644 --- a/server/sonar-web/src/main/js/components/issue/components/IssueCommentAction.js +++ b/server/sonar-web/src/main/js/components/issue/components/IssueCommentAction.js @@ -19,25 +19,26 @@ */ // @flow import React from 'react'; +import { updateIssue } from '../actions'; import BubblePopupHelper from '../../../components/common/BubblePopupHelper'; import CommentPopup from '../popups/CommentPopup'; import { addIssueComment } from '../../../api/issues'; import { translate } from '../../../helpers/l10n'; import type { Issue } from '../types'; -type Props = { - issueKey: string, +type Props = {| commentPlaceholder: string, currentPopup: string, - onIssueChange: (Promise<*>, oldIssue?: Issue, newIssue?: Issue) => void, + issueKey: string, + onChange: (Issue) => void, toggleComment: (open?: boolean, placeholder?: string) => void -}; +|}; export default class IssueCommentAction extends React.PureComponent { props: Props; addComment = (text: string) => { - this.props.onIssueChange(addIssueComment({ issue: this.props.issueKey, text })); + updateIssue(this.props.onChange, addIssueComment({ issue: this.props.issueKey, text })); this.props.toggleComment(false); }; diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueTags.js b/server/sonar-web/src/main/js/components/issue/components/IssueTags.js index ab850061b7d..c7cebdf74d7 100644 --- a/server/sonar-web/src/main/js/components/issue/components/IssueTags.js +++ b/server/sonar-web/src/main/js/components/issue/components/IssueTags.js @@ -19,6 +19,7 @@ */ // @flow import React from 'react'; +import { updateIssue } from '../actions'; import BubblePopupHelper from '../../../components/common/BubblePopupHelper'; import SetIssueTagsPopup from '../popups/SetIssueTagsPopup'; import TagsList from '../../../components/tags/TagsList'; @@ -26,14 +27,14 @@ import { setIssueTags } from '../../../api/issues'; import { translate } from '../../../helpers/l10n'; import type { Issue } from '../types'; -type Props = { +type Props = {| canSetTags: boolean, isOpen: boolean, issue: Issue, + onChange: (Issue) => void, onFail: (Error) => void, - onIssueChange: (Promise<*>, oldIssue?: Issue, newIssue?: Issue) => void, togglePopup: (string) => void -}; +|}; export default class IssueTags extends React.PureComponent { props: Props; @@ -45,7 +46,8 @@ export default class IssueTags extends React.PureComponent { setTags = (tags: Array<string>) => { const { issue } = this.props; const newIssue = { ...issue, tags }; - this.props.onIssueChange( + updateIssue( + this.props.onChange, setIssueTags({ issue: issue.key, tags: tags.join(',') }), issue, newIssue diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueTitleBar.js b/server/sonar-web/src/main/js/components/issue/components/IssueTitleBar.js index 4f847049f54..55ef295f55d 100644 --- a/server/sonar-web/src/main/js/components/issue/components/IssueTitleBar.js +++ b/server/sonar-web/src/main/js/components/issue/components/IssueTitleBar.js @@ -19,23 +19,27 @@ */ // @flow import React from 'react'; +import { Link } from 'react-router'; import IssueChangelog from './IssueChangelog'; import IssueMessage from './IssueMessage'; +import SimilarIssuesFilter from './SimilarIssuesFilter'; import { getSingleIssueUrl } from '../../../helpers/urls'; import { translate } from '../../../helpers/l10n'; import type { Issue } from '../types'; -type Props = { +type Props = {| issue: Issue, currentPopup: string, onFail: (Error) => void, - onFilterClick?: () => void, + onFilter?: (property: string, issue: Issue) => void, togglePopup: (string) => void -}; +|}; + +const stopPropagation = (event: Event) => event.stopPropagation(); export default function IssueTitleBar(props: Props) { const { issue } = props; - const hasSimilarIssuesFilter = props.onFilterClick != null; + const hasSimilarIssuesFilter = props.onFilter != null; return ( <table className="issue-table"> @@ -66,21 +70,21 @@ export default function IssueTitleBar(props: Props) { </span> </li>} <li className="issue-meta"> - <a + <Link className="js-issue-permalink icon-link" - href={getSingleIssueUrl(issue.key)} - target="_blank" + onClick={stopPropagation} + to={getSingleIssueUrl(issue.key)} /> </li> {hasSimilarIssuesFilter && <li className="issue-meta"> - <button - className="js-issue-filter button-link issue-action issue-action-with-options" - aria-label={translate('issue.filter_similar_issues')} - onClick={props.onFilterClick}> - <i className="icon-filter icon-half-transparent" />{' '} - <i className="icon-dropdown" /> - </button> + <SimilarIssuesFilter + isOpen={props.currentPopup === 'similarIssues'} + issue={issue} + togglePopup={props.togglePopup} + onFail={props.onFail} + onFilter={props.onFilter} + /> </li>} </ul> </td> diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueTransition.js b/server/sonar-web/src/main/js/components/issue/components/IssueTransition.js index 03cd4e41d86..24e3625d529 100644 --- a/server/sonar-web/src/main/js/components/issue/components/IssueTransition.js +++ b/server/sonar-web/src/main/js/components/issue/components/IssueTransition.js @@ -19,6 +19,7 @@ */ // @flow import React from 'react'; +import { updateIssue } from '../actions'; import BubblePopupHelper from '../../../components/common/BubblePopupHelper'; import SetTransitionPopup from '../popups/SetTransitionPopup'; import StatusHelper from '../../../components/shared/StatusHelper'; @@ -29,15 +30,20 @@ type Props = { hasTransitions: boolean, isOpen: boolean, issue: Issue, - setIssueProperty: (string, string, apiCall: (Object) => Promise<*>, string) => void, + onChange: (Issue) => void, togglePopup: (string) => void }; export default class IssueTransition extends React.PureComponent { props: Props; - setTransition = (transition: string) => - this.props.setIssueProperty('transition', 'transition', setIssueTransition, transition); + setTransition = (transition: string) => { + updateIssue( + this.props.onChange, + setIssueTransition({ issue: this.props.issue.key, transition }) + ); + this.toggleSetTransition(); + }; toggleSetTransition = (open?: boolean) => { this.props.togglePopup('transition', open); diff --git a/server/sonar-web/src/main/js/components/issue/components/SimilarIssuesFilter.js b/server/sonar-web/src/main/js/components/issue/components/SimilarIssuesFilter.js new file mode 100644 index 00000000000..c28593d7c89 --- /dev/null +++ b/server/sonar-web/src/main/js/components/issue/components/SimilarIssuesFilter.js @@ -0,0 +1,69 @@ +/* + * 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 BubblePopupHelper from '../../../components/common/BubblePopupHelper'; +import SimilarIssuesPopup from '../popups/SimilarIssuesPopup'; +import { translate } from '../../../helpers/l10n'; +import type { Issue } from '../types'; + +type Props = {| + isOpen: boolean, + issue: Issue, + togglePopup: (string) => void, + onFail: (Error) => void, + onFilter: (property: string, issue: Issue) => void +|}; + +export default class SimilarIssuesFilter extends React.PureComponent { + props: Props; + + handleClick = (evt: SyntheticInputEvent) => { + evt.preventDefault(); + this.togglePopup(); + }; + + handleFilter = (property: string, issue: Issue) => { + this.togglePopup(false); + this.props.onFilter(property, issue); + }; + + togglePopup = (open?: boolean) => { + this.props.togglePopup('similarIssues', open); + }; + + render() { + return ( + <BubblePopupHelper + isOpen={this.props.isOpen} + position="bottomright" + togglePopup={this.togglePopup} + popup={<SimilarIssuesPopup issue={this.props.issue} onFilter={this.handleFilter} />}> + <button + className="js-issue-filter button-link issue-action issue-action-with-options" + aria-label={translate('issue.filter_similar_issues')} + onClick={this.handleClick}> + <i className="icon-filter icon-half-transparent" />{' '} + <i className="icon-dropdown" /> + </button> + </BubblePopupHelper> + ); + } +} diff --git a/server/sonar-web/src/main/js/components/issue/components/__tests__/IssueChangelog-test.js b/server/sonar-web/src/main/js/components/issue/components/__tests__/IssueChangelog-test.js index ca4a95ff08b..608112423d5 100644 --- a/server/sonar-web/src/main/js/components/issue/components/__tests__/IssueChangelog-test.js +++ b/server/sonar-web/src/main/js/components/issue/components/__tests__/IssueChangelog-test.js @@ -19,7 +19,6 @@ */ import { shallow } from 'enzyme'; import React from 'react'; -import moment from 'moment'; import IssueChangelog from '../IssueChangelog'; import { click } from '../../../../helpers/testUtils'; @@ -29,7 +28,11 @@ const issue = { creationDate: '2017-03-01T09:36:01+0100' }; -moment.fn.fromNow = jest.fn(() => 'a month ago'); +jest.mock('moment', () => + () => ({ + format: () => 'March 1, 2017 9:36 AM', + fromNow: () => 'a month ago' + })); it('should render correctly', () => { const element = shallow( diff --git a/server/sonar-web/src/main/js/components/issue/components/__tests__/IssueCommentLine-test.js b/server/sonar-web/src/main/js/components/issue/components/__tests__/IssueCommentLine-test.js index d681183f2c3..9096b729386 100644 --- a/server/sonar-web/src/main/js/components/issue/components/__tests__/IssueCommentLine-test.js +++ b/server/sonar-web/src/main/js/components/issue/components/__tests__/IssueCommentLine-test.js @@ -19,7 +19,6 @@ */ import { shallow } from 'enzyme'; import React from 'react'; -import moment from 'moment'; import IssueCommentLine from '../IssueCommentLine'; import { click } from '../../../../helpers/testUtils'; @@ -32,7 +31,7 @@ const comment = { updatable: true }; -moment.fn.fromNow = jest.fn(() => 'a month ago'); +jest.mock('moment', () => () => ({ fromNow: () => 'a month ago' })); it('should render correctly a comment that is not updatable', () => { const element = shallow( diff --git a/server/sonar-web/src/main/js/components/issue/components/__tests__/IssueTitleBar-test.js b/server/sonar-web/src/main/js/components/issue/components/__tests__/IssueTitleBar-test.js index 3e110b92f36..1d3b7ac4e0e 100644 --- a/server/sonar-web/src/main/js/components/issue/components/__tests__/IssueTitleBar-test.js +++ b/server/sonar-web/src/main/js/components/issue/components/__tests__/IssueTitleBar-test.js @@ -43,7 +43,7 @@ it('should render the titlebar with the filter', () => { issue={issue} currentPopup="" onFail={jest.fn()} - onFilterClick={jest.fn()} + onFilter={jest.fn()} togglePopup={jest.fn()} /> ); diff --git a/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueTitleBar-test.js.snap b/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueTitleBar-test.js.snap index f51811bbd0f..e00752935ca 100644 --- a/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueTitleBar-test.js.snap +++ b/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueTitleBar-test.js.snap @@ -42,10 +42,19 @@ exports[`test should render the titlebar correctly 1`] = ` </li> <li className="issue-meta"> - <a + <Link className="js-issue-permalink icon-link" - href="/issues/search#issues=AVsae-CQS-9G3txfbFN2" - target="_blank" /> + onClick={[Function]} + onlyActiveOnIndex={false} + style={Object {}} + to={ + Object { + "pathname": "/issues", + "query": Object { + "issues": "AVsae-CQS-9G3txfbFN2", + }, + } + } /> </li> </ul> </td> @@ -98,23 +107,37 @@ exports[`test should render the titlebar with the filter 1`] = ` </li> <li className="issue-meta"> - <a + <Link className="js-issue-permalink icon-link" - href="/issues/search#issues=AVsae-CQS-9G3txfbFN2" - target="_blank" /> + onClick={[Function]} + onlyActiveOnIndex={false} + style={Object {}} + to={ + Object { + "pathname": "/issues", + "query": Object { + "issues": "AVsae-CQS-9G3txfbFN2", + }, + } + } /> </li> <li className="issue-meta"> - <button - aria-label="issue.filter_similar_issues" - className="js-issue-filter button-link issue-action issue-action-with-options" - onClick={[Function]}> - <i - className="icon-filter icon-half-transparent" /> - - <i - className="icon-dropdown" /> - </button> + <SimilarIssuesFilter + isOpen={false} + issue={ + Object { + "creationDate": "2017-03-01T09:36:01+0100", + "key": "AVsae-CQS-9G3txfbFN2", + "line": 26, + "message": "Reduce the number of conditional operators (4) used in the expression", + "organization": "myorg", + "rule": "javascript:S1067", + } + } + onFail={[Function]} + onFilter={[Function]} + togglePopup={[Function]} /> </li> </ul> </td> diff --git a/server/sonar-web/src/main/js/components/issue/issue-view.js b/server/sonar-web/src/main/js/components/issue/issue-view.js deleted file mode 100644 index e9b4c47cfcd..00000000000 --- a/server/sonar-web/src/main/js/components/issue/issue-view.js +++ /dev/null @@ -1,319 +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. - */ -import $ from 'jquery'; -import Backbone from 'backbone'; -import Marionette from 'backbone.marionette'; -import ChangeLog from './models/changelog'; -import ChangeLogView from './views/changelog-view'; -import TransitionsFormView from './views/transitions-form-view'; -import AssignFormView from './views/assign-form-view'; -import CommentFormView from './views/comment-form-view'; -import DeleteCommentView from './views/DeleteCommentView'; -import SetSeverityFormView from './views/set-severity-form-view'; -import SetTypeFormView from './views/set-type-form-view'; -import TagsFormView from './views/tags-form-view'; -import Template from './templates/issue.hbs'; -import getCurrentUserFromStore from '../../app/utils/getCurrentUserFromStore'; - -export default Marionette.ItemView.extend({ - template: Template, - - modelEvents: { - change: 'notifyAndRender', - transition: 'onTransition' - }, - - className() { - const hasCheckbox = this.options.onCheck != null; - return hasCheckbox ? 'issue issue-with-checkbox' : 'issue'; - }, - - events() { - return { - click: 'handleClick', - 'click .js-issue-comment': 'onComment', - 'click .js-issue-comment-edit': 'editComment', - 'click .js-issue-comment-delete': 'deleteComment', - 'click .js-issue-transition': 'transition', - 'click .js-issue-set-severity': 'setSeverity', - 'click .js-issue-set-type': 'setType', - 'click .js-issue-assign': 'assign', - 'click .js-issue-assign-to-me': 'assignToMe', - 'click .js-issue-plan': 'plan', - 'click .js-issue-show-changelog': 'showChangeLog', - 'click .js-issue-rule': 'showRule', - 'click .js-issue-edit-tags': 'editTags', - 'click .js-issue-locations': 'showLocations', - 'click .js-issue-filter': 'filterSimilarIssues', - 'click .js-toggle': 'onIssueCheck', - 'click .js-issue-permalink': 'onPermalinkClick' - }; - }, - - notifyAndRender() { - const { onIssueChange } = this.options; - if (onIssueChange) { - onIssueChange(this.model.toJSON()); - } - - // if ConnectedIssue is used, this view can be destroyed just after onIssueChange() - if (!this.isDestroyed) { - this.render(); - } - }, - - onRender() { - this.$el.attr('data-key', this.model.get('key')); - }, - - disableControls() { - this.$(':input').prop('disabled', true); - }, - - enableControls() { - this.$(':input').prop('disabled', false); - }, - - resetIssue(options) { - const that = this; - const key = this.model.get('key'); - const componentUuid = this.model.get('componentUuid'); - this.model.reset({ key, componentUuid }, { silent: true }); - return this.model.fetch(options).done(() => that.trigger('reset')); - }, - - showChangeLog(e) { - e.preventDefault(); - e.stopPropagation(); - const that = this; - const t = $(e.currentTarget); - const changeLog = new ChangeLog(); - return changeLog - .fetch({ - data: { issue: this.model.get('key') } - }) - .done(() => { - if (that.popup) { - that.popup.destroy(); - } - that.popup = new ChangeLogView({ - triggerEl: t, - bottomRight: true, - collection: changeLog, - issue: that.model - }); - that.popup.render(); - }); - }, - - updateAfterAction(response) { - if (this.popup) { - this.popup.destroy(); - } - if (response) { - this.model.set(this.model.parse(response)); - } - }, - - onComment(e) { - e.stopPropagation(); - this.comment(); - }, - - comment(options) { - $('body').click(); - this.popup = new CommentFormView({ - triggerEl: this.$('.js-issue-comment'), - bottom: true, - issue: this.model, - detailView: this, - additionalOptions: options - }); - this.popup.render(); - }, - - editComment(e) { - e.stopPropagation(); - $('body').click(); - const commentEl = $(e.currentTarget).closest('.issue-comment'); - const commentKey = commentEl.data('comment-key'); - const comment = this.model.get('comments').find(comment => comment.key === commentKey); - this.popup = new CommentFormView({ - triggerEl: $(e.currentTarget), - bottomRight: true, - model: new Backbone.Model(comment), - issue: this.model, - detailView: this - }); - this.popup.render(); - }, - - deleteComment(e) { - e.stopPropagation(); - $('body').click(); - const commentEl = $(e.currentTarget).closest('.issue-comment'); - const commentKey = commentEl.data('comment-key'); - this.popup = new DeleteCommentView({ - triggerEl: $(e.currentTarget), - bottomRight: true, - onDelete: () => { - this.disableControls(); - $.ajax({ - type: 'POST', - url: window.baseUrl + '/api/issues/delete_comment?key=' + commentKey - }).done(r => this.updateAfterAction(r)); - } - }); - this.popup.render(); - }, - - transition(e) { - e.stopPropagation(); - $('body').click(); - this.popup = new TransitionsFormView({ - triggerEl: $(e.currentTarget), - bottom: true, - model: this.model, - view: this - }); - this.popup.render(); - }, - - setSeverity(e) { - e.stopPropagation(); - $('body').click(); - this.popup = new SetSeverityFormView({ - triggerEl: $(e.currentTarget), - bottom: true, - model: this.model - }); - this.popup.render(); - }, - - setType(e) { - e.stopPropagation(); - $('body').click(); - this.popup = new SetTypeFormView({ - triggerEl: $(e.currentTarget), - bottom: true, - model: this.model - }); - this.popup.render(); - }, - - assign(e) { - e.stopPropagation(); - $('body').click(); - this.popup = new AssignFormView({ - triggerEl: $(e.currentTarget), - bottom: true, - model: this.model - }); - this.popup.render(); - }, - - assignToMe() { - const view = new AssignFormView({ - model: this.model, - triggerEl: $('body') - }); - const currentUser = getCurrentUserFromStore(); - view.submit(currentUser.login, currentUser.name); - view.destroy(); - }, - - showRule(e) { - e.preventDefault(); - e.stopPropagation(); - const ruleKey = this.model.get('rule'); - // lazy load Workspace - const Workspace = require('../workspace/main').default; - Workspace.openRule({ key: ruleKey, organization: this.model.get('projectOrganization') }); - }, - - action(action) { - this.disableControls(); - return this.model - .customAction(action) - .done(r => this.updateAfterAction(r)) - .fail(() => this.enableControls()); - }, - - editTags(e) { - e.stopPropagation(); - $('body').click(); - this.popup = new TagsFormView({ - triggerEl: $(e.currentTarget), - bottomRight: true, - model: this.model - }); - this.popup.render(); - }, - - showLocations() { - this.model.trigger('locations', this.model); - }, - - select() { - this.$el.addClass('selected'); - }, - - unselect() { - this.$el.removeClass('selected'); - }, - - onTransition(transition) { - if (transition === 'falsepositive' || transition === 'wontfix') { - this.comment({ fromTransition: true }); - } - }, - - handleClick(e) { - e.preventDefault(); - const { onClick } = this.options; - if (onClick) { - onClick(this.model.get('key')); - } - }, - - filterSimilarIssues(e) { - this.options.onFilterClick(e); - }, - - onIssueCheck(e) { - this.options.onCheck(e); - }, - - onPermalinkClick(e) { - e.stopPropagation(); - }, - - serializeData() { - const issueKey = encodeURIComponent(this.model.get('key')); - return { - ...Marionette.ItemView.prototype.serializeData.apply(this, arguments), - permalink: window.baseUrl + '/issues/search#issues=' + issueKey, - hasSecondaryLocations: this.model.get('flows').length, - hasSimilarIssuesFilter: this.options.onFilterClick != null, - hasCheckbox: this.options.onCheck != null, - checked: this.options.checked - }; - } -}); diff --git a/server/sonar-web/src/main/js/components/issue/models/issue.js b/server/sonar-web/src/main/js/components/issue/models/issue.js deleted file mode 100644 index 1abeee02e24..00000000000 --- a/server/sonar-web/src/main/js/components/issue/models/issue.js +++ /dev/null @@ -1,281 +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. - */ -import Backbone from 'backbone'; - -export default Backbone.Model.extend({ - idAttribute: 'key', - - defaults() { - return { - flows: [] - }; - }, - - url() { - return window.baseUrl + '/api/issues'; - }, - - urlRoot() { - return window.baseUrl + '/api/issues'; - }, - - parse(r) { - let issue = Array.isArray(r.issues) && r.issues.length > 0 ? r.issues[0] : r.issue; - if (issue) { - issue = this._injectRelational(issue, r.components, 'component', 'key'); - issue = this._injectRelational(issue, r.components, 'project', 'key'); - issue = this._injectRelational(issue, r.components, 'subProject', 'key'); - issue = this._injectRelational(issue, r.rules, 'rule', 'key'); - issue = this._injectRelational(issue, r.users, 'assignee', 'login'); - issue = this._injectCommentsRelational(issue, r.users); - issue = this._prepareClosed(issue); - issue = this.ensureTextRange(issue); - return issue; - } else { - return r; - } - }, - - _injectRelational(issue, source, baseField, lookupField) { - const baseValue = issue[baseField]; - if (baseValue != null && Array.isArray(source) && source.length > 0) { - const lookupValue = source.find(candidate => candidate[lookupField] === baseValue); - if (lookupValue != null) { - Object.keys(lookupValue).forEach(key => { - const newKey = baseField + key.charAt(0).toUpperCase() + key.slice(1); - issue[newKey] = lookupValue[key]; - }); - } - } - return issue; - }, - - _injectCommentsRelational(issue, users) { - if (issue.comments) { - const newComments = issue.comments.map(comment => { - let newComment = { ...comment, author: comment.login }; - delete newComment.login; - newComment = this._injectRelational(newComment, users, 'author', 'login'); - return newComment; - }); - return { ...issue, comments: newComments }; - } - return issue; - }, - - _prepareClosed(issue) { - if (issue.status === 'CLOSED') { - issue.flows = []; - delete issue.textRange; - } - return issue; - }, - - ensureTextRange(issue) { - if (issue.line && !issue.textRange) { - // FIXME 999999 - issue.textRange = { - startLine: issue.line, - endLine: issue.line, - startOffset: 0, - endOffset: 999999 - }; - } - return issue; - }, - - sync(method, model, options) { - const opts = options || {}; - opts.contentType = 'application/x-www-form-urlencoded'; - if (method === 'read') { - Object.assign(opts, { - type: 'GET', - url: this.urlRoot() + '/search', - data: { - issues: model.id, - additionalFields: '_all' - } - }); - } - if (method === 'create') { - Object.assign(opts, { - type: 'POST', - url: this.urlRoot() + '/create', - data: { - component: model.get('component'), - line: model.get('line'), - message: model.get('message'), - rule: model.get('rule'), - severity: model.get('severity') - } - }); - } - const xhr = (options.xhr = Backbone.ajax(opts)); - model.trigger('request', model, xhr, opts); - return xhr; - }, - - /** - * Reset issue attributes (delete old, replace with new) - * @param attrs - * @param options - * @returns {Object} - */ - reset(attrs, options) { - for (const key in this.attributes) { - if (this.attributes.hasOwnProperty(key) && !(key in attrs)) { - attrs[key] = void 0; - } - } - return this.set(attrs, options); - }, - - /** - * Do an action over an issue - * @param {Object|null} options Options for jQuery ajax - * @returns {jqXHR} - * @private - */ - _action(options) { - const that = this; - const success = function(r) { - const attrs = that.parse(r); - that.reset(attrs); - if (options.success) { - options.success(that, r, options); - } - }; - const opts = { type: 'POST', ...options, success }; - const xhr = (options.xhr = Backbone.ajax(opts)); - this.trigger('request', this, xhr, opts); - return xhr; - }, - - /** - * Assign issue - * @param {String|null} assignee Assignee, can be null to unassign issue - * @param {Object|null} options Options for jQuery ajax - * @returns {jqXHR} - */ - assign(assignee, options) { - const opts = { - url: this.urlRoot() + '/assign', - data: { issue: this.id, assignee }, - ...options - }; - return this._action(opts); - }, - - /** - * Plan issue - * @param {String|null} plan Action Plan, can be null to unplan issue - * @param {Object|null} options Options for jQuery ajax - * @returns {jqXHR} - */ - plan(plan, options) { - const opts = { - url: this.urlRoot() + '/plan', - data: { issue: this.id, plan }, - ...options - }; - return this._action(opts); - }, - - /** - * Set severity of issue - * @param {String|null} severity Severity - * @param {Object|null} options Options for jQuery ajax - * @returns {jqXHR} - */ - setSeverity(severity, options) { - const opts = { - url: this.urlRoot() + '/set_severity', - data: { issue: this.id, severity }, - ...options - }; - return this._action(opts); - }, - - /** - * Do transition on issue - * @param {String|null} transition Transition - * @param {Object|null} options Options for jQuery ajax - * @returns {jqXHR} - */ - transition(transition, options) { - const that = this; - const opts = { - url: this.urlRoot() + '/do_transition', - data: { issue: this.id, transition }, - ...options - }; - return this._action(opts).done(() => { - that.trigger('transition', transition); - }); - }, - - /** - * Set type of issue - * @param {String|null} issueType Issue type - * @param {Object|null} options Options for jQuery ajax - * @returns {jqXHR} - */ - setType(issueType, options) { - const opts = { - url: this.urlRoot() + '/set_type', - data: { issue: this.id, type: issueType }, - ...options - }; - return this._action(opts); - }, - - /** - * Do a custom (plugin) action - * @param {String} actionKey Action Key - * @param {Object|null} options Options for jQuery ajax - * @returns {jqXHR} - */ - customAction(actionKey, options) { - const opts = { - type: 'POST', - url: this.urlRoot() + '/do_action', - data: { issue: this.id, actionKey }, - ...options - }; - const xhr = Backbone.ajax(opts); - this.trigger('request', this, xhr, opts); - return xhr; - }, - - getLinearLocations() { - const textRange = this.get('textRange'); - if (!textRange) { - return []; - } - const locations = []; - for (let line = textRange.startLine; line <= textRange.endLine; line++) { - // TODO fix 999999 - const from = line === textRange.startLine ? textRange.startOffset : 0; - const to = line === textRange.endLine ? textRange.endOffset : 999999; - locations.push({ line, from, to }); - } - return locations; - } -}); diff --git a/server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.js b/server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.js new file mode 100644 index 00000000000..88d352e9950 --- /dev/null +++ b/server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.js @@ -0,0 +1,137 @@ +/* + * 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 BubblePopup from '../../../components/common/BubblePopup'; +import SelectList from '../../../components/common/SelectList'; +import SelectListItem from '../../../components/common/SelectListItem'; +import SeverityHelper from '../../../components/shared/SeverityHelper'; +import StatusHelper from '../../../components/shared/StatusHelper'; +import QualifierIcon from '../../../components/shared/QualifierIcon'; +import IssueTypeIcon from '../../../components/ui/IssueTypeIcon'; +import Avatar from '../../../components/ui/Avatar'; +import { translate } from '../../../helpers/l10n'; +import { fileFromPath, limitComponentName } from '../../../helpers/path'; +import type { Issue } from '../types'; + +type Props = {| + issue: Issue, + onFilter: (property: string, issue: Issue) => void, + popupPosition?: {} +|}; + +export default class SimilarIssuesPopup extends React.PureComponent { + props: Props; + + handleSelect = (property: string) => { + this.props.onFilter(property, this.props.issue); + }; + + render() { + const { issue } = this.props; + + const items = [ + 'type', + 'severity', + 'status', + 'resolution', + 'assignee', + 'rule', + ...(issue.tags || []).map(tag => `tag###${tag}`), + 'project', + // $FlowFixMe items are filtered later + issue.subProject ? 'module' : undefined, + 'file' + ].filter(item => item); + + return ( + <BubblePopup + position={this.props.popupPosition} + customClass="bubble-popup-menu bubble-popup-bottom-right"> + <header className="menu-search"> + <h6>{translate('issue.filter_similar_issues')}</h6> + </header> + + <SelectList currentItem={items[0]} items={items} onSelect={this.handleSelect}> + <SelectListItem item="type"> + <IssueTypeIcon className="little-spacer-right" query={issue.type} /> + {translate('issue.type', issue.type)} + </SelectListItem> + + <SelectListItem item="severity"> + <SeverityHelper severity={issue.severity} /> + </SelectListItem> + + <SelectListItem item="status"> + <StatusHelper status={issue.status} /> + </SelectListItem> + + <SelectListItem item="resolution"> + {issue.resolution != null + ? translate('issue.resolution', issue.resolution) + : translate('unresolved')} + </SelectListItem> + + <SelectListItem item="assignee"> + {issue.assignee != null + ? <span> + {translate('assigned_to')} + <Avatar + className="little-spacer-left little-spacer-right" + hash={issue.assigneeAvatar} + size={16} + /> + {issue.assigneeName} + </span> + : translate('unassigned')} + </SelectListItem> + + <SelectListItem item="rule"> + {limitComponentName(issue.ruleName)} + </SelectListItem> + + {issue.tags != null && + issue.tags.map(tag => ( + <SelectListItem key={`tag###${tag}`} item={`tag###${tag}`}> + <i className="icon-tags icon-half-transparent little-spacer-right" /> + {tag} + </SelectListItem> + ))} + + <SelectListItem item="project"> + <QualifierIcon className="little-spacer-right" qualifier="TRK" /> + {issue.projectName} + </SelectListItem> + + {issue.subProject != null && + <SelectListItem item="module"> + <QualifierIcon className="little-spacer-right" qualifier="BRC" /> + {issue.subProjectName} + </SelectListItem>} + + <SelectListItem item="file"> + <QualifierIcon className="little-spacer-right" qualifier={issue.componentQualifier} /> + {fileFromPath(issue.componentLongName)} + </SelectListItem> + </SelectList> + </BubblePopup> + ); + } +} diff --git a/server/sonar-web/src/main/js/components/issue/popups/__tests__/ChangelogPopup-test.js b/server/sonar-web/src/main/js/components/issue/popups/__tests__/ChangelogPopup-test.js index 6c4f9d5977e..35d5c05b5f2 100644 --- a/server/sonar-web/src/main/js/components/issue/popups/__tests__/ChangelogPopup-test.js +++ b/server/sonar-web/src/main/js/components/issue/popups/__tests__/ChangelogPopup-test.js @@ -21,6 +21,8 @@ import { shallow } from 'enzyme'; import React from 'react'; import ChangelogPopup from '../ChangelogPopup'; +jest.mock('moment', () => () => ({ format: () => 'March 1, 2017 9:36 AM' })); + it('should render the changelog popup correctly', () => { const element = shallow( <ChangelogPopup diff --git a/server/sonar-web/src/main/js/components/issue/templates/DeleteComment.hbs b/server/sonar-web/src/main/js/components/issue/templates/DeleteComment.hbs deleted file mode 100644 index 939bf523509..00000000000 --- a/server/sonar-web/src/main/js/components/issue/templates/DeleteComment.hbs +++ /dev/null @@ -1,6 +0,0 @@ -<div class="text-right"> - <div class="spacer-bottom">{{t 'issue.comment.delete_confirm_message'}}</div> - <button class="button-red">{{t 'delete'}}</button> -</div> - -<div class="bubble-popup-arrow"></div> diff --git a/server/sonar-web/src/main/js/components/issue/templates/comment-form.hbs b/server/sonar-web/src/main/js/components/issue/templates/comment-form.hbs deleted file mode 100644 index d88b8a7da8d..00000000000 --- a/server/sonar-web/src/main/js/components/issue/templates/comment-form.hbs +++ /dev/null @@ -1,18 +0,0 @@ -<div class="issue-comment-form-text"> - <textarea rows="2" {{#if options.fromTransition}}placeholder="Please tell why?"{{/if}}>{{show raw markdown}}</textarea> -</div> - -<div class="issue-comment-form-footer"> - <div class="issue-comment-form-actions"> - <div class="button-group"> - <button class="js-issue-comment-submit" disabled> - {{#if id}}{{t 'save'}}{{else}}{{t 'issue.comment.submit'}}{{/if}} - </button> - </div> - <a class="js-issue-comment-cancel">{{t 'cancel'}}</a> - </div> - - <div class="issue-comment-form-tips">{{> ../../common/templates/_markdown-tips }}</div> -</div> - -<div class="bubble-popup-arrow"></div> diff --git a/server/sonar-web/src/main/js/components/issue/templates/issue-assign-form-option.hbs b/server/sonar-web/src/main/js/components/issue/templates/issue-assign-form-option.hbs deleted file mode 100644 index 29550cde4da..00000000000 --- a/server/sonar-web/src/main/js/components/issue/templates/issue-assign-form-option.hbs +++ /dev/null @@ -1,5 +0,0 @@ -<li> - <a href="#" class="js-issue-assignee" data-value="{{id}}" data-text="{{text}}"> - {{text}} - </a> -</li> diff --git a/server/sonar-web/src/main/js/components/issue/templates/issue-assign-form.hbs b/server/sonar-web/src/main/js/components/issue/templates/issue-assign-form.hbs deleted file mode 100644 index 64d2d0d7166..00000000000 --- a/server/sonar-web/src/main/js/components/issue/templates/issue-assign-form.hbs +++ /dev/null @@ -1,10 +0,0 @@ -<div class="search-box menu-search"> - <button class="search-box-submit button-clean"> - <i class="icon-search-new"></i> - </button> - <input class="search-box-input" type="search" placeholder="{{t 'search_verb'}}" value="{{query}}"> -</div> - -<ul class="menu"></ul> - -<div class="bubble-popup-arrow"></div> diff --git a/server/sonar-web/src/main/js/components/issue/templates/issue-changelog.hbs b/server/sonar-web/src/main/js/components/issue/templates/issue-changelog.hbs deleted file mode 100644 index 7ba2e7c2937..00000000000 --- a/server/sonar-web/src/main/js/components/issue/templates/issue-changelog.hbs +++ /dev/null @@ -1,37 +0,0 @@ -<div class="issue-changelog"> - <table class="spaced"> - <tbody> - - <tr> - <td class="thin text-left text-top" nowrap>{{dt issue.creationDate}}</td> - <td class="thin text-left text-top" nowrap></td> - <td class="text-left text-top"> - {{#if issue.author}} - {{t 'created_by'}} {{issue.author}} - {{else}} - {{t 'created'}} - {{/if}} - </td> - </tr> - - {{#each items}} - <tr> - <td class="thin text-left text-top" nowrap>{{dt creationDate}}</td> - <td class="thin text-left text-top" nowrap> - {{#if userName}} - {{#ifShowAvatars}}{{avatarHelperNew avatar 16}}{{/ifShowAvatars}} - {{/if}} - {{userName}} - </td> - <td class="text-left text-top"> - {{#each diffs}} - {{changelog this}}<br> - {{/each}} - </td> - </tr> - {{/each}} - </tbody> - </table> -</div> - -<div class="bubble-popup-arrow"></div> diff --git a/server/sonar-web/src/main/js/components/issue/templates/issue-plan-form.hbs b/server/sonar-web/src/main/js/components/issue/templates/issue-plan-form.hbs deleted file mode 100644 index 9184dd34b64..00000000000 --- a/server/sonar-web/src/main/js/components/issue/templates/issue-plan-form.hbs +++ /dev/null @@ -1,13 +0,0 @@ -<ul class="menu"> - {{#each items}} - {{#notEq status 'CLOSED'}} - <li> - <a href="#" class="js-issue-assignee" data-value="{{key}}" data-text="{{name}}"> - {{name}} - </a> - </li> - {{/notEq}} - {{/each}} -</ul> - -<div class="bubble-popup-arrow"></div> diff --git a/server/sonar-web/src/main/js/components/issue/templates/issue-set-severity-form.hbs b/server/sonar-web/src/main/js/components/issue/templates/issue-set-severity-form.hbs deleted file mode 100644 index ea6a3a92b15..00000000000 --- a/server/sonar-web/src/main/js/components/issue/templates/issue-set-severity-form.hbs +++ /dev/null @@ -1,11 +0,0 @@ -<ul class="menu"> - {{#each items}} - <li> - <a href="#" class="js-issue-severity" data-value="{{this}}"> - {{severityIcon this}} {{t 'severity' this}} - </a> - </li> - {{/each}} -</ul> - -<div class="bubble-popup-arrow"></div> diff --git a/server/sonar-web/src/main/js/components/issue/templates/issue-set-type-form.hbs b/server/sonar-web/src/main/js/components/issue/templates/issue-set-type-form.hbs deleted file mode 100644 index 3f42921aba2..00000000000 --- a/server/sonar-web/src/main/js/components/issue/templates/issue-set-type-form.hbs +++ /dev/null @@ -1,11 +0,0 @@ -<ul class="menu"> - {{#each items}} - <li> - <a href="#" class="js-issue-type" data-value="{{this}}"> - {{issueType this}} - </a> - </li> - {{/each}} -</ul> - -<div class="bubble-popup-arrow"></div> diff --git a/server/sonar-web/src/main/js/components/issue/templates/issue-tags-form-option.hbs b/server/sonar-web/src/main/js/components/issue/templates/issue-tags-form-option.hbs deleted file mode 100644 index 90df7aa6e62..00000000000 --- a/server/sonar-web/src/main/js/components/issue/templates/issue-tags-form-option.hbs +++ /dev/null @@ -1,17 +0,0 @@ -<li> - <a href="#" data-value="{{tag}}" data-text="{{tag}}" - {{#if selected}}data-selected{{/if}}> - - {{#if selected}} - <i class="icon-checkbox icon-checkbox-checked"></i> - {{else}} - <i class="icon-checkbox"></i> - {{/if}} - - {{#if custom}} - + {{tag}} - {{else}} - {{tag}} - {{/if}} - </a> -</li> diff --git a/server/sonar-web/src/main/js/components/issue/templates/issue-tags-form.hbs b/server/sonar-web/src/main/js/components/issue/templates/issue-tags-form.hbs deleted file mode 100644 index 64d2d0d7166..00000000000 --- a/server/sonar-web/src/main/js/components/issue/templates/issue-tags-form.hbs +++ /dev/null @@ -1,10 +0,0 @@ -<div class="search-box menu-search"> - <button class="search-box-submit button-clean"> - <i class="icon-search-new"></i> - </button> - <input class="search-box-input" type="search" placeholder="{{t 'search_verb'}}" value="{{query}}"> -</div> - -<ul class="menu"></ul> - -<div class="bubble-popup-arrow"></div> diff --git a/server/sonar-web/src/main/js/components/issue/templates/issue-transitions-form.hbs b/server/sonar-web/src/main/js/components/issue/templates/issue-transitions-form.hbs deleted file mode 100644 index ef8ae2f24af..00000000000 --- a/server/sonar-web/src/main/js/components/issue/templates/issue-transitions-form.hbs +++ /dev/null @@ -1,12 +0,0 @@ -<ul class="menu"> - {{#each transitions}} - <li> - <a href="#" class="js-issue-transition" data-value="{{this}}" - title="{{t 'issue.transition' this 'description'}}" data-placement="right" data-container="body"> - {{t 'issue.transition' this}} - </a> - </li> - {{/each}} -</ul> - -<div class="bubble-popup-arrow"></div> diff --git a/server/sonar-web/src/main/js/components/issue/templates/issue.hbs b/server/sonar-web/src/main/js/components/issue/templates/issue.hbs deleted file mode 100644 index 72e59a58a1f..00000000000 --- a/server/sonar-web/src/main/js/components/issue/templates/issue.hbs +++ /dev/null @@ -1,182 +0,0 @@ -<div class="issue-inner"> - - <table class="issue-table"> - <tr> - <td> - <div class="issue-message"> - {{message}} - <button class="button-link js-issue-rule issue-rule icon-ellipsis-h" - aria-label="{{t 'issue.rule_details'}}"></button> - </div> - </td> - - <td class="issue-table-meta-cell issue-table-meta-cell-first"> - <ul class="list-inline issue-meta-list"> - <li class="issue-meta"> - <button class="button-link issue-action issue-action-with-options js-issue-show-changelog" title="{{dt creationDate}}"> - <span class="issue-meta-label">{{fromNow creationDate}}</span> <i class="icon-dropdown"></i> - </button> - </li> - - {{#if line}} - <li class="issue-meta"> - <span class="issue-meta-label" title="{{t 'line_number'}}">L{{line}}</span> - </li> - {{/if}} - - {{#if hasSecondaryLocations}} - <li class="issue-meta issue-meta-locations"> - <button class="button-link issue-action js-issue-locations"> - <i class="icon-issue-flow"></i> - </button> - </li> - {{/if}} - - <li class="issue-meta"> - <a class="js-issue-permalink icon-link" href="{{permalink}}" target="_blank"></a> - </li> - - {{#if hasSimilarIssuesFilter}} - <li class="issue-meta"> - <button class="button-link issue-action issue-action-with-options js-issue-filter" - aria-label="{{t "issue.filter_similar_issues"}}"> - <i class="icon-filter icon-half-transparent"></i> <i class="icon-dropdown"></i> - </button> - </li> - {{/if}} - </ul> - </td> - </tr> - </table> - - <table class="issue-table"> - <tr> - <td> - <ul class="list-inline issue-meta-list"> - <li class="issue-meta"> - {{#inArray actions "set_severity"}} - <button class="button-link issue-action issue-action-with-options js-issue-set-type"> - {{issueTypeIcon this.type}} {{issueType this.type}} <i class="icon-dropdown"></i> - </button> - {{else}} - {{issueTypeIcon this.type}} {{issueType this.type}} - {{/inArray}} - </li> - - <li class="issue-meta"> - {{#inArray actions "set_severity"}} - <button class="button-link issue-action issue-action-with-options js-issue-set-severity"> - <span class="issue-meta-label">{{severityHelper severity}}</span> <i class="icon-dropdown"></i> - </button> - {{else}} - {{severityHelper severity}} - {{/inArray}} - </li> - - <li class="issue-meta"> - {{#notEmpty transitions}} - <button class="button-link issue-action issue-action-with-options js-issue-transition"> - <span class="issue-meta-label">{{statusHelper status resolution}}</span> <i - class="icon-dropdown"></i> - </button> - {{else}} - {{statusHelper status resolution}} - {{/notEmpty}} - </li> - - <li class="issue-meta"> - {{#inArray actions "assign"}} - <button class="button-link issue-action issue-action-with-options js-issue-assign"> - {{#if assignee}} - {{#ifShowAvatars}} - <span class="text-top">{{avatarHelperNew assigneeAvatar 16}}</span> - {{/ifShowAvatars}} - {{/if}} - <span class="issue-meta-label">{{#if assignee}}{{assigneeName}}{{else}}{{t 'unassigned'}}{{/if}}</span> <i - class="icon-dropdown"></i> - </button> - {{else}} - {{#if assignee}} - {{#ifShowAvatars}} - <span class="text-top">{{avatarHelperNew assigneeAvatar 16}}</span> - {{/ifShowAvatars}} - {{/if}} - <span class="issue-meta-label">{{#if assignee}}{{assigneeName}}{{else}}{{t 'unassigned'}}{{/if}}</span> - {{/inArray}} - </li> - - {{#if debt}} - <li class="issue-meta"> - <span class="issue-meta-label"> - {{tp 'issue.x_effort' debt}} - </span> - </li> - {{/if}} - - {{#inArray actions "comment"}} - <li class="issue-meta"> - <button class="button-link issue-action js-issue-comment"><span - class="issue-meta-label">{{t 'issue.comment.formlink' }}</span></button> - </li> - {{/inArray}} - </ul> - - {{#inArray actions "assign_to_me"}} - <button class="button-link hidden js-issue-assign-to-me"></button> - {{/inArray}} - </td> - - <td class="issue-table-meta-cell"> - <ul class="list-inline"> - <li class="issue-meta js-issue-tags"> - {{#inArray actions "set_tags"}} - <button class="button-link issue-action issue-action-with-options js-issue-edit-tags"> - <span> - <i class="icon-tags icon-half-transparent"></i> <span>{{#if tags}}{{join tags ', '}}{{else}}{{t 'issue.no_tag'}}{{/if}}</span> - </span> <i class="icon-dropdown"></i> - </button> - {{else}} - <span> - <i class="icon-tags icon-half-transparent"></i> <span>{{#if tags}}{{join tags ', '}}{{else}}{{t 'issue.no_tag'}}{{/if}}</span> - </span> - {{/inArray}} - </li> - </ul> - </td> - </tr> - </table> - - {{#notEmpty comments}} - <div class="issue-comments"> - {{#each comments}} - <div class="issue-comment" data-comment-key="{{key}}"> - <div class="issue-comment-author" title="{{authorName}}"> - {{#ifShowAvatars}}{{avatarHelperNew authorAvatar 16}}{{else}} - <i class="icon-comment icon-half-transparent"></i>{{/ifShowAvatars}} {{authorName}} - </div> - <div class="issue-comment-text markdown">{{{show html htmlText}}}</div> - <div class="issue-comment-age">({{fromNow createdAt}})</div> - <div class="issue-comment-actions"> - {{#if updatable}} - <button class="js-issue-comment-edit button-link icon-edit icon-half-transparent"></button> - <button class="js-issue-comment-delete button-link icon-delete icon-half-transparent" - data-confirm-msg="{{t 'issue.comment.delete_confirm_message'}}"></button> - {{/if}} - </div> - </div> - {{/each}} - </div> - {{/notEmpty}} - -</div> - -<a class="issue-navigate js-issue-navigate"> - <i class="issue-navigate-to-left icon-chevron-left"></i> - <i class="issue-navigate-to-right icon-chevron-right"></i> -</a> - -{{#if hasCheckbox}} - <div class="js-toggle issue-checkbox-container"> - <i class="issue-checkbox icon-checkbox {{#if checked}}icon-checkbox-checked{{/if}}"></i> - </div> -{{/if}} diff --git a/server/sonar-web/src/main/js/components/issue/types.js b/server/sonar-web/src/main/js/components/issue/types.js index 690c38146cb..4a07b129eeb 100644 --- a/server/sonar-web/src/main/js/components/issue/types.js +++ b/server/sonar-web/src/main/js/components/issue/types.js @@ -52,6 +52,10 @@ export type Issue = { assigneeName?: string, author?: string, comments?: Array<IssueComment>, + component: string, + componentLongName: string, + componentQualifier: string, + componentUuid: string, creationDate: string, effort?: string, key: string, @@ -61,11 +65,18 @@ export type Issue = { line?: number, message: string, organization: string, + project: string, + projectName: string, projectOrganization: string, + projectUuid: string, resolution?: string, rule: string, + ruleName: string, severity: string, status: string, + subProject?: string, + subProjectName?: string, + subProjectUuid?: string, tags?: Array<string>, textRange: TextRange, transitions?: Array<string>, diff --git a/server/sonar-web/src/main/js/components/issue/views/assign-form-view.js b/server/sonar-web/src/main/js/components/issue/views/assign-form-view.js deleted file mode 100644 index a3e81ef0dae..00000000000 --- a/server/sonar-web/src/main/js/components/issue/views/assign-form-view.js +++ /dev/null @@ -1,172 +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. - */ -import $ from 'jquery'; -import { debounce, uniqBy } from 'lodash'; -import ActionOptionsView from '../../common/action-options-view'; -import Template from '../templates/issue-assign-form.hbs'; -import OptionTemplate from '../templates/issue-assign-form-option.hbs'; -import { translate } from '../../../helpers/l10n'; -import getCurrentUserFromStore from '../../../app/utils/getCurrentUserFromStore'; -import { areThereCustomOrganizations } from '../../../store/organizations/utils'; - -export default ActionOptionsView.extend({ - template: Template, - optionTemplate: OptionTemplate, - - events() { - return { - ...ActionOptionsView.prototype.events.apply(this, arguments), - 'click input': 'onInputClick', - 'keydown input': 'onInputKeydown', - 'keyup input': 'onInputKeyup' - }; - }, - - initialize() { - ActionOptionsView.prototype.initialize.apply(this, arguments); - this.assignees = null; - this.organizationKey = areThereCustomOrganizations() - ? this.model.get('projectOrganization') - : null; - this.debouncedSearch = debounce(this.search, 250); - }, - - getAssignee() { - return this.model.get('assignee'); - }, - - getAssigneeName() { - return this.model.get('assigneeName'); - }, - - onRender() { - const that = this; - ActionOptionsView.prototype.onRender.apply(this, arguments); - this.renderTags(); - setTimeout( - () => { - that.$('input').focus(); - }, - 100 - ); - }, - - renderTags() { - this.$('.menu').empty(); - this.getAssignees().forEach(this.renderAssignee, this); - this.bindUIElements(); - this.selectInitialOption(); - }, - - renderAssignee(assignee) { - const html = this.optionTemplate(assignee); - this.$('.menu').append(html); - }, - - selectOption(e) { - const assignee = $(e.currentTarget).data('value'); - const assigneeName = $(e.currentTarget).data('text'); - this.submit(assignee, assigneeName); - return ActionOptionsView.prototype.selectOption.apply(this, arguments); - }, - - submit(assignee) { - return this.model.assign(assignee); - }, - - onInputClick(e) { - e.stopPropagation(); - }, - - onInputKeydown(e) { - this.query = this.$('input').val(); - if (e.keyCode === 38) { - this.selectPreviousOption(); - } - if (e.keyCode === 40) { - this.selectNextOption(); - } - if (e.keyCode === 13) { - this.selectActiveOption(); - } - if (e.keyCode === 27) { - this.destroy(); - } - if ([9, 13, 27, 38, 40].indexOf(e.keyCode) !== -1) { - return false; - } - }, - - onInputKeyup() { - let query = this.$('input').val(); - if (query !== this.query) { - if (query.length < 2) { - query = ''; - } - this.query = query; - this.debouncedSearch(query); - } - }, - - search(query) { - const that = this; - if (query.length > 1) { - const searchUrl = this.organizationKey != null - ? '/organizations/search_members' - : '/users/search'; - const queryData = { q: query }; - if (this.organizationKey != null) { - queryData.organization = this.organizationKey; - } - $.get(window.baseUrl + '/api' + searchUrl, queryData).done(data => { - that.resetAssignees(data.users); - }); - } else { - this.resetAssignees(); - } - }, - - resetAssignees(users) { - if (users) { - this.assignees = users.map(user => { - return { id: user.login, text: user.name }; - }); - } else { - this.assignees = null; - } - this.renderTags(); - }, - - getAssignees() { - if (this.assignees) { - return this.assignees; - } - const currentUser = getCurrentUserFromStore(); - const assignees = [ - { id: currentUser.login, text: currentUser.name }, - { id: '', text: translate('unassigned') } - ]; - return this.makeUnique(assignees); - }, - - makeUnique(assignees) { - return uniqBy(assignees, assignee => assignee.id); - } -}); diff --git a/server/sonar-web/src/main/js/components/issue/views/comment-form-view.js b/server/sonar-web/src/main/js/components/issue/views/comment-form-view.js deleted file mode 100644 index 52d68bcd7c0..00000000000 --- a/server/sonar-web/src/main/js/components/issue/views/comment-form-view.js +++ /dev/null @@ -1,113 +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. - */ -import $ from 'jquery'; -import PopupView from '../../common/popup'; -import Template from '../templates/comment-form.hbs'; - -export default PopupView.extend({ - className: 'bubble-popup issue-comment-bubble-popup', - template: Template, - - ui: { - textarea: '.issue-comment-form-text textarea', - cancelButton: '.js-issue-comment-cancel', - submitButton: '.js-issue-comment-submit' - }, - - events: { - click: 'onClick', - 'keydown @ui.textarea': 'onKeydown', - 'keyup @ui.textarea': 'toggleSubmit', - 'click @ui.cancelButton': 'cancel', - 'click @ui.submitButton': 'submit' - }, - - onRender() { - const that = this; - PopupView.prototype.onRender.apply(this, arguments); - setTimeout( - () => { - that.ui.textarea.focus(); - }, - 100 - ); - }, - - toggleSubmit() { - this.ui.submitButton.prop('disabled', this.ui.textarea.val().length === 0); - }, - - onClick(e) { - e.stopPropagation(); - }, - - onKeydown(e) { - if (e.keyCode === 27) { - this.destroy(); - } - if (e.keyCode === 13 && (e.metaKey || e.ctrlKey)) { - this.submit(); - } - }, - - cancel() { - this.options.detailView.updateAfterAction(); - }, - - disableForm() { - this.$(':input').prop('disabled', true); - }, - - enableForm() { - this.$(':input').prop('disabled', false); - }, - - submit() { - const text = this.ui.textarea.val(); - - if (!text.length) { - return; - } - - const update = this.model && this.model.has('key'); - const method = update ? 'edit_comment' : 'add_comment'; - const url = window.baseUrl + '/api/issues/' + method; - const data = { text }; - if (update) { - data.key = this.model.get('key'); - } else { - data.issue = this.options.issue.id; - } - this.disableForm(); - this.options.detailView.disableControls(); - $.post(url, data).done(r => this.options.detailView.updateAfterAction(r)).fail(() => { - this.enableForm(); - this.options.detailView.enableControls(); - }); - }, - - serializeData() { - const options = { fromTransition: false, ...this.options.additionalOptions }; - return { - ...PopupView.prototype.serializeData.apply(this, arguments), - options - }; - } -}); diff --git a/server/sonar-web/src/main/js/components/issue/views/set-severity-form-view.js b/server/sonar-web/src/main/js/components/issue/views/set-severity-form-view.js deleted file mode 100644 index b30c689e1e9..00000000000 --- a/server/sonar-web/src/main/js/components/issue/views/set-severity-form-view.js +++ /dev/null @@ -1,51 +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. - */ -import $ from 'jquery'; -import ActionOptionsView from '../../common/action-options-view'; -import Template from '../templates/issue-set-severity-form.hbs'; - -export default ActionOptionsView.extend({ - template: Template, - - getTransition() { - return this.model.get('severity'); - }, - - selectInitialOption() { - return this.makeActive(this.getOptions().filter(`[data-value="${this.getTransition()}"]`)); - }, - - selectOption(e) { - const severity = $(e.currentTarget).data('value'); - this.submit(severity); - return ActionOptionsView.prototype.selectOption.apply(this, arguments); - }, - - submit(severity) { - return this.model.setSeverity(severity); - }, - - serializeData() { - return { - ...ActionOptionsView.prototype.serializeData.apply(this, arguments), - items: ['BLOCKER', 'CRITICAL', 'MAJOR', 'MINOR', 'INFO'] - }; - } -}); diff --git a/server/sonar-web/src/main/js/components/issue/views/set-type-form-view.js b/server/sonar-web/src/main/js/components/issue/views/set-type-form-view.js deleted file mode 100644 index 719d679e762..00000000000 --- a/server/sonar-web/src/main/js/components/issue/views/set-type-form-view.js +++ /dev/null @@ -1,51 +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. - */ -import $ from 'jquery'; -import ActionOptionsView from '../../common/action-options-view'; -import Template from '../templates/issue-set-type-form.hbs'; - -export default ActionOptionsView.extend({ - template: Template, - - getType() { - return this.model.get('type'); - }, - - selectInitialOption() { - return this.makeActive(this.getOptions().filter(`[data-value="${this.getType()}"]`)); - }, - - selectOption(e) { - const issueType = $(e.currentTarget).data('value'); - this.submit(issueType); - return ActionOptionsView.prototype.selectOption.apply(this, arguments); - }, - - submit(issueType) { - return this.model.setType(issueType); - }, - - serializeData() { - return { - ...ActionOptionsView.prototype.serializeData.apply(this, arguments), - items: ['BUG', 'VULNERABILITY', 'CODE_SMELL'] - }; - } -}); diff --git a/server/sonar-web/src/main/js/components/issue/views/tags-form-view.js b/server/sonar-web/src/main/js/components/issue/views/tags-form-view.js deleted file mode 100644 index 87b1287bee9..00000000000 --- a/server/sonar-web/src/main/js/components/issue/views/tags-form-view.js +++ /dev/null @@ -1,196 +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. - */ -import $ from 'jquery'; -import { debounce, difference, without } from 'lodash'; -import ActionOptionsView from '../../common/action-options-view'; -import Template from '../templates/issue-tags-form.hbs'; -import OptionTemplate from '../templates/issue-tags-form-option.hbs'; - -export default ActionOptionsView.extend({ - template: Template, - optionTemplate: OptionTemplate, - - modelEvents: { - 'change:tags': 'renderTags' - }, - - events() { - return { - ...ActionOptionsView.prototype.events.apply(this, arguments), - 'click input': 'onInputClick', - 'keydown input': 'onInputKeydown', - 'keyup input': 'onInputKeyup' - }; - }, - - initialize() { - ActionOptionsView.prototype.initialize.apply(this, arguments); - this.query = ''; - this.tags = []; - this.selected = 0; - this.debouncedSearch = debounce(this.search, 250); - this.requestTags(); - }, - - requestTags(query) { - const that = this; - return $.get(window.baseUrl + '/api/issues/tags', { ps: 10, q: query }).done(data => { - that.tags = data.tags; - that.renderTags(); - }); - }, - - onRender() { - const that = this; - ActionOptionsView.prototype.onRender.apply(this, arguments); - this.renderTags(); - setTimeout( - () => { - that.$('input').focus(); - }, - 100 - ); - }, - - selectInitialOption() { - this.selected = Math.max(Math.min(this.selected, this.getOptions().length - 1), 0); - this.makeActive(this.getOptions().eq(this.selected)); - }, - - filterTags(tags) { - return tags.filter(tag => tag.indexOf(this.query) !== -1); - }, - - renderTags() { - this.$('.menu').empty(); - this.filterTags(this.getTags()).forEach(this.renderSelectedTag, this); - this.filterTags(difference(this.tags, this.getTags())).forEach(this.renderTag, this); - if ( - this.query.length > 0 && - this.tags.indexOf(this.query) === -1 && - this.getTags().indexOf(this.query) === -1 - ) { - this.renderCustomTag(this.query); - } - this.selectInitialOption(); - }, - - renderSelectedTag(tag) { - const html = this.optionTemplate({ - tag, - selected: true, - custom: false - }); - return this.$('.menu').append(html); - }, - - renderTag(tag) { - const html = this.optionTemplate({ - tag, - selected: false, - custom: false - }); - return this.$('.menu').append(html); - }, - - renderCustomTag(tag) { - const html = this.optionTemplate({ - tag, - selected: false, - custom: true - }); - return this.$('.menu').append(html); - }, - - selectOption(e) { - e.preventDefault(); - e.stopPropagation(); - let tags = this.getTags().slice(); - const tag = $(e.currentTarget).data('value'); - if ($(e.currentTarget).data('selected') != null) { - tags = without(tags, tag); - } else { - tags.push(tag); - } - this.selected = this.getOptions().index($(e.currentTarget)); - return this.submit(tags); - }, - - submit(tags) { - const that = this; - const _tags = this.getTags(); - this.model.set({ tags }); - return $.ajax({ - type: 'POST', - url: window.baseUrl + '/api/issues/set_tags', - data: { - key: this.model.id, - tags: tags.join() - } - }).fail(() => that.model.set({ tags: _tags })); - }, - - onInputClick(e) { - e.stopPropagation(); - }, - - onInputKeydown(e) { - this.query = this.$('input').val(); - if (e.keyCode === 38) { - this.selectPreviousOption(); - } - if (e.keyCode === 40) { - this.selectNextOption(); - } - if (e.keyCode === 13) { - this.selectActiveOption(); - } - if (e.keyCode === 27) { - this.destroy(); - } - if ([9, 13, 27, 38, 40].indexOf(e.keyCode) !== -1) { - return false; - } - }, - - onInputKeyup() { - const query = this.$('input').val(); - if (query !== this.query) { - this.query = query; - this.debouncedSearch(query); - } - }, - - search(query) { - this.query = query; - return this.requestTags(query); - }, - - resetAssignees(users) { - this.assignees = users.map(user => { - return { id: user.login, text: user.name }; - }); - this.renderTags(); - }, - - getTags() { - return this.model.get('tags') || []; - } -}); diff --git a/server/sonar-web/src/main/js/components/issue/views/transitions-form-view.js b/server/sonar-web/src/main/js/components/issue/views/transitions-form-view.js deleted file mode 100644 index 0a44b5a4b22..00000000000 --- a/server/sonar-web/src/main/js/components/issue/views/transitions-form-view.js +++ /dev/null @@ -1,40 +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. - */ -import $ from 'jquery'; -import ActionOptionsView from '../../common/action-options-view'; -import Template from '../templates/issue-transitions-form.hbs'; - -export default ActionOptionsView.extend({ - template: Template, - - selectInitialOption() { - this.makeActive(this.getOptions().first()); - }, - - selectOption(e) { - const transition = $(e.currentTarget).data('value'); - this.submit(transition); - return ActionOptionsView.prototype.selectOption.apply(this, arguments); - }, - - submit(transition) { - return this.model.transition(transition); - } -}); diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicatorContainer.js b/server/sonar-web/src/main/js/components/layout/Page.js index 8d15af06288..a8adef56e19 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicatorContainer.js +++ b/server/sonar-web/src/main/js/components/layout/Page.js @@ -18,12 +18,25 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ // @flow -import { connect } from 'react-redux'; -import LineIssuesIndicator from './LineIssuesIndicator'; -import { getIssueByKey } from '../../../store/rootReducer'; +import React from 'react'; +import { css } from 'glamor'; -const mapStateToProps = (state, ownProps: { issueKeys: Array<string> }) => ({ - issues: ownProps.issueKeys.map(issueKey => getIssueByKey(state, issueKey)) +type Props = { + className?: string, + children?: React.Element<*> +}; + +const styles = css({ + display: 'flex', + alignItems: 'stretch', + width: '100%', + flexGrow: 1 }); -export default connect(mapStateToProps)(LineIssuesIndicator); +const Page = ({ className, children, ...other }: Props) => ( + <div className={styles + (className ? ` ${className}` : '')} {...other}> + {children} + </div> +); + +export default Page; diff --git a/server/sonar-web/src/main/js/components/shared/qualifier-icon.js b/server/sonar-web/src/main/js/components/layout/PageFilters.js index d3e8aead051..f969366de69 100644 --- a/server/sonar-web/src/main/js/components/shared/qualifier-icon.js +++ b/server/sonar-web/src/main/js/components/layout/PageFilters.js @@ -17,14 +17,18 @@ * 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 { css } from 'glamor'; -export default React.createClass({ - render() { - if (!this.props.qualifier) { - return null; - } - const className = 'icon-qualifier-' + this.props.qualifier.toLowerCase(); - return <i className={className} />; - } -}); +type Props = { + children?: React.Element<*> +}; + +const PageSide = (props: Props) => ( + <div className={css({ width: 260, padding: 20 })}> + {props.children} + </div> +); + +export default PageSide; diff --git a/server/sonar-web/src/main/js/components/issue/views/DeleteCommentView.js b/server/sonar-web/src/main/js/components/layout/PageMain.js index 3360de2f416..6195a1f651a 100644 --- a/server/sonar-web/src/main/js/components/issue/views/DeleteCommentView.js +++ b/server/sonar-web/src/main/js/components/layout/PageMain.js @@ -17,18 +17,18 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import PopupView from '../../common/popup'; -import Template from '../templates/DeleteComment.hbs'; +// @flow +import React from 'react'; +import { css } from 'glamor'; -export default PopupView.extend({ - template: Template, +type Props = { + children?: React.Element<*> +}; - events: { - 'click button': 'handleSubmit' - }, +const PageMain = (props: Props) => ( + <div className={css({ flexGrow: 1, minWidth: 740, padding: 20 })}> + {props.children} + </div> +); - handleSubmit(e) { - e.preventDefault(); - this.options.onDelete(); - } -}); +export default PageMain; diff --git a/server/sonar-web/src/main/js/components/issue/models/changelog.js b/server/sonar-web/src/main/js/components/layout/PageMainInner.js index bf1150a310a..41beed6518f 100644 --- a/server/sonar-web/src/main/js/components/issue/models/changelog.js +++ b/server/sonar-web/src/main/js/components/layout/PageMainInner.js @@ -17,14 +17,18 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import Backbone from 'backbone'; +// @flow +import React from 'react'; +import { css } from 'glamor'; -export default Backbone.Collection.extend({ - url() { - return window.baseUrl + '/api/issues/changelog'; - }, +type Props = { + children?: React.Element<*> +}; - parse(r) { - return r.changelog; - } -}); +const PageMainInner = (props: Props) => ( + <div className={css({ minWidth: 740, maxWidth: 980 })}> + {props.children} + </div> +); + +export default PageMainInner; diff --git a/server/sonar-web/src/main/js/components/layout/PageSide.js b/server/sonar-web/src/main/js/components/layout/PageSide.js new file mode 100644 index 00000000000..0488fbfceb9 --- /dev/null +++ b/server/sonar-web/src/main/js/components/layout/PageSide.js @@ -0,0 +1,73 @@ +/* + * 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 { css, media } from 'glamor'; + +type Props = { + children?: React.Element<*>, + top?: number +}; + +const width = css( + { + width: 'calc(50vw - 360px)' + }, + media('(max-width: 1320px)', { width: 300 }) +); + +const sideStyles = css(width, { + flexGrow: 0, + flexShrink: 0, + borderRight: '1px solid #e6e6e6', + backgroundColor: '#f3f3f3' +}); + +const sideStickyStyles = css(width, { + position: 'fixed', + zIndex: 40, + top: 0, + bottom: 0, + left: 0, + overflowY: 'auto', + overflowX: 'hidden', + backgroundColor: '#f3f3f3' +}); + +const sideInnerStyles = css( + { + width: 300, + marginLeft: 'calc(50vw - 660px)', + backgroundColor: '#f3f3f3' + }, + media('(max-width: 1320px)', { marginLeft: 0 }) +); + +const PageSide = (props: Props) => ( + <div className={sideStyles}> + <div className={sideStickyStyles} style={{ top: props.top || 30 }}> + <div className={sideInnerStyles}> + {props.children} + </div> + </div> + </div> +); + +export default PageSide; diff --git a/server/sonar-web/src/main/js/components/navigator/workspace-list-view.js b/server/sonar-web/src/main/js/components/navigator/workspace-list-view.js index d47b6e82ba4..b7b35b539c5 100644 --- a/server/sonar-web/src/main/js/components/navigator/workspace-list-view.js +++ b/server/sonar-web/src/main/js/components/navigator/workspace-list-view.js @@ -20,6 +20,7 @@ import $ from 'jquery'; import { throttle } from 'lodash'; import Marionette from 'backbone.marionette'; +import key from 'keymaster'; const BOTTOM_OFFSET = 60; diff --git a/server/sonar-web/src/main/js/components/shared/Organization.js b/server/sonar-web/src/main/js/components/shared/Organization.js index 807abd3d11e..ac723440c9c 100644 --- a/server/sonar-web/src/main/js/components/shared/Organization.js +++ b/server/sonar-web/src/main/js/components/shared/Organization.js @@ -29,6 +29,7 @@ type OwnProps = { type Props = { link?: boolean, + linkClassName?: string, organizationKey: string, organization: { key: string, name: string } | null, shouldBeDisplayed: boolean @@ -51,7 +52,9 @@ class Organization extends React.Component { return ( <span> {this.props.link - ? <OrganizationLink organization={organization}>{organization.name}</OrganizationLink> + ? <OrganizationLink className={this.props.linkClassName} organization={organization}> + {organization.name} + </OrganizationLink> : organization.name} <span className="slash-separator" /> </span> diff --git a/server/sonar-web/src/main/js/components/issue/views/changelog-view.js b/server/sonar-web/src/main/js/components/shared/QualifierIcon.js index b04b56abd6a..82ed9f7e5e1 100644 --- a/server/sonar-web/src/main/js/components/issue/views/changelog-view.js +++ b/server/sonar-web/src/main/js/components/shared/QualifierIcon.js @@ -17,20 +17,27 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import PopupView from '../../common/popup'; -import Template from '../templates/issue-changelog.hbs'; +import React from 'react'; +import classNames from 'classnames'; -export default PopupView.extend({ - template: Template, +type Props = { + className?: string, + qualifier: ?string +}; - collectionEvents: { - sync: 'render' - }, +export default class QualifierIcon extends React.PureComponent { + props: Props; - serializeData() { - return { - ...PopupView.prototype.serializeData.apply(this, arguments), - issue: this.options.issue.toJSON() - }; + render() { + if (!this.props.qualifier) { + return null; + } + + const className = classNames( + 'icon-qualifier-' + this.props.qualifier.toLowerCase(), + this.props.className + ); + + return <i className={className} />; } -}); +} diff --git a/server/sonar-web/src/main/js/components/issue/views/issue-popup.js b/server/sonar-web/src/main/js/components/shared/__tests__/QualifierIcon-test.js index 96488cd1e45..857ccbaf450 100644 --- a/server/sonar-web/src/main/js/components/issue/views/issue-popup.js +++ b/server/sonar-web/src/main/js/components/shared/__tests__/QualifierIcon-test.js @@ -17,30 +17,19 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import PopupView from '../../common/popup'; +import React from 'react'; +import { shallow } from 'enzyme'; +import QualifierIcon from '../QualifierIcon'; -export default PopupView.extend({ - className: 'bubble-popup issue-bubble-popup', - - template() { - return '<div class="bubble-popup-arrow"></div>'; - }, - - events() { - return { - 'click .js-issue-form-cancel': 'destroy' - }; - }, - - onRender() { - PopupView.prototype.onRender.apply(this, arguments); - this.options.view.$el.appendTo(this.$el); - this.options.view.render(); - }, +it('should render icon', () => { + expect(shallow(<QualifierIcon qualifier="TRK" />)).toMatchSnapshot(); + expect(shallow(<QualifierIcon qualifier="trk" />)).toMatchSnapshot(); +}); - onDestroy() { - this.options.view.destroy(); - }, +it('should not render icon', () => { + expect(shallow(<QualifierIcon qualifier={null} />)).toMatchSnapshot(); +}); - attachCloseEvents() {} +it('should render with custom class', () => { + expect(shallow(<QualifierIcon className="spacer-right" qualifier="TRK" />)).toMatchSnapshot(); }); diff --git a/server/sonar-web/src/main/js/components/shared/__tests__/__snapshots__/QualifierIcon-test.js.snap b/server/sonar-web/src/main/js/components/shared/__tests__/__snapshots__/QualifierIcon-test.js.snap new file mode 100644 index 00000000000..58ac761a183 --- /dev/null +++ b/server/sonar-web/src/main/js/components/shared/__tests__/__snapshots__/QualifierIcon-test.js.snap @@ -0,0 +1,16 @@ +exports[`test should not render icon 1`] = `null`; + +exports[`test should render icon 1`] = ` +<i + className="icon-qualifier-trk" /> +`; + +exports[`test should render icon 2`] = ` +<i + className="icon-qualifier-trk" /> +`; + +exports[`test should render with custom class 1`] = ` +<i + className="icon-qualifier-trk spacer-right" /> +`; |