diff options
author | Stas Vilchik <vilchiks@gmail.com> | 2017-04-03 17:56:23 +0200 |
---|---|---|
committer | Stas Vilchik <stas-vilchik@users.noreply.github.com> | 2017-04-13 12:21:37 +0200 |
commit | 139261bbc13192621ef795d6d45298e1d8e1b7f3 (patch) | |
tree | 7aa153b4b3fec7e8fbf3b3b4f5ed0a1a5cc69113 /server/sonar-web/src | |
parent | d665528c8751ead9ca93e3d18dd8600fac92834b (diff) | |
download | sonarqube-139261bbc13192621ef795d6d45298e1d8e1b7f3.tar.gz sonarqube-139261bbc13192621ef795d6d45298e1d8e1b7f3.zip |
SONAR-9064 Rework facets sidebar on the issues page
Diffstat (limited to 'server/sonar-web/src')
235 files changed, 6724 insertions, 6708 deletions
diff --git a/server/sonar-web/src/main/js/api/components.js b/server/sonar-web/src/main/js/api/components.js index 512f3a1f7ef..c9bea5e039d 100644 --- a/server/sonar-web/src/main/js/api/components.js +++ b/server/sonar-web/src/main/js/api/components.js @@ -137,6 +137,9 @@ export function searchProjects(data?: Object) { return getJSON(url, data); } +export const searchComponents = (data?: { q?: string, qualifiers?: string, ps?: number }) => + getJSON('/api/components/search', data); + /** * Change component's key * @param {string} from diff --git a/server/sonar-web/src/main/js/api/issues.js b/server/sonar-web/src/main/js/api/issues.js index 912f8b2f6d3..ae316d8c228 100644 --- a/server/sonar-web/src/main/js/api/issues.js +++ b/server/sonar-web/src/main/js/api/issues.js @@ -41,15 +41,6 @@ type IssuesResponse = { users?: Array<*> }; -export type Transition = - | 'confirm' - | 'unconfirm' - | 'reopen' - | 'resolve' - | 'falsepositive' - | 'wontfix' - | 'close'; - export const searchIssues = (query: {}): Promise<IssuesResponse> => getJSON('/api/issues/search', query); @@ -97,7 +88,9 @@ export function getIssuesCount(query: {}): Promise<*> { }); } -export const searchIssueTags = (ps: number = 500) => getJSON('/api/issues/tags', { ps }); +export const searchIssueTags = ( + data: { ps?: number, q?: string } = { ps: 500 } +): Promise<Array<string>> => getJSON('/api/issues/tags', data).then(r => r.tags); export function getIssueChangelog(issue: string): Promise<*> { const url = '/api/issues/changelog'; @@ -142,7 +135,7 @@ export function setIssueTags(data: { issue: string, tags: string }): Promise<Iss } export function setIssueTransition( - data: { issue: string, transition: Transition } + data: { issue: string, transition: string } ): Promise<IssueResponse> { const url = '/api/issues/do_transition'; return postJSON(url, data); diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBreadcrumbs.js b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBreadcrumbs.js index 2f668b0b8b9..0d4200d6bf5 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBreadcrumbs.js +++ b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBreadcrumbs.js @@ -20,7 +20,7 @@ import React from 'react'; import { connect } from 'react-redux'; import { Link } from 'react-router'; -import QualifierIcon from '../../../../components/shared/qualifier-icon'; +import QualifierIcon from '../../../../components/shared/QualifierIcon'; import { getOrganizationByKey, areThereCustomOrganizations } from '../../../../store/rootReducer'; import OrganizationLink from '../../../../components/ui/OrganizationLink'; diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.js b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.js index 6b5ba3661a8..6c68203c03b 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.js +++ b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.js @@ -101,11 +101,14 @@ export default class ComponentNavMenu extends React.Component { ); } - renderComponentIssuesLink() { + renderIssuesLink() { return ( <li> <Link - to={{ pathname: '/component_issues', query: { id: this.props.component.key } }} + to={{ + pathname: '/project/issues', + query: { id: this.props.component.key, resolved: 'false' } + }} activeClassName="active"> {translate('issues.page')} </Link> @@ -343,7 +346,7 @@ export default class ComponentNavMenu extends React.Component { return ( <ul className="nav navbar-nav nav-tabs"> {this.renderDashboardLink()} - {this.renderComponentIssuesLink()} + {this.renderIssuesLink()} {this.renderComponentMeasuresLink()} {this.renderCodeLink()} {this.renderActivityLink()} diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavBreadcrumbs-test.js.snap b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavBreadcrumbs-test.js.snap index d9bd895c7b1..810d438e08e 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavBreadcrumbs-test.js.snap +++ b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavBreadcrumbs-test.js.snap @@ -4,7 +4,7 @@ exports[`test should not render breadcrumbs with one element 1`] = ` <span> <span className="navbar-context-title-qualifier little-spacer-right"> - <qualifier-icon + <QualifierIcon qualifier="TRK" /> </span> <Link @@ -33,7 +33,7 @@ exports[`test should render organization 1`] = ` <span> <span className="navbar-context-title-qualifier little-spacer-right"> - <qualifier-icon + <QualifierIcon qualifier="TRK" /> </span> <OrganizationLink diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavMenu-test.js.snap b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavMenu-test.js.snap index d6af75c3865..898c8de040f 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavMenu-test.js.snap +++ b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavMenu-test.js.snap @@ -25,9 +25,10 @@ exports[`test should work with extensions 1`] = ` style={Object {}} to={ Object { - "pathname": "/component_issues", + "pathname": "/project/issues", "query": Object { "id": "foo", + "resolved": "false", }, } }> diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMenu.js b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMenu.js index 1c7f395f741..3a5f67cdada 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMenu.js +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMenu.js @@ -25,7 +25,10 @@ import { isUserAdmin } from '../../../../helpers/users'; export default class GlobalNavMenu extends React.Component { static propTypes = { appState: React.PropTypes.object.isRequired, - currentUser: React.PropTypes.object.isRequired + currentUser: React.PropTypes.object.isRequired, + location: React.PropTypes.shape({ + pathname: React.PropTypes.string.isRequired + }).isRequired }; static defaultProps = { @@ -59,12 +62,12 @@ export default class GlobalNavMenu extends React.Component { renderIssuesLink() { const query = this.props.currentUser.isLoggedIn - ? '#resolved=false|assigned_to_me=true' - : '#resolved=false'; - const url = '/issues' + query; + ? { myIssues: 'true', resolved: 'false' } + : { resolved: 'false' }; + const active = this.props.location.pathname === 'issues'; return ( <li> - <Link to={url} className={this.activeLink('/issues')}> + <Link to={{ pathname: '/issues', query }} className={active ? 'active' : undefined}> {translate('issues.page')} </Link> </li> diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearch.js b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearch.js index cb8349ecca5..d7089b5a536 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearch.js +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearch.js @@ -20,6 +20,7 @@ import Backbone from 'backbone'; import React from 'react'; import { connect } from 'react-redux'; +import key from 'keymaster'; import SearchView from './SearchView'; import { getCurrentUser } from '../../../../store/rootReducer'; diff --git a/server/sonar-web/src/main/js/app/components/nav/global/SearchView.js b/server/sonar-web/src/main/js/app/components/nav/global/SearchView.js index 163ec964e26..9277cb5acb5 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/SearchView.js +++ b/server/sonar-web/src/main/js/app/components/nav/global/SearchView.js @@ -253,7 +253,7 @@ export default Marionette.LayoutView.extend({ getNavigationFindings(q) { const DEFAULT_ITEMS = [ - { name: translate('issues.page'), url: window.baseUrl + '/issues/search' }, + { name: translate('issues.page'), url: window.baseUrl + '/issues' }, { name: translate('layout.measures'), url: window.baseUrl + '/measures/search?qualifiers[]=TRK' diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavMenu-test.js b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavMenu-test.js index d21f3f609ee..99b97c82c4f 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavMenu-test.js +++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavMenu-test.js @@ -30,6 +30,8 @@ it('should work with extensions', () => { isLoggedIn: false, permissions: { global: [] } }; - const wrapper = shallow(<GlobalNavMenu appState={appState} currentUser={currentUser} />); + const wrapper = shallow( + <GlobalNavMenu appState={appState} currentUser={currentUser} location={{ pathname: '' }} /> + ); expect(wrapper).toMatchSnapshot(); }); diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavMenu-test.js.snap b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavMenu-test.js.snap index 2f2dc6dd0f3..30ec923b76a 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavMenu-test.js.snap +++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavMenu-test.js.snap @@ -12,10 +12,16 @@ exports[`test should work with extensions 1`] = ` </li> <li> <Link - className={null} onlyActiveOnIndex={false} style={Object {}} - to="/issues#resolved=false"> + to={ + Object { + "pathname": "/issues", + "query": Object { + "resolved": "false", + }, + } + }> issues.page </Link> </li> diff --git a/server/sonar-web/src/main/js/app/utils/startReactApp.js b/server/sonar-web/src/main/js/app/utils/startReactApp.js index 34f86b21085..932192fabc3 100644 --- a/server/sonar-web/src/main/js/app/utils/startReactApp.js +++ b/server/sonar-web/src/main/js/app/utils/startReactApp.js @@ -44,7 +44,6 @@ import backgroundTasksRoutes from '../../apps/background-tasks/routes'; import codeRoutes from '../../apps/code/routes'; import codingRulesRoutes from '../../apps/coding-rules/routes'; import componentRoutes from '../../apps/component/routes'; -import componentIssuesRoutes from '../../apps/component-issues/routes'; import componentMeasuresRoutes from '../../apps/component-measures/routes'; import customMeasuresRoutes from '../../apps/custom-measures/routes'; import groupsRoutes from '../../apps/groups/routes'; @@ -89,9 +88,8 @@ const startReactApp = () => { <Router history={history} onUpdate={handleUpdate}> <Route path="/account/issues" - onEnter={() => { - const defaultFilter = window.location.hash || '#resolve=false'; - window.location = `${window.baseUrl}/issues${defaultFilter}|assigned_to_me=true`; + onEnter={(_, replace) => { + replace({ pathname: '/issues', query: { myIssues: 'true', resolved: 'false' } }); }} /> @@ -117,6 +115,7 @@ const startReactApp = () => { /> <Redirect from="/component/index" to="/component" /> + <Redirect from="/component_issues" to="/project/issues" /> <Redirect from="/dashboard/index" to="/dashboard" /> <Redirect from="/governance" to="/view" /> <Redirect from="/extension/governance/portfolios" to="/portfolios" /> @@ -158,7 +157,6 @@ const startReactApp = () => { <Route component={ProjectContainer}> <Route path="code" childRoutes={codeRoutes} /> - <Route path="component_issues" childRoutes={componentIssuesRoutes} /> <Route path="component_measures" childRoutes={componentMeasuresRoutes} /> <Route path="custom_measures" childRoutes={customMeasuresRoutes} /> <Route path="dashboard" childRoutes={overviewRoutes} /> @@ -176,6 +174,7 @@ const startReactApp = () => { component={ProjectPageExtension} /> <Route path="background_tasks" childRoutes={backgroundTasksRoutes} /> + <Route path="issues" childRoutes={issuesRoutes} /> <Route path="settings" childRoutes={settingsRoutes} /> {projectAdminRoutes} </Route> diff --git a/server/sonar-web/src/main/js/apps/about/components/EntryIssueTypes.js b/server/sonar-web/src/main/js/apps/about/components/EntryIssueTypes.js index 3ae7d82992d..fd231aa512b 100644 --- a/server/sonar-web/src/main/js/apps/about/components/EntryIssueTypes.js +++ b/server/sonar-web/src/main/js/apps/about/components/EntryIssueTypes.js @@ -43,7 +43,7 @@ export default class EntryIssueTypes extends React.Component { <tr> <td className="about-page-issue-type-number"> <Link - to={getIssuesUrl({ resolved: false, types: 'BUG' })} + to={getIssuesUrl({ resolved: 'false', types: 'BUG' })} className="about-page-issue-type-link"> {formatMeasure(bugs, 'SHORT_INT')} </Link> @@ -56,7 +56,7 @@ export default class EntryIssueTypes extends React.Component { <tr> <td className="about-page-issue-type-number"> <Link - to={getIssuesUrl({ resolved: false, types: 'VULNERABILITY' })} + to={getIssuesUrl({ resolved: 'false', types: 'VULNERABILITY' })} className="about-page-issue-type-link"> {formatMeasure(vulnerabilities, 'SHORT_INT')} </Link> @@ -69,7 +69,7 @@ export default class EntryIssueTypes extends React.Component { <tr> <td className="about-page-issue-type-number"> <Link - to={getIssuesUrl({ resolved: false, types: 'CODE_SMELL' })} + to={getIssuesUrl({ resolved: 'false', types: 'CODE_SMELL' })} className="about-page-issue-type-link"> {formatMeasure(codeSmells, 'SHORT_INT')} </Link> diff --git a/server/sonar-web/src/main/js/apps/about/components/EntryIssueTypesForSonarQubeDotCom.js b/server/sonar-web/src/main/js/apps/about/components/EntryIssueTypesForSonarQubeDotCom.js index 3b974ffd770..11db35cac3b 100644 --- a/server/sonar-web/src/main/js/apps/about/components/EntryIssueTypesForSonarQubeDotCom.js +++ b/server/sonar-web/src/main/js/apps/about/components/EntryIssueTypesForSonarQubeDotCom.js @@ -43,7 +43,7 @@ export default class EntryIssueTypesForSonarQubeDotCom extends React.Component { <tr> <td className="about-page-issue-type-number"> <Link - to={getIssuesUrl({ resolved: false, types: 'BUG' })} + to={getIssuesUrl({ resolved: 'false', types: 'BUG' })} className="about-page-issue-type-link"> {formatMeasure(bugs, 'SHORT_INT')} </Link> @@ -56,7 +56,7 @@ export default class EntryIssueTypesForSonarQubeDotCom extends React.Component { <tr> <td className="about-page-issue-type-number"> <Link - to={getIssuesUrl({ resolved: false, types: 'VULNERABILITY' })} + to={getIssuesUrl({ resolved: 'false', types: 'VULNERABILITY' })} className="about-page-issue-type-link"> {formatMeasure(vulnerabilities, 'SHORT_INT')} </Link> @@ -69,7 +69,7 @@ export default class EntryIssueTypesForSonarQubeDotCom extends React.Component { <tr> <td className="about-page-issue-type-number"> <Link - to={getIssuesUrl({ resolved: false, types: 'CODE_SMELL' })} + to={getIssuesUrl({ resolved: 'false', types: 'CODE_SMELL' })} className="about-page-issue-type-link"> {formatMeasure(codeSmells, 'SHORT_INT')} </Link> diff --git a/server/sonar-web/src/main/js/apps/background-tasks/components/TaskComponent.js b/server/sonar-web/src/main/js/apps/background-tasks/components/TaskComponent.js index 6c7d13c15b7..2a9fb4157ae 100644 --- a/server/sonar-web/src/main/js/apps/background-tasks/components/TaskComponent.js +++ b/server/sonar-web/src/main/js/apps/background-tasks/components/TaskComponent.js @@ -21,7 +21,7 @@ import React from 'react'; import { Link } from 'react-router'; import TaskType from './TaskType'; -import QualifierIcon from '../../../components/shared/qualifier-icon'; +import QualifierIcon from '../../../components/shared/QualifierIcon'; import Organization from '../../../components/shared/Organization'; import { Task } from '../types'; diff --git a/server/sonar-web/src/main/js/apps/code/components/ComponentName.js b/server/sonar-web/src/main/js/apps/code/components/ComponentName.js index 9ad27ba80ec..76ea80e652f 100644 --- a/server/sonar-web/src/main/js/apps/code/components/ComponentName.js +++ b/server/sonar-web/src/main/js/apps/code/components/ComponentName.js @@ -20,7 +20,7 @@ import React from 'react'; import { Link } from 'react-router'; import Truncated from './Truncated'; -import QualifierIcon from '../../../components/shared/qualifier-icon'; +import QualifierIcon from '../../../components/shared/QualifierIcon'; function getTooltip(component) { const isFile = component.qualifier === 'FIL' || component.qualifier === 'UTS'; diff --git a/server/sonar-web/src/main/js/apps/coding-rules/controller.js b/server/sonar-web/src/main/js/apps/coding-rules/controller.js index 01943b685bf..dd15fb39b92 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/controller.js +++ b/server/sonar-web/src/main/js/apps/coding-rules/controller.js @@ -18,6 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import $ from 'jquery'; +import key from 'keymaster'; import Controller from '../../components/navigator/controller'; import Rule from './models/rule'; import RuleDetailsView from './rule-details-view'; diff --git a/server/sonar-web/src/main/js/apps/coding-rules/init.js b/server/sonar-web/src/main/js/apps/coding-rules/init.js index 57e689c5178..437505c5eaf 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/init.js +++ b/server/sonar-web/src/main/js/apps/coding-rules/init.js @@ -22,6 +22,7 @@ import $ from 'jquery'; import { sortBy } from 'lodash'; import Backbone from 'backbone'; import Marionette from 'backbone.marionette'; +import key from 'keymaster'; import State from './models/state'; import Layout from './layout'; import Rules from './models/rules'; @@ -105,7 +106,7 @@ App.on('start', function(options: { }); this.layout.filtersRegion.show(this.filtersView); - window.key.setScope('list'); + key.setScope('list'); this.router = new Router({ app: this }); diff --git a/server/sonar-web/src/main/js/apps/coding-rules/rule-details-view.js b/server/sonar-web/src/main/js/apps/coding-rules/rule-details-view.js index 307765e54c6..722e3f22d3e 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/rule-details-view.js +++ b/server/sonar-web/src/main/js/apps/coding-rules/rule-details-view.js @@ -21,6 +21,7 @@ import $ from 'jquery'; import { union } from 'lodash'; import Backbone from 'backbone'; import Marionette from 'backbone.marionette'; +import key from 'keymaster'; import Rules from './models/rules'; import MetaView from './rule/rule-meta-view'; import DescView from './rule/rule-description-view'; diff --git a/server/sonar-web/src/main/js/apps/coding-rules/workspace-list-view.js b/server/sonar-web/src/main/js/apps/coding-rules/workspace-list-view.js index 1e32a633a9a..b218d6939b9 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/workspace-list-view.js +++ b/server/sonar-web/src/main/js/apps/coding-rules/workspace-list-view.js @@ -17,6 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import key from 'keymaster'; import WorkspaceListView from '../../components/navigator/workspace-list-view'; import WorkspaceListItemView from './workspace-list-item-view'; import WorkspaceListEmptyView from './workspace-list-empty-view'; diff --git a/server/sonar-web/src/main/js/apps/component-issues/init.js b/server/sonar-web/src/main/js/apps/component-issues/init.js deleted file mode 100644 index 4b5abd4eb72..00000000000 --- a/server/sonar-web/src/main/js/apps/component-issues/init.js +++ /dev/null @@ -1,130 +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 { difference } from 'lodash'; -import Backbone from 'backbone'; -import Marionette from 'backbone.marionette'; -import State from '../issues/models/state'; -import Layout from '../issues/layout'; -import Issues from '../issues/models/issues'; -import Facets from '../../components/navigator/models/facets'; -import Controller from '../issues/controller'; -import Router from '../issues/router'; -import WorkspaceListView from '../issues/workspace-list-view'; -import WorkspaceHeaderView from '../issues/workspace-header-view'; -import FacetsView from './../issues/facets-view'; -import HeaderView from './../issues/HeaderView'; - -const App = new Marionette.Application(); -const init = function({ el, component, currentUser }) { - this.config = { - resource: component.id, - resourceName: component.name, - resourceQualifier: component.qualifier - }; - this.state = new State({ - canBulkChange: currentUser.isLoggedIn, - isContext: true, - contextQuery: { componentUuids: this.config.resource }, - contextComponentUuid: this.config.resource, - contextComponentName: this.config.resourceName, - contextComponentQualifier: this.config.resourceQualifier, - contextOrganization: component.organization - }); - this.updateContextFacets(); - this.list = new Issues(); - this.facets = new Facets(); - - this.layout = new Layout({ app: this, el }); - this.layout.render(); - $('#footer').addClass('search-navigator-footer'); - - this.controller = new Controller({ app: this }); - - this.issuesView = new WorkspaceListView({ - app: this, - collection: this.list - }); - this.layout.workspaceListRegion.show(this.issuesView); - this.issuesView.bindScrollEvents(); - - this.workspaceHeaderView = new WorkspaceHeaderView({ - app: this, - collection: this.list - }); - this.layout.workspaceHeaderRegion.show(this.workspaceHeaderView); - - this.facetsView = new FacetsView({ - app: this, - collection: this.facets - }); - this.layout.facetsRegion.show(this.facetsView); - - this.headerView = new HeaderView({ - app: this - }); - this.layout.filtersRegion.show(this.headerView); - - key.setScope('list'); - App.router = new Router({ app: App }); - Backbone.history.start(); -}; - -App.getContextQuery = function() { - return { componentUuids: this.config.resource }; -}; - -App.getRestrictedFacets = function() { - return { - TRK: ['projectUuids'], - BRC: ['projectUuids'], - DIR: ['projectUuids', 'moduleUuids', 'directories'], - DEV: ['authors'], - DEV_PRJ: ['projectUuids', 'authors'] - }; -}; - -App.updateContextFacets = function() { - const facets = this.state.get('facets'); - const allFacets = this.state.get('allFacets'); - const facetsFromServer = this.state.get('facetsFromServer'); - return this.state.set({ - facets, - allFacets: difference(allFacets, this.getRestrictedFacets()[this.config.resourceQualifier]), - facetsFromServer: difference( - facetsFromServer, - this.getRestrictedFacets()[this.config.resourceQualifier] - ) - }); -}; - -App.on('start', options => { - init.call(App, options); -}); - -export default function(el, component, currentUser) { - App.start({ el, component, currentUser }); - - return () => { - Backbone.history.stop(); - App.layout.destroy(); - $('#footer').removeClass('search-navigator-footer'); - }; -} diff --git a/server/sonar-web/src/main/js/apps/component-measures/details/drilldown/Breadcrumb.js b/server/sonar-web/src/main/js/apps/component-measures/details/drilldown/Breadcrumb.js index 03d857462bf..5261badeaf2 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/details/drilldown/Breadcrumb.js +++ b/server/sonar-web/src/main/js/apps/component-measures/details/drilldown/Breadcrumb.js @@ -18,7 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import React from 'react'; -import QualifierIcon from '../../../../components/shared/qualifier-icon'; +import QualifierIcon from '../../../../components/shared/QualifierIcon'; import { isDiffMetric, formatLeak } from '../../utils'; import { formatMeasure } from '../../../../helpers/measures'; diff --git a/server/sonar-web/src/main/js/apps/component-measures/details/drilldown/ComponentCell.js b/server/sonar-web/src/main/js/apps/component-measures/details/drilldown/ComponentCell.js index 1ccdd7eed1b..43d0d8bd422 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/details/drilldown/ComponentCell.js +++ b/server/sonar-web/src/main/js/apps/component-measures/details/drilldown/ComponentCell.js @@ -19,7 +19,7 @@ */ import React from 'react'; import classNames from 'classnames'; -import QualifierIcon from '../../../../components/shared/qualifier-icon'; +import QualifierIcon from '../../../../components/shared/QualifierIcon'; import { splitPath } from '../../../../helpers/path'; import { getComponentUrl } from '../../../../helpers/urls'; diff --git a/server/sonar-web/src/main/js/apps/issues/BulkChangeForm.js b/server/sonar-web/src/main/js/apps/issues/BulkChangeForm.js deleted file mode 100644 index 1c9b447b231..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/BulkChangeForm.js +++ /dev/null @@ -1,303 +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 { debounce, sortBy } from 'lodash'; -import ModalForm from '../../components/common/modal-form'; -import Template from './templates/BulkChangeForm.hbs'; -import getCurrentUserFromStore from '../../app/utils/getCurrentUserFromStore'; -import { searchIssues, searchIssueTags, bulkChangeIssues } from '../../api/issues'; -import { searchUsers } from '../../api/users'; -import { searchMembers } from '../../api/organizations'; -import { translate, translateWithParameters } from '../../helpers/l10n'; - -const LIMIT = 500; -const INPUT_WIDTH = '250px'; -const MINIMUM_QUERY_LENGTH = 2; -const UNASSIGNED = '<UNASSIGNED>'; - -type Issue = { - actions?: Array<string>, - assignee: string | null, - transitions?: Array<string> -}; - -const hasAction = (action: string) => - (issue: Issue) => issue.actions && issue.actions.includes(action); - -export default ModalForm.extend({ - template: Template, - - initialize() { - this.issues = null; - this.paging = null; - this.tags = null; - this.loadIssues(); - this.loadTags(); - }, - - loadIssues() { - const { query } = this.options; - searchIssues({ - ...query, - additionalFields: 'actions,transitions', - ps: LIMIT - }).then(r => { - this.issues = r.issues; - this.paging = r.paging; - this.render(); - }); - }, - - loadTags() { - searchIssueTags().then(r => { - this.tags = r.tags; - this.render(); - }); - }, - - assigneeSearch(defaultOptions) { - const { context } = this.options; - return debounce( - query => { - if (query.term.length === 0) { - query.callback({ results: defaultOptions }); - } else if (query.term.length >= MINIMUM_QUERY_LENGTH) { - const onSuccess = r => { - query.callback({ - results: r.users.map(user => ({ - id: user.login, - text: `${user.name} (${user.login})` - })) - }); - }; - if (context.isContext) { - searchMembers({ organization: context.organization, q: query.term }).then(onSuccess); - } else { - searchUsers(query.term).then(onSuccess); - } - } - }, - 250 - ); - }, - - prepareAssigneeSelect() { - const input = this.$('#assignee'); - if (input.length) { - const canBeAssignedToMe = this.issues && this.canBeAssignedToMe(this.issues); - const currentUser = getCurrentUserFromStore(); - const canBeUnassigned = this.issues && this.canBeUnassigned(this.issues); - const defaultOptions = []; - if (canBeAssignedToMe && currentUser.isLoggedIn) { - defaultOptions.push({ - id: currentUser.login, - text: `${currentUser.name} (${currentUser.login})` - }); - } - if (canBeUnassigned) { - defaultOptions.push({ id: UNASSIGNED, text: translate('unassigned') }); - } - - input.select2({ - allowClear: false, - placeholder: translate('search_verb'), - width: INPUT_WIDTH, - formatNoMatches: () => translate('select2.noMatches'), - formatSearching: () => translate('select2.searching'), - formatInputTooShort: () => - translateWithParameters('select2.tooShort', MINIMUM_QUERY_LENGTH), - query: this.assigneeSearch(defaultOptions) - }); - - input.on('change', () => this.$('#assign-action').prop('checked', true)); - } - }, - - prepareTypeSelect() { - this.$('#type') - .select2({ - minimumResultsForSearch: 999, - width: INPUT_WIDTH - }) - .on('change', () => this.$('#set-type-action').prop('checked', true)); - }, - - prepareSeveritySelect() { - const format = state => - state.id - ? `<i class="icon-severity-${state.id.toLowerCase()}"></i> ${state.text}` - : state.text; - this.$('#severity') - .select2({ - minimumResultsForSearch: 999, - width: INPUT_WIDTH, - formatResult: format, - formatSelection: format - }) - .on('change', () => this.$('#set-severity-action').prop('checked', true)); - }, - - prepareTagsInput() { - this.$('#add_tags') - .select2({ - width: INPUT_WIDTH, - tags: this.tags - }) - .on('change', () => this.$('#add-tags-action').prop('checked', true)); - - this.$('#remove_tags') - .select2({ - width: INPUT_WIDTH, - tags: this.tags - }) - .on('change', () => this.$('#remove-tags-action').prop('checked', true)); - }, - - onRender() { - ModalForm.prototype.onRender.apply(this, arguments); - this.prepareAssigneeSelect(); - this.prepareTypeSelect(); - this.prepareSeveritySelect(); - this.prepareTagsInput(); - }, - - onFormSubmit() { - ModalForm.prototype.onFormSubmit.apply(this, arguments); - const query = {}; - - const assignee = this.$('#assignee').val(); - if (this.$('#assign-action').is(':checked') && assignee != null) { - query['assign'] = assignee === UNASSIGNED ? '' : assignee; - } - - const type = this.$('#type').val(); - if (this.$('#set-type-action').is(':checked') && type) { - query['set_type'] = type; - } - - const severity = this.$('#severity').val(); - if (this.$('#set-severity-action').is(':checked') && severity) { - query['set_severity'] = severity; - } - - const addedTags = this.$('#add_tags').val(); - if (this.$('#add-tags-action').is(':checked') && addedTags) { - query['add_tags'] = addedTags; - } - - const removedTags = this.$('#remove_tags').val(); - if (this.$('#remove-tags-action').is(':checked') && removedTags) { - query['remove_tags'] = removedTags; - } - - const transition = this.$('[name="do_transition.transition"]:checked').val(); - if (transition) { - query['do_transition'] = transition; - } - - const comment = this.$('#comment').val(); - if (comment) { - query['comment'] = comment; - } - - const sendNotifications = this.$('#send-notifications').is(':checked'); - if (sendNotifications) { - query['sendNotifications'] = sendNotifications; - } - - this.disableForm(); - this.showSpinner(); - - const issueKeys = this.issues.map(issue => issue.key); - bulkChangeIssues(issueKeys, query).then( - () => { - this.destroy(); - this.options.onChange(); - }, - (e: Object) => { - this.enableForm(); - this.hideSpinner(); - e.response.json().then(r => this.showErrors(r.errors, r.warnings)); - } - ); - }, - - canBeAssigned(issues: Array<Issue>) { - return issues.filter(hasAction('assign')).length; - }, - - canBeAssignedToMe(issues: Array<Issue>) { - return issues.filter(hasAction('assign_to_me')).length; - }, - - canBeUnassigned(issues: Array<Issue>) { - return issues.filter(issue => issue.assignee).length; - }, - - canChangeType(issues: Array<Issue>) { - return issues.filter(hasAction('set_type')).length; - }, - - canChangeSeverity(issues: Array<Issue>) { - return issues.filter(hasAction('set_severity')).length; - }, - - canChangeTags(issues: Array<Issue>) { - return issues.filter(hasAction('set_tags')).length; - }, - - canBeCommented(issues: Array<Issue>) { - return issues.filter(hasAction('comment')).length; - }, - - availableTransitions(issues: Array<Issue>) { - const transitions = {}; - issues.forEach(issue => { - if (issue.transitions) { - issue.transitions.forEach(t => { - if (transitions[t] != null) { - transitions[t]++; - } else { - transitions[t] = 1; - } - }); - } - }); - return sortBy(Object.keys(transitions)).map(transition => ({ - transition, - count: transitions[transition] - })); - }, - - serializeData() { - return { - ...ModalForm.prototype.serializeData.apply(this, arguments), - isLoaded: this.issues != null && this.tags != null, - issues: this.issues, - limitReached: this.paging && this.paging.total > LIMIT, - canBeAssigned: this.issues && this.canBeAssigned(this.issues), - canChangeType: this.issues && this.canChangeType(this.issues), - canChangeSeverity: this.issues && this.canChangeSeverity(this.issues), - canChangeTags: this.issues && this.canChangeTags(this.issues), - canBeCommented: this.issues && this.canBeCommented(this.issues), - availableTransitions: this.issues && this.availableTransitions(this.issues) - }; - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/HeaderView.js b/server/sonar-web/src/main/js/apps/issues/HeaderView.js deleted file mode 100644 index 596996f9d5c..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/HeaderView.js +++ /dev/null @@ -1,60 +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 Marionette from 'backbone.marionette'; -import Template from './templates/facets/issues-my-issues-facet.hbs'; - -export default Marionette.ItemView.extend({ - template: Template, - className: 'issues-header-inner', - - events: { - 'change [name="issues-page-my"]': 'onMyIssuesChange' - }, - - initialize() { - this.listenTo(this.options.app.state, 'change:query', this.render); - }, - - onMyIssuesChange() { - const mode = this.$('[name="issues-page-my"]:checked').val(); - if (mode === 'my') { - this.options.app.state.updateFilter({ - assigned_to_me: 'true', - assignees: null, - assigned: null - }); - } else { - this.options.app.state.updateFilter({ - assigned_to_me: null, - assignees: null, - assigned: null - }); - } - }, - serializeData() { - const me = !!this.options.app.state.get('query').assigned_to_me; - return { - ...Marionette.ItemView.prototype.serializeData.apply(this, arguments), - me, - isContext: this.options.app.state.get('isContext'), - user: this.options.app.state.get('user') - }; - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/component-viewer/main.js b/server/sonar-web/src/main/js/apps/issues/component-viewer/main.js deleted file mode 100644 index a1dcb64d7dc..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/component-viewer/main.js +++ /dev/null @@ -1,132 +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 React from 'react'; -import { render, unmountComponentAtNode } from 'react-dom'; -import Marionette from 'backbone.marionette'; -import SourceViewer from '../../../components/SourceViewer/SourceViewer'; -import WithStore from '../../../components/shared/WithStore'; - -export default Marionette.ItemView.extend({ - template() { - return '<div></div>'; - }, - - initialize(options) { - this.handleLoadIssues = this.handleLoadIssues.bind(this); - this.scrollToBaseIssue = this.scrollToBaseIssue.bind(this); - this.selectIssue = this.selectIssue.bind(this); - this.listenTo(options.app.state, 'change:selectedIndex', this.select); - }, - - onRender() { - this.showViewer(); - }, - - onDestroy() { - this.unbindShortcuts(); - unmountComponentAtNode(this.el); - }, - - handleLoadIssues(component: string) { - // TODO fromLine: number, toLine: number - const issues = this.options.app.list.toJSON().filter(issue => issue.componentKey === component); - return Promise.resolve(issues); - }, - - showViewer(onLoaded) { - if (!this.baseIssue) { - return; - } - - const componentKey = this.baseIssue.get('component'); - - render( - <WithStore> - <SourceViewer - aroundLine={this.baseIssue.get('line')} - component={componentKey} - displayAllIssues={true} - loadIssues={this.handleLoadIssues} - onLoaded={onLoaded} - onIssueSelect={this.selectIssue} - selectedIssue={this.baseIssue.get('key')} - /> - </WithStore>, - this.el - ); - }, - - openFileByIssue(issue) { - this.baseIssue = issue; - this.selectedIssue = issue.get('key'); - this.showViewer(this.scrollToBaseIssue); - this.bindShortcuts(); - }, - - bindShortcuts() { - key('up', 'componentViewer', () => { - this.options.app.controller.selectPrev(); - return false; - }); - key('down', 'componentViewer', () => { - this.options.app.controller.selectNext(); - return false; - }); - key('left,backspace', 'componentViewer', () => { - this.options.app.controller.closeComponentViewer(); - return false; - }); - }, - - unbindShortcuts() { - key.deleteScope('componentViewer'); - }, - - select() { - const selected = this.options.app.state.get('selectedIndex'); - const selectedIssue = this.options.app.list.at(selected); - - if (selectedIssue.get('component') === this.baseIssue.get('component')) { - this.baseIssue = selectedIssue; - this.showViewer(this.scrollToBaseIssue); - this.scrollToBaseIssue(); - } else { - this.options.app.controller.showComponentViewer(selectedIssue); - } - }, - - scrollToLine(line) { - const row = this.$(`[data-line-number=${line}]`); - const topOffset = $(window).height() / 2 - 60; - const goal = row.length > 0 ? row.offset().top - topOffset : 0; - $(window).scrollTop(goal); - }, - - selectIssue(issueKey) { - const issue = this.options.app.list.find(model => model.get('key') === issueKey); - const index = this.options.app.list.indexOf(issue); - this.options.app.state.set({ selectedIndex: index }); - }, - - scrollToBaseIssue() { - this.scrollToLine(this.baseIssue.get('line')); - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/components/App.js b/server/sonar-web/src/main/js/apps/issues/components/App.js new file mode 100644 index 00000000000..73574c611b5 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/components/App.js @@ -0,0 +1,649 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +// @flow +import React from 'react'; +import Helmet from 'react-helmet'; +import key from 'keymaster'; +import { keyBy, without } from 'lodash'; +import HeaderPanel from './HeaderPanel'; +import PageActions from './PageActions'; +import FiltersHeader from './FiltersHeader'; +import MyIssuesFilter from './MyIssuesFilter'; +import Sidebar from '../sidebar/Sidebar'; +import IssuesList from './IssuesList'; +import ComponentBreadcrumbs from './ComponentBreadcrumbs'; +import IssuesSourceViewer from './IssuesSourceViewer'; +import BulkChangeModal from './BulkChangeModal'; +import { + parseQuery, + areMyIssuesSelected, + areQueriesEqual, + getOpen, + serializeQuery, + parseFacets +} from '../utils'; +import type { + Query, + Paging, + Facet, + ReferencedComponent, + ReferencedUser, + ReferencedLanguage, + Component, + CurrentUser +} from '../utils'; +import ListFooter from '../../../components/controls/ListFooter'; +import EmptySearch from '../../../components/common/EmptySearch'; +import Page from '../../../components/layout/Page'; +import PageMain from '../../../components/layout/PageMain'; +import PageMainInner from '../../../components/layout/PageMainInner'; +import PageSide from '../../../components/layout/PageSide'; +import PageFilters from '../../../components/layout/PageFilters'; +import { translate, translateWithParameters } from '../../../helpers/l10n'; +import { scrollToElement } from '../../../helpers/scrolling'; +import type { Issue } from '../../../components/issue/types'; + +type Props = { + component?: Component, + currentUser: CurrentUser, + fetchIssues: () => Promise<*>, + location: { pathname: string, query: { [string]: string } }, + onRequestFail: (Error) => void, + router: { push: () => void, replace: () => void } +}; + +type State = { + bulkChange: 'all' | 'selected' | null, + checked: Array<string>, + facets: { [string]: Facet }, + issues: Array<Issue>, + loading: boolean, + myIssues: boolean, + openFacets: { [string]: boolean }, + paging?: Paging, + query: Query, + referencedComponents: { [string]: ReferencedComponent }, + referencedLanguages: { [string]: ReferencedLanguage }, + referencedRules: { [string]: { name: string } }, + referencedUsers: { [string]: ReferencedUser }, + selected?: string +}; + +const DEFAULT_QUERY = { resolved: 'false' }; + +export default class App extends React.PureComponent { + mounted: boolean; + props: Props; + state: State; + + constructor(props: Props) { + super(props); + this.state = { + bulkChange: null, + checked: [], + facets: {}, + issues: [], + loading: true, + myIssues: areMyIssuesSelected(props.location.query), + openFacets: { resolutions: true, types: true }, + query: parseQuery(props.location.query), + referencedComponents: {}, + referencedLanguages: {}, + referencedRules: {}, + referencedUsers: {}, + selected: getOpen(props.location.query) + }; + } + + componentDidMount() { + this.mounted = true; + + const footer = document.getElementById('footer'); + if (footer) { + footer.classList.add('search-navigator-footer'); + } + + this.attachShortcuts(); + this.fetchFirstIssues(); + } + + componentWillReceiveProps(nextProps: Props) { + const open = getOpen(nextProps.location.query); + if (open != null && open !== this.state.selected) { + this.setState({ selected: open }); + } + this.setState({ + myIssues: areMyIssuesSelected(nextProps.location.query), + query: parseQuery(nextProps.location.query) + }); + } + + componentDidUpdate(prevProps: Props, prevState: State) { + const { query } = this.props.location; + const { query: prevQuery } = prevProps.location; + if ( + !areQueriesEqual(prevQuery, query) || + areMyIssuesSelected(prevQuery) !== areMyIssuesSelected(query) + ) { + this.fetchFirstIssues(); + } else if (prevState.selected !== this.state.selected) { + const open = getOpen(query); + if (!open) { + this.scrollToSelectedIssue(); + } + } + } + + componentWillUnmount() { + this.detachShortcuts(); + + const footer = document.getElementById('footer'); + if (footer) { + footer.classList.remove('search-navigator-footer'); + } + + this.mounted = false; + } + + attachShortcuts() { + key.setScope('issues'); + key('up', 'issues', () => { + this.selectPreviousIssue(); + return false; + }); + key('down', 'issues', () => { + this.selectNextIssue(); + return false; + }); + key('right', 'issues', () => { + this.openSelectedIssue(); + return false; + }); + key('left', 'issues', () => { + this.closeIssue(); + return false; + }); + } + + detachShortcuts() { + key.deleteScope('issues'); + } + + getSelectedIndex(): ?number { + const { issues, selected } = this.state; + const index = issues.findIndex(issue => issue.key === selected); + return index !== -1 ? index : null; + } + + selectNextIssue = () => { + const { issues } = this.state; + const selectedIndex = this.getSelectedIndex(); + if (issues != null && selectedIndex != null && selectedIndex < issues.length - 1) { + if (getOpen(this.props.location.query)) { + this.openIssue(issues[selectedIndex + 1].key); + } else { + this.setState({ selected: issues[selectedIndex + 1].key }); + } + } + }; + + selectPreviousIssue = () => { + const { issues } = this.state; + const selectedIndex = this.getSelectedIndex(); + if (issues != null && selectedIndex != null && selectedIndex > 0) { + if (getOpen(this.props.location.query)) { + this.openIssue(issues[selectedIndex - 1].key); + } else { + this.setState({ selected: issues[selectedIndex - 1].key }); + } + } + }; + + openIssue = (issue: string) => { + const path = { + pathname: this.props.location.pathname, + query: { + ...serializeQuery(this.state.query), + id: this.props.component && this.props.component.key, + myIssues: this.state.myIssues ? 'true' : undefined, + open: issue + } + }; + const open = getOpen(this.props.location.query); + if (open) { + this.props.router.replace(path); + } else { + this.props.router.push(path); + } + }; + + closeIssue = () => { + if (this.state.query) { + this.props.router.push({ + pathname: this.props.location.pathname, + query: { + ...serializeQuery(this.state.query), + id: this.props.component && this.props.component.key, + myIssues: this.state.myIssues ? 'true' : undefined, + open: undefined + } + }); + } + }; + + openSelectedIssue = () => { + const { selected } = this.state; + if (selected) { + this.openIssue(selected); + } + }; + + scrollToSelectedIssue = () => { + const { selected } = this.state; + if (selected) { + const element = document.querySelector(`[data-issue="${selected}"]`); + if (element) { + scrollToElement(element, 150, 100); + } + } + }; + + fetchIssues = (additional?: {}, requestFacets?: boolean = false): Promise<*> => { + const { component } = this.props; + const { myIssues, query } = this.state; + + const parameters = { + componentKeys: component && component.key, + ...serializeQuery(query), + s: 'FILE_LINE', + ps: 25, + facets: requestFacets + ? [ + 'assignees', + 'authors', + 'createdAt', + 'directories', + 'fileUuids', + 'languages', + 'moduleUuids', + 'projectUuids', + 'resolutions', + 'rules', + 'severities', + 'statuses', + 'tags', + 'types' + ].join() + : undefined, + ...additional + }; + + if (myIssues) { + Object.assign(parameters, { assignees: '__me__' }); + } + + return this.props.fetchIssues(parameters); + }; + + fetchFirstIssues() { + this.setState({ loading: true }); + this.fetchIssues({}, true).then(({ facets, issues, paging, ...other }) => { + if (this.mounted) { + const open = getOpen(this.props.location.query); + this.setState({ + facets: parseFacets(facets), + loading: false, + issues, + paging, + referencedComponents: keyBy(other.components, 'uuid'), + referencedLanguages: keyBy(other.languages, 'key'), + referencedRules: keyBy(other.rules, 'key'), + referencedUsers: keyBy(other.users, 'login'), + selected: issues.length > 0 + ? issues.find(issue => issue.key === open) != null ? open : issues[0].key + : undefined + }); + } + }); + } + + fetchIssuesPage = (p: number): Promise<*> => { + return this.fetchIssues({ p }); + }; + + fetchIssuesUntil = (p: number, done: (Array<Issue>, Paging) => boolean) => { + return this.fetchIssuesPage(p).then(response => { + const { issues, paging } = response; + + return done(issues, paging) + ? { issues, paging } + : this.fetchIssuesUntil(p + 1, done).then(nextResponse => { + return { + issues: [...issues, ...nextResponse.issues], + paging: nextResponse.paging + }; + }); + }); + }; + + fetchMoreIssues = () => { + const { paging } = this.state; + + if (!paging) { + return; + } + + const p = paging.pageIndex + 1; + + this.setState({ loading: true }); + this.fetchIssuesPage(p).then(response => { + if (this.mounted) { + this.setState(state => ({ + loading: false, + issues: [...state.issues, ...response.issues], + paging: response.paging + })); + } + }); + }; + + fetchIssuesForComponent = (): Promise<Array<Issue>> => { + const { issues, paging } = this.state; + + const open = getOpen(this.props.location.query); + const openIssue = issues.find(issue => issue.key === open); + + if (!openIssue || !paging) { + return Promise.reject(); + } + + const isSameComponent = (issue: Issue): boolean => issue.component === openIssue.component; + + const done = (issues: Array<Issue>, paging: Paging): boolean => + paging.total <= paging.pageIndex * paging.pageSize || + issues[issues.length - 1].component !== openIssue.component; + + if (done(issues, paging)) { + return Promise.resolve(issues.filter(isSameComponent)); + } + + this.setState({ loading: true }); + return this.fetchIssuesUntil(paging.pageIndex + 1, done).then(response => { + const nextIssues = [...issues, ...response.issues]; + + this.setState({ + issues: nextIssues, + loading: false, + paging: response.paging + }); + return nextIssues.filter(isSameComponent); + }); + }; + + isFiltered = () => { + const serialized = serializeQuery(this.state.query); + return !areQueriesEqual(serialized, DEFAULT_QUERY); + }; + + getCheckedIssues = () => { + const issues = this.state.checked.map(checked => + this.state.issues.find(issue => issue.key === checked)); + const paging = { pageIndex: 1, pageSize: issues.length, total: issues.length }; + return Promise.resolve({ issues, paging }); + }; + + handleFilterChange = (changes: {}) => { + this.props.router.push({ + pathname: this.props.location.pathname, + query: { + ...serializeQuery({ ...this.state.query, ...changes }), + id: this.props.component && this.props.component.key, + myIssues: this.state.myIssues ? 'true' : undefined + } + }); + }; + + handleMyIssuesChange = (myIssues: boolean) => { + this.closeFacet('assignees'); + this.props.router.push({ + pathname: this.props.location.pathname, + query: { + ...serializeQuery({ ...this.state.query, assigned: true, assignees: [] }), + id: this.props.component && this.props.component.key, + myIssues: myIssues ? 'true' : undefined + } + }); + }; + + closeFacet = (property: string) => { + this.setState(state => ({ + openFacets: { ...state.openFacets, [property]: false } + })); + }; + + handleFacetToggle = (property: string) => { + this.setState(state => ({ + openFacets: { ...state.openFacets, [property]: !state.openFacets[property] } + })); + }; + + handleReset = () => { + this.props.router.push({ + pathname: this.props.location.pathname, + query: { + ...DEFAULT_QUERY, + id: this.props.component && this.props.component.key, + myIssues: this.state.myIssues ? 'true' : undefined + } + }); + }; + + handleIssueCheck = (issue: string) => { + this.setState(state => ({ + checked: state.checked.includes(issue) + ? without(state.checked, issue) + : [...state.checked, issue] + })); + }; + + handleIssueChange = (issue: Issue) => { + this.setState(state => ({ + issues: state.issues.map(candidate => candidate.key === issue.key ? issue : candidate) + })); + }; + + openBulkChange = (mode: 'all' | 'selected') => { + this.setState({ bulkChange: mode }); + key.setScope('issues-bulk-change'); + }; + + closeBulkChange = () => { + key.setScope('issues'); + this.setState({ bulkChange: null }); + }; + + handleBulkChangeClick = (e: Event & { target: HTMLElement }) => { + e.preventDefault(); + e.target.blur(); + this.openBulkChange('all'); + }; + + handleBulkChangeSelectedClick = (e: Event & { target: HTMLElement }) => { + e.preventDefault(); + e.target.blur(); + this.openBulkChange('selected'); + }; + + handleBulkChangeDone = () => { + this.fetchFirstIssues(); + this.closeBulkChange(); + }; + + renderBulkChange(openIssue?: Issue) { + const { component, currentUser } = this.props; + const { bulkChange, checked, paging } = this.state; + + if (!currentUser.isLoggedIn || openIssue != null) { + return null; + } + + return ( + <div className="pull-left"> + {checked.length > 0 + ? <div className="dropdown"> + <button id="issues-bulk-change" data-toggle="dropdown"> + {translate('bulk_change')} + <i className="icon-dropdown little-spacer-left" /> + </button> + <ul className="dropdown-menu"> + <li> + <a href="#" onClick={this.handleBulkChangeClick}> + {translateWithParameters('issues.bulk_change', paging ? paging.total : 0)} + </a> + </li> + <li> + <a href="#" onClick={this.handleBulkChangeSelectedClick}> + {translateWithParameters('issues.bulk_change_selected', checked.length)} + </a> + </li> + </ul> + </div> + : <button id="issues-bulk-change" onClick={this.handleBulkChangeClick}> + {translate('bulk_change')} + </button>} + {bulkChange != null && + <BulkChangeModal + component={component} + currentUser={currentUser} + fetchIssues={bulkChange === 'all' ? this.fetchIssues : this.getCheckedIssues} + onClose={this.closeBulkChange} + onDone={this.handleBulkChangeDone} + onRequestFail={this.props.onRequestFail} + />} + </div> + ); + } + + renderList(openIssue?: Issue) { + const { component, currentUser } = this.props; + const { issues, paging } = this.state; + const selectedIndex = this.getSelectedIndex(); + const selectedIssue = selectedIndex != null ? issues[selectedIndex] : null; + + if (paging == null) { + return null; + } + + return ( + <div className={openIssue != null ? 'hidden' : undefined}> + {paging.total > 0 && + <IssuesList + checked={this.state.checked} + component={component} + issues={issues} + onFilterChange={this.handleFilterChange} + onIssueChange={this.handleIssueChange} + onIssueCheck={currentUser.isLoggedIn ? this.handleIssueCheck : undefined} + onIssueClick={this.openIssue} + selectedIssue={selectedIssue} + />} + + {paging.total > 0 && + <ListFooter total={paging.total} count={issues.length} loadMore={this.fetchMoreIssues} />} + + {paging.total === 0 && <EmptySearch />} + </div> + ); + } + + render() { + const { component, currentUser } = this.props; + const { issues, paging, query } = this.state; + + const open = getOpen(this.props.location.query); + const openIssue = issues.find(issue => issue.key === open); + + const selectedIndex = this.getSelectedIndex(); + + const top = component ? 95 : 30; + + return ( + <Page className="issues" id="issues-page"> + <Helmet title={translate('issues.page')} titleTemplate="%s - SonarQube" /> + + <PageSide top={top}> + <PageFilters> + {currentUser.isLoggedIn && + <MyIssuesFilter + myIssues={this.state.myIssues} + onMyIssuesChange={this.handleMyIssuesChange} + />} + <FiltersHeader displayReset={this.isFiltered()} onReset={this.handleReset} /> + <Sidebar + component={component} + facets={this.state.facets} + myIssues={this.state.myIssues} + onFacetToggle={this.handleFacetToggle} + onFilterChange={this.handleFilterChange} + openFacets={this.state.openFacets} + query={query} + referencedComponents={this.state.referencedComponents} + referencedLanguages={this.state.referencedLanguages} + referencedRules={this.state.referencedRules} + referencedUsers={this.state.referencedUsers} + /> + </PageFilters> + </PageSide> + + <PageMain> + <HeaderPanel border={true} top={top}> + <PageMainInner> + {this.renderBulkChange(openIssue)} + {openIssue != null && + <div className="pull-left"> + <ComponentBreadcrumbs component={component} issue={openIssue} /> + </div>} + <PageActions + loading={this.state.loading} + openIssue={openIssue} + paging={paging} + selectedIndex={selectedIndex} + /> + </PageMainInner> + </HeaderPanel> + + <PageMainInner> + <div> + {openIssue != null && + <IssuesSourceViewer + openIssue={openIssue} + loadIssues={this.fetchIssuesForComponent} + onIssueChange={this.handleIssueChange} + onIssueSelect={this.openIssue} + />} + + {this.renderList(openIssue)} + </div> + </PageMainInner> + </PageMain> + </Page> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/component-issues/components/ComponentIssuesAppContainer.js b/server/sonar-web/src/main/js/apps/issues/components/AppContainer.js index 061b3e8f8c6..c605961dcad 100644 --- a/server/sonar-web/src/main/js/apps/component-issues/components/ComponentIssuesAppContainer.js +++ b/server/sonar-web/src/main/js/apps/issues/components/AppContainer.js @@ -17,36 +17,38 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import React from 'react'; +// @flow import { connect } from 'react-redux'; -import init from '../init'; +import { withRouter } from 'react-router'; +import type { Dispatch } from 'redux'; +import App from './App'; +import { onFail } from '../../../store/rootActions'; import { getComponent, getCurrentUser } from '../../../store/rootReducer'; +import { searchIssues } from '../../../api/issues'; +import { parseIssueFromResponse } from '../../../helpers/issues'; -class ComponentIssuesAppContainer extends React.Component { - componentDidMount() { - this.stop = init(this.refs.container, this.props.component, this.props.currentUser); - } - - componentWillUnmount() { - this.stop(); - } - - render() { - // placing container inside div is required, - // because when backbone.marionette's layout is destroyed, - // it also destroys the root element, - // but react wants it to be there to unmount it - return ( - <div> - <div ref="container" /> - </div> - ); - } -} +type Query = { [string]: string }; const mapStateToProps = (state, ownProps) => ({ - component: getComponent(state, ownProps.location.query.id), + component: ownProps.location.query.id + ? getComponent(state, ownProps.location.query.id) + : undefined, currentUser: getCurrentUser(state) }); -export default connect(mapStateToProps)(ComponentIssuesAppContainer); +const fetchIssues = (query: Query) => + (dispatch: Dispatch<*>) => + searchIssues({ ...query, additionalFields: '_all' }).then( + response => { + const parsedIssues = response.issues.map(issue => + parseIssueFromResponse(issue, response.components, response.users, response.rules)); + return { ...response, issues: parsedIssues }; + }, + onFail(dispatch) + ); + +const onRequestFail = (error: Error) => (dispatch: Dispatch<*>) => onFail(dispatch)(error); + +const mapDispatchToProps = { fetchIssues, onRequestFail }; + +export default connect(mapStateToProps, mapDispatchToProps)(withRouter(App)); diff --git a/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.js b/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.js new file mode 100644 index 00000000000..3b7d454aa72 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.js @@ -0,0 +1,522 @@ +/* + * 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 Modal from 'react-modal'; +import Select from 'react-select'; +import { css } from 'glamor'; +import { pickBy, sortBy } from 'lodash'; +import SearchSelect from './SearchSelect'; +import Checkbox from '../../../components/controls/Checkbox'; +import Tooltip from '../../../components/controls/Tooltip'; +import MarkdownTips from '../../../components/common/MarkdownTips'; +import SeverityHelper from '../../../components/shared/SeverityHelper'; +import Avatar from '../../../components/ui/Avatar'; +import IssueTypeIcon from '../../../components/ui/IssueTypeIcon'; +import { searchIssueTags, bulkChangeIssues } from '../../../api/issues'; +import { translate, translateWithParameters } from '../../../helpers/l10n'; +import { searchAssignees } from '../utils'; +import type { Paging, Component, CurrentUser } from '../utils'; +import type { Issue } from '../../../components/issue/types'; + +type Props = {| + component?: Component, + currentUser: CurrentUser, + fetchIssues: ({}) => Promise<*>, + onClose: () => void, + onDone: () => void, + onRequestFail: (Error) => void +|}; + +type State = {| + issues: Array<Issue>, + // used for initial loading of issues + loading: boolean, + paging?: Paging, + // used when submitting a form + submitting: boolean, + tags?: Array<string>, + + // form fields + addTags?: Array<string>, + assignee?: string, + comment?: string, + notifications?: boolean, + removeTags?: Array<string>, + severity?: string, + transition?: string, + type?: string +|}; + +const hasAction = (action: string) => + (issue: Issue): boolean => issue.actions && issue.actions.includes(action); + +export default class BulkChangeModal extends React.PureComponent { + mounted: boolean; + props: Props; + state: State; + + constructor(props: Props) { + super(props); + this.state = { issues: [], loading: true, submitting: false }; + } + + componentDidMount() { + this.mounted = true; + Promise.all([this.loadIssues(), searchIssueTags()]).then(([issues, tags]) => { + if (this.mounted) { + this.setState({ + issues: issues.issues, + loading: false, + paging: issues.paging, + tags + }); + } + }); + } + + componentWillUnmount() { + this.mounted = false; + } + + handleCloseClick = (e: Event & { target: HTMLElement }) => { + e.preventDefault(); + e.target.blur(); + this.props.onClose(); + }; + + loadIssues = () => { + return this.props.fetchIssues({ additionalFields: 'actions,transitions', ps: 250 }); + }; + + handleAssigneeSearch = (query: string) => { + if (query.length > 1) { + return searchAssignees(query, this.props.component); + } else { + const { currentUser } = this.props; + const { issues } = this.state; + const options = []; + + if (currentUser.isLoggedIn) { + const canBeAssignedToMe = issues.filter( + issue => issue.assignee !== currentUser.login + ).length > 0; + if (canBeAssignedToMe) { + options.push({ + email: currentUser.email, + label: currentUser.name, + value: currentUser.login + }); + } + } + + const canBeUnassigned = issues.filter(issue => issue.assignee).length > 0; + if (canBeUnassigned) { + options.push({ label: translate('unassigned'), value: '' }); + } + + return Promise.resolve(options); + } + }; + + handleAssigneeSelect = (assignee: string) => { + this.setState({ assignee }); + }; + + handleFieldCheck = (field: string) => + (checked: boolean) => { + if (!checked) { + this.setState({ [field]: undefined }); + } else if (field === 'notifications') { + this.setState({ [field]: true }); + } + }; + + handleFieldChange = (field: string) => + (event: { target: HTMLInputElement }) => { + this.setState({ [field]: event.target.value }); + }; + + handleSelectFieldChange = (field: string) => + ({ value }: { value: string }) => { + this.setState({ [field]: value }); + }; + + handleMultiSelectFieldChange = (field: string) => + (options: Array<{ value: string }>) => { + this.setState({ [field]: options.map(option => option.value) }); + }; + + handleSubmit = (e: Event) => { + e.preventDefault(); + const query = pickBy({ + assign: this.state.assignee, + set_type: this.state.type, + set_severity: this.state.severity, + add_tags: this.state.addTags && this.state.addTags.join(), + remove_tags: this.state.removeTags && this.state.removeTags.join(), + do_transition: this.state.transition, + comment: this.state.comment, + sendNotifications: this.state.notifications + }); + const issueKeys = this.state.issues.map(issue => issue.key); + + this.setState({ submitting: true }); + bulkChangeIssues(issueKeys, query).then( + () => { + this.setState({ submitting: false }); + this.props.onDone(); + }, + (error: Error) => { + this.setState({ submitting: false }); + this.props.onRequestFail(error); + } + ); + }; + + getAvailableTransitions(issues: Array<Issue>): Array<{ transition: string, count: number }> { + const transitions = {}; + issues.forEach(issue => { + if (issue.transitions) { + issue.transitions.forEach(t => { + if (transitions[t] != null) { + transitions[t]++; + } else { + transitions[t] = 1; + } + }); + } + }); + return sortBy(Object.keys(transitions)).map(transition => ({ + transition, + count: transitions[transition] + })); + } + + renderCancelButton = () => ( + <a id="bulk-change-cancel" href="#" onClick={this.handleCloseClick}> + {translate('cancel')} + </a> + ); + + renderLoading = () => ( + <div> + <div className="modal-head"> + <h2>{translate('bulk_change')}</h2> + </div> + <div className="modal-body"> + <div className="text-center"> + <i className="spinner spinner-margin" /> + </div> + </div> + <div className="modal-foot"> + {this.renderCancelButton()} + </div> + </div> + ); + + renderCheckbox = (field: string) => ( + <Checkbox + className={css({ paddingTop: 6, paddingRight: 8 })} + checked={this.state[field] != null} + onCheck={this.handleFieldCheck(field)} + /> + ); + + renderAffected = (affected: number) => ( + <div className="pull-right note"> + ({translateWithParameters('issue_bulk_change.x_issues', affected)}) + </div> + ); + + renderField = (field: string, label: string, affected: ?number, input: Object) => ( + <div className="modal-field" id={`issues-bulk-change-${field}`}> + <label htmlFor={field}>{translate(label)}</label> + {this.renderCheckbox(field)} + {input} + {affected != null && this.renderAffected(affected)} + </div> + ); + + renderAssigneeOption = (option: { avatar?: string, email?: string, label: string }) => ( + <span> + {(option.avatar != null || option.email != null) && + <Avatar + className="little-spacer-right" + email={option.email} + hash={option.avatar} + size={16} + />} + {option.label} + </span> + ); + + renderAssigneeField = () => { + const affected: number = this.state.issues.filter(hasAction('assign')).length; + + if (affected === 0) { + return null; + } + + const input = ( + <SearchSelect + onSearch={this.handleAssigneeSearch} + onSelect={this.handleAssigneeSelect} + minimumQueryLength={0} + renderOption={this.renderAssigneeOption} + resetOnBlur={false} + value={this.state.assignee} + /> + ); + + return this.renderField('assignee', 'issue.assign.formlink', affected, input); + }; + + renderTypeField = () => { + const affected: number = this.state.issues.filter(hasAction('set_type')).length; + + if (affected === 0) { + return null; + } + + const types = ['BUG', 'VULNERABILITY', 'CODE_SMELL']; + const options = types.map(type => ({ label: translate('issue.type', type), value: type })); + + const optionRenderer = (option: { label: string, value: string }) => ( + <span> + <IssueTypeIcon className="little-spacer-right" query={option.value} /> + {option.label} + </span> + ); + + const input = ( + <Select + clearable={false} + id="type" + onChange={this.handleSelectFieldChange('type')} + options={options} + optionRenderer={optionRenderer} + searchable={false} + value={this.state.type} + valueRenderer={optionRenderer} + /> + ); + + return this.renderField('type', 'issue.set_type', affected, input); + }; + + renderSeverityField = () => { + const affected: number = this.state.issues.filter(hasAction('set_severity')).length; + + if (affected === 0) { + return null; + } + + const severities = ['BLOCKER', 'CRITICAL', 'MAJOR', 'MINOR', 'INFO']; + const options = severities.map(severity => ({ + label: translate('severity', severity), + value: severity + })); + + const input = ( + <Select + clearable={false} + id="severity" + onChange={this.handleSelectFieldChange('severity')} + options={options} + optionRenderer={option => <SeverityHelper severity={option.value} />} + searchable={false} + value={this.state.severity} + valueRenderer={option => <SeverityHelper severity={option.value} />} + /> + ); + + return this.renderField('severity', 'issue.set_severity', affected, input); + }; + + renderAddTagsField = () => { + const affected: number = this.state.issues.filter(hasAction('set_tags')).length; + + if (this.state.tags == null || affected === 0) { + return null; + } + + const options = this.state.tags.map(tag => ({ label: tag, value: tag })); + + const input = ( + <Select + clearable={false} + id="add_tags" + multi={true} + onChange={this.handleMultiSelectFieldChange('addTags')} + options={options} + searchable={true} + value={this.state.addTags} + /> + ); + + return this.renderField('addTags', 'issue.add_tags', affected, input); + }; + + renderRemoveTagsField = () => { + const affected: number = this.state.issues.filter(hasAction('set_tags')).length; + + if (this.state.tags == null || affected === 0) { + return null; + } + + const options = this.state.tags.map(tag => ({ label: tag, value: tag })); + + const input = ( + <Select + clearable={false} + id="remove_tags" + multi={true} + onChange={this.handleMultiSelectFieldChange('removeTags')} + options={options} + searchable={true} + value={this.state.removeTags} + /> + ); + + return this.renderField('removeTags', 'issue.remove_tags', affected, input); + }; + + renderTransitionsField = () => { + const transitions = this.getAvailableTransitions(this.state.issues); + + if (transitions.length === 0) { + return null; + } + + return ( + <div className="modal-field"> + <label>{translate('issue.transition')}</label> + {transitions.map(transition => ( + <span key={transition.transition}> + <input + checked={this.state.transition === transition.transition} + id={`transition-${transition.transition}`} + name="do_transition.transition" + onChange={this.handleFieldChange('transition')} + type="radio" + value={transition.transition} + /> + <label + htmlFor={`transition-${transition.transition}`} + style={{ float: 'none', display: 'inline', left: 0, cursor: 'pointer' }}> + {translate('issue.transition', transition.transition)} + </label> + {this.renderAffected(transition.count)} + <br /> + </span> + ))} + </div> + ); + }; + + renderCommentField = () => { + const affected: number = this.state.issues.filter(hasAction('comment')).length; + + if (affected === 0) { + return null; + } + + return ( + <div className="modal-field"> + <label htmlFor="comment"> + {translate('issue.comment.formlink')} + <Tooltip overlay={translate('issue_bulk_change.comment.help')}> + <i className="icon-help little-spacer-left" /> + </Tooltip> + </label> + <div> + <textarea + id="comment" + onChange={this.handleFieldChange('comment')} + rows="4" + style={{ width: '100%' }} + value={this.state.comment || ''} + /> + </div> + <div className="pull-right"> + <MarkdownTips /> + </div> + </div> + ); + }; + + renderNotificationsField = () => ( + <div className="modal-field"> + <label htmlFor="send-notifications">{translate('issue.send_notifications')}</label> + {this.renderCheckbox('notifications')} + </div> + ); + + renderForm = () => { + const { issues, paging, submitting } = this.state; + + const limitReached: boolean = paging != null && + paging.total > paging.pageIndex * paging.pageSize; + + return ( + <form id="bulk-change-form" onSubmit={this.handleSubmit}> + <div className="modal-head"> + <h2>{translateWithParameters('issue_bulk_change.form.title', issues.length)}</h2> + </div> + + <div className="modal-body"> + {limitReached && + <div className="alert alert-warning"> + {translateWithParameters('issue_bulk_change.max_issues_reached', issues.length)} + </div>} + + {this.renderAssigneeField()} + {this.renderTypeField()} + {this.renderSeverityField()} + {this.renderAddTagsField()} + {this.renderRemoveTagsField()} + {this.renderTransitionsField()} + {this.renderCommentField()} + {this.renderNotificationsField()} + </div> + + <div className="modal-foot"> + {submitting && <i className="spinner spacer-right" />} + <button disabled={submitting} id="bulk-change-submit">{translate('apply')}</button> + {this.renderCancelButton()} + </div> + </form> + ); + }; + + render() { + return ( + <Modal + isOpen={true} + contentLabel="modal" + className="modal" + overlayClassName="modal-overlay" + onRequestClose={this.props.onClose}> + {this.state.loading ? this.renderLoading() : this.renderForm()} + </Modal> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/components/ComponentBreadcrumbs.js b/server/sonar-web/src/main/js/apps/issues/components/ComponentBreadcrumbs.js new file mode 100644 index 00000000000..bc12da43bf1 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/components/ComponentBreadcrumbs.js @@ -0,0 +1,72 @@ +/* + * 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 { Link } from 'react-router'; +import Organization from '../../../components/shared/Organization'; +import { collapsePath, limitComponentName } from '../../../helpers/path'; +import { getProjectUrl } from '../../../helpers/urls'; +import type { Component } from '../utils'; + +type Props = { + component?: Component, + issue: Object +}; + +export default class ComponentBreadcrumbs extends React.PureComponent { + props: Props; + + render() { + const { component, issue } = this.props; + + const displayOrganization = component == null || ['VW', 'SVW'].includes(component.qualifier); + const displayProject = component == null || + !['TRK', 'BRC', 'DIR'].includes(component.qualifier); + const displaySubProject = component == null || !['BRC', 'DIR'].includes(component.qualifier); + + return ( + <div className="component-name"> + {displayOrganization && + <Organization linkClassName="link-no-underline" organizationKey={issue.organization} />} + + {displayProject && + <span> + <Link to={getProjectUrl(issue.project)} className="link-no-underline"> + {limitComponentName(issue.projectName)} + </Link> + <span className="slash-separator" /> + </span>} + + {displaySubProject && + issue.subProject != null && + <span> + <Link to={getProjectUrl(issue.subProject)} className="link-no-underline"> + {limitComponentName(issue.subProjectName)} + </Link> + <span className="slash-separator" /> + </span>} + + <Link to={getProjectUrl(issue.component)} className="link-no-underline"> + {collapsePath(issue.componentLongName)} + </Link> + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssuesAppContainer.js b/server/sonar-web/src/main/js/apps/issues/components/FiltersHeader.js index b1f3e70f3f0..9740f63e718 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/IssuesAppContainer.js +++ b/server/sonar-web/src/main/js/apps/issues/components/FiltersHeader.js @@ -17,39 +17,39 @@ * 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 { connect } from 'react-redux'; -import init from '../init'; -import { getCurrentUser } from '../../../store/rootReducer'; +import { css } from 'glamor'; +import { translate } from '../../../helpers/l10n'; -class IssuesAppContainer extends React.Component { - static propTypes = { - currentUser: React.PropTypes.any.isRequired - }; +type Props = { + displayReset: boolean, + onReset: () => void +}; - componentDidMount() { - this.stop = init(this.refs.container, this.props.currentUser); - } +const styles = css({ marginBottom: 12, paddingBottom: 11, borderBottom: '1px solid #e6e6e6' }); - componentWillUnmount() { - this.stop(); - } +export default class FiltersHeader extends React.PureComponent { + props: Props; + + handleResetClick = (e: Event & { currentTarget: HTMLElement }) => { + e.preventDefault(); + e.currentTarget.blur(); + this.props.onReset(); + }; render() { - // placing container inside div is required, - // because when backbone.marionette's layout is destroyed, - // it also destroys the root element, - // but react wants it to be there to unmount it return ( - <div> - <div ref="container" /> + <div className={styles}> + {this.props.displayReset && + <div className={css({ float: 'right' })}> + <button className="button-red" onClick={this.handleResetClick}> + {translate('clear_all_filters')} + </button> + </div>} + + <h3>{translate('filters')}</h3> </div> ); } } - -const mapStateToProps = state => ({ - currentUser: getCurrentUser(state) -}); - -export default connect(mapStateToProps)(IssuesAppContainer); diff --git a/server/sonar-web/src/main/js/apps/issues/components/HeaderPanel.js b/server/sonar-web/src/main/js/apps/issues/components/HeaderPanel.js new file mode 100644 index 00000000000..2d8530dab51 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/components/HeaderPanel.js @@ -0,0 +1,106 @@ +/* + * 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'; +import { clearfix } from 'glamor/utils'; +import { throttle } from 'lodash'; + +type Props = {| + border: boolean, + children?: React.Element<*>, + top?: number +|}; + +type State = { + scrolled: boolean +}; + +export default class HeaderPanel extends React.PureComponent { + props: Props; + state: State; + + constructor(props: Props) { + super(props); + this.state = { scrolled: this.isScrolled() }; + this.handleScroll = throttle(this.handleScroll, 50); + } + + componentDidMount() { + if (this.props.top != null) { + window.addEventListener('scroll', this.handleScroll); + } + } + + componentWillUnmount() { + if (this.props.top != null) { + window.removeEventListener('scroll', this.handleScroll); + } + } + + isScrolled = () => window.scrollY > 10; + + handleScroll = () => { + this.setState({ scrolled: this.isScrolled() }); + }; + + render() { + const commonStyles = { + height: 56, + lineHeight: '24px', + padding: '16px 20px', + boxSizing: 'border-box', + borderBottom: this.props.border ? '1px solid #e6e6e6' : undefined, + backgroundColor: '#f3f3f3' + }; + + const inner = this.props.top + ? <div + className={css( + commonStyles, + { + position: 'fixed', + zIndex: 30, + top: this.props.top, + left: 'calc(50vw - 360px + 1px)', + right: 0, + boxShadow: this.state.scrolled ? '0 2px 4px rgba(0, 0, 0, .125)' : 'none', + transition: 'box-shadow 0.3s ease' + }, + media('(max-width: 1320px)', { left: 301 }) + )}> + {this.props.children} + </div> + : this.props.children; + + return ( + <div + className={css(clearfix(), commonStyles, { + marginTop: -20, + marginBottom: 20, + marginLeft: -20, + marginRight: -20, + '& .component-name': { lineHeight: '24px' } + })}> + {inner} + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssuesList.js b/server/sonar-web/src/main/js/apps/issues/components/IssuesList.js new file mode 100644 index 00000000000..4bd9cf945d6 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/components/IssuesList.js @@ -0,0 +1,62 @@ +/* + * 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 ListItem from './ListItem'; +import type { Issue } from '../../../components/issue/types'; +import type { Component } from '../utils'; + +type Props = {| + checked: Array<string>, + component?: Component, + issues: Array<Issue>, + onFilterChange: (changes: {}) => void, + onIssueChange: (Issue) => void, + onIssueCheck?: (string) => void, + onIssueClick: (string) => void, + selectedIssue: ?Issue +|}; + +export default class IssuesList extends React.PureComponent { + props: Props; + + render() { + const { checked, component, issues, selectedIssue } = this.props; + + return ( + <div> + {issues.map((issue, index) => ( + <ListItem + checked={checked.includes(issue.key)} + component={component} + key={issue.key} + issue={issue} + onChange={this.props.onIssueChange} + onCheck={this.props.onIssueCheck} + onClick={this.props.onIssueClick} + onFilterChange={this.props.onFilterChange} + previousIssue={index > 0 ? issues[index - 1] : null} + selected={selectedIssue != null && selectedIssue.key === issue.key} + /> + ))} + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssuesSourceViewer.js b/server/sonar-web/src/main/js/apps/issues/components/IssuesSourceViewer.js new file mode 100644 index 00000000000..ff090ed8287 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/components/IssuesSourceViewer.js @@ -0,0 +1,68 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +// @flow +import React from 'react'; +import SourceViewer from '../../../components/SourceViewer/SourceViewer'; +import { scrollToElement } from '../../../helpers/scrolling'; +import type { Issue } from '../../../components/issue/types'; + +type Props = {| + loadIssues: () => Promise<*>, + onIssueChange: (Issue) => void, + onIssueSelect: (string) => void, + openIssue: Issue +|}; + +export default class IssuesSourceViewer extends React.PureComponent { + node: HTMLElement; + props: Props; + + componentDidUpdate(prevProps: Props) { + if (prevProps.openIssue.component === this.props.openIssue.component) { + this.scrollToIssue(); + } + } + + scrollToIssue = () => { + const element = this.node.querySelector(`[data-issue="${this.props.openIssue.key}"]`); + if (element) { + scrollToElement(element, 100, 100); + } + }; + + render() { + const { openIssue } = this.props; + + return ( + <div ref={node => this.node = node}> + <SourceViewer + aroundLine={openIssue.line} + component={openIssue.component} + displayAllIssues={true} + loadIssues={this.props.loadIssues} + onLoaded={this.scrollToIssue} + onIssueChange={this.props.onIssueChange} + onIssueSelect={this.props.onIssueSelect} + selectedIssue={openIssue.key} + /> + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/components/ListItem.js b/server/sonar-web/src/main/js/apps/issues/components/ListItem.js new file mode 100644 index 00000000000..f8a0bdda14a --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/components/ListItem.js @@ -0,0 +1,106 @@ +/* + * 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 ComponentBreadcrumbs from './ComponentBreadcrumbs'; +import Issue from '../../../components/issue/Issue'; +import type { Issue as IssueType } from '../../../components/issue/types'; +import type { Component } from '../utils'; + +type Props = {| + checked: boolean, + component?: Component, + issue: IssueType, + onChange: (IssueType) => void, + onCheck?: (string) => void, + onClick: (string) => void, + onFilterChange: (changes: {}) => void, + previousIssue: ?Object, + selected: boolean +|}; + +type State = { + similarIssues: boolean +}; + +export default class ListItem extends React.PureComponent { + props: Props; + state: State = { similarIssues: false }; + + handleFilter = (property: string, issue: IssueType) => { + const { onFilterChange } = this.props; + + const issuesReset = { issues: [] }; + + if (property.startsWith('tag###')) { + const tag = property.substr(6); + return onFilterChange({ ...issuesReset, tags: [tag] }); + } + + switch (property) { + case 'type': + return onFilterChange({ ...issuesReset, types: [issue.type] }); + case 'severity': + return onFilterChange({ ...issuesReset, severities: [issue.severity] }); + case 'status': + return onFilterChange({ ...issuesReset, statuses: [issue.status] }); + case 'resolution': + return issue.resolution != null + ? onFilterChange({ ...issuesReset, resolved: true, resolutions: [issue.resolution] }) + : onFilterChange({ ...issuesReset, resolved: false, resolutions: [] }); + case 'assignee': + return issue.assignee != null + ? onFilterChange({ ...issuesReset, assigned: true, assignees: [issue.assignee] }) + : onFilterChange({ ...issuesReset, assigned: false, assignees: [] }); + case 'rule': + return onFilterChange({ ...issuesReset, rules: [issue.rule] }); + case 'project': + return onFilterChange({ ...issuesReset, projects: [issue.projectUuid] }); + case 'module': + return onFilterChange({ ...issuesReset, modules: [issue.subProjectUuid] }); + case 'file': + return onFilterChange({ ...issuesReset, files: [issue.componentUuid] }); + } + }; + + render() { + const { component, issue, previousIssue } = this.props; + + const displayComponent = previousIssue == null || previousIssue.component !== issue.component; + + return ( + <div className="issues-workspace-list-item"> + {displayComponent && + <div className="issues-workspace-list-component"> + <ComponentBreadcrumbs component={component} issue={this.props.issue} /> + </div>} + <Issue + checked={this.props.checked} + issue={issue} + onChange={this.props.onChange} + onCheck={this.props.onCheck} + onClick={this.props.onClick} + onFilter={this.handleFilter} + selected={this.props.selected} + /> + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/components/MyIssuesFilter.js b/server/sonar-web/src/main/js/apps/issues/components/MyIssuesFilter.js new file mode 100644 index 00000000000..a8f6441b1fd --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/components/MyIssuesFilter.js @@ -0,0 +1,60 @@ +/* + * 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'; + +type Props = {| + myIssues: boolean, + onMyIssuesChange: (boolean) => void +|}; + +export default class MyIssuesFilter extends React.Component { + props: Props; + + handleClick = (myIssues: boolean) => + (e: Event & { currentTarget: HTMLElement }) => { + e.preventDefault(); + e.currentTarget.blur(); + this.props.onMyIssuesChange(myIssues); + }; + + render() { + const { myIssues } = this.props; + + return ( + <div className={css({ marginBottom: 24, textAlign: 'center' })}> + <div className="button-group"> + <button + className={myIssues ? 'button-active' : undefined} + onClick={this.handleClick(true)}> + {translate('issues.my_issues')} + </button> + <button + className={myIssues ? undefined : 'button-active'} + onClick={this.handleClick(false)}> + {translate('all')} + </button> + </div> + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/components/PageActions.js b/server/sonar-web/src/main/js/apps/issues/components/PageActions.js new file mode 100644 index 00000000000..262c3ad8699 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/components/PageActions.js @@ -0,0 +1,77 @@ +/* + * 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 type { Paging } from '../utils'; +import { translate } from '../../../helpers/l10n'; +import { formatMeasure } from '../../../helpers/measures'; + +type Props = {| + loading: boolean, + openIssue: ?{}, + paging: ?Paging, + selectedIndex: ?number +|}; + +export default class PageActions extends React.Component { + props: Props; + + renderShortcuts() { + return ( + <span className="note big-spacer-right"> + <span className="big-spacer-right"> + <span className="shortcut-button little-spacer-right">↑</span> + <span className="shortcut-button little-spacer-right">↓</span> + {translate('issues.to_select_issues')} + </span> + + <span> + <span className="shortcut-button little-spacer-right">←</span> + <span className="shortcut-button little-spacer-right">→</span> + {translate('issues.to_navigate')} + </span> + </span> + ); + } + + render() { + const { openIssue, paging, selectedIndex } = this.props; + + return ( + <div className={css({ float: 'right' })}> + {openIssue == null && this.renderShortcuts()} + + <div className={css({ display: 'inline-block', minWidth: 80, textAlign: 'right' })}> + {this.props.loading && <i className="spinner spacer-right" />} + {paging != null && + <span> + <strong> + {selectedIndex != null && <span>{selectedIndex + 1} / </span>} + {formatMeasure(paging.total, 'INT')} + </strong> + {' '} + {translate('issues.issues')} + </span>} + </div> + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/components/SearchSelect.js b/server/sonar-web/src/main/js/apps/issues/components/SearchSelect.js new file mode 100644 index 00000000000..eddea6983ae --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/components/SearchSelect.js @@ -0,0 +1,122 @@ +/* + * 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 Select from 'react-select'; +import { debounce } from 'lodash'; +import { translate, translateWithParameters } from '../../../helpers/l10n'; + +type Option = { label: string, value: string }; + +type Props = {| + minimumQueryLength: number, + onSearch: (query: string) => Promise<Array<Option>>, + onSelect: (value: string) => void, + renderOption?: (option: Object) => React.Element<*>, + resetOnBlur: boolean, + value?: string +|}; + +type State = { + loading: boolean, + options: Array<Option>, + query: string +}; + +export default class SearchSelect extends React.PureComponent { + mounted: boolean; + props: Props; + state: State; + + static defaultProps = { + minimumQueryLength: 2, + resetOnBlur: true + }; + + constructor(props: Props) { + super(props); + this.state = { loading: false, options: [], query: '' }; + this.search = debounce(this.search, 250); + } + + componentDidMount() { + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + } + + search = (query: string) => { + this.props.onSearch(query).then(options => { + if (this.mounted) { + this.setState({ loading: false, options }); + } + }); + }; + + handleBlur = () => { + this.setState({ options: [], query: '' }); + }; + + handleChange = (option: Option) => { + this.props.onSelect(option.value); + }; + + handleInputChange = (query: string = '') => { + if (query.length >= this.props.minimumQueryLength) { + this.setState({ loading: true, query }); + this.search(query); + } else { + this.setState({ options: [], query }); + } + }; + + // disable internal filtering + handleFilterOption = () => true; + + render() { + return ( + <Select + autofocus={true} + cache={false} + className="input-super-large" + clearable={false} + filterOption={this.handleFilterOption} + isLoading={this.state.loading} + noResultsText={ + this.state.query.length < this.props.minimumQueryLength + ? translateWithParameters('select2.tooShort', this.props.minimumQueryLength) + : translate('select2.noMatches') + } + onBlur={this.props.resetOnBlur ? this.handleBlur : undefined} + onChange={this.handleChange} + onInputChange={this.handleInputChange} + onOpen={this.props.minimumQueryLength === 0 ? this.handleInputChange : undefined} + optionRenderer={this.props.renderOption} + options={this.state.options} + placeholder={translate('search_verb')} + searchable={true} + value={this.props.value} + valueRenderer={this.props.renderOption} + /> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/components/__tests__/SearchSelect-test.js b/server/sonar-web/src/main/js/apps/issues/components/__tests__/SearchSelect-test.js new file mode 100644 index 00000000000..f4d46d4f8a8 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/components/__tests__/SearchSelect-test.js @@ -0,0 +1,49 @@ +/* + * 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 { shallow } from 'enzyme'; +import SearchSelect from '../SearchSelect'; + +jest.mock('lodash', () => ({ + debounce: fn => fn +})); + +it('should render Select', () => { + expect(shallow(<SearchSelect onSearch={jest.fn()} onSelect={jest.fn()} />)).toMatchSnapshot(); +}); + +it('should call onSelect', () => { + const onSelect = jest.fn(); + const wrapper = shallow(<SearchSelect onSearch={jest.fn()} onSelect={onSelect} />); + wrapper.prop('onChange')({ value: 'foo' }); + expect(onSelect).lastCalledWith('foo'); +}); + +it('should call onSearch', () => { + const onSearch = jest.fn().mockReturnValue(Promise.resolve([])); + const wrapper = shallow( + <SearchSelect minimumQueryLength={2} onSearch={onSearch} onSelect={jest.fn()} /> + ); + wrapper.prop('onInputChange')('f'); + expect(onSearch).not.toHaveBeenCalled(); + wrapper.prop('onInputChange')('foo'); + expect(onSearch).lastCalledWith('foo'); +}); diff --git a/server/sonar-web/src/main/js/apps/issues/components/__tests__/__snapshots__/SearchSelect-test.js.snap b/server/sonar-web/src/main/js/apps/issues/components/__tests__/__snapshots__/SearchSelect-test.js.snap new file mode 100644 index 00000000000..4f2fe37cc62 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/components/__tests__/__snapshots__/SearchSelect-test.js.snap @@ -0,0 +1,48 @@ +exports[`test should render Select 1`] = ` +<Select + addLabelText="Add \"{label}\"?" + arrowRenderer={[Function]} + autofocus={true} + autosize={true} + backspaceRemoves={true} + backspaceToRemoveMessage="Press backspace to remove {label}" + cache={false} + className="input-super-large" + clearAllText="Clear all" + clearValueText="Clear value" + clearable={false} + delimiter="," + disabled={false} + escapeClearsValue={true} + filterOption={[Function]} + filterOptions={[Function]} + ignoreAccents={true} + ignoreCase={true} + inputProps={Object {}} + isLoading={false} + joinValues={false} + labelKey="label" + matchPos="any" + matchProp="any" + menuBuffer={0} + menuRenderer={[Function]} + multi={false} + noResultsText="select2.tooShort.2" + onBlur={[Function]} + onBlurResetsInput={true} + onChange={[Function]} + onCloseResetsInput={true} + onInputChange={[Function]} + openAfterFocus={false} + optionComponent={[Function]} + options={Array []} + pageSize={5} + placeholder="search_verb" + required={false} + scrollMenuIntoView={true} + searchable={true} + simpleValue={false} + tabSelectsValue={true} + valueComponent={[Function]} + valueKey="value" /> +`; diff --git a/server/sonar-web/src/main/js/apps/issues/controller.js b/server/sonar-web/src/main/js/apps/issues/controller.js deleted file mode 100644 index f98f292d4bd..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/controller.js +++ /dev/null @@ -1,217 +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 Controller from '../../components/navigator/controller'; -import ComponentViewer from './component-viewer/main'; -import getStore from '../../app/utils/getStore'; -import { receiveIssues } from '../../store/issues/duck'; - -const FACET_DATA_FIELDS = ['components', 'users', 'rules', 'languages']; - -export default Controller.extend({ - _issuesParameters() { - return { - p: this.options.app.state.get('page'), - ps: this.pageSize, - asc: true, - additionalFields: '_all', - facets: this._facetsFromServer().join() - }; - }, - - receiveIssues(issues) { - const store = getStore(); - store.dispatch(receiveIssues(issues)); - }, - - fetchList(firstPage) { - const that = this; - if (firstPage == null) { - firstPage = true; - } - if (firstPage) { - this.options.app.state.set({ selectedIndex: 0, page: 1 }, { silent: true }); - this.closeComponentViewer(); - } - const data = this._issuesParameters(); - Object.assign(data, this.options.app.state.get('query')); - if (this.options.app.state.get('query').assigned_to_me) { - Object.assign(data, { assignees: '__me__' }); - } - if (this.options.app.state.get('isContext')) { - Object.assign(data, this.options.app.state.get('contextQuery')); - } - return $.get(window.baseUrl + '/api/issues/search', data).done(r => { - const issues = that.options.app.list.parseIssues(r); - this.receiveIssues(issues); - if (firstPage) { - const issues = that.options.app.list.parseIssues(r); - that.options.app.list.reset(issues); - } else { - const issues = that.options.app.list.parseIssues(r, that.options.app.list.length); - that.options.app.list.add(issues); - } - that.options.app.list.setIndex(); - FACET_DATA_FIELDS.forEach(field => { - that.options.app.facets[field] = r[field]; - }); - that.options.app.facets.reset(that._allFacets()); - that.options.app.facets.add(r.facets, { merge: true }); - that.enableFacets(that._enabledFacets()); - if (firstPage) { - that.options.app.state.set({ - page: r.p, - pageSize: r.ps, - total: r.total, - maxResultsReached: r.p * r.ps >= r.total - }); - } else { - that.options.app.state.set({ - page: r.p, - maxResultsReached: r.p * r.ps >= r.total - }); - } - if (firstPage && that.isIssuePermalink()) { - that.showComponentViewer(that.options.app.list.first()); - } - }); - }, - - isIssuePermalink() { - const query = this.options.app.state.get('query'); - return query.issues != null && this.options.app.list.length === 1; - }, - - _mergeCollections(a, b) { - const collection = new Backbone.Collection(a); - collection.add(b, { merge: true }); - return collection.toJSON(); - }, - - requestFacet(id) { - const that = this; - const facet = this.options.app.facets.get(id); - const data = { - facets: id, - ps: 1, - additionalFields: '_all', - ...this.options.app.state.get('query') - }; - if (this.options.app.state.get('query').assigned_to_me) { - Object.assign(data, { assignees: '__me__' }); - } - if (this.options.app.state.get('isContext')) { - Object.assign(data, this.options.app.state.get('contextQuery')); - } - return $.get(window.baseUrl + '/api/issues/search', data, r => { - FACET_DATA_FIELDS.forEach(field => { - that.options.app.facets[field] = that._mergeCollections( - that.options.app.facets[field], - r[field] - ); - }); - const facetData = r.facets.find(facet => facet.property === id); - if (facetData != null) { - facet.set(facetData); - } - }); - }, - - newSearch() { - this.options.app.state.unset('filter'); - return this.options.app.state.setQuery({ resolved: 'false' }); - }, - - parseQuery() { - const q = Controller.prototype.parseQuery.apply(this, arguments); - delete q.asc; - delete q.s; - delete q.id; - return q; - }, - - getQueryAsObject() { - const state = this.options.app.state; - const query = state.get('query'); - if (query.assigned_to_me) { - Object.assign(query, { assignees: '__me__' }); - } - if (state.get('isContext')) { - Object.assign(query, state.get('contextQuery')); - } - return query; - }, - - getQuery(separator, addContext, handleMyIssues = false) { - if (separator == null) { - separator = '|'; - } - if (addContext == null) { - addContext = false; - } - const filter = this.options.app.state.get('query'); - if (addContext && this.options.app.state.get('isContext')) { - Object.assign(filter, this.options.app.state.get('contextQuery')); - } - if (handleMyIssues && this.options.app.state.get('query').assigned_to_me) { - Object.assign(filter, { assignees: '__me__' }); - } - const route = []; - Object.keys(filter).forEach(property => { - route.push(`${property}=${encodeURIComponent(filter[property])}`); - }); - return route.join(separator); - }, - - _prepareComponent(issue) { - return { - key: issue.get('component'), - name: issue.get('componentLongName'), - qualifier: issue.get('componentQualifier'), - subProject: issue.get('subProject'), - subProjectName: issue.get('subProjectLongName'), - project: issue.get('project'), - projectName: issue.get('projectLongName'), - projectOrganization: issue.get('projectOrganization') - }; - }, - - showComponentViewer(issue) { - this.options.app.layout.workspaceComponentViewerRegion.reset(); - key.setScope('componentViewer'); - this.options.app.issuesView.unbindScrollEvents(); - this.options.app.state.set('component', this._prepareComponent(issue)); - this.options.app.componentViewer = new ComponentViewer({ app: this.options.app }); - this.options.app.layout.workspaceComponentViewerRegion.show(this.options.app.componentViewer); - this.options.app.layout.showComponentViewer(); - return this.options.app.componentViewer.openFileByIssue(issue); - }, - - closeComponentViewer() { - key.setScope('list'); - $('body').click(); - this.options.app.state.unset('component'); - this.options.app.layout.workspaceComponentViewerRegion.reset(); - this.options.app.layout.hideComponentViewer(); - this.options.app.issuesView.bindScrollEvents(); - return this.options.app.issuesView.scrollTo(); - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/facets-view.js b/server/sonar-web/src/main/js/apps/issues/facets-view.js deleted file mode 100644 index f706ef53a39..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/facets-view.js +++ /dev/null @@ -1,63 +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 FacetsView from '../../components/navigator/facets-view'; -import BaseFacet from './facets/base-facet'; -import TypeFacet from './facets/type-facet'; -import SeverityFacet from './facets/severity-facet'; -import StatusFacet from './facets/status-facet'; -import ProjectFacet from './facets/project-facet'; -import ModuleFacet from './facets/module-facet'; -import AssigneeFacet from './facets/assignee-facet'; -import RuleFacet from './facets/rule-facet'; -import TagFacet from './facets/tag-facet'; -import ResolutionFacet from './facets/resolution-facet'; -import CreationDateFacet from './facets/creation-date-facet'; -import FileFacet from './facets/file-facet'; -import LanguageFacet from './facets/language-facet'; -import AuthorFacet from './facets/author-facet'; -import IssueKeyFacet from './facets/issue-key-facet'; -import ContextFacet from './facets/context-facet'; -import ModeFacet from './facets/mode-facet'; - -const viewsMapping = { - types: TypeFacet, - severities: SeverityFacet, - statuses: StatusFacet, - assignees: AssigneeFacet, - resolutions: ResolutionFacet, - createdAt: CreationDateFacet, - projectUuids: ProjectFacet, - moduleUuids: ModuleFacet, - rules: RuleFacet, - tags: TagFacet, - fileUuids: FileFacet, - languages: LanguageFacet, - authors: AuthorFacet, - issues: IssueKeyFacet, - context: ContextFacet, - facetMode: ModeFacet -}; - -export default FacetsView.extend({ - getChildView(model) { - const view = viewsMapping[model.get('property')]; - return view ? view : BaseFacet; - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/facets/assignee-facet.js b/server/sonar-web/src/main/js/apps/issues/facets/assignee-facet.js deleted file mode 100644 index 597afcaee47..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/facets/assignee-facet.js +++ /dev/null @@ -1,135 +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 { sortBy } from 'lodash'; -import CustomValuesFacet from './custom-values-facet'; -import Template from '../templates/facets/issues-assignee-facet.hbs'; - -export default CustomValuesFacet.extend({ - template: Template, - - initialize() { - this.context = { - isContext: this.options.app.state.get('isContext'), - organization: this.options.app.state.get('contextOrganization') - }; - }, - - getUrl() { - return window.baseUrl + - (this.context.isContext ? '/api/organizations/search_members' : '/api/users/search'); - }, - - prepareAjaxSearch() { - return { - quietMillis: 300, - url: this.getUrl(), - data: (term, page) => { - if (this.context.isContext && this.context.organization) { - return { q: term, p: page, organization: this.context.organization }; - } else { - return { q: term, p: page }; - } - }, - results: window.usersToSelect2 - }; - }, - - onRender() { - CustomValuesFacet.prototype.onRender.apply(this, arguments); - - const myIssuesSelected = !!this.options.app.state.get('query').assigned_to_me; - this.$el.toggleClass('hidden', myIssuesSelected); - - const value = this.options.app.state.get('query').assigned; - if (value != null && (!value || value === 'false')) { - this.$('.js-facet').filter('[data-unassigned]').addClass('active'); - } - }, - - toggleFacet(e) { - const unassigned = $(e.currentTarget).is('[data-unassigned]'); - $(e.currentTarget).toggleClass('active'); - if (unassigned) { - const checked = $(e.currentTarget).is('.active'); - const value = checked ? 'false' : null; - return this.options.app.state.updateFilter({ - assigned: value, - assignees: null, - assigned_to_me: null - }); - } else { - return this.options.app.state.updateFilter({ - assigned: null, - assignees: this.getValue(), - assigned_to_me: null - }); - } - }, - - getValuesWithLabels() { - const values = this.model.getValues(); - const users = this.options.app.facets.users; - values.forEach(v => { - const login = v.val; - let name = ''; - if (login) { - const user = users.find(user => user.login === login); - if (user != null) { - name = user.name; - } - } - v.label = name; - }); - return values; - }, - - disable() { - return this.options.app.state.updateFilter({ - assigned: null, - assignees: null - }); - }, - - addCustomValue() { - const property = this.model.get('property'); - const customValue = this.$('.js-custom-value').select2('val'); - let value = this.getValue(); - if (value.length > 0) { - value += ','; - } - value += customValue; - const obj = {}; - obj[property] = value; - obj.assigned = null; - return this.options.app.state.updateFilter(obj); - }, - - sortValues(values) { - return sortBy(values, v => v.val === '' ? -999999 : -v.count); - }, - - serializeData() { - return { - ...CustomValuesFacet.prototype.serializeData.apply(this, arguments), - values: this.sortValues(this.getValuesWithLabels()) - }; - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/facets/author-facet.js b/server/sonar-web/src/main/js/apps/issues/facets/author-facet.js deleted file mode 100644 index c652eba0abd..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/facets/author-facet.js +++ /dev/null @@ -1,60 +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 CustomValuesFacet from './custom-values-facet'; -import { translate, translateWithParameters } from '../../../helpers/l10n'; - -export default CustomValuesFacet.extend({ - getUrl() { - return window.baseUrl + '/api/issues/authors'; - }, - - prepareSearch() { - return this.$('.js-custom-value').select2({ - placeholder: translate('search_verb'), - minimumInputLength: 2, - allowClear: false, - formatNoMatches() { - return translate('select2.noMatches'); - }, - formatSearching() { - return translate('select2.searching'); - }, - formatInputTooShort() { - return translateWithParameters('select2.tooShort', 2); - }, - width: '100%', - ajax: { - quietMillis: 300, - url: this.getUrl(), - data(term) { - return { q: term, ps: 25 }; - }, - results(data) { - return { - more: false, - results: data.authors.map(author => { - return { id: author, text: author }; - }) - }; - } - } - }); - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/facets/base-facet.js b/server/sonar-web/src/main/js/apps/issues/facets/base-facet.js deleted file mode 100644 index 5d05caaeb2e..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/facets/base-facet.js +++ /dev/null @@ -1,41 +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 BaseFacet from '../../../components/navigator/facets/base-facet'; -import Template from '../templates/facets/issues-base-facet.hbs'; - -export default BaseFacet.extend({ - template: Template, - - onRender() { - BaseFacet.prototype.onRender.apply(this, arguments); - return this.$('[data-toggle="tooltip"]').tooltip({ container: 'body' }); - }, - - onDestroy() { - return this.$('[data-toggle="tooltip"]').tooltip('destroy'); - }, - - serializeData() { - return { - ...BaseFacet.prototype.serializeData.apply(this, arguments), - state: this.options.app.state.toJSON() - }; - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/facets/context-facet.js b/server/sonar-web/src/main/js/apps/issues/facets/context-facet.js deleted file mode 100644 index a2f31cb9c37..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/facets/context-facet.js +++ /dev/null @@ -1,32 +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 BaseFacet from './base-facet'; -import Template from '../templates/facets/issues-context-facet.hbs'; - -export default BaseFacet.extend({ - template: Template, - - serializeData() { - return { - ...BaseFacet.prototype.serializeData.apply(this, arguments), - state: this.options.app.state.toJSON() - }; - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/facets/creation-date-facet.js b/server/sonar-web/src/main/js/apps/issues/facets/creation-date-facet.js deleted file mode 100644 index 81c31973b79..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/facets/creation-date-facet.js +++ /dev/null @@ -1,176 +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 moment from 'moment'; -import { times } from 'lodash'; -import BaseFacet from './base-facet'; -import Template from '../templates/facets/issues-creation-date-facet.hbs'; -import '../../../components/widgets/barchart'; -import { formatMeasure } from '../../../helpers/measures'; - -export default BaseFacet.extend({ - template: Template, - - events() { - return { - ...BaseFacet.prototype.events.apply(this, arguments), - 'change input': 'applyFacet', - 'click .js-select-period-start': 'selectPeriodStart', - 'click .js-select-period-end': 'selectPeriodEnd', - 'click .sonar-d3 rect': 'selectBar', - 'click .js-all': 'onAllClick', - 'click .js-last-week': 'onLastWeekClick', - 'click .js-last-month': 'onLastMonthClick', - 'click .js-last-year': 'onLastYearClick', - 'click .js-leak': 'onLeakClick' - }; - }, - - onRender() { - const that = this; - this.$el.toggleClass('search-navigator-facet-box-collapsed', !this.model.get('enabled')); - this.$('input').datepicker({ - dateFormat: 'yy-mm-dd', - changeMonth: true, - changeYear: true - }); - const props = ['createdAfter', 'createdBefore', 'createdAt']; - const query = this.options.app.state.get('query'); - props.forEach(prop => { - const value = query[prop]; - if (value != null) { - that.$(`input[name=${prop}]`).val(value); - } - }); - let values = this.model.getValues(); - if (!(Array.isArray(values) && values.length > 0)) { - let date = moment(); - values = []; - times(10, () => { - values.push({ count: 0, val: date.toDate().toString() }); - date = date.subtract(1, 'days'); - }); - values.reverse(); - } - values = values.map(v => { - const format = that.options.app.state.getFacetMode() === 'count' - ? 'SHORT_INT' - : 'SHORT_WORK_DUR'; - const text = formatMeasure(v.count, format); - return { ...v, text }; - }); - return this.$('.js-barchart').barchart(values); - }, - - selectPeriodStart() { - return this.$('.js-period-start').datepicker('show'); - }, - - selectPeriodEnd() { - return this.$('.js-period-end').datepicker('show'); - }, - - applyFacet() { - const obj = { createdAt: null, createdInLast: null }; - this.$('input').each(function() { - const property = $(this).prop('name'); - const value = $(this).val(); - obj[property] = value; - }); - return this.options.app.state.updateFilter(obj); - }, - - disable() { - return this.options.app.state.updateFilter({ - createdAfter: null, - createdBefore: null, - createdAt: null, - sinceLeakPeriod: null, - createdInLast: null - }); - }, - - selectBar(e) { - const periodStart = $(e.currentTarget).data('period-start'); - const periodEnd = $(e.currentTarget).data('period-end'); - return this.options.app.state.updateFilter({ - createdAfter: periodStart, - createdBefore: periodEnd, - createdAt: null, - sinceLeakPeriod: null, - createdInLast: null - }); - }, - - selectPeriod(period) { - return this.options.app.state.updateFilter({ - createdAfter: null, - createdBefore: null, - createdAt: null, - sinceLeakPeriod: null, - createdInLast: period - }); - }, - - onAllClick(e) { - e.preventDefault(); - return this.disable(); - }, - - onLastWeekClick(e) { - e.preventDefault(); - return this.selectPeriod('1w'); - }, - - onLastMonthClick(e) { - e.preventDefault(); - return this.selectPeriod('1m'); - }, - - onLastYearClick(e) { - e.preventDefault(); - return this.selectPeriod('1y'); - }, - - onLeakClick(e) { - e.preventDefault(); - this.options.app.state.updateFilter({ - createdAfter: null, - createdBefore: null, - createdAt: null, - createdInLast: null, - sinceLeakPeriod: 'true' - }); - }, - - serializeData() { - const hasLeak = this.options.app.state.get('contextComponentQualifier') === 'TRK'; - - return { - ...BaseFacet.prototype.serializeData.apply(this, arguments), - hasLeak, - periodStart: this.options.app.state.get('query').createdAfter, - periodEnd: this.options.app.state.get('query').createdBefore, - createdAt: this.options.app.state.get('query').createdAt, - sinceLeakPeriod: this.options.app.state.get('query').sinceLeakPeriod, - createdInLast: this.options.app.state.get('query').createdInLast - }; - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/facets/custom-values-facet.js b/server/sonar-web/src/main/js/apps/issues/facets/custom-values-facet.js deleted file mode 100644 index a6168f350f1..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/facets/custom-values-facet.js +++ /dev/null @@ -1,85 +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 BaseFacet from './base-facet'; -import Template from '../templates/facets/issues-custom-values-facet.hbs'; -import { translate, translateWithParameters } from '../../../helpers/l10n'; - -export default BaseFacet.extend({ - template: Template, - - events() { - return { - ...BaseFacet.prototype.events.apply(this, arguments), - 'change .js-custom-value': 'addCustomValue' - }; - }, - - getUrl() {}, - - onRender() { - BaseFacet.prototype.onRender.apply(this, arguments); - return this.prepareSearch(); - }, - - prepareSearch() { - return this.$('.js-custom-value').select2({ - placeholder: translate('search_verb'), - minimumInputLength: 2, - allowClear: false, - formatNoMatches() { - return translate('select2.noMatches'); - }, - formatSearching() { - return translate('select2.searching'); - }, - formatInputTooShort() { - return translateWithParameters('select2.tooShort', 2); - }, - width: '100%', - ajax: this.prepareAjaxSearch() - }); - }, - - prepareAjaxSearch() { - return { - quietMillis: 300, - url: this.getUrl(), - data(term, page) { - return { s: term, p: page }; - }, - results(data) { - return { more: data.more, results: data.results }; - } - }; - }, - - addCustomValue() { - const property = this.model.get('property'); - const customValue = this.$('.js-custom-value').select2('val'); - let value = this.getValue(); - if (value.length > 0) { - value += ','; - } - value += customValue; - const obj = {}; - obj[property] = value; - return this.options.app.state.updateFilter(obj); - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/facets/file-facet.js b/server/sonar-web/src/main/js/apps/issues/facets/file-facet.js deleted file mode 100644 index 11b9a69a46d..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/facets/file-facet.js +++ /dev/null @@ -1,61 +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 BaseFacet from './base-facet'; -import Template from '../templates/facets/issues-file-facet.hbs'; - -export default BaseFacet.extend({ - template: Template, - - onRender() { - BaseFacet.prototype.onRender.apply(this, arguments); - const widths = this.$('.facet-stat') - .map(function() { - return $(this).outerWidth(); - }) - .get(); - const maxValueWidth = Math.max(...widths); - return this.$('.facet-name').css('padding-right', maxValueWidth); - }, - - getValuesWithLabels() { - const values = this.model.getValues(); - const source = this.options.app.facets.components; - values.forEach(v => { - const key = v.val; - let label = null; - if (key) { - const item = source.find(file => file.uuid === key); - if (item != null) { - label = item.longName; - } - } - v.label = label; - }); - return values; - }, - - serializeData() { - return { - ...BaseFacet.prototype.serializeData.apply(this, arguments), - values: this.sortValues(this.getValuesWithLabels()) - }; - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/facets/issue-key-facet.js b/server/sonar-web/src/main/js/apps/issues/facets/issue-key-facet.js deleted file mode 100644 index d57d9c79cce..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/facets/issue-key-facet.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 BaseFacet from './base-facet'; -import Template from '../templates/facets/issues-issue-key-facet.hbs'; - -export default BaseFacet.extend({ - template: Template, - - onRender() { - return this.$el.toggleClass('hidden', !this.options.app.state.get('query').issues); - }, - - disable() { - return this.options.app.state.updateFilter({ issues: null }); - }, - - serializeData() { - return { - ...BaseFacet.prototype.serializeData.apply(this, arguments), - issues: this.options.app.state.get('query').issues - }; - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/facets/language-facet.js b/server/sonar-web/src/main/js/apps/issues/facets/language-facet.js deleted file mode 100644 index 789ae16ca93..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/facets/language-facet.js +++ /dev/null @@ -1,84 +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 CustomValuesFacet from './custom-values-facet'; -import { translate, translateWithParameters } from '../../../helpers/l10n'; - -export default CustomValuesFacet.extend({ - getUrl() { - return window.baseUrl + '/api/languages/list'; - }, - - prepareSearch() { - return this.$('.js-custom-value').select2({ - placeholder: translate('search_verb'), - minimumInputLength: 2, - allowClear: false, - formatNoMatches() { - return translate('select2.noMatches'); - }, - formatSearching() { - return translate('select2.searching'); - }, - formatInputTooShort() { - return translateWithParameters('select2.tooShort', 2); - }, - width: '100%', - ajax: { - quietMillis: 300, - url: this.getUrl(), - data(term) { - return { q: term, ps: 0 }; - }, - results(data) { - return { - more: false, - results: data.languages.map(lang => { - return { id: lang.key, text: lang.name }; - }) - }; - } - } - }); - }, - - getValuesWithLabels() { - const values = this.model.getValues(); - const source = this.options.app.facets.languages; - values.forEach(v => { - const key = v.val; - let label = null; - if (key) { - const item = source.find(lang => lang.key === key); - if (item != null) { - label = item.name; - } - } - v.label = label; - }); - return values; - }, - - serializeData() { - return { - ...CustomValuesFacet.prototype.serializeData.apply(this, arguments), - values: this.sortValues(this.getValuesWithLabels()) - }; - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/facets/module-facet.js b/server/sonar-web/src/main/js/apps/issues/facets/module-facet.js deleted file mode 100644 index b6e87598bff..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/facets/module-facet.js +++ /dev/null @@ -1,46 +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 BaseFacet from './base-facet'; - -export default BaseFacet.extend({ - getValuesWithLabels() { - const values = this.model.getValues(); - const components = this.options.app.facets.components; - values.forEach(v => { - const uuid = v.val; - let label = uuid; - if (uuid) { - const component = components.find(c => c.uuid === uuid); - if (component != null) { - label = component.longName; - } - } - v.label = label; - }); - return values; - }, - - serializeData() { - return { - ...BaseFacet.prototype.serializeData.apply(this, arguments), - values: this.sortValues(this.getValuesWithLabels()) - }; - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/facets/project-facet.js b/server/sonar-web/src/main/js/apps/issues/facets/project-facet.js deleted file mode 100644 index 9bd37e64b03..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/facets/project-facet.js +++ /dev/null @@ -1,112 +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 CustomValuesFacet from './custom-values-facet'; -import Template from '../templates/facets/issues-projects-facet.hbs'; -import { translate, translateWithParameters } from '../../../helpers/l10n'; -import { areThereCustomOrganizations, getOrganization } from '../../../store/organizations/utils'; - -export default CustomValuesFacet.extend({ - template: Template, - - getUrl() { - return window.baseUrl + '/api/components/search'; - }, - - prepareSearchForViews() { - const contextId = this.options.app.state.get('contextComponentUuid'); - return { - url: window.baseUrl + '/api/components/tree', - data(term, page) { - return { q: term, p: page, qualifiers: 'TRK', baseComponentId: contextId }; - } - }; - }, - - prepareAjaxSearch() { - const options = { - quietMillis: 300, - url: this.getUrl(), - data(term, page) { - return { q: term, p: page, qualifiers: 'TRK' }; - }, - results: r => ({ - more: r.paging.total > r.paging.pageIndex * r.paging.pageSize, - results: r.components.map(component => ({ - id: component.id, - text: component.name - })) - }) - }; - const contextQualifier = this.options.app.state.get('contextComponentQualifier'); - if (contextQualifier === 'VW' || contextQualifier === 'SVW') { - Object.assign(options, this.prepareSearchForViews()); - } - return options; - }, - - prepareSearch() { - return this.$('.js-custom-value').select2({ - placeholder: translate('search_verb'), - minimumInputLength: 3, - allowClear: false, - formatNoMatches() { - return translate('select2.noMatches'); - }, - formatSearching() { - return translate('select2.searching'); - }, - formatInputTooShort() { - return translateWithParameters('select2.tooShort', 3); - }, - width: '100%', - ajax: this.prepareAjaxSearch() - }); - }, - - getValuesWithLabels() { - const values = this.model.getValues(); - const projects = this.options.app.facets.components; - const displayOrganizations = areThereCustomOrganizations(); - values.forEach(v => { - const uuid = v.val; - let label = ''; - let organization = null; - if (uuid) { - const project = projects.find(p => p.uuid === uuid); - if (project != null) { - label = project.longName; - organization = displayOrganizations && project.organization - ? getOrganization(project.organization) - : null; - } - } - v.label = label; - v.organization = organization; - }); - return values; - }, - - serializeData() { - return { - ...CustomValuesFacet.prototype.serializeData.apply(this, arguments), - values: this.sortValues(this.getValuesWithLabels()) - }; - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/facets/reporter-facet.js b/server/sonar-web/src/main/js/apps/issues/facets/reporter-facet.js deleted file mode 100644 index 3e691aea9be..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/facets/reporter-facet.js +++ /dev/null @@ -1,61 +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 CustomValuesFacet from './custom-values-facet'; - -export default CustomValuesFacet.extend({ - getUrl() { - return window.baseUrl + '/api/users/search'; - }, - - prepareAjaxSearch() { - return { - quietMillis: 300, - url: this.getUrl(), - data(term, page) { - return { q: term, p: page }; - }, - results: window.usersToSelect2 - }; - }, - - getValuesWithLabels() { - const values = this.model.getValues(); - const source = this.options.app.facets.users; - values.forEach(v => { - const key = v.val; - let label = null; - if (key) { - const item = source.find(user => user.login === key); - if (item != null) { - label = item.name; - } - } - v.label = label; - }); - return values; - }, - - serializeData() { - return { - ...CustomValuesFacet.prototype.serializeData.apply(this, arguments), - values: this.sortValues(this.getValuesWithLabels()) - }; - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/facets/resolution-facet.js b/server/sonar-web/src/main/js/apps/issues/facets/resolution-facet.js deleted file mode 100644 index d4e01b1c0f4..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/facets/resolution-facet.js +++ /dev/null @@ -1,65 +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 { sortBy } from 'lodash'; -import BaseFacet from './base-facet'; -import Template from '../templates/facets/issues-resolution-facet.hbs'; - -export default BaseFacet.extend({ - template: Template, - - onRender() { - BaseFacet.prototype.onRender.apply(this, arguments); - const value = this.options.app.state.get('query').resolved; - if (value != null && (!value || value === 'false')) { - this.$('.js-facet').filter('[data-unresolved]').addClass('active'); - } - }, - - toggleFacet(e) { - const unresolved = $(e.currentTarget).is('[data-unresolved]'); - $(e.currentTarget).toggleClass('active'); - if (unresolved) { - const checked = $(e.currentTarget).is('.active'); - const value = checked ? 'false' : null; - return this.options.app.state.updateFilter({ - resolved: value, - resolutions: null - }); - } else { - return this.options.app.state.updateFilter({ - resolved: null, - resolutions: this.getValue() - }); - } - }, - - disable() { - return this.options.app.state.updateFilter({ - resolved: null, - resolutions: null - }); - }, - - sortValues(values) { - const order = ['', 'FIXED', 'FALSE-POSITIVE', 'WONTFIX', 'REMOVED']; - return sortBy(values, v => order.indexOf(v.val)); - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/facets/rule-facet.js b/server/sonar-web/src/main/js/apps/issues/facets/rule-facet.js deleted file mode 100644 index 75445570098..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/facets/rule-facet.js +++ /dev/null @@ -1,95 +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 CustomValuesFacet from './custom-values-facet'; -import { translate, translateWithParameters } from '../../../helpers/l10n'; - -export default CustomValuesFacet.extend({ - prepareSearch() { - let url = window.baseUrl + '/api/rules/search?f=name,langName'; - const languages = this.options.app.state.get('query').languages; - if (languages != null) { - url += '&languages=' + languages; - } - return this.$('.js-custom-value').select2({ - placeholder: translate('search_verb'), - minimumInputLength: 2, - allowClear: false, - formatNoMatches() { - return translate('select2.noMatches'); - }, - formatSearching() { - return translate('select2.searching'); - }, - formatInputTooShort() { - return translateWithParameters('select2.tooShort', 2); - }, - width: '100%', - ajax: { - url, - quietMillis: 300, - data(term, page) { - return { q: term, p: page }; - }, - results(data) { - const results = data.rules.map(rule => { - const lang = rule.langName || translate('manual'); - return { - id: rule.key, - text: '(' + lang + ') ' + rule.name - }; - }); - return { - more: data.p * data.ps < data.total, - results - }; - } - } - }); - }, - - getValuesWithLabels() { - const values = this.model.getValues(); - const rules = this.options.app.facets.rules; - values.forEach(v => { - const key = v.val; - let label = ''; - let extra = ''; - if (key) { - const rule = rules.find(r => r.key === key); - if (rule != null) { - label = rule.name; - } - if (rule != null) { - extra = rule.langName; - } - } - v.label = label; - v.extra = extra; - }); - return values; - }, - - serializeData() { - return { - ...CustomValuesFacet.prototype.serializeData.apply(this, arguments), - values: this.sortValues(this.getValuesWithLabels()) - }; - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/facets/severity-facet.js b/server/sonar-web/src/main/js/apps/issues/facets/severity-facet.js deleted file mode 100644 index db5cd337838..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/facets/severity-facet.js +++ /dev/null @@ -1,31 +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 { sortBy } from 'lodash'; -import BaseFacet from './base-facet'; -import Template from '../templates/facets/issues-severity-facet.hbs'; - -export default BaseFacet.extend({ - template: Template, - - sortValues(values) { - const order = ['BLOCKER', 'MINOR', 'CRITICAL', 'INFO', 'MAJOR']; - return sortBy(values, v => order.indexOf(v.val)); - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/facets/status-facet.js b/server/sonar-web/src/main/js/apps/issues/facets/status-facet.js deleted file mode 100644 index cb4e88a5f49..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/facets/status-facet.js +++ /dev/null @@ -1,31 +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 { sortBy } from 'lodash'; -import BaseFacet from './base-facet'; -import Template from '../templates/facets/issues-status-facet.hbs'; - -export default BaseFacet.extend({ - template: Template, - - sortValues(values) { - const order = ['OPEN', 'RESOLVED', 'REOPENED', 'CLOSED', 'CONFIRMED']; - return sortBy(values, v => order.indexOf(v.val)); - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/facets/tag-facet.js b/server/sonar-web/src/main/js/apps/issues/facets/tag-facet.js deleted file mode 100644 index 8bb629ff3d4..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/facets/tag-facet.js +++ /dev/null @@ -1,72 +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 CustomValuesFacet from './custom-values-facet'; -import { translate } from '../../../helpers/l10n'; - -export default CustomValuesFacet.extend({ - prepareSearch() { - let url = window.baseUrl + '/api/issues/tags?ps=10'; - const tags = this.options.app.state.get('query').tags; - if (tags != null) { - url += '&tags=' + tags; - } - return this.$('.js-custom-value').select2({ - placeholder: translate('search_verb'), - minimumInputLength: 0, - allowClear: false, - formatNoMatches() { - return translate('select2.noMatches'); - }, - formatSearching() { - return translate('select2.searching'); - }, - width: '100%', - ajax: { - url, - quietMillis: 300, - data(term) { - return { q: term, ps: 10 }; - }, - results(data) { - const results = data.tags.map(tag => { - return { id: tag, text: tag }; - }); - return { more: false, results }; - } - } - }); - }, - - getValuesWithLabels() { - const values = this.model.getValues(); - values.forEach(v => { - v.label = v.val; - v.extra = ''; - }); - return values; - }, - - serializeData() { - return { - ...CustomValuesFacet.prototype.serializeData.apply(this, arguments), - values: this.sortValues(this.getValuesWithLabels()) - }; - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/facets/type-facet.js b/server/sonar-web/src/main/js/apps/issues/facets/type-facet.js deleted file mode 100644 index efac17ea9f4..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/facets/type-facet.js +++ /dev/null @@ -1,31 +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 { sortBy } from 'lodash'; -import BaseFacet from './base-facet'; -import Template from '../templates/facets/issues-type-facet.hbs'; - -export default BaseFacet.extend({ - template: Template, - - sortValues(values) { - const order = ['BUG', 'VULNERABILITY', 'CODE_SMELL']; - return sortBy(values, v => order.indexOf(v.val)); - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/init.js b/server/sonar-web/src/main/js/apps/issues/init.js deleted file mode 100644 index 0fb32e839ab..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/init.js +++ /dev/null @@ -1,87 +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 State from './models/state'; -import Layout from './layout'; -import Issues from './models/issues'; -import Facets from '../../components/navigator/models/facets'; -import Controller from './controller'; -import Router from './router'; -import WorkspaceListView from './workspace-list-view'; -import WorkspaceHeaderView from './workspace-header-view'; -import FacetsView from './facets-view'; -import HeaderView from './HeaderView'; - -const App = new Marionette.Application(); -const init = function({ el, user }) { - this.state = new State({ user, canBulkChange: user.isLoggedIn }); - this.list = new Issues(); - this.facets = new Facets(); - - this.layout = new Layout({ app: this, el }); - this.layout.render(); - $('#footer').addClass('search-navigator-footer'); - - this.controller = new Controller({ app: this }); - - this.issuesView = new WorkspaceListView({ - app: this, - collection: this.list - }); - this.layout.workspaceListRegion.show(this.issuesView); - this.issuesView.bindScrollEvents(); - - this.workspaceHeaderView = new WorkspaceHeaderView({ - app: this, - collection: this.list - }); - this.layout.workspaceHeaderRegion.show(this.workspaceHeaderView); - - this.facetsView = new FacetsView({ - app: this, - collection: this.facets - }); - this.layout.facetsRegion.show(this.facetsView); - - this.headerView = new HeaderView({ - app: this - }); - this.layout.filtersRegion.show(this.headerView); - - key.setScope('list'); - App.router = new Router({ app: App }); - Backbone.history.start(); -}; - -App.on('start', el => { - init.call(App, el); -}); - -export default function(el, user) { - App.start({ el, user }); - - return () => { - Backbone.history.stop(); - App.layout.destroy(); - $('#footer').removeClass('search-navigator-footer'); - }; -} diff --git a/server/sonar-web/src/main/js/apps/issues/issue-filter-view.js b/server/sonar-web/src/main/js/apps/issues/issue-filter-view.js deleted file mode 100644 index c3f45efbfe9..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/issue-filter-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 '../../components/common/action-options-view'; -import Template from './templates/issues-issue-filter-form.hbs'; - -export default ActionOptionsView.extend({ - template: Template, - - selectOption(e) { - const property = $(e.currentTarget).data('property'); - const value = $(e.currentTarget).data('value'); - this.trigger('select', property, value); - ActionOptionsView.prototype.selectOption.apply(this, arguments); - }, - - serializeData() { - return { - ...ActionOptionsView.prototype.serializeData.apply(this, arguments), - s: this.model.get('severity') - }; - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/layout.js b/server/sonar-web/src/main/js/apps/issues/layout.js deleted file mode 100644 index c796baccb9c..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/layout.js +++ /dev/null @@ -1,62 +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 Marionette from 'backbone.marionette'; -import Template from './templates/issues-layout.hbs'; -import './styles.css'; - -export default Marionette.LayoutView.extend({ - template: Template, - - regions: { - filtersRegion: '.issues-header', - facetsRegion: '.search-navigator-facets', - workspaceHeaderRegion: '.search-navigator-workspace-header', - workspaceListRegion: '.search-navigator-workspace-list', - workspaceComponentViewerRegion: '.issues-workspace-component-viewer' - }, - - onRender() { - this.$('.search-navigator').addClass('sticky'); - const top = this.$('.search-navigator').offset().top; - this.$('.search-navigator-workspace-header').css({ top }); - this.$('.search-navigator-side').css({ top }).isolatedScroll(); - }, - - showSpinner(region) { - return this[region].show( - new Marionette.ItemView({ - template: () => '<i class="spinner"></i>' - }) - ); - }, - - showComponentViewer() { - this.scroll = $(window).scrollTop(); - this.$('.issues').addClass('issues-extended-view'); - }, - - hideComponentViewer() { - this.$('.issues').removeClass('issues-extended-view'); - if (this.scroll != null) { - $(window).scrollTop(this.scroll); - } - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/models/issue.js b/server/sonar-web/src/main/js/apps/issues/models/issue.js deleted file mode 100644 index 218e27810ad..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/models/issue.js +++ /dev/null @@ -1,30 +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 '../../../components/issue/models/issue'; - -export default Issue.extend({ - reset(attrs, options) { - const keepFields = ['index', 'selected', 'comments']; - keepFields.forEach(field => { - attrs[field] = this.get(field); - }); - return Issue.prototype.reset.call(this, attrs, options); - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/models/issues.js b/server/sonar-web/src/main/js/apps/issues/models/issues.js deleted file mode 100644 index 9ea6454a484..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/models/issues.js +++ /dev/null @@ -1,108 +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 './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; - }, - - parseIssues(r, startIndex = 0) { - const that = this; - return r.issues.map((issue, index) => { - Object.assign(issue, { index: startIndex + index }); - 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; - }); - }, - - setIndex() { - return this.forEach((issue, index) => issue.set({ index })); - }, - - selectByKeys(keys) { - const that = this; - keys.forEach(key => { - const issue = that.get(key); - if (issue) { - issue.set({ selected: true }); - } - }); - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/models/state.js b/server/sonar-web/src/main/js/apps/issues/models/state.js deleted file mode 100644 index b367cc670b0..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/models/state.js +++ /dev/null @@ -1,81 +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 State from '../../../components/navigator/models/state'; - -export default State.extend({ - defaults: { - page: 1, - maxResultsReached: false, - query: {}, - facets: ['facetMode', 'types', 'resolutions'], - isContext: false, - allFacets: [ - 'facetMode', - 'issues', - 'types', - 'resolutions', - 'severities', - 'statuses', - 'createdAt', - 'rules', - 'tags', - 'projectUuids', - 'moduleUuids', - 'directories', - 'fileUuids', - 'assignees', - 'authors', - 'languages' - ], - facetsFromServer: [ - 'types', - 'severities', - 'statuses', - 'resolutions', - 'projectUuids', - 'directories', - 'rules', - 'moduleUuids', - 'tags', - 'assignees', - 'authors', - 'fileUuids', - 'languages', - 'createdAt' - ], - transform: { - resolved: 'resolutions', - assigned: 'assignees', - createdBefore: 'createdAt', - createdAfter: 'createdAt', - sinceLeakPeriod: 'createdAt', - createdInLast: 'createdAt' - } - }, - - getFacetMode() { - const query = this.get('query'); - return query.facetMode || 'count'; - }, - - toJSON() { - return { facetMode: this.getFacetMode(), ...this.attributes }; - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/redirects.js b/server/sonar-web/src/main/js/apps/issues/redirects.js new file mode 100644 index 00000000000..3efc1af64be --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/redirects.js @@ -0,0 +1,55 @@ +/* + * 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 { parseQuery, areMyIssuesSelected, serializeQuery } from './utils'; +import type { RawQuery } from './utils'; + +const parseHash = (hash: string): RawQuery => { + const query: RawQuery = {}; + const parts = hash.split('|'); + parts.forEach(part => { + const tokens = part.split('='); + if (tokens.length === 2) { + const property = decodeURIComponent(tokens[0]); + const value = decodeURIComponent(tokens[1]); + if (property === 'assigned_to_me' && value === 'true') { + query.myIssues = 'true'; + } else { + query[property] = value; + } + } + }); + return query; +}; + +export const onEnter = (state: Object, replace: Function) => { + const { hash } = window.location; + if (hash.length > 1) { + const query = parseHash(hash.substr(1)); + const normalizedQuery = { + ...serializeQuery(parseQuery(query)), + myIssues: areMyIssuesSelected(query) ? 'true' : undefined + }; + replace({ + pathname: state.location.pathname, + query: normalizedQuery + }); + } +}; diff --git a/server/sonar-web/src/main/js/apps/issues/router.js b/server/sonar-web/src/main/js/apps/issues/router.js deleted file mode 100644 index ac35322a355..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/router.js +++ /dev/null @@ -1,35 +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 Router from '../../components/navigator/router'; - -export default Router.extend({ - routes: { - '': 'home', - ':query': 'index' - }, - - home() { - return this.navigate('resolved=false', { trigger: true, replace: true }); - }, - - index(query) { - this.options.app.state.setQuery(this.options.app.controller.parseQuery(query)); - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/routes.js b/server/sonar-web/src/main/js/apps/issues/routes.js index 5f3a27c905c..61893ce7fec 100644 --- a/server/sonar-web/src/main/js/apps/issues/routes.js +++ b/server/sonar-web/src/main/js/apps/issues/routes.js @@ -17,13 +17,16 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { onEnter } from './redirects'; + const routes = [ { - indexRoute: { - getComponent(_, callback) { - require.ensure([], require => - callback(null, require('./components/IssuesAppContainer').default)); - } + getIndexRoute(_, callback) { + require.ensure([], require => + callback(null, { + component: require('./components/AppContainer').default, + onEnter + })); } } ]; diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.js b/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.js new file mode 100644 index 00000000000..cf7bb4be8f2 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.js @@ -0,0 +1,168 @@ +/* + * 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 { sortBy, uniq, without } from 'lodash'; +import FacetBox from './components/FacetBox'; +import FacetHeader from './components/FacetHeader'; +import FacetItem from './components/FacetItem'; +import FacetItemsList from './components/FacetItemsList'; +import FacetFooter from './components/FacetFooter'; +import { searchAssignees } from '../utils'; +import type { ReferencedUser, Component } from '../utils'; +import Avatar from '../../../components/ui/Avatar'; +import { translate } from '../../../helpers/l10n'; + +type Props = {| + assigned: boolean, + assignees: Array<string>, + component?: Component, + facetMode: string, + onChange: (changes: {}) => void, + onToggle: (property: string) => void, + open: boolean, + stats?: { [string]: number }, + referencedUsers: { [string]: ReferencedUser } +|}; + +export default class AssigneeFacet extends React.PureComponent { + props: Props; + + static defaultProps = { + open: true + }; + + property = 'assignees'; + + handleItemClick = (itemValue: string) => { + if (itemValue === '') { + // unassigned + this.props.onChange({ assigned: !this.props.assigned, assignees: [] }); + } else { + // defined assignee + const { assignees } = this.props; + const newValue = sortBy( + assignees.includes(itemValue) ? without(assignees, itemValue) : [...assignees, itemValue] + ); + this.props.onChange({ assigned: true, assignees: newValue }); + } + }; + + handleHeaderClick = () => { + this.props.onToggle(this.property); + }; + + handleSearch = (query: string) => searchAssignees(query, this.props.component); + + handleSelect = (assignee: string) => { + const { assignees } = this.props; + this.props.onChange({ assigned: true, [this.property]: uniq([...assignees, assignee]) }); + }; + + isAssigneeActive(assignee: string) { + return assignee === '' ? !this.props.assigned : this.props.assignees.includes(assignee); + } + + getAssigneeName(assignee: string): React.Element<*> | string { + if (assignee === '') { + return translate('unassigned'); + } else { + const { referencedUsers } = this.props; + if (referencedUsers[assignee]) { + return ( + <span> + <Avatar + className="little-spacer-right" + hash={referencedUsers[assignee].avatar} + size={16} + /> + {referencedUsers[assignee].name} + </span> + ); + } else { + return assignee; + } + } + } + + getStat(assignee: string): ?number { + const { stats } = this.props; + return stats ? stats[assignee] : null; + } + + renderOption = (option: { avatar: string, label: string }) => { + return ( + <span> + {option.avatar != null && + <Avatar className="little-spacer-right" hash={option.avatar} size={16} />} + {option.label} + </span> + ); + }; + + render() { + const { stats } = this.props; + + if (!stats) { + return null; + } + + const assignees = sortBy( + Object.keys(stats), + // put unassigned first + key => key === '' ? 0 : 1, + // the sort by number + key => -stats[key] + ); + + return ( + <FacetBox property={this.property}> + <FacetHeader + hasValue={!this.props.assigned || this.props.assignees.length > 0} + name={translate('issues.facet', this.property)} + onClick={this.handleHeaderClick} + open={this.props.open} + /> + + {this.props.open && + <FacetItemsList> + {assignees.map(assignee => ( + <FacetItem + active={this.isAssigneeActive(assignee)} + facetMode={this.props.facetMode} + key={assignee} + name={this.getAssigneeName(assignee)} + onClick={this.handleItemClick} + stat={this.getStat(assignee)} + value={assignee} + /> + ))} + </FacetItemsList>} + + {this.props.open && + <FacetFooter + onSearch={this.handleSearch} + onSelect={this.handleSelect} + renderOption={this.renderOption} + />} + </FacetBox> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/AuthorFacet.js b/server/sonar-web/src/main/js/apps/issues/sidebar/AuthorFacet.js new file mode 100644 index 00000000000..d539229ff78 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/AuthorFacet.js @@ -0,0 +1,99 @@ +/* + * 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 { sortBy, without } from 'lodash'; +import FacetBox from './components/FacetBox'; +import FacetHeader from './components/FacetHeader'; +import FacetItem from './components/FacetItem'; +import FacetItemsList from './components/FacetItemsList'; +import { translate } from '../../../helpers/l10n'; + +type Props = {| + facetMode: string, + onChange: (changes: {}) => void, + onToggle: (property: string) => void, + open: boolean, + stats?: { [string]: number }, + authors: Array<string> +|}; + +export default class AuthorFacet extends React.PureComponent { + props: Props; + + static defaultProps = { + open: true + }; + + property = 'authors'; + + handleItemClick = (itemValue: string) => { + const { authors } = this.props; + const newValue = sortBy( + authors.includes(itemValue) ? without(authors, itemValue) : [...authors, itemValue] + ); + this.props.onChange({ [this.property]: newValue }); + }; + + handleHeaderClick = () => { + this.props.onToggle(this.property); + }; + + getStat(author: string): ?number { + const { stats } = this.props; + return stats ? stats[author] : null; + } + + render() { + const { stats } = this.props; + + if (!stats) { + return null; + } + + const authors = sortBy(Object.keys(stats), key => -stats[key]); + + return ( + <FacetBox property={this.property}> + <FacetHeader + hasValue={this.props.authors.length > 0} + name={translate('issues.facet', this.property)} + onClick={this.handleHeaderClick} + open={this.props.open} + /> + + {this.props.open && + <FacetItemsList> + {authors.map(author => ( + <FacetItem + active={this.props.authors.includes(author)} + facetMode={this.props.facetMode} + key={author} + name={author} + onClick={this.handleItemClick} + stat={this.getStat(author)} + value={author} + /> + ))} + </FacetItemsList>} + </FacetBox> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/CreationDateFacet.js b/server/sonar-web/src/main/js/apps/issues/sidebar/CreationDateFacet.js new file mode 100644 index 00000000000..dd28ccfddea --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/CreationDateFacet.js @@ -0,0 +1,276 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +// @flow +import React from 'react'; +import classNames from 'classnames'; +import moment from 'moment'; +import { max } from 'lodash'; +import FacetBox from './components/FacetBox'; +import FacetHeader from './components/FacetHeader'; +import { BarChart } from '../../../components/charts/bar-chart'; +import DateInput from '../../../components/controls/DateInput'; +import { translate } from '../../../helpers/l10n'; +import { formatMeasure } from '../../../helpers/measures'; +import type { Component } from '../utils'; + +type Props = {| + component?: Component, + createdAfter: string, + createdAt: string, + createdBefore: string, + createdInLast: string, + facetMode: string, + onChange: (changes: {}) => void, + onToggle: (property: string) => void, + open: boolean, + sinceLeakPeriod: boolean, + stats?: { [string]: number } +|}; + +const DATE_FORMAT = 'YYYY-MM-DDTHH:mm:ssZZ'; + +export default class CreationDateFacet extends React.PureComponent { + props: Props; + + static defaultProps = { + open: true + }; + + property = 'createdAt'; + + handleHeaderClick = () => { + this.props.onToggle(this.property); + }; + + resetTo = (changes: {}) => { + this.props.onChange({ + createdAfter: undefined, + createdAt: undefined, + createdBefore: undefined, + createdInLast: undefined, + sinceLeakPeriod: undefined, + ...changes + }); + }; + + handleBarClick = ( + { createdAfter, createdBefore }: { createdAfter: Object, createdBefore?: Object } + ) => { + this.resetTo({ + createdAfter: createdAfter.format(DATE_FORMAT), + createdBefore: createdBefore && createdBefore.format(DATE_FORMAT) + }); + }; + + handlePeriodChange = (property: string) => + (value: string) => { + this.props.onChange({ + createdAt: undefined, + createdInLast: undefined, + sinceLeakPeriod: undefined, + [property]: value + }); + }; + + handlePeriodClick = (period?: string) => + (e: Event & { target: HTMLElement }) => { + e.preventDefault(); + e.target.blur; + this.resetTo({ createdInLast: period }); + }; + + handleLeakPeriodClick = () => + (e: Event & { target: HTMLElement }) => { + e.preventDefault(); + e.target.blur; + this.resetTo({ sinceLeakPeriod: true }); + }; + + renderBarChart() { + const { createdBefore, stats } = this.props; + + if (!stats) { + return null; + } + + const periods = Object.keys(stats); + + if (periods.length < 2) { + return null; + } + + const data = periods.map((startDate, index) => { + const startMoment = moment(startDate); + const nextStartMoment = index < periods.length - 1 + ? moment(periods[index + 1]) + : createdBefore ? moment(createdBefore) : undefined; + const endMoment = nextStartMoment && nextStartMoment.clone().subtract(1, 'days'); + + let tooltip = formatMeasure(stats[startDate], 'SHORT_INT') + + '<br>' + + startMoment.format('LL'); + + if (endMoment) { + const isSameDay = endMoment.diff(startMoment, 'days') <= 1; + if (!isSameDay) { + tooltip += ' – ' + endMoment.format('LL'); + } + } + + return { + createdAfter: startMoment, + createdBefore: nextStartMoment, + startMoment, + tooltip, + x: index, + y: stats[startDate] + }; + }); + + const barsWidth = Math.floor(240 / data.length); + const width = barsWidth * data.length - 1 + 20; + + const maxValue = max(data.map(d => d.y)); + const format = this.props.facetMode === 'count' ? 'SHORT_INT' : 'SHORT_WORK_DUR'; + const xValues = data.map(d => d.y === maxValue ? formatMeasure(maxValue, format) : ''); + + return ( + <BarChart + barsWidth={barsWidth - 1} + data={data} + height={75} + onBarClick={this.handleBarClick} + padding={[25, 10, 5, 10]} + width={width} + xValues={xValues} + /> + ); + } + + renderExactDate() { + const m = moment(this.props.createdAt); + return ( + <div className="search-navigator-facet-container"> + {m.format('LLL')} + <br /> + <span className="note">({m.fromNow()})</span> + </div> + ); + } + + renderPeriodSelectors() { + const { createdAfter, createdBefore } = this.props; + + return ( + <div className="search-navigator-date-facet-selection"> + <DateInput + className="search-navigator-date-facet-selection-dropdown-left" + onChange={this.handlePeriodChange('createdAfter')} + placeholder={translate('from')} + value={createdAfter ? moment(createdAfter).format('YYYY-MM-DD') : undefined} + /> + <DateInput + className="search-navigator-date-facet-selection-dropdown-right" + onChange={this.handlePeriodChange('createdBefore')} + placeholder={translate('to')} + value={createdBefore ? moment(createdBefore).format('YYYY-MM-DD') : undefined} + /> + </div> + ); + } + + renderPrefefinedPeriods() { + const { component, createdInLast, sinceLeakPeriod } = this.props; + return ( + <div className="spacer-top"> + <span className="spacer-right">{translate('issues.facet.createdAt.or')}</span> + <a className="spacer-right" href="#" onClick={this.handlePeriodClick()}> + {translate('issues.facet.createdAt.all')} + </a> + {component == null && + <a + className={classNames('spacer-right', { 'active-link': createdInLast === '1w' })} + href="#" + onClick={this.handlePeriodClick('1w')}> + {translate('issues.facet.createdAt.last_week')} + </a>} + {component == null && + <a + className={classNames('spacer-right', { 'active-link': createdInLast === '1m' })} + href="#" + onClick={this.handlePeriodClick('1m')}> + {translate('issues.facet.createdAt.last_month')} + </a>} + {component == null && + <a + className={classNames('spacer-right', { 'active-link': createdInLast === '1y' })} + href="#" + onClick={this.handlePeriodClick('1y')}> + {translate('issues.facet.createdAt.last_year')} + </a>} + {component != null && + <a + className={classNames('spacer-right', { 'active-link': sinceLeakPeriod })} + href="#" + onClick={this.handleLeakPeriodClick()}> + {translate('issues.leak_period')} + </a>} + </div> + ); + } + + renderInner() { + const { createdAt } = this.props; + return createdAt + ? this.renderExactDate() + : <div> + {this.renderBarChart()} + {this.renderPeriodSelectors()} + {this.renderPrefefinedPeriods()} + </div>; + } + + render() { + const hasValue = this.props.createdAfter.length > 0 || + this.props.createdAt.length > 0 || + this.props.createdBefore.length > 0 || + this.props.createdInLast.length > 0 || + this.props.sinceLeakPeriod; + + const { stats } = this.props; + + if (!stats) { + return null; + } + + return ( + <FacetBox property={this.property}> + <FacetHeader + hasValue={hasValue} + name={translate('issues.facet', this.property)} + onClick={this.handleHeaderClick} + open={this.props.open} + /> + + {this.props.open && this.renderInner()} + </FacetBox> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/DirectoryFacet.js b/server/sonar-web/src/main/js/apps/issues/sidebar/DirectoryFacet.js new file mode 100644 index 00000000000..ddd82db1eab --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/DirectoryFacet.js @@ -0,0 +1,120 @@ +/* + * 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 { sortBy, without } from 'lodash'; +import FacetBox from './components/FacetBox'; +import FacetHeader from './components/FacetHeader'; +import FacetItem from './components/FacetItem'; +import FacetItemsList from './components/FacetItemsList'; +import type { ReferencedComponent } from '../utils'; +import QualifierIcon from '../../../components/shared/QualifierIcon'; +import { translate } from '../../../helpers/l10n'; + +type Props = {| + facetMode: string, + onChange: (changes: { [string]: Array<string> }) => void, + onToggle: (property: string) => void, + open: boolean, + stats?: { [string]: number }, + referencedComponents: { [string]: ReferencedComponent }, + directories: Array<string> +|}; + +export default class DirectoryFacet extends React.PureComponent { + props: Props; + + static defaultProps = { + open: true + }; + + property = 'directories'; + + handleItemClick = (itemValue: string) => { + const { directories } = this.props; + const newValue = sortBy( + directories.includes(itemValue) + ? without(directories, itemValue) + : [...directories, itemValue] + ); + this.props.onChange({ [this.property]: newValue }); + }; + + handleHeaderClick = () => { + this.props.onToggle(this.property); + }; + + getStat(directory: string): ?number { + const { stats } = this.props; + return stats ? stats[directory] : null; + } + + renderName(directory: string): React.Element<*> | string { + // `referencedComponents` are indexed by uuid + // so we have to browse them all to find a matching one + const { referencedComponents } = this.props; + const uuid = Object.keys(referencedComponents).find( + uuid => referencedComponents[uuid].key === directory + ); + const name = uuid ? referencedComponents[uuid].name : directory; + return ( + <span> + <QualifierIcon className="little-spacer-right" qualifier="DIR" /> + {name} + </span> + ); + } + + render() { + const { stats } = this.props; + + if (!stats) { + return null; + } + + const directories = sortBy(Object.keys(stats), key => -stats[key]); + + return ( + <FacetBox property={this.property}> + <FacetHeader + hasValue={this.props.directories.length > 0} + name={translate('issues.facet', this.property)} + onClick={this.handleHeaderClick} + open={this.props.open} + /> + + {this.props.open && + <FacetItemsList> + {directories.map(directory => ( + <FacetItem + active={this.props.directories.includes(directory)} + facetMode={this.props.facetMode} + key={directory} + name={this.renderName(directory)} + onClick={this.handleItemClick} + stat={this.getStat(directory)} + value={directory} + /> + ))} + </FacetItemsList>} + </FacetBox> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/FacetMode.js b/server/sonar-web/src/main/js/apps/issues/sidebar/FacetMode.js new file mode 100644 index 00000000000..dcfb16202e9 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/FacetMode.js @@ -0,0 +1,67 @@ +/* + * 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 FacetBox from './components/FacetBox'; +import FacetHeader from './components/FacetHeader'; +import FacetItem from './components/FacetItem'; +import FacetItemsList from './components/FacetItemsList'; +import { translate } from '../../../helpers/l10n'; + +type Props = {| + facetMode: string, + onChange: (changes: {}) => void +|}; + +export default class FacetMode extends React.PureComponent { + props: Props; + + property = 'facetMode'; + + handleItemClick = (itemValue: string) => { + this.props.onChange({ [this.property]: itemValue }); + }; + + render() { + const { facetMode } = this.props; + const modes = ['count', 'effort']; + + return ( + <FacetBox property={this.property}> + <FacetHeader name={translate('issues.facet.mode')} /> + + <FacetItemsList> + {modes.map(mode => ( + <FacetItem + active={facetMode === mode} + facetMode={this.props.facetMode} + halfWidth={true} + key={mode} + name={translate('issues.facet.mode', mode)} + onClick={this.handleItemClick} + stat={null} + value={mode} + /> + ))} + </FacetItemsList> + </FacetBox> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/FileFacet.js b/server/sonar-web/src/main/js/apps/issues/sidebar/FileFacet.js new file mode 100644 index 00000000000..5d914f8380d --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/FileFacet.js @@ -0,0 +1,116 @@ +/* + * 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 { sortBy, without } from 'lodash'; +import FacetBox from './components/FacetBox'; +import FacetHeader from './components/FacetHeader'; +import FacetItem from './components/FacetItem'; +import FacetItemsList from './components/FacetItemsList'; +import type { ReferencedComponent } from '../utils'; +import QualifierIcon from '../../../components/shared/QualifierIcon'; +import { translate } from '../../../helpers/l10n'; +import { collapsePath } from '../../../helpers/path'; + +type Props = {| + facetMode: string, + onChange: (changes: { [string]: Array<string> }) => void, + onToggle: (property: string) => void, + open: boolean, + stats?: { [string]: number }, + referencedComponents: { [string]: ReferencedComponent }, + files: Array<string> +|}; + +export default class FileFacet extends React.PureComponent { + props: Props; + + static defaultProps = { + open: true + }; + + property = 'files'; + + handleItemClick = (itemValue: string) => { + const { files } = this.props; + const newValue = sortBy( + files.includes(itemValue) ? without(files, itemValue) : [...files, itemValue] + ); + this.props.onChange({ [this.property]: newValue }); + }; + + handleHeaderClick = () => { + this.props.onToggle(this.property); + }; + + getStat(file: string): ?number { + const { stats } = this.props; + return stats ? stats[file] : null; + } + + renderName(file: string): React.Element<*> | string { + const { referencedComponents } = this.props; + const name = referencedComponents[file] + ? collapsePath(referencedComponents[file].path, 15) + : file; + return ( + <span> + <QualifierIcon className="little-spacer-right" qualifier="FIL" /> + {name} + </span> + ); + } + + render() { + const { stats } = this.props; + + if (!stats) { + return null; + } + + const files = sortBy(Object.keys(stats), key => -stats[key]); + + return ( + <FacetBox property={this.property}> + <FacetHeader + hasValue={this.props.files.length > 0} + name={translate('issues.facet', this.property)} + onClick={this.handleHeaderClick} + open={this.props.open} + /> + + {this.props.open && + <FacetItemsList> + {files.map(file => ( + <FacetItem + active={this.props.files.includes(file)} + facetMode={this.props.facetMode} + key={file} + name={this.renderName(file)} + onClick={this.handleItemClick} + stat={this.getStat(file)} + value={file} + /> + ))} + </FacetItemsList>} + </FacetBox> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/LanguageFacet.js b/server/sonar-web/src/main/js/apps/issues/sidebar/LanguageFacet.js new file mode 100644 index 00000000000..411c9b74d2e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/LanguageFacet.js @@ -0,0 +1,114 @@ +/* + * 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 { sortBy, uniq, without } from 'lodash'; +import FacetBox from './components/FacetBox'; +import FacetHeader from './components/FacetHeader'; +import FacetItem from './components/FacetItem'; +import FacetItemsList from './components/FacetItemsList'; +import LanguageFacetFooter from './LanguageFacetFooter'; +import type { ReferencedLanguage } from '../utils'; +import { translate } from '../../../helpers/l10n'; + +type Props = {| + facetMode: string, + onChange: (changes: { [string]: Array<string> }) => void, + onToggle: (property: string) => void, + open: boolean, + stats?: { [string]: number }, + referencedLanguages: { [string]: ReferencedLanguage }, + languages: Array<string> +|}; + +export default class LanguageFacet extends React.PureComponent { + props: Props; + + static defaultProps = { + open: true + }; + + property = 'languages'; + + handleItemClick = (itemValue: string) => { + const { languages } = this.props; + const newValue = sortBy( + languages.includes(itemValue) ? without(languages, itemValue) : [...languages, itemValue] + ); + this.props.onChange({ [this.property]: newValue }); + }; + + handleHeaderClick = () => { + this.props.onToggle(this.property); + }; + + getLanguageName(language: string): string { + const { referencedLanguages } = this.props; + return referencedLanguages[language] ? referencedLanguages[language].name : language; + } + + getStat(language: string): ?number { + const { stats } = this.props; + return stats ? stats[language] : null; + } + + handleSelect = (language: string) => { + const { languages } = this.props; + this.props.onChange({ [this.property]: uniq([...languages, language]) }); + }; + + render() { + const { stats } = this.props; + + if (!stats) { + return null; + } + + const languages = sortBy(Object.keys(stats), key => -stats[key]); + + return ( + <FacetBox property={this.property}> + <FacetHeader + hasValue={this.props.languages.length > 0} + name={translate('issues.facet', this.property)} + onClick={this.handleHeaderClick} + open={this.props.open} + /> + + {this.props.open && + <FacetItemsList> + {languages.map(language => ( + <FacetItem + active={this.props.languages.includes(language)} + facetMode={this.props.facetMode} + key={language} + name={this.getLanguageName(language)} + onClick={this.handleItemClick} + stat={this.getStat(language)} + value={language} + /> + ))} + </FacetItemsList>} + + {this.props.open && <LanguageFacetFooter onSelect={this.handleSelect} />} + </FacetBox> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/LanguageFacetFooter.js b/server/sonar-web/src/main/js/apps/issues/sidebar/LanguageFacetFooter.js new file mode 100644 index 00000000000..4ad3bc32825 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/LanguageFacetFooter.js @@ -0,0 +1,68 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +// @flow +import React from 'react'; +import Select from 'react-select'; +import { connect } from 'react-redux'; +import { translate } from '../../../helpers/l10n'; +import { getLanguages } from '../../../store/rootReducer'; + +type Option = { label: string, value: string }; + +type Props = {| + languages: Array<{ key: string, name: string }>, + onSelect: (value: string) => void +|}; + +class LanguageFacetFooter extends React.PureComponent { + props: Props; + + handleChange = (option: Option) => { + this.props.onSelect(option.value); + }; + + render() { + const options = this.props.languages.map(language => ({ + label: language.name, + value: language.key + })); + + return ( + <div className="search-navigator-facet-footer"> + <Select + autofocus={true} + className="input-super-large" + clearable={false} + noResultsText={translate('select2.noMatches')} + onChange={this.handleChange} + options={options} + placeholder={translate('search_verb')} + searchable={true} + /> + </div> + ); + } +} + +const mapStateToProps = state => ({ + languages: Object.values(getLanguages(state)) +}); + +export default connect(mapStateToProps)(LanguageFacetFooter); diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/ModuleFacet.js b/server/sonar-web/src/main/js/apps/issues/sidebar/ModuleFacet.js new file mode 100644 index 00000000000..8711e017462 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/ModuleFacet.js @@ -0,0 +1,113 @@ +/* + * 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 { sortBy, without } from 'lodash'; +import FacetBox from './components/FacetBox'; +import FacetHeader from './components/FacetHeader'; +import FacetItem from './components/FacetItem'; +import FacetItemsList from './components/FacetItemsList'; +import type { ReferencedComponent } from '../utils'; +import QualifierIcon from '../../../components/shared/QualifierIcon'; +import { translate } from '../../../helpers/l10n'; + +type Props = {| + facetMode: string, + onChange: (changes: { [string]: Array<string> }) => void, + onToggle: (property: string) => void, + open: boolean, + stats?: { [string]: number }, + referencedComponents: { [string]: ReferencedComponent }, + modules: Array<string> +|}; + +export default class ModuleFacet extends React.PureComponent { + props: Props; + + static defaultProps = { + open: true + }; + + property = 'modules'; + + handleItemClick = (itemValue: string) => { + const { modules } = this.props; + const newValue = sortBy( + modules.includes(itemValue) ? without(modules, itemValue) : [...modules, itemValue] + ); + this.props.onChange({ [this.property]: newValue }); + }; + + handleHeaderClick = () => { + this.props.onToggle(this.property); + }; + + getStat(module: string): ?number { + const { stats } = this.props; + return stats ? stats[module] : null; + } + + renderName(module: string): React.Element<*> | string { + const { referencedComponents } = this.props; + const name = referencedComponents[module] ? referencedComponents[module].name : module; + return ( + <span> + <QualifierIcon className="little-spacer-right" qualifier="BRC" /> + {name} + </span> + ); + } + + render() { + const { stats } = this.props; + + if (!stats) { + return null; + } + + const modules = sortBy(Object.keys(stats), key => -stats[key]); + + return ( + <FacetBox property={this.property}> + <FacetHeader + hasValue={this.props.modules.length > 0} + name={translate('issues.facet', this.property)} + onClick={this.handleHeaderClick} + open={this.props.open} + /> + + {this.props.open && + <FacetItemsList> + {modules.map(module => ( + <FacetItem + active={this.props.modules.includes(module)} + facetMode={this.props.facetMode} + key={module} + name={this.renderName(module)} + onClick={this.handleItemClick} + stat={this.getStat(module)} + value={module} + /> + ))} + </FacetItemsList>} + </FacetBox> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/ProjectFacet.js b/server/sonar-web/src/main/js/apps/issues/sidebar/ProjectFacet.js new file mode 100644 index 00000000000..2b7046f69ef --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/ProjectFacet.js @@ -0,0 +1,160 @@ +/* + * 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 { sortBy, uniq, without } from 'lodash'; +import FacetBox from './components/FacetBox'; +import FacetHeader from './components/FacetHeader'; +import FacetItem from './components/FacetItem'; +import FacetItemsList from './components/FacetItemsList'; +import FacetFooter from './components/FacetFooter'; +import type { ReferencedComponent, Component } from '../utils'; +import Organization from '../../../components/shared/Organization'; +import QualifierIcon from '../../../components/shared/QualifierIcon'; +import { searchComponents, getTree } from '../../../api/components'; +import { translate } from '../../../helpers/l10n'; + +type Props = {| + component?: Component, + facetMode: string, + onChange: (changes: { [string]: Array<string> }) => void, + onToggle: (property: string) => void, + open: boolean, + stats?: { [string]: number }, + referencedComponents: { [string]: ReferencedComponent }, + projects: Array<string> +|}; + +export default class ProjectFacet extends React.PureComponent { + props: Props; + + static defaultProps = { + open: true + }; + + property = 'projects'; + + handleItemClick = (itemValue: string) => { + const { projects } = this.props; + const newValue = sortBy( + projects.includes(itemValue) ? without(projects, itemValue) : [...projects, itemValue] + ); + this.props.onChange({ [this.property]: newValue }); + }; + + handleHeaderClick = () => { + this.props.onToggle(this.property); + }; + + handleSearch = (query: string) => { + const { component } = this.props; + + return component != null && ['VW', 'SVW'].includes(component.qualifier) + ? getTree(component.key, { ps: 50, q: query, qualifiers: 'TRK' }).then(response => + response.components.map(component => ({ + label: component.name, + organization: component.organization, + value: component.refId + }))) + : searchComponents({ ps: 50, q: query, qualifiers: 'TRK' }).then(response => + response.components.map(component => ({ + label: component.name, + organization: component.organization, + value: component.id + }))); + }; + + handleSelect = (rule: string) => { + const { projects } = this.props; + this.props.onChange({ [this.property]: uniq([...projects, rule]) }); + }; + + getStat(project: string): ?number { + const { stats } = this.props; + return stats ? stats[project] : null; + } + + renderName(project: string): React.Element<*> | string { + const { referencedComponents } = this.props; + return referencedComponents[project] + ? <span> + <QualifierIcon className="little-spacer-right" qualifier="TRK" /> + <Organization link={false} organizationKey={referencedComponents[project].organization} /> + {referencedComponents[project].name} + </span> + : <span> + <QualifierIcon className="little-spacer-right" qualifier="TRK" /> + {project} + </span>; + } + + renderOption = (option: { label: string, organization: string }) => { + return ( + <span> + <Organization link={false} organizationKey={option.organization} /> + {option.label} + </span> + ); + }; + + render() { + const { stats } = this.props; + + if (!stats) { + return null; + } + + const projects = sortBy(Object.keys(stats), key => -stats[key]); + + return ( + <FacetBox property={this.property}> + <FacetHeader + hasValue={this.props.projects.length > 0} + name={translate('issues.facet', this.property)} + onClick={this.handleHeaderClick} + open={this.props.open} + /> + + {this.props.open && + <FacetItemsList> + {projects.map(project => ( + <FacetItem + active={this.props.projects.includes(project)} + facetMode={this.props.facetMode} + key={project} + name={this.renderName(project)} + onClick={this.handleItemClick} + stat={this.getStat(project)} + value={project} + /> + ))} + </FacetItemsList>} + + {this.props.open && + <FacetFooter + minimumQueryLength={3} + onSearch={this.handleSearch} + onSelect={this.handleSelect} + renderOption={this.renderOption} + />} + </FacetBox> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/ResolutionFacet.js b/server/sonar-web/src/main/js/apps/issues/sidebar/ResolutionFacet.js new file mode 100644 index 00000000000..d83c56cd917 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/ResolutionFacet.js @@ -0,0 +1,119 @@ +/* + * 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 { orderBy, without } from 'lodash'; +import FacetBox from './components/FacetBox'; +import FacetHeader from './components/FacetHeader'; +import FacetItem from './components/FacetItem'; +import FacetItemsList from './components/FacetItemsList'; +import { translate } from '../../../helpers/l10n'; + +type Props = {| + facetMode: string, + onChange: (changes: {}) => void, + onToggle: (property: string) => void, + open: boolean, + resolved: boolean, + resolutions: Array<string>, + stats?: { [string]: number } +|}; + +export default class ResolutionFacet extends React.PureComponent { + props: Props; + + static defaultProps = { + open: true + }; + + property = 'resolutions'; + + handleItemClick = (itemValue: string) => { + if (itemValue === '') { + // unresolved + this.props.onChange({ resolved: !this.props.resolved, resolutions: [] }); + } else { + // defined resolution + const { resolutions } = this.props; + const newValue = orderBy( + resolutions.includes(itemValue) + ? without(resolutions, itemValue) + : [...resolutions, itemValue] + ); + this.props.onChange({ resolved: true, resolutions: newValue }); + } + }; + + handleHeaderClick = () => { + this.props.onToggle(this.property); + }; + + isFacetItemActive(resolution: string) { + return resolution === '' ? !this.props.resolved : this.props.resolutions.includes(resolution); + } + + getFacetItemName(resolution: string) { + return resolution === '' ? translate('unresolved') : translate('issue.resolution', resolution); + } + + getStat(resolution: string): ?number { + const { stats } = this.props; + return stats ? stats[resolution] : null; + } + + renderItem = (resolution: string) => { + const active = this.isFacetItemActive(resolution); + const stat = this.getStat(resolution); + + return ( + <FacetItem + active={active} + disabled={stat === 0 && !active} + facetMode={this.props.facetMode} + key={resolution} + halfWidth={true} + name={this.getFacetItemName(resolution)} + onClick={this.handleItemClick} + stat={stat} + value={resolution} + /> + ); + }; + + render() { + const resolutions = ['', 'FIXED', 'FALSE-POSITIVE', 'WONTFIX', 'REMOVED']; + + return ( + <FacetBox property={this.property}> + <FacetHeader + hasValue={!this.props.resolved || this.props.resolutions.length > 0} + name={translate('issues.facet', this.property)} + onClick={this.handleHeaderClick} + open={this.props.open} + /> + + {this.props.open && + <FacetItemsList> + {resolutions.map(this.renderItem)} + </FacetItemsList>} + </FacetBox> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/RuleFacet.js b/server/sonar-web/src/main/js/apps/issues/sidebar/RuleFacet.js new file mode 100644 index 00000000000..ce5d5d49468 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/RuleFacet.js @@ -0,0 +1,126 @@ +/* + * 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 { sortBy, uniq, without } from 'lodash'; +import FacetBox from './components/FacetBox'; +import FacetHeader from './components/FacetHeader'; +import FacetItem from './components/FacetItem'; +import FacetItemsList from './components/FacetItemsList'; +import FacetFooter from './components/FacetFooter'; +import { searchRules } from '../../../api/rules'; +import { translate } from '../../../helpers/l10n'; + +type Props = {| + facetMode: string, + languages: Array<string>, + onChange: (changes: { [string]: Array<string> }) => void, + onToggle: (property: string) => void, + open: boolean, + stats?: { [string]: number }, + referencedRules: { [string]: { name: string } }, + rules: Array<string> +|}; + +export default class RuleFacet extends React.PureComponent { + props: Props; + + static defaultProps = { + open: true + }; + + property = 'rules'; + + handleItemClick = (itemValue: string) => { + const { rules } = this.props; + const newValue = sortBy( + rules.includes(itemValue) ? without(rules, itemValue) : [...rules, itemValue] + ); + this.props.onChange({ [this.property]: newValue }); + }; + + handleHeaderClick = () => { + this.props.onToggle(this.property); + }; + + handleSearch = (query: string) => { + const { languages } = this.props; + return searchRules({ + f: 'name,langName', + languages: languages.length ? languages.join() : undefined, + q: query + }).then(response => + response.rules.map(rule => ({ label: `(${rule.langName}) ${rule.name}`, value: rule.key }))); + }; + + handleSelect = (rule: string) => { + const { rules } = this.props; + this.props.onChange({ [this.property]: uniq([...rules, rule]) }); + }; + + getRuleName(rule: string): string { + const { referencedRules } = this.props; + return referencedRules[rule] ? referencedRules[rule].name : rule; + } + + getStat(rule: string): ?number { + const { stats } = this.props; + return stats ? stats[rule] : null; + } + + render() { + const { stats } = this.props; + + if (!stats) { + return null; + } + + const rules = sortBy(Object.keys(stats), key => -stats[key]); + + return ( + <FacetBox property={this.property}> + <FacetHeader + hasValue={this.props.rules.length > 0} + name={translate('issues.facet', this.property)} + onClick={this.handleHeaderClick} + open={this.props.open} + /> + + {this.props.open && + <FacetItemsList> + {rules.map(rule => ( + <FacetItem + active={this.props.rules.includes(rule)} + facetMode={this.props.facetMode} + key={rule} + name={this.getRuleName(rule)} + onClick={this.handleItemClick} + stat={this.getStat(rule)} + value={rule} + /> + ))} + </FacetItemsList>} + + {this.props.open && + <FacetFooter onSearch={this.handleSearch} onSelect={this.handleSelect} />} + </FacetBox> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues2/sidebar/SeverityFacet.js b/server/sonar-web/src/main/js/apps/issues/sidebar/SeverityFacet.js index cadec2974e3..e95f44008d0 100644 --- a/server/sonar-web/src/main/js/apps/issues2/sidebar/SeverityFacet.js +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/SeverityFacet.js @@ -19,7 +19,7 @@ */ // @flow import React from 'react'; -import { orderBy, uniq, without } from 'lodash'; +import { orderBy, without } from 'lodash'; import FacetBox from './components/FacetBox'; import FacetHeader from './components/FacetHeader'; import FacetItem from './components/FacetItem'; @@ -28,6 +28,7 @@ import SeverityHelper from '../../../components/shared/SeverityHelper'; import { translate } from '../../../helpers/l10n'; type Props = {| + facetMode: string, onChange: (changes: { [string]: Array<string> }) => void, onToggle: (property: string) => void, open: boolean, @@ -47,9 +48,7 @@ export default class SeverityFacet extends React.PureComponent { handleItemClick = (itemValue: string) => { const { severities } = this.props; const newValue = orderBy( - severities.includes(itemValue) - ? without(severities, itemValue) - : uniq([...severities, itemValue]) + severities.includes(itemValue) ? without(severities, itemValue) : [...severities, itemValue] ); this.props.onChange({ [this.property]: newValue }); }; @@ -63,6 +62,25 @@ export default class SeverityFacet extends React.PureComponent { return stats ? stats[severity] : null; } + renderItem = (severity: string) => { + const active = this.props.severities.includes(severity); + const stat = this.getStat(severity); + + return ( + <FacetItem + active={active} + disabled={stat === 0 && !active} + facetMode={this.props.facetMode} + halfWidth={true} + key={severity} + name={<SeverityHelper severity={severity} />} + onClick={this.handleItemClick} + stat={stat} + value={severity} + /> + ); + }; + render() { const severities = ['BLOCKER', 'MINOR', 'CRITICAL', 'INFO', 'MAJOR']; @@ -75,19 +93,10 @@ export default class SeverityFacet extends React.PureComponent { open={this.props.open} /> - <FacetItemsList open={this.props.open}> - {severities.map(severity => ( - <FacetItem - active={this.props.severities.includes(severity)} - halfWidth={true} - key={severity} - name={<SeverityHelper severity={severity} />} - onClick={this.handleItemClick} - stat={this.getStat(severity)} - value={severity} - /> - ))} - </FacetItemsList> + {this.props.open && + <FacetItemsList> + {severities.map(this.renderItem)} + </FacetItemsList>} </FacetBox> ); } diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.js b/server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.js new file mode 100644 index 00000000000..fd4c2c3f91c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.js @@ -0,0 +1,212 @@ +/* + * 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 AssigneeFacet from './AssigneeFacet'; +import AuthorFacet from './AuthorFacet'; +import CreationDateFacet from './CreationDateFacet'; +import DirectoryFacet from './DirectoryFacet'; +import FacetMode from './FacetMode'; +import FileFacet from './FileFacet'; +import LanguageFacet from './LanguageFacet'; +import ModuleFacet from './ModuleFacet'; +import ProjectFacet from './ProjectFacet'; +import ResolutionFacet from './ResolutionFacet'; +import RuleFacet from './RuleFacet'; +import SeverityFacet from './SeverityFacet'; +import StatusFacet from './StatusFacet'; +import TagFacet from './TagFacet'; +import TypeFacet from './TypeFacet'; +import type { + Query, + Facet, + ReferencedComponent, + ReferencedUser, + ReferencedLanguage, + Component +} from '../utils'; + +type Props = {| + component?: Component, + facets: { [string]: Facet }, + myIssues: boolean, + onFacetToggle: (property: string) => void, + onFilterChange: (changes: { [string]: Array<string> }) => void, + openFacets: { [string]: boolean }, + query: Query, + referencedComponents: { [string]: ReferencedComponent }, + referencedLanguages: { [string]: ReferencedLanguage }, + referencedRules: { [string]: { name: string } }, + referencedUsers: { [string]: ReferencedUser } +|}; + +export default class Sidebar extends React.PureComponent { + props: Props; + + render() { + const { component, facets, openFacets, query } = this.props; + + const displayProjectsFacet: boolean = component == null || + !['TRK', 'BRC', 'DIR', 'DEV_PRJ'].includes(component.qualifier); + const displayModulesFacet = component == null || component.qualifier !== 'DIR'; + const displayDirectoriesFacet = component == null || component.qualifier !== 'DIR'; + const displayAuthorFacet = component == null || component.qualifier !== 'DEV'; + + return ( + <div className="search-navigator-facets-list"> + <FacetMode facetMode={query.facetMode} onChange={this.props.onFilterChange} /> + <TypeFacet + facetMode={query.facetMode} + onChange={this.props.onFilterChange} + onToggle={this.props.onFacetToggle} + open={!!openFacets.types} + stats={facets.types} + types={query.types} + /> + <ResolutionFacet + facetMode={query.facetMode} + onChange={this.props.onFilterChange} + onToggle={this.props.onFacetToggle} + open={!!openFacets.resolutions} + resolved={query.resolved} + resolutions={query.resolutions} + stats={facets.resolutions} + /> + <SeverityFacet + facetMode={query.facetMode} + onChange={this.props.onFilterChange} + onToggle={this.props.onFacetToggle} + open={!!openFacets.severities} + severities={query.severities} + stats={facets.severities} + /> + <StatusFacet + facetMode={query.facetMode} + onChange={this.props.onFilterChange} + onToggle={this.props.onFacetToggle} + open={!!openFacets.statuses} + stats={facets.statuses} + statuses={query.statuses} + /> + <CreationDateFacet + component={component} + createdAfter={query.createdAfter} + createdAt={query.createdAt} + createdBefore={query.createdBefore} + createdInLast={query.createdInLast} + facetMode={query.facetMode} + onChange={this.props.onFilterChange} + onToggle={this.props.onFacetToggle} + open={!!openFacets.createdAt} + sinceLeakPeriod={query.sinceLeakPeriod} + stats={facets.createdAt} + /> + <RuleFacet + facetMode={query.facetMode} + languages={query.languages} + onChange={this.props.onFilterChange} + onToggle={this.props.onFacetToggle} + open={!!openFacets.rules} + stats={facets.rules} + referencedRules={this.props.referencedRules} + rules={query.rules} + /> + <TagFacet + facetMode={query.facetMode} + onChange={this.props.onFilterChange} + onToggle={this.props.onFacetToggle} + open={!!openFacets.tags} + stats={facets.tags} + tags={query.tags} + /> + {displayProjectsFacet && + <ProjectFacet + component={component} + facetMode={query.facetMode} + onChange={this.props.onFilterChange} + onToggle={this.props.onFacetToggle} + open={!!openFacets.projects} + projects={query.projects} + referencedComponents={this.props.referencedComponents} + stats={facets.projects} + />} + {displayModulesFacet && + <ModuleFacet + facetMode={query.facetMode} + onChange={this.props.onFilterChange} + onToggle={this.props.onFacetToggle} + open={!!openFacets.modules} + modules={query.modules} + referencedComponents={this.props.referencedComponents} + stats={facets.modules} + />} + {displayDirectoriesFacet && + <DirectoryFacet + facetMode={query.facetMode} + onChange={this.props.onFilterChange} + onToggle={this.props.onFacetToggle} + open={!!openFacets.directories} + directories={query.directories} + referencedComponents={this.props.referencedComponents} + stats={facets.directories} + />} + <FileFacet + facetMode={query.facetMode} + onChange={this.props.onFilterChange} + onToggle={this.props.onFacetToggle} + open={!!openFacets.files} + files={query.files} + referencedComponents={this.props.referencedComponents} + stats={facets.files} + /> + {!this.props.myIssues && + <AssigneeFacet + component={component} + facetMode={query.facetMode} + onChange={this.props.onFilterChange} + onToggle={this.props.onFacetToggle} + open={!!openFacets.assignees} + assigned={query.assigned} + assignees={query.assignees} + referencedUsers={this.props.referencedUsers} + stats={facets.assignees} + />} + {displayAuthorFacet && + <AuthorFacet + facetMode={query.facetMode} + onChange={this.props.onFilterChange} + onToggle={this.props.onFacetToggle} + open={!!openFacets.authors} + authors={query.authors} + stats={facets.authors} + />} + <LanguageFacet + facetMode={query.facetMode} + onChange={this.props.onFilterChange} + onToggle={this.props.onFacetToggle} + open={!!openFacets.languages} + languages={query.languages} + referencedLanguages={this.props.referencedLanguages} + stats={facets.languages} + /> + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/StatusFacet.js b/server/sonar-web/src/main/js/apps/issues/sidebar/StatusFacet.js new file mode 100644 index 00000000000..40bfb251141 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/StatusFacet.js @@ -0,0 +1,112 @@ +/* + * 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 { orderBy, without } from 'lodash'; +import FacetBox from './components/FacetBox'; +import FacetHeader from './components/FacetHeader'; +import FacetItem from './components/FacetItem'; +import FacetItemsList from './components/FacetItemsList'; +import { translate } from '../../../helpers/l10n'; + +type Props = {| + facetMode: string, + onChange: (changes: { [string]: Array<string> }) => void, + onToggle: (property: string) => void, + open: boolean, + stats?: { [string]: number }, + statuses: Array<string> +|}; + +export default class StatusFacet extends React.PureComponent { + props: Props; + + static defaultProps = { + open: true + }; + + property = 'statuses'; + + handleItemClick = (itemValue: string) => { + const { statuses } = this.props; + const newValue = orderBy( + statuses.includes(itemValue) ? without(statuses, itemValue) : [...statuses, itemValue] + ); + this.props.onChange({ [this.property]: newValue }); + }; + + handleHeaderClick = () => { + this.props.onToggle(this.property); + }; + + getStat(status: string): ?number { + const { stats } = this.props; + return stats ? stats[status] : null; + } + + renderStatus(status: string) { + return ( + <span> + <i className={`icon-status-${status.toLowerCase()}`} /> + {' '} + {translate('issue.status', status)} + </span> + ); + } + + renderItem = (status: string) => { + const active = this.props.statuses.includes(status); + const stat = this.getStat(status); + + return ( + <FacetItem + active={active} + disabled={stat === 0 && !active} + facetMode={this.props.facetMode} + halfWidth={true} + key={status} + name={this.renderStatus(status)} + onClick={this.handleItemClick} + stat={stat} + value={status} + /> + ); + }; + + render() { + const statuses = ['OPEN', 'RESOLVED', 'REOPENED', 'CLOSED', 'CONFIRMED']; + + return ( + <FacetBox property={this.property}> + <FacetHeader + hasValue={this.props.statuses.length > 0} + name={translate('issues.facet', this.property)} + onClick={this.handleHeaderClick} + open={this.props.open} + /> + + {this.props.open && + <FacetItemsList> + {statuses.map(this.renderItem)} + </FacetItemsList>} + </FacetBox> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/TagFacet.js b/server/sonar-web/src/main/js/apps/issues/sidebar/TagFacet.js new file mode 100644 index 00000000000..cd6ab46e679 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/TagFacet.js @@ -0,0 +1,123 @@ +/* + * 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 { sortBy, uniq, without } from 'lodash'; +import FacetBox from './components/FacetBox'; +import FacetHeader from './components/FacetHeader'; +import FacetItem from './components/FacetItem'; +import FacetItemsList from './components/FacetItemsList'; +import FacetFooter from './components/FacetFooter'; +import { searchIssueTags } from '../../../api/issues'; +import { translate } from '../../../helpers/l10n'; + +type Props = {| + facetMode: string, + onChange: (changes: { [string]: Array<string> }) => void, + onToggle: (property: string) => void, + open: boolean, + stats?: { [string]: number }, + tags: Array<string> +|}; + +export default class TagFacet extends React.PureComponent { + props: Props; + + static defaultProps = { + open: true + }; + + property = 'tags'; + + handleItemClick = (itemValue: string) => { + const { tags } = this.props; + const newValue = sortBy( + tags.includes(itemValue) ? without(tags, itemValue) : [...tags, itemValue] + ); + this.props.onChange({ [this.property]: newValue }); + }; + + handleHeaderClick = () => { + this.props.onToggle(this.property); + }; + + handleSearch = (query: string) => { + return searchIssueTags({ ps: 50, q: query }).then(tags => + tags.map(tag => ({ label: tag, value: tag }))); + }; + + handleSelect = (tag: string) => { + const { tags } = this.props; + this.props.onChange({ [this.property]: uniq([...tags, tag]) }); + }; + + getStat(tag: string): ?number { + const { stats } = this.props; + return stats ? stats[tag] : null; + } + + renderTag(tag: string) { + return ( + <span> + <i className="icon-tags icon-gray little-spacer-right" /> + {tag} + </span> + ); + } + + render() { + const { stats } = this.props; + + if (!stats) { + return null; + } + + const tags = sortBy(Object.keys(stats), key => -stats[key]); + + return ( + <FacetBox property={this.property}> + <FacetHeader + hasValue={this.props.tags.length > 0} + name={translate('issues.facet', this.property)} + onClick={this.handleHeaderClick} + open={this.props.open} + /> + + {this.props.open && + <FacetItemsList> + {tags.map(tag => ( + <FacetItem + active={this.props.tags.includes(tag)} + facetMode={this.props.facetMode} + key={tag} + name={this.renderTag(tag)} + onClick={this.handleItemClick} + stat={this.getStat(tag)} + value={tag} + /> + ))} + </FacetItemsList>} + + {this.props.open && + <FacetFooter onSearch={this.handleSearch} onSelect={this.handleSelect} />} + </FacetBox> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/TypeFacet.js b/server/sonar-web/src/main/js/apps/issues/sidebar/TypeFacet.js new file mode 100644 index 00000000000..c0eb0271058 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/TypeFacet.js @@ -0,0 +1,102 @@ +/* + * 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 { orderBy, without } from 'lodash'; +import FacetBox from './components/FacetBox'; +import FacetHeader from './components/FacetHeader'; +import FacetItem from './components/FacetItem'; +import FacetItemsList from './components/FacetItemsList'; +import IssueTypeIcon from '../../../components/ui/IssueTypeIcon'; +import { translate } from '../../../helpers/l10n'; + +type Props = {| + facetMode: string, + onChange: (changes: { [string]: Array<string> }) => void, + onToggle: (property: string) => void, + open: boolean, + stats?: { [string]: number }, + types: Array<string> +|}; + +export default class TypeFacet extends React.PureComponent { + props: Props; + + static defaultProps = { + open: true + }; + + property = 'types'; + + handleItemClick = (itemValue: string) => { + const { types } = this.props; + const newValue = orderBy( + types.includes(itemValue) ? without(types, itemValue) : [...types, itemValue] + ); + this.props.onChange({ [this.property]: newValue }); + }; + + handleHeaderClick = () => { + this.props.onToggle(this.property); + }; + + getStat(type: string): ?number { + const { stats } = this.props; + return stats ? stats[type] : null; + } + + renderItem = (type: string) => { + const active = this.props.types.includes(type); + const stat = this.getStat(type); + + return ( + <FacetItem + active={active} + disabled={stat === 0 && !active} + facetMode={this.props.facetMode} + key={type} + name={<span><IssueTypeIcon query={type} /> {translate('issue.type', type)}</span>} + onClick={this.handleItemClick} + stat={stat} + value={type} + /> + ); + }; + + render() { + const types = ['BUG', 'VULNERABILITY', 'CODE_SMELL']; + + return ( + <FacetBox property={this.property}> + <FacetHeader + hasValue={this.props.types.length > 0} + name={translate('issues.facet', this.property)} + onClick={this.handleHeaderClick} + open={this.props.open} + /> + + {this.props.open && + <FacetItemsList> + {types.map(this.renderItem)} + </FacetItemsList>} + </FacetBox> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.js b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.js new file mode 100644 index 00000000000..5dc2230f4e3 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.js @@ -0,0 +1,95 @@ +/* + * 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 React from 'react'; +import { shallow } from 'enzyme'; +import AssigneeFacet from '../AssigneeFacet'; + +jest.mock('../../../../store/rootReducer', () => ({})); + +const renderAssigneeFacet = (props?: {}) => + shallow( + <AssigneeFacet + assigned={true} + assignees={[]} + facetMode="count" + onChange={jest.fn()} + onToggle={jest.fn()} + open={true} + referencedUsers={{ foo: { avatar: 'avatart-foo', name: 'name-foo' } }} + stats={{ '': 5, foo: 13, bar: 7 }} + {...props} + /> + ); + +it('should render', () => { + expect(renderAssigneeFacet()).toMatchSnapshot(); +}); + +it('should not render without stats', () => { + expect(renderAssigneeFacet({ stats: null })).toMatchSnapshot(); +}); + +it('should select unassigned', () => { + expect(renderAssigneeFacet({ assigned: false })).toMatchSnapshot(); +}); + +it('should select user', () => { + expect(renderAssigneeFacet({ assignees: ['foo'] })).toMatchSnapshot(); +}); + +it('should render footer select option', () => { + const wrapper = renderAssigneeFacet(); + expect( + wrapper.instance().renderOption({ avatar: 'avatar-foo', label: 'name-foo' }) + ).toMatchSnapshot(); +}); + +it('should call onChange', () => { + const onChange = jest.fn(); + const wrapper = renderAssigneeFacet({ assignees: ['foo'], onChange }); + const itemOnClick = wrapper.find('FacetItem').first().prop('onClick'); + + itemOnClick(''); + expect(onChange).lastCalledWith({ assigned: false, assignees: [] }); + + itemOnClick('bar'); + expect(onChange).lastCalledWith({ assigned: true, assignees: ['bar', 'foo'] }); + + itemOnClick('foo'); + expect(onChange).lastCalledWith({ assigned: true, assignees: [] }); +}); + +it('should call onToggle', () => { + const onToggle = jest.fn(); + const wrapper = renderAssigneeFacet({ onToggle }); + const headerOnClick = wrapper.find('FacetHeader').prop('onClick'); + + headerOnClick(); + expect(onToggle).lastCalledWith('assignees'); +}); + +it('should handle footer callbacks', () => { + const onChange = jest.fn(); + const wrapper = renderAssigneeFacet({ assignees: ['foo'], onChange }); + const onSelect = wrapper.find('FacetFooter').prop('onSelect'); + + onSelect('qux'); + expect(onChange).lastCalledWith({ assigned: true, assignees: ['foo', 'qux'] }); +}); diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/Sidebar-test.js b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/Sidebar-test.js new file mode 100644 index 00000000000..a55c903b90c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/Sidebar-test.js @@ -0,0 +1,53 @@ +/* + * 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 React from 'react'; +import { shallow } from 'enzyme'; +import Sidebar from '../Sidebar'; + +jest.mock('../../../../store/rootReducer', () => ({})); + +const renderSidebar = props => + shallow(<Sidebar facets={{}} myIssues={false} openFacets={{}} query={{}} {...props} />) + .children() + .map(node => node.name()); + +it('should render all facets', () => { + expect(renderSidebar()).toMatchSnapshot(); +}); + +it('should render facets for project', () => { + expect(renderSidebar({ component: { qualifier: 'TRK' } })).toMatchSnapshot(); +}); + +it('should render facets for module', () => { + expect(renderSidebar({ component: { qualifier: 'BRC' } })).toMatchSnapshot(); +}); + +it('should render facets for directory', () => { + expect(renderSidebar({ component: { qualifier: 'DIR' } })).toMatchSnapshot(); +}); + +it('should render facets for developer', () => { + expect(renderSidebar({ component: { qualifier: 'DEV' } })).toMatchSnapshot(); +}); + +it('should render facets when my issues are selected', () => { + expect(renderSidebar({ myIssues: true })).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.js.snap b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.js.snap new file mode 100644 index 00000000000..80726a28b0b --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.js.snap @@ -0,0 +1,167 @@ +exports[`test should not render without stats 1`] = `null`; + +exports[`test should render 1`] = ` +<FacetBox + property="assignees"> + <FacetHeader + hasValue={false} + name="issues.facet.assignees" + onClick={[Function]} + open={true} /> + <FacetItemsList> + <FacetItem + active={false} + disabled={false} + facetMode="count" + halfWidth={false} + name="unassigned" + onClick={[Function]} + stat={5} + value="" /> + <FacetItem + active={false} + disabled={false} + facetMode="count" + halfWidth={false} + name={ + <span> + <Connect(Avatar) + className="little-spacer-right" + hash="avatart-foo" + size={16} /> + name-foo + </span> + } + onClick={[Function]} + stat={13} + value="foo" /> + <FacetItem + active={false} + disabled={false} + facetMode="count" + halfWidth={false} + name="bar" + onClick={[Function]} + stat={7} + value="bar" /> + </FacetItemsList> + <FacetFooter + onSearch={[Function]} + onSelect={[Function]} + renderOption={[Function]} /> +</FacetBox> +`; + +exports[`test should render footer select option 1`] = ` +<span> + <Connect(Avatar) + className="little-spacer-right" + hash="avatar-foo" + size={16} /> + name-foo +</span> +`; + +exports[`test should select unassigned 1`] = ` +<FacetBox + property="assignees"> + <FacetHeader + hasValue={true} + name="issues.facet.assignees" + onClick={[Function]} + open={true} /> + <FacetItemsList> + <FacetItem + active={true} + disabled={false} + facetMode="count" + halfWidth={false} + name="unassigned" + onClick={[Function]} + stat={5} + value="" /> + <FacetItem + active={false} + disabled={false} + facetMode="count" + halfWidth={false} + name={ + <span> + <Connect(Avatar) + className="little-spacer-right" + hash="avatart-foo" + size={16} /> + name-foo + </span> + } + onClick={[Function]} + stat={13} + value="foo" /> + <FacetItem + active={false} + disabled={false} + facetMode="count" + halfWidth={false} + name="bar" + onClick={[Function]} + stat={7} + value="bar" /> + </FacetItemsList> + <FacetFooter + onSearch={[Function]} + onSelect={[Function]} + renderOption={[Function]} /> +</FacetBox> +`; + +exports[`test should select user 1`] = ` +<FacetBox + property="assignees"> + <FacetHeader + hasValue={true} + name="issues.facet.assignees" + onClick={[Function]} + open={true} /> + <FacetItemsList> + <FacetItem + active={false} + disabled={false} + facetMode="count" + halfWidth={false} + name="unassigned" + onClick={[Function]} + stat={5} + value="" /> + <FacetItem + active={true} + disabled={false} + facetMode="count" + halfWidth={false} + name={ + <span> + <Connect(Avatar) + className="little-spacer-right" + hash="avatart-foo" + size={16} /> + name-foo + </span> + } + onClick={[Function]} + stat={13} + value="foo" /> + <FacetItem + active={false} + disabled={false} + facetMode="count" + halfWidth={false} + name="bar" + onClick={[Function]} + stat={7} + value="bar" /> + </FacetItemsList> + <FacetFooter + onSearch={[Function]} + onSelect={[Function]} + renderOption={[Function]} /> +</FacetBox> +`; diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/Sidebar-test.js.snap b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/Sidebar-test.js.snap new file mode 100644 index 00000000000..81d6ce875fc --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/Sidebar-test.js.snap @@ -0,0 +1,112 @@ +exports[`test should render all facets 1`] = ` +Array [ + "FacetMode", + "TypeFacet", + "ResolutionFacet", + "SeverityFacet", + "StatusFacet", + "CreationDateFacet", + "RuleFacet", + "TagFacet", + "ProjectFacet", + "ModuleFacet", + "DirectoryFacet", + "FileFacet", + "AssigneeFacet", + "AuthorFacet", + "LanguageFacet", +] +`; + +exports[`test should render facets for developer 1`] = ` +Array [ + "FacetMode", + "TypeFacet", + "ResolutionFacet", + "SeverityFacet", + "StatusFacet", + "CreationDateFacet", + "RuleFacet", + "TagFacet", + "ProjectFacet", + "ModuleFacet", + "DirectoryFacet", + "FileFacet", + "AssigneeFacet", + "LanguageFacet", +] +`; + +exports[`test should render facets for directory 1`] = ` +Array [ + "FacetMode", + "TypeFacet", + "ResolutionFacet", + "SeverityFacet", + "StatusFacet", + "CreationDateFacet", + "RuleFacet", + "TagFacet", + "FileFacet", + "AssigneeFacet", + "AuthorFacet", + "LanguageFacet", +] +`; + +exports[`test should render facets for module 1`] = ` +Array [ + "FacetMode", + "TypeFacet", + "ResolutionFacet", + "SeverityFacet", + "StatusFacet", + "CreationDateFacet", + "RuleFacet", + "TagFacet", + "ModuleFacet", + "DirectoryFacet", + "FileFacet", + "AssigneeFacet", + "AuthorFacet", + "LanguageFacet", +] +`; + +exports[`test should render facets for project 1`] = ` +Array [ + "FacetMode", + "TypeFacet", + "ResolutionFacet", + "SeverityFacet", + "StatusFacet", + "CreationDateFacet", + "RuleFacet", + "TagFacet", + "ModuleFacet", + "DirectoryFacet", + "FileFacet", + "AssigneeFacet", + "AuthorFacet", + "LanguageFacet", +] +`; + +exports[`test should render facets when my issues are selected 1`] = ` +Array [ + "FacetMode", + "TypeFacet", + "ResolutionFacet", + "SeverityFacet", + "StatusFacet", + "CreationDateFacet", + "RuleFacet", + "TagFacet", + "ProjectFacet", + "ModuleFacet", + "DirectoryFacet", + "FileFacet", + "AuthorFacet", + "LanguageFacet", +] +`; diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/components/FacetBox.js b/server/sonar-web/src/main/js/apps/issues/sidebar/components/FacetBox.js new file mode 100644 index 00000000000..358f6ee3a19 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/components/FacetBox.js @@ -0,0 +1,34 @@ +/* + * 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'; + +type Props = {| + children?: React.Element<*>, + property: string +|}; + +const FacetBox = (props: Props) => ( + <div className="search-navigator-facet-box" data-property={props.property}> + {props.children} + </div> +); + +export default FacetBox; diff --git a/server/sonar-web/src/main/js/components/issue/ConnectedIssue.js b/server/sonar-web/src/main/js/apps/issues/sidebar/components/FacetFooter.js index 67d71fe37cc..8f0fca20fdc 100644 --- a/server/sonar-web/src/main/js/components/issue/ConnectedIssue.js +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/components/FacetFooter.js @@ -18,19 +18,26 @@ * 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'; +import React from 'react'; +import SearchSelect from '../../components/SearchSelect'; -const mapStateToProps = (state, ownProps) => ({ - issue: getIssueByKey(state, ownProps.issueKey) -}); +type Option = { label: string, value: string }; -const mapDispatchToProps = { - onIssueChange: updateIssue, - onFail: error => dispatch => onFail(dispatch)(error) -}; +type Props = {| + minimumQueryLength?: number, + onSearch: (query: string) => Promise<Array<Option>>, + onSelect: (value: string) => void, + renderOption?: (option: Object) => React.Element<*> +|}; -export default connect(mapStateToProps, mapDispatchToProps)(BaseIssue); +export default class FacetFooter extends React.PureComponent { + props: Props; + + render() { + return ( + <div className="search-navigator-facet-footer"> + <SearchSelect {...this.props} /> + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/components/FacetHeader.js b/server/sonar-web/src/main/js/apps/issues/sidebar/components/FacetHeader.js new file mode 100644 index 00000000000..ff6ef8387c9 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/components/FacetHeader.js @@ -0,0 +1,83 @@ +/* + * 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 +/* eslint-disable max-len */ +import React from 'react'; + +type Props = { + hasValue: boolean, + name: string, + onClick?: () => void, + open: boolean +}; + +export default class FacetHeader extends React.PureComponent { + props: Props; + + static defaultProps = { + hasValue: false, + open: true + }; + + handleClick = (e: Event & { currentTarget: HTMLElement }) => { + e.preventDefault(); + e.currentTarget.blur(); + if (this.props.onClick) { + this.props.onClick(); + } + }; + + renderCheckbox() { + return ( + <svg viewBox="0 0 1792 1792" width="10" height="10" style={{ paddingTop: 3 }}> + {this.props.open + ? <path + style={{ fill: 'currentColor ' }} + d="M1683 808l-742 741q-19 19-45 19t-45-19l-742-741q-19-19-19-45.5t19-45.5l166-165q19-19 45-19t45 19l531 531 531-531q19-19 45-19t45 19l166 165q19 19 19 45.5t-19 45.5z" + /> + : <path + style={{ fill: 'currentColor ' }} + d="M1363 877l-742 742q-19 19-45 19t-45-19l-166-166q-19-19-19-45t19-45l531-531-531-531q-19-19-19-45t19-45l166-166q19-19 45-19t45 19l742 742q19 19 19 45t-19 45z" + />} + </svg> + ); + } + + renderValueIndicator() { + return this.props.hasValue && !this.props.open + ? <svg viewBox="0 0 1792 1792" width="8" height="8" style={{ paddingTop: 5, paddingLeft: 8 }}> + <path + d="M1664 896q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z" + fill="#4b9fd5" + /> + </svg> + : null; + } + + render() { + return this.props.onClick + ? <a className="search-navigator-facet-header" href="#" onClick={this.handleClick}> + {this.renderCheckbox()}{' '}{this.props.name}{' '}{this.renderValueIndicator()} + </a> + : <span className="search-navigator-facet-header"> + {this.props.name} + </span>; + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/components/FacetItem.js b/server/sonar-web/src/main/js/apps/issues/sidebar/components/FacetItem.js new file mode 100644 index 00000000000..94d512d195b --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/components/FacetItem.js @@ -0,0 +1,71 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +// @flow +import React from 'react'; +import classNames from 'classnames'; +import { formatMeasure } from '../../../../helpers/measures'; + +type Props = {| + active: boolean, + disabled: boolean, + facetMode: string, + halfWidth: boolean, + name: string | React.Element<*>, + onClick: (string) => void, + stat: ?number, + value: string +|}; + +export default class FacetItem extends React.PureComponent { + props: Props; + + static defaultProps = { + disabled: false, + halfWidth: false + }; + + handleClick = (event: Event & { currentTarget: HTMLElement }) => { + event.preventDefault(); + const value = event.currentTarget.dataset.value; + this.props.onClick(value); + }; + + render() { + const { stat } = this.props; + + const className = classNames('facet', 'search-navigator-facet', { + active: this.props.active, + 'search-navigator-facet-half': this.props.halfWidth + }); + + const formattedStat = stat && + formatMeasure(stat, this.props.facetMode === 'effort' ? 'SHORT_WORK_DUR' : 'SHORT_INT'); + + return this.props.disabled + ? <span className={className}> + <span className="facet-name">{this.props.name}</span> + <span className="facet-stat">{formattedStat}</span> + </span> + : <a className={className} data-value={this.props.value} href="#" onClick={this.handleClick}> + <span className="facet-name">{this.props.name}</span> + <span className="facet-stat">{formattedStat}</span> + </a>; + } +} diff --git a/server/sonar-web/src/main/js/apps/component-issues/routes.js b/server/sonar-web/src/main/js/apps/issues/sidebar/components/FacetItemsList.js index 954ba1487bb..4a203f0c071 100644 --- a/server/sonar-web/src/main/js/apps/component-issues/routes.js +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/components/FacetItemsList.js @@ -17,15 +17,17 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -const routes = [ - { - indexRoute: { - getComponent(_, callback) { - require.ensure([], require => - callback(null, require('./components/ComponentIssuesAppContainer').default)); - } - } - } -]; +// @flow +import React from 'react'; -export default routes; +type Props = {| + children?: Array<React.Element<*>> +|}; + +const FacetItemsList = (props: Props) => ( + <div className="search-navigator-facet-list"> + {props.children} + </div> +); + +export default FacetItemsList; diff --git a/server/sonar-web/src/main/js/components/issue/models/changelog.js b/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/FacetBox-test.js index bf1150a310a..0eb453e8066 100644 --- a/server/sonar-web/src/main/js/components/issue/models/changelog.js +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/FacetBox-test.js @@ -17,14 +17,11 @@ * 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 { shallow } from 'enzyme'; +import FacetBox from '../FacetBox'; -export default Backbone.Collection.extend({ - url() { - return window.baseUrl + '/api/issues/changelog'; - }, - - parse(r) { - return r.changelog; - } +it('should render', () => { + expect(shallow(<FacetBox property="foo"><div /></FacetBox>)).toMatchSnapshot(); }); diff --git a/server/sonar-web/src/main/js/components/shared/qualifier-icon.js b/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/FacetFooter-test.js index d3e8aead051..4dbf1cc3ece 100644 --- a/server/sonar-web/src/main/js/components/shared/qualifier-icon.js +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/FacetFooter-test.js @@ -17,14 +17,11 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +// @flow import React from 'react'; +import { shallow } from 'enzyme'; +import FacetFooter from '../FacetFooter'; -export default React.createClass({ - render() { - if (!this.props.qualifier) { - return null; - } - const className = 'icon-qualifier-' + this.props.qualifier.toLowerCase(); - return <i className={className} />; - } +it('should render', () => { + expect(shallow(<FacetFooter onSearch={jest.fn()} onSelect={jest.fn()} />)).toMatchSnapshot(); }); diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/FacetHeader-test.js b/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/FacetHeader-test.js new file mode 100644 index 00000000000..5aa00c4a41e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/FacetHeader-test.js @@ -0,0 +1,61 @@ +/* + * 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 { shallow } from 'enzyme'; +import { click } from '../../../../../helpers/testUtils'; +import FacetHeader from '../FacetHeader'; + +it('should render open facet with value', () => { + expect( + shallow(<FacetHeader hasValue={true} name="foo" onClick={jest.fn()} open={true} />) + ).toMatchSnapshot(); +}); + +it('should render open facet without value', () => { + expect( + shallow(<FacetHeader hasValue={false} name="foo" onClick={jest.fn()} open={true} />) + ).toMatchSnapshot(); +}); + +it('should render closed facet with value', () => { + expect( + shallow(<FacetHeader hasValue={true} name="foo" onClick={jest.fn()} open={false} />) + ).toMatchSnapshot(); +}); + +it('should render closed facet without value', () => { + expect( + shallow(<FacetHeader hasValue={false} name="foo" onClick={jest.fn()} open={false} />) + ).toMatchSnapshot(); +}); + +it('should render without link', () => { + expect(shallow(<FacetHeader hasValue={false} name="foo" open={false} />)).toMatchSnapshot(); +}); + +it('should call onClick', () => { + const onClick = jest.fn(); + const wrapper = shallow( + <FacetHeader hasValue={false} name="foo" onClick={onClick} open={false} /> + ); + click(wrapper); + expect(onClick).toHaveBeenCalled(); +}); diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/FacetItem-test.js b/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/FacetItem-test.js new file mode 100644 index 00000000000..ddc84eca458 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/FacetItem-test.js @@ -0,0 +1,68 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +// @flow +import React from 'react'; +import { shallow } from 'enzyme'; +import { click } from '../../../../../helpers/testUtils'; +import FacetItem from '../FacetItem'; + +const renderFacetItem = (props: {}) => + shallow( + <FacetItem + active={false} + facetMode="count" + name="foo" + onClick={jest.fn()} + stat={null} + value="bar" + {...props} + /> + ); + +it('should render active', () => { + expect(renderFacetItem({ active: true })).toMatchSnapshot(); +}); + +it('should render inactive', () => { + expect(renderFacetItem({ active: false })).toMatchSnapshot(); +}); + +it('should render stat', () => { + expect(renderFacetItem({ stat: 13 })).toMatchSnapshot(); +}); + +it('should render disabled', () => { + expect(renderFacetItem({ disabled: true })).toMatchSnapshot(); +}); + +it('should render half width', () => { + expect(renderFacetItem({ halfWidth: true })).toMatchSnapshot(); +}); + +it('should render effort stat', () => { + expect(renderFacetItem({ facetMode: 'effort', stat: 1234 })).toMatchSnapshot(); +}); + +it('should call onClick', () => { + const onClick = jest.fn(); + const wrapper = renderFacetItem({ onClick }); + click(wrapper, { currentTarget: { dataset: { value: 'bar' } } }); + expect(onClick).toHaveBeenCalled(); +}); diff --git a/server/sonar-web/src/main/js/helpers/handlebars/issueFilterHomeLink.js b/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/FacetItemsList-test.js index c65b1c7a8d0..883e62b88d4 100644 --- a/server/sonar-web/src/main/js/helpers/handlebars/issueFilterHomeLink.js +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/FacetItemsList-test.js @@ -17,6 +17,11 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -module.exports = function(id) { - return window.baseUrl + '/issues/search#id=' + id; -}; +// @flow +import React from 'react'; +import { shallow } from 'enzyme'; +import FacetItemsList from '../FacetItemsList'; + +it('should render', () => { + expect(shallow(<FacetItemsList><div /></FacetItemsList>)).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/__snapshots__/FacetBox-test.js.snap b/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/__snapshots__/FacetBox-test.js.snap new file mode 100644 index 00000000000..2722912ca7d --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/__snapshots__/FacetBox-test.js.snap @@ -0,0 +1,7 @@ +exports[`test should render 1`] = ` +<div + className="search-navigator-facet-box" + data-property="foo"> + <div /> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/__snapshots__/FacetFooter-test.js.snap b/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/__snapshots__/FacetFooter-test.js.snap new file mode 100644 index 00000000000..21f8096c8e8 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/__snapshots__/FacetFooter-test.js.snap @@ -0,0 +1,10 @@ +exports[`test should render 1`] = ` +<div + className="search-navigator-facet-footer"> + <SearchSelect + minimumQueryLength={2} + onSearch={[Function]} + onSelect={[Function]} + resetOnBlur={true} /> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/__snapshots__/FacetHeader-test.js.snap b/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/__snapshots__/FacetHeader-test.js.snap new file mode 100644 index 00000000000..3333ae8944d --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/__snapshots__/FacetHeader-test.js.snap @@ -0,0 +1,132 @@ +exports[`test should render closed facet with value 1`] = ` +<a + className="search-navigator-facet-header" + href="#" + onClick={[Function]}> + <svg + height="10" + style={ + Object { + "paddingTop": 3, + } + } + viewBox="0 0 1792 1792" + width="10"> + <path + d="M1363 877l-742 742q-19 19-45 19t-45-19l-166-166q-19-19-19-45t19-45l531-531-531-531q-19-19-19-45t19-45l166-166q19-19 45-19t45 19l742 742q19 19 19 45t-19 45z" + style={ + Object { + "fill": "currentColor ", + } + } /> + </svg> + + foo + + <svg + height="8" + style={ + Object { + "paddingLeft": 8, + "paddingTop": 5, + } + } + viewBox="0 0 1792 1792" + width="8"> + <path + d="M1664 896q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z" + fill="#4b9fd5" /> + </svg> +</a> +`; + +exports[`test should render closed facet without value 1`] = ` +<a + className="search-navigator-facet-header" + href="#" + onClick={[Function]}> + <svg + height="10" + style={ + Object { + "paddingTop": 3, + } + } + viewBox="0 0 1792 1792" + width="10"> + <path + d="M1363 877l-742 742q-19 19-45 19t-45-19l-166-166q-19-19-19-45t19-45l531-531-531-531q-19-19-19-45t19-45l166-166q19-19 45-19t45 19l742 742q19 19 19 45t-19 45z" + style={ + Object { + "fill": "currentColor ", + } + } /> + </svg> + + foo + +</a> +`; + +exports[`test should render open facet with value 1`] = ` +<a + className="search-navigator-facet-header" + href="#" + onClick={[Function]}> + <svg + height="10" + style={ + Object { + "paddingTop": 3, + } + } + viewBox="0 0 1792 1792" + width="10"> + <path + d="M1683 808l-742 741q-19 19-45 19t-45-19l-742-741q-19-19-19-45.5t19-45.5l166-165q19-19 45-19t45 19l531 531 531-531q19-19 45-19t45 19l166 165q19 19 19 45.5t-19 45.5z" + style={ + Object { + "fill": "currentColor ", + } + } /> + </svg> + + foo + +</a> +`; + +exports[`test should render open facet without value 1`] = ` +<a + className="search-navigator-facet-header" + href="#" + onClick={[Function]}> + <svg + height="10" + style={ + Object { + "paddingTop": 3, + } + } + viewBox="0 0 1792 1792" + width="10"> + <path + d="M1683 808l-742 741q-19 19-45 19t-45-19l-742-741q-19-19-19-45.5t19-45.5l166-165q19-19 45-19t45 19l531 531 531-531q19-19 45-19t45 19l166 165q19 19 19 45.5t-19 45.5z" + style={ + Object { + "fill": "currentColor ", + } + } /> + </svg> + + foo + +</a> +`; + +exports[`test should render without link 1`] = ` +<span + className="search-navigator-facet-header"> + foo +</span> +`; diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/__snapshots__/FacetItem-test.js.snap b/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/__snapshots__/FacetItem-test.js.snap new file mode 100644 index 00000000000..0a22b710499 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/__snapshots__/FacetItem-test.js.snap @@ -0,0 +1,90 @@ +exports[`test should render active 1`] = ` +<a + className="facet search-navigator-facet active" + data-value="bar" + href="#" + onClick={[Function]}> + <span + className="facet-name"> + foo + </span> + <span + className="facet-stat" /> +</a> +`; + +exports[`test should render disabled 1`] = ` +<span + className="facet search-navigator-facet"> + <span + className="facet-name"> + foo + </span> + <span + className="facet-stat" /> +</span> +`; + +exports[`test should render effort stat 1`] = ` +<a + className="facet search-navigator-facet" + data-value="bar" + href="#" + onClick={[Function]}> + <span + className="facet-name"> + foo + </span> + <span + className="facet-stat"> + work_duration.x_days.3 + </span> +</a> +`; + +exports[`test should render half width 1`] = ` +<a + className="facet search-navigator-facet search-navigator-facet-half" + data-value="bar" + href="#" + onClick={[Function]}> + <span + className="facet-name"> + foo + </span> + <span + className="facet-stat" /> +</a> +`; + +exports[`test should render inactive 1`] = ` +<a + className="facet search-navigator-facet" + data-value="bar" + href="#" + onClick={[Function]}> + <span + className="facet-name"> + foo + </span> + <span + className="facet-stat" /> +</a> +`; + +exports[`test should render stat 1`] = ` +<a + className="facet search-navigator-facet" + data-value="bar" + href="#" + onClick={[Function]}> + <span + className="facet-name"> + foo + </span> + <span + className="facet-stat"> + 13 + </span> +</a> +`; diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/__snapshots__/FacetItemsList-test.js.snap b/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/__snapshots__/FacetItemsList-test.js.snap new file mode 100644 index 00000000000..15686ac81d6 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/__snapshots__/FacetItemsList-test.js.snap @@ -0,0 +1,6 @@ +exports[`test should render 1`] = ` +<div + className="search-navigator-facet-list"> + <div /> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/issues/styles.css b/server/sonar-web/src/main/js/apps/issues/styles.css deleted file mode 100644 index 46942bb2796..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/styles.css +++ /dev/null @@ -1,23 +0,0 @@ -.issues-header-inner { - padding: 32px 10px 15px; - background-color: #f3f3f3; - text-align: center; -} - -.issues-header-inner:empty { - display: none; -} - -.issues-header-order { - display: inline-block; - vertical-align: top; - margin-right: 20px; - font-size: 12px; -} - -.issues-header-order { - display: inline-block; - vertical-align: top; - margin-right: 20px; - font-size: 12px; -} diff --git a/server/sonar-web/src/main/js/apps/issues/templates/BulkChangeForm.hbs b/server/sonar-web/src/main/js/apps/issues/templates/BulkChangeForm.hbs deleted file mode 100644 index fda291ab2b3..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/templates/BulkChangeForm.hbs +++ /dev/null @@ -1,145 +0,0 @@ -{{#if isLoaded}} - <form id="bulk-change-form"> - <div class="modal-head"> - <h2>{{tp 'issue_bulk_change.form.title' issues.length}}</h2> - </div> - <div class="modal-body"> - <div class="js-modal-messages"></div> - - {{#if limitReached}} - <div class="alert alert-warning"> - {{tp 'issue_bulk_change.max_issues_reached' issues.length}} - </div> - {{/if}} - - {{! assign }} - {{#if canBeAssigned}} - <div class="modal-field"> - <label for="assignee">{{t 'issue.assign.formlink'}}</label> - <input id="assign-action" name="actions[]" type="checkbox" value="assign"> - <input id="assignee" type="hidden"> - <div class="pull-right note"> - ({{tp 'issue_bulk_change.x_issues' canBeAssigned}}) - </div> - </div> - {{/if}} - - {{! type }} - {{#if canChangeType}} - <div class="modal-field"> - <label for="type">{{t 'issue.set_type'}}</label> - <input id="set-type-action" name="actions[]" type="checkbox" value="set_type"> - <select id="type" name="set_type.type"> - <option value="BUG">{{t 'issue.type.BUG'}}</option> - <option value="VULNERABILITY">{{t 'issue.type.VULNERABILITY'}}</option> - <option value="CODE_SMELL">{{t 'issue.type.CODE_SMELL'}}</option> - </select> - <div class="pull-right note"> - ({{tp 'issue_bulk_change.x_issues' canChangeType}}) - </div> - </div> - {{/if}} - - {{! severity }} - {{#if canChangeSeverity}} - <div class="modal-field"> - <label for="severity">{{t 'issue.set_severity'}}</label> - <input id="set-severity-action" name="actions[]" type="checkbox" value="set_severity"> - <select id="severity" name="set_severity.severity"> - <option value="BLOCKER">{{t 'severity.BLOCKER'}}</option> - <option value="CRITICAL">{{t 'severity.CRITICAL'}}</option> - <option value="MAJOR">{{t 'severity.MAJOR'}}</option> - <option value="MINOR">{{t 'severity.MINOR'}}</option> - <option value="INFO">{{t 'severity.INFO'}}</option> - </select> - <div class="pull-right note"> - ({{tp 'issue_bulk_change.x_issues' canChangeSeverity}}) - </div> - </div> - {{/if}} - - {{! add tags }} - {{#if canChangeTags}} - <div class="modal-field"> - <label for="add_tags">{{t 'issue.add_tags'}}</label> - <input id="add-tags-action" name="actions[]" type="checkbox" value="add_tags"> - <input id="add_tags" name="add_tags.tags" type="text"> - <div class="pull-right note"> - ({{tp 'issue_bulk_change.x_issues' canChangeTags}}) - </div> - </div> - {{/if}} - - {{! remove tags }} - {{#if canChangeTags}} - <div class="modal-field"> - <label for="remove_tags">{{t 'issue.remove_tags'}}</label> - <input id="remove-tags-action" name="actions[]" type="checkbox" value="remove_tags"> - <input id="remove_tags" name="remove_tags.tags" type="text"> - <div class="pull-right note"> - ({{tp 'issue_bulk_change.x_issues' canChangeTags}}) - </div> - </div> - {{/if}} - - {{! transitions }} - {{#notEmpty availableTransitions}} - <div class="modal-field"> - <label>{{t 'issue.transition'}}</label> - {{#each availableTransitions}} - <input type="radio" id="transition-{{transition}}" name="do_transition.transition" - value="{{transition}}"> - <label for="transition-{{transition}}" style="float: none; display: inline; left: 0; cursor: pointer;"> - {{t 'issue.transition' transition}} - </label> - <div class="pull-right note"> - ({{tp 'issue_bulk_change.x_issues' count}}) - </div> - <br> - {{/each}} - </div> - {{/notEmpty}} - - {{! comment }} - {{#if canBeCommented}} - <div class="modal-field"> - <label for="comment"> - {{t 'issue.comment.formlink'}} - <i class="icon-help" title="{{t 'issue_bulk_change.comment.help'}}"></i> - </label> - <div> - <textarea rows="4" name="comment" id="comment" style="width: 100%"></textarea> - </div> - <div class="pull-right"> - {{> ../../../components/common/templates/_markdown-tips}} - </div> - </div> - {{/if}} - - {{! notifications }} - <div class="modal-field"> - <label for="send-notifications">{{t 'issue.send_notifications'}}</label> - <input id="send-notifications" name="sendNotifications" type="checkbox" value="true"> - </div> - - </div> - <div class="modal-foot"> - <i class="js-modal-spinner spinner spacer-right hidden"></i> - <button id="bulk-change-submit">{{t 'apply'}}</button> - <a id="bulk-change-cancel" href="#" class="js-modal-close">{{t 'cancel'}}</a> - </div> - </form> -{{else}} - <div class="modal-head"> - <h2>{{t 'bulk_change'}}</h2> - </div> - <div class="modal-body"> - <div class="js-modal-messages"></div> - <div class="text-center"> - <i class="spinner spinner-margin"></i> - </div> - </div> - <div class="modal-foot"> - <a id="bulk-change-cancel" href="#" class="js-modal-close">{{t 'cancel'}}</a> - </div> -{{/if}} diff --git a/server/sonar-web/src/main/js/apps/issues/templates/facets/_issues-facet-header.hbs b/server/sonar-web/src/main/js/apps/issues/templates/facets/_issues-facet-header.hbs deleted file mode 100644 index 64237bc0e79..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/templates/facets/_issues-facet-header.hbs +++ /dev/null @@ -1,4 +0,0 @@ -<a class="search-navigator-facet-header js-facet-toggle"> - <i class="icon-checkbox {{#if enabled}}icon-checkbox-checked{{/if}}"></i> - {{t "issues.facet" property}} -</a> diff --git a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-assignee-facet.hbs b/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-assignee-facet.hbs deleted file mode 100644 index 9fb23f78ab8..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-assignee-facet.hbs +++ /dev/null @@ -1,26 +0,0 @@ -{{> "_issues-facet-header"}} - -<div class="search-navigator-facet-list"> - {{#each values}} - {{#eq val ""}} - {{! unassigned }} - <a class="facet search-navigator-facet js-facet" data-unassigned title="{{t "unassigned"}}"> - <span class="facet-name">{{t "unassigned"}}</span> - <span class="facet-stat"> - {{formatFacetValue count ../../state.facetMode}} - </span> - </a> - {{else}} - <a class="facet search-navigator-facet js-facet" data-value="{{val}}" title="{{label}}"> - <span class="facet-name">{{label}}</span> - <span class="facet-stat"> - {{formatFacetValue count ../../state.facetMode}} - </span> - </a> - {{/eq}} - {{/each}} - - <div class="search-navigator-facet-custom-value"> - <input type="hidden" class="js-custom-value"> - </div> -</div> diff --git a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-base-facet.hbs b/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-base-facet.hbs deleted file mode 100644 index 83442f81b37..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-base-facet.hbs +++ /dev/null @@ -1,11 +0,0 @@ -{{> "_issues-facet-header"}} -<div class="search-navigator-facet-list"> - {{#each values}} - <a class="facet search-navigator-facet js-facet" data-value="{{val}}" title="{{default label val}}"> - <span class="facet-name">{{default label val}}</span> - <span class="facet-stat"> - {{formatFacetValue count ../state.facetMode}} - </span> - </a> - {{/each}} -</div> diff --git a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-context-facet.hbs b/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-context-facet.hbs deleted file mode 100644 index 9f981c07c1a..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-context-facet.hbs +++ /dev/null @@ -1,3 +0,0 @@ -<div class="search-navigator-facet-query"> - Issues of {{qualifierIcon state.contextComponentQualifier}} {{state.contextComponentName}} -</div> diff --git a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-creation-date-facet.hbs b/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-creation-date-facet.hbs deleted file mode 100644 index bf5410833c9..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-creation-date-facet.hbs +++ /dev/null @@ -1,42 +0,0 @@ -{{> "_issues-facet-header"}} - -{{#if createdAt}} - <input type="hidden" name="createdAt"> - <div class="search-navigator-facet-container"> - {{dt createdAt}} ({{fromNow createdAt}}) - </div> -{{else}} - <div class="search-navigator-facet-container"> - <div class="js-barchart" data-height="75" {{#if periodEnd}}data-end-date="{{periodEnd}}"{{/if}}></div> - <div class="search-navigator-date-facet-selection"> - <a class="js-select-period-start search-navigator-date-facet-selection-dropdown-left"> - {{#if periodStart}}{{d periodStart}}{{else}}Past{{/if}} <i class="icon-dropdown"></i> - </a> - <a class="js-select-period-end search-navigator-date-facet-selection-dropdown-right"> - {{#if periodEnd}}{{d periodEnd}}{{else}}Now{{/if}} <i class="icon-dropdown"></i> - </a> - <input class="js-period-start search-navigator-date-facet-selection-input-left" - type="text" value="{{#if periodStart}}{{ds periodStart}}{{/if}}" name="createdAfter"> - <input class="js-period-end search-navigator-date-facet-selection-input-right" - type="text" value="{{#if periodEnd}}{{ds periodEnd}}{{/if}}" name="createdBefore"> - </div> - - <div class="spacer-top"> - <span class="spacer-right">{{t "issues.facet.createdAt.or"}}</span> - <a class="js-all spacer-right" href="#">{{t "issues.facet.createdAt.all"}}</a> - {{#if hasLeak}} - <a class="js-leak spacer-right {{#eq sinceLeakPeriod "true"}}active-link{{/eq}}" href="#">Leak Period</a> - {{else}} - <a class="js-last-week spacer-right {{#eq createdInLast "1w"}}active-link{{/eq}}" href="#"> - {{t "issues.facet.createdAt.last_week"}} - </a> - <a class="js-last-month spacer-right {{#eq createdInLast "1m"}}active-link{{/eq}}" href="#"> - {{t "issues.facet.createdAt.last_month"}} - </a> - <a class="js-last-year {{#eq createdInLast "1y"}}active-link{{/eq}}" href="#"> - {{t "issues.facet.createdAt.last_year"}} - </a> - {{/if}} - </div> - </div> -{{/if}} diff --git a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-custom-values-facet.hbs b/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-custom-values-facet.hbs deleted file mode 100644 index 0674f5b87bb..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-custom-values-facet.hbs +++ /dev/null @@ -1,16 +0,0 @@ -{{> "_issues-facet-header"}} - -<div class="search-navigator-facet-list"> - {{#each values}} - <a class="facet search-navigator-facet js-facet" data-value="{{val}}" title="{{#if extra}}({{extra}}) {{/if}}{{default label val}}"> - <span class="facet-name">{{default label val}}</span> - <span class="facet-stat"> - {{#eq ../state.facetMode 'count'}}{{numberShort count}}{{else}}{{formatMeasure count 'SHORT_WORK_DUR'}}{{/eq}} - </span> - </a> - {{/each}} - - <div class="search-navigator-facet-custom-value"> - <input type="hidden" class="js-custom-value"> - </div> -</div> diff --git a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-file-facet.hbs b/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-file-facet.hbs deleted file mode 100644 index 3569040b69c..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-file-facet.hbs +++ /dev/null @@ -1,12 +0,0 @@ -{{> "_issues-facet-header"}} - -<div class="search-navigator-facet-list search-navigator-facet-list-align-right"> - {{#each values}} - <a class="facet search-navigator-facet js-facet" data-value="{{val}}" title="{{default label val}}"> - <span class="facet-name">{{default label val}}</span> - <span class="facet-stat"> - {{#eq ../state.facetMode 'count'}}{{numberShort count}}{{else}}{{formatMeasure count 'SHORT_WORK_DUR'}}{{/eq}} - </span> - </a> - {{/each}} -</div> diff --git a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-issue-key-facet.hbs b/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-issue-key-facet.hbs deleted file mode 100644 index 28ae140f75b..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-issue-key-facet.hbs +++ /dev/null @@ -1,7 +0,0 @@ -{{> "_issues-facet-header"}} - -<div class="search-navigator-facet-container"> - <div class="facet search-navigator-facet active" style="cursor: default;"> - <span class="facet-name">{{issues}}</span> - </div> -</div> diff --git a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-mode-facet.hbs b/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-mode-facet.hbs deleted file mode 100644 index af69286d92a..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-mode-facet.hbs +++ /dev/null @@ -1,15 +0,0 @@ -<div class="search-navigator-facet-header"> - {{t 'issues.facet.mode'}} -</div> - - -<div class="search-navigator-facet-list"> - <a class="facet search-navigator-facet search-navigator-facet-half js-facet {{#eq mode 'count'}}active{{/eq}}" - data-value="count"> - <span class="facet-name">{{t 'issues.facet.mode.issues'}}</span> - </a> - <a class="facet search-navigator-facet search-navigator-facet-half js-facet {{#eq mode 'effort'}}active{{/eq}}" - data-value="effort"> - <span class="facet-name">{{t 'issues.facet.mode.effort'}}</span> - </a> -</div> diff --git a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-my-issues-facet.hbs b/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-my-issues-facet.hbs deleted file mode 100644 index c11181a70dd..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-my-issues-facet.hbs +++ /dev/null @@ -1,12 +0,0 @@ -{{#if user.isLoggedIn}} - <ul class="radio-toggle"> - <li> - <input type="radio" name="issues-page-my" value="my" id="issues-page-my-my" {{#if me}}checked{{/if}}> - <label for="issues-page-my-my">My Issues</label> - </li> - <li> - <input type="radio" name="issues-page-my" value="all" id="issues-page-my-all" {{#unless me}}checked{{/unless}}> - <label for="issues-page-my-all">All</label> - </li> - </ul> -{{/if}} diff --git a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-projects-facet.hbs b/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-projects-facet.hbs deleted file mode 100644 index 7693af65549..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-projects-facet.hbs +++ /dev/null @@ -1,16 +0,0 @@ -{{> "_issues-facet-header"}} - -<div class="search-navigator-facet-list"> - {{#each values}} - <a class="facet search-navigator-facet js-facet" data-value="{{val}}" title="{{#if extra}}({{extra}}) {{/if}}{{default label val}}"> - <span class="facet-name">{{#if organization}}{{organization.name}}<span class="slash-separator"></span>{{/if}}{{default label val}}</span> - <span class="facet-stat"> - {{#eq ../state.facetMode 'count'}}{{numberShort count}}{{else}}{{formatMeasure count 'SHORT_WORK_DUR'}}{{/eq}} - </span> - </a> - {{/each}} - - <div class="search-navigator-facet-custom-value"> - <input type="hidden" class="js-custom-value"> - </div> -</div> diff --git a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-resolution-facet.hbs b/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-resolution-facet.hbs deleted file mode 100644 index 19e47071f8b..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-resolution-facet.hbs +++ /dev/null @@ -1,24 +0,0 @@ -{{> "_issues-facet-header"}} - -<div class="search-navigator-facet-list"> - {{#each values}} - {{#eq val ""}} - {{! unresolved }} - <a class="facet search-navigator-facet search-navigator-facet-half js-facet" data-unresolved - title="{{t "issue.unresolved.description"}}" data-toggle="tooltip" data-placement="right"> - <span class="facet-name">{{t "unresolved"}}</span> - <span class="facet-stat"> - {{#eq ../../state.facetMode 'count'}}{{numberShort count}}{{else}}{{formatMeasure count 'SHORT_WORK_DUR'}}{{/eq}} - </span> - </a> - {{else}} - <a class="facet search-navigator-facet search-navigator-facet-half js-facet" data-value="{{val}}" - title="{{t "issue.resolution" val "description"}}" data-toggle="tooltip" data-placement="right"> - <span class="facet-name">{{t "issue.resolution" val}}</span> - <span class="facet-stat"> - {{#eq ../../state.facetMode 'count'}}{{numberShort count}}{{else}}{{formatMeasure count 'SHORT_WORK_DUR'}}{{/eq}} - </span> - </a> - {{/eq}} - {{/each}} -</div> diff --git a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-severity-facet.hbs b/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-severity-facet.hbs deleted file mode 100644 index 88b9bd0585b..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-severity-facet.hbs +++ /dev/null @@ -1,13 +0,0 @@ -{{> "_issues-facet-header"}} - -<div class="search-navigator-facet-list"> - {{#each values}} - <a class="facet search-navigator-facet search-navigator-facet-half js-facet" - data-value="{{val}}" title="{{t "severity" val "description"}}" data-toggle="tooltip" data-placement="right"> - <span class="facet-name">{{severityIcon val}} {{t "severity" val}}</span> - <span class="facet-stat"> - {{#eq ../state.facetMode 'count'}}{{numberShort count}}{{else}}{{formatMeasure count 'SHORT_WORK_DUR'}}{{/eq}} - </span> - </a> - {{/each}} -</div> diff --git a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-status-facet.hbs b/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-status-facet.hbs deleted file mode 100644 index cc7f1fcefdb..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-status-facet.hbs +++ /dev/null @@ -1,13 +0,0 @@ -{{> "_issues-facet-header"}} - -<div class="search-navigator-facet-list"> - {{#each values}} - <a class="facet search-navigator-facet search-navigator-facet-half js-facet" - data-value="{{val}}" title="{{t "issue.status" val "description"}}" data-toggle="tooltip" data-placement="right"> - <span class="facet-name">{{statusIcon val}} {{t "issue.status" val}}</span> - <span class="facet-stat"> - {{#eq ../state.facetMode 'count'}}{{numberShort count}}{{else}}{{formatMeasure count 'SHORT_WORK_DUR'}}{{/eq}} - </span> - </a> - {{/each}} -</div> diff --git a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-type-facet.hbs b/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-type-facet.hbs deleted file mode 100644 index f97ada41dca..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/templates/facets/issues-type-facet.hbs +++ /dev/null @@ -1,13 +0,0 @@ -{{> "_issues-facet-header"}} - -<div class="search-navigator-facet-list"> - {{#each values}} - <a class="facet search-navigator-facet js-facet" - data-value="{{val}}"> - <span class="facet-name">{{issueTypeIcon val}} {{t 'issue.type' val}}</span> - <span class="facet-stat"> - {{#eq ../state.facetMode 'count'}}{{numberShort count}}{{else}}{{formatMeasure count 'SHORT_WORK_DUR'}}{{/eq}} - </span> - </a> - {{/each}} -</div> diff --git a/server/sonar-web/src/main/js/apps/issues/templates/issues-issue-filter-form.hbs b/server/sonar-web/src/main/js/apps/issues/templates/issues-issue-filter-form.hbs deleted file mode 100644 index f18f6845d3a..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/templates/issues-issue-filter-form.hbs +++ /dev/null @@ -1,89 +0,0 @@ -<header class="menu-search"> - <h6>{{t "issue.filter_similar_issues"}}</h6> -</header> - -<ul class="menu"> - <li> - <a href="#" data-property="types" data-value="{{this.type}}"> - {{issueType this.type}} - </a> - </li> - - <li> - <a href="#" data-property="severities" data-value="{{s}}"> - {{severityIcon severity}} {{t "severity" severity}} - </a> - </li> - - <li> - <a href="#" data-property="statuses" data-value="{{status}}"> - {{statusIcon status}} {{t "issue.status" status}} - </a> - </li> - - <li> - {{#if resolution}} - <a href="#" data-property="resolutions" data-value="{{resolution}}"> - {{t "issue.resolution" resolution}} - </a> - {{else}} - <a href="#" data-property="resolved" data-value="false"> - {{t "unresolved"}} - </a> - {{/if}} - </li> - - <li> - {{#if assignee}} - <a href="#" class="issue-action-option" data-property="assignees" data-value="{{assignee}}"> - {{t "assigned_to"}} - {{#ifShowAvatars}}<span class="spacer-left">{{avatarHelperNew assigneeAvatar 16}}</span>{{/ifShowAvatars}} - {{assigneeName}} - </a> - {{else}} - <a href="#" data-property="assigned" data-value="false"> - {{t "unassigned"}} - </a> - {{/if}} - </li> - - <li class="divider"></li> - - <li> - <a href="#" data-property="rules" data-value="{{rule}}"> - {{limitString ruleName}} - </a> - </li> - - {{#each tags}} - <li> - <a href="#" data-property="tags" data-value="{{this}}"> - <i class="icon-tags icon-half-transparent"></i> {{this}} - </a> - </li> - {{/each}} - - <li class="divider"></li> - - <li> - <a href="#" data-property="projectUuids" data-value="{{projectUuid}}"> - {{qualifierIcon "TRK"}} {{projectLongName}} - </a> - </li> - - {{#if subProject}} - <li> - <a href="#" data-property="moduleUuids" data-value="{{subProjectUuid}}"> - {{qualifierIcon "BRC"}} {{subProjectLongName}} - </a> - </li> - {{/if}} - - <li> - <a href="#" data-property="fileUuids" data-value="{{componentUuid}}"> - {{qualifierIcon componentQualifier}} {{fileFromPath componentLongName}} - </a> - </li> -</ul> - -<div class="bubble-popup-arrow"></div> diff --git a/server/sonar-web/src/main/js/apps/issues/templates/issues-layout.hbs b/server/sonar-web/src/main/js/apps/issues/templates/issues-layout.hbs deleted file mode 100644 index 83add61b366..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/templates/issues-layout.hbs +++ /dev/null @@ -1,12 +0,0 @@ -<div class="issues search-navigator"> - <div class="search-navigator-side"> - <div class="issues-header"></div> - <div class="search-navigator-facets"></div> - </div> - - <div class="search-navigator-workspace"> - <div class="search-navigator-workspace-header"></div> - <div class="search-navigator-workspace-list"></div> - <div class="issues-workspace-component-viewer"></div> - </div> -</div> diff --git a/server/sonar-web/src/main/js/apps/issues/templates/issues-workspace-header.hbs b/server/sonar-web/src/main/js/apps/issues/templates/issues-workspace-header.hbs deleted file mode 100644 index 6a28b925ad8..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/templates/issues-workspace-header.hbs +++ /dev/null @@ -1,80 +0,0 @@ -<div class="issues-header-component nowrap"> - <div class="component-name"> - {{#if state.component}} - - <div class="component-name-parent"> - <a class="js-back">{{t "issues.return_to_list"}}</a> - </div> - - <div class="component-name-parent"> - {{#if organization}} - <a href="{{link '/organizations/' organization.key}}">{{organization.name}}</a> - <span class="slash-separator"></span> - {{/if}} - {{#with state.component}} - {{#if project}} - <a href="{{dashboardUrl project}}" title="{{projectName}}">{{projectName}}</a> - <span class="slash-separator"></span> - {{/if}} - {{#if subProject}} - <a href="{{dashboardUrl subProject}}" title="{{subProjectName}}">{{subProjectName}}</a> - <span class="slash-separator"></span> - {{/if}} - <a href="{{dashboardUrl key}}" title="{{name}}">{{collapsePath name}}</a> - {{/with}} - </div> - - {{else}} - {{#if state.canBulkChange}} - <a class="js-selection icon-checkbox {{#if allSelected}}icon-checkbox-checked{{/if}} {{#if someSelected}}icon-checkbox-checked icon-checkbox-single{{/if}}" - data-toggle="tooltip" title="{{t 'issues.toggle_selection_tooltip'}}"></a> - {{else}} - - {{/if}} - {{/if}} - </div> -</div> - - -<div class="search-navigator-header-actions"> - {{#notNull state.total}} - {{#unless state.component}} - <div class="issues-header-order">{{t 'issues.ordered'}} <strong>{{t 'issues.by_creation_date'}}</strong></div> - {{/unless}} - <div class="search-navigator-header-pagination flash flash-heavy"> - {{#gt state.total 0}} - <strong> - <span class="current"> - {{sum state.selectedIndex 1}} - / - <span id="issues-total">{{formatMeasure state.total 'INT'}}</span> - </span> - </strong> - {{else}} - <span class="current">0 / <span id="issues-total">0</span></span> - {{/gt}} - {{t 'issues.issues'}} - </div> - {{/notNull}} - - - <div class="search-navigator-header-buttons button-group dropdown"> - <button id="issues-reload" class="js-reload">{{t "reload"}}</button> - <button class="js-new-search" id="issues-new-search">{{t "issue_filter.new_search"}}</button> - {{#if state.canBulkChange}} - <button id="issues-bulk-change" class="dropdown-toggle" data-toggle="dropdown"> - {{t "bulk_change"}} - </button> - <ul class="dropdown-menu dropdown-menu-right"> - <li> - <a class="js-bulk-change" href="#">{{tp 'issues.bulk_change' state.total}}</a> - </li> - {{#gt selectedCount 0}} - <li> - <a class="js-bulk-change-selected" href="#">{{tp 'issues.bulk_change_selected' selectedCount}}</a> - </li> - {{/gt}} - </ul> - {{/if}} - </div> -</div> diff --git a/server/sonar-web/src/main/js/apps/issues/templates/issues-workspace-list-component.hbs b/server/sonar-web/src/main/js/apps/issues/templates/issues-workspace-list-component.hbs deleted file mode 100644 index b0e305ecddb..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/templates/issues-workspace-list-component.hbs +++ /dev/null @@ -1,26 +0,0 @@ -<div class="component-name issues-workspace-list-component"> - {{#if organization}} - <a class="link-no-underline" href="{{link '/organizations/' organization.key}}"> - {{organization.name}} - </a> - <span class="slash-separator"></span> - {{/if}} - - {{#if project}} - <a class="link-no-underline" href="{{dashboardUrl project}}"> - {{projectLongName}} - </a> - <span class="slash-separator"></span> - {{/if}} - - {{#if subProject}} - <a class="link-no-underline" href="{{dashboardUrl subProject}}"> - {{subProjectLongName}} - </a> - <span class="slash-separator"></span> - {{/if}} - - <a class="link-no-underline" href="{{dashboardUrl component}}"> - {{collapsePath componentLongName}} - </a> -</div> diff --git a/server/sonar-web/src/main/js/apps/issues/templates/issues-workspace-list.hbs b/server/sonar-web/src/main/js/apps/issues/templates/issues-workspace-list.hbs deleted file mode 100644 index cae9c964a0b..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/templates/issues-workspace-list.hbs +++ /dev/null @@ -1,5 +0,0 @@ -<div class="js-list"></div> - -<div class="search-navigator-workspace-list-more"> - <span class="js-more"><i class="spinner"></i></span> -</div> diff --git a/server/sonar-web/src/main/js/apps/issues/utils.js b/server/sonar-web/src/main/js/apps/issues/utils.js new file mode 100644 index 00000000000..3c9d8a235ed --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/utils.js @@ -0,0 +1,229 @@ +/* + * 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 { isNil, omitBy } from 'lodash'; +import { searchMembers } from '../../api/organizations'; +import { searchUsers } from '../../api/users'; + +export type RawQuery = { [string]: string }; + +export type Query = {| + assigned: boolean, + assignees: Array<string>, + authors: Array<string>, + createdAfter: string, + createdAt: string, + createdBefore: string, + createdInLast: string, + directories: Array<string>, + facetMode: string, + files: Array<string>, + issues: Array<string>, + languages: Array<string>, + modules: Array<string>, + projects: Array<string>, + resolved: boolean, + resolutions: Array<string>, + rules: Array<string>, + severities: Array<string>, + sinceLeakPeriod: boolean, + statuses: Array<string>, + tags: Array<string>, + types: Array<string> +|}; + +export type Paging = { + pageIndex: number, + pageSize: number, + total: number +}; + +const parseAsBoolean = (value: ?string, defaultValue: boolean = true): boolean => + value === 'false' ? false : value === 'true' ? true : defaultValue; + +const parseAsString = (value: ?string): string => value || ''; + +const parseAsStringArray = (value: ?string): Array<string> => value ? value.split(',') : []; + +const parseAsFacetMode = (facetMode: string) => + facetMode === 'debt' || facetMode === 'effort' ? 'effort' : 'count'; + +export const parseQuery = (query: RawQuery): Query => ({ + assigned: parseAsBoolean(query.assigned), + assignees: parseAsStringArray(query.assignees), + authors: parseAsStringArray(query.authors), + createdAfter: parseAsString(query.createdAfter), + createdAt: parseAsString(query.createdAt), + createdBefore: parseAsString(query.createdBefore), + createdInLast: parseAsString(query.createdInLast), + directories: parseAsStringArray(query.directories), + facetMode: parseAsFacetMode(query.facetMode), + files: parseAsStringArray(query.fileUuids), + issues: parseAsStringArray(query.issues), + languages: parseAsStringArray(query.languages), + modules: parseAsStringArray(query.moduleUuids), + projects: parseAsStringArray(query.projectUuids), + resolved: parseAsBoolean(query.resolved), + resolutions: parseAsStringArray(query.resolutions), + rules: parseAsStringArray(query.rules), + severities: parseAsStringArray(query.severities), + sinceLeakPeriod: parseAsBoolean(query.sinceLeakPeriod, false), + statuses: parseAsStringArray(query.statuses), + tags: parseAsStringArray(query.tags), + types: parseAsStringArray(query.types) +}); + +export const getOpen = (query: RawQuery) => query.open; + +export const areMyIssuesSelected = (query: RawQuery): boolean => query.myIssues === 'true'; + +const serializeString = (value: string): ?string => value || undefined; + +const serializeValue = (value: Array<string>): ?string => value.length ? value.join() : undefined; + +export const serializeQuery = (query: Query): RawQuery => { + const filter = { + assigned: query.assigned ? undefined : 'false', + assignees: serializeValue(query.assignees), + authors: serializeValue(query.authors), + createdAfter: serializeString(query.createdAfter), + createdAt: serializeString(query.createdAt), + createdBefore: serializeString(query.createdBefore), + createdInLast: serializeString(query.createdInLast), + directories: serializeValue(query.directories), + facetMode: query.facetMode === 'effort' ? serializeString(query.facetMode) : undefined, + fileUuids: serializeValue(query.files), + issues: serializeValue(query.issues), + languages: serializeValue(query.languages), + moduleUuids: serializeValue(query.modules), + projectUuids: serializeValue(query.projects), + resolved: query.resolved ? undefined : 'false', + resolutions: serializeValue(query.resolutions), + severities: serializeValue(query.severities), + sinceLeakPeriod: query.sinceLeakPeriod ? 'true' : undefined, + statuses: serializeValue(query.statuses), + rules: serializeValue(query.rules), + tags: serializeValue(query.tags), + types: serializeValue(query.types) + }; + return omitBy(filter, isNil); +}; + +const areArraysEqual = (a: Array<string>, b: Array<string>) => { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; +}; + +export const areQueriesEqual = (a: RawQuery, b: RawQuery) => { + const parsedA: Query = parseQuery(a); + const parsedB: Query = parseQuery(b); + + const keysA = Object.keys(parsedA); + const keysB = Object.keys(parsedB); + + if (keysA.length !== keysB.length) { + return false; + } + + return keysA.every( + key => + Array.isArray(parsedA[key]) && Array.isArray(parsedB[key]) + ? areArraysEqual(parsedA[key], parsedB[key]) + : parsedA[key] === parsedB[key] + ); +}; + +type RawFacet = { + property: string, + values: Array<{ val: string, count: number }> +}; + +export type Facet = { [string]: number }; + +export const parseFacets = (facets: Array<RawFacet>): { [string]: Facet } => { + // for readability purpose + const propertyMapping = { + fileUuids: 'files', + moduleUuids: 'modules', + projectUuids: 'projects' + }; + + const result = {}; + facets.forEach(facet => { + const values = {}; + facet.values.forEach(value => { + values[value.val] = value.count; + }); + const finalProperty = propertyMapping[facet.property] || facet.property; + result[finalProperty] = values; + }); + return result; +}; + +export type ReferencedComponent = { + key: string, + name: string, + organization: string, + path: string +}; + +export type ReferencedUser = { + avatar: string, + name: string +}; + +export type ReferencedLanguage = { + name: string +}; + +export type Component = { + key: string, + organization: string, + qualifier: string +}; + +export type CurrentUser = + | { isLoggedIn: false } + | { isLoggedIn: true, email?: string, login: string, name: string }; + +export const searchAssignees = (query: string, component?: Component) => { + return component + ? searchMembers({ organization: component.organization, ps: 50, q: query }).then(response => + response.users.map(user => ({ + avatar: user.avatar, + label: user.name, + value: user.login + }))) + : searchUsers(query, 50).then(response => + response.users.map(user => ({ + // TODO this WS returns no avatar + avatar: user.avatar, + email: user.email, + label: user.name, + value: user.login + }))); +}; diff --git a/server/sonar-web/src/main/js/apps/issues/workspace-header-view.js b/server/sonar-web/src/main/js/apps/issues/workspace-header-view.js deleted file mode 100644 index 0fccbb3ab65..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/workspace-header-view.js +++ /dev/null @@ -1,151 +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 WorkspaceHeaderView from '../../components/navigator/workspace-header-view'; -import BulkChangeForm from './BulkChangeForm'; -import Template from './templates/issues-workspace-header.hbs'; -import { getOrganization, areThereCustomOrganizations } from '../../store/organizations/utils'; - -export default WorkspaceHeaderView.extend({ - template: Template, - - initialize() { - this.context = { - isContext: this.options.app.state.get('isContext'), - organization: this.options.app.state.get('contextOrganization') - }; - }, - - events() { - return { - ...WorkspaceHeaderView.prototype.events.apply(this, arguments), - 'click .js-selection': 'onSelectionClick', - 'click .js-back': 'returnToList', - 'click .js-new-search': 'newSearch', - 'click .js-bulk-change-selected': 'onBulkChangeSelectedClick' - }; - }, - - onSelectionClick(e) { - e.preventDefault(); - this.toggleSelection(); - }, - - onBulkChangeSelectedClick(e) { - e.preventDefault(); - this.bulkChangeSelected(); - }, - - afterBulkChange() { - const selectedIndex = this.options.app.state.get('selectedIndex'); - const selectedKeys = this.options.app.list.where({ selected: true }).map(item => item.id); - this.options.app.controller.fetchList().done(() => { - this.options.app.state.set({ selectedIndex }); - this.options.app.list.selectByKeys(selectedKeys); - }); - }, - - render() { - if (!this._suppressUpdate) { - WorkspaceHeaderView.prototype.render.apply(this, arguments); - } - }, - - toggleSelection() { - this._suppressUpdate = true; - const selectedCount = this.options.app.list.where({ selected: true }).length; - const someSelected = selectedCount > 0; - return someSelected ? this.selectNone() : this.selectAll(); - }, - - selectNone() { - this.options.app.list.where({ selected: true }).forEach(issue => { - issue.set({ selected: false }); - }); - this._suppressUpdate = false; - this.render(); - }, - - selectAll() { - this.options.app.list.forEach(issue => { - issue.set({ selected: true }); - }); - this._suppressUpdate = false; - this.render(); - }, - - returnToList() { - this.options.app.controller.closeComponentViewer(); - }, - - newSearch() { - this.options.app.controller.newSearch(); - }, - - bulkChange() { - const query = this.options.app.controller.getQueryAsObject(); - new BulkChangeForm({ - query, - context: this.context, - onChange: () => this.afterBulkChange() - }).render(); - }, - - bulkChangeSelected() { - const selected = this.options.app.list.where({ selected: true }); - const selectedKeys = selected.map(item => item.id).slice(0, 500); - const query = { issues: selectedKeys.join() }; - new BulkChangeForm({ - query, - context: this.context, - onChange: () => this.afterBulkChange() - }).render(); - }, - - serializeData() { - const issuesCount = this.options.app.list.length; - const selectedCount = this.options.app.list.where({ selected: true }).length; - const allSelected = issuesCount > 0 && issuesCount === selectedCount; - const someSelected = !allSelected && selectedCount > 0; - const data = { - ...WorkspaceHeaderView.prototype.serializeData.apply(this, arguments), - selectedCount, - allSelected, - someSelected - }; - const component = this.options.app.state.get('component'); - if (component) { - const qualifier = this.options.app.state.get('contextComponentQualifier'); - if (qualifier === 'VW' || qualifier === 'SVW') { - // do nothing - } else if (qualifier === 'TRK') { - data.state.component.project = null; - } else if (qualifier === 'BRC') { - data.state.component.project = null; - data.state.component.subProject = null; - } else { - const organization = areThereCustomOrganizations() - ? getOrganization(component.projectOrganization) - : null; - Object.assign(data, { organization }); - } - } - return data; - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/workspace-list-item-view.js b/server/sonar-web/src/main/js/apps/issues/workspace-list-item-view.js deleted file mode 100644 index ec59af550c0..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/workspace-list-item-view.js +++ /dev/null @@ -1,162 +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 React from 'react'; -import { render, unmountComponentAtNode } from 'react-dom'; -import Marionette from 'backbone.marionette'; -import ConnectedIssue from '../../components/issue/ConnectedIssue'; -import IssueFilterView from './issue-filter-view'; -import WithStore from '../../components/shared/WithStore'; -import getStore from '../../app/utils/getStore'; -import { getIssueByKey } from '../../store/rootReducer'; - -const SHOULD_NULL = { - any: ['issues'], - resolutions: ['resolved'], - resolved: ['resolutions'], - assignees: ['assigned'], - assigned: ['assignees'] -}; - -export default Marionette.ItemView.extend({ - className: 'issues-workspace-list-item', - - initialize(options) { - this.openComponentViewer = this.openComponentViewer.bind(this); - this.onIssueFilterClick = this.onIssueFilterClick.bind(this); - this.onIssueCheck = this.onIssueCheck.bind(this); - this.listenTo(options.app.state, 'change:selectedIndex', this.showIssue); - this.listenTo(this.model, 'change:selected', this.showIssue); - this.subscribeToStore(); - }, - - template() { - return '<div></div>'; - }, - - subscribeToStore() { - const store = getStore(); - store.subscribe(() => { - const issue = getIssueByKey(store.getState(), this.model.get('key')); - this.model.set(issue); - }); - }, - - onRender() { - this.showIssue(); - }, - - onDestroy() { - unmountComponentAtNode(this.el); - }, - - showIssue() { - const selected = this.model.get('index') === this.options.app.state.get('selectedIndex'); - - render( - <WithStore> - <ConnectedIssue - issueKey={this.model.get('key')} - checked={this.model.get('selected')} - onCheck={this.onIssueCheck} - onClick={this.openComponentViewer} - onFilterClick={this.onIssueFilterClick} - selected={selected} - /> - </WithStore>, - this.el - ); - }, - - onIssueFilterClick(e) { - const that = this; - e.preventDefault(); - e.stopPropagation(); - $('body').click(); - this.popup = new IssueFilterView({ - triggerEl: $(e.currentTarget), - bottomRight: true, - model: this.model - }); - this.popup.on('select', (property, value) => { - const obj = {}; - obj[property] = '' + value; - SHOULD_NULL.any.forEach(p => { - obj[p] = null; - }); - if (SHOULD_NULL[property] != null) { - SHOULD_NULL[property].forEach(p => { - obj[p] = null; - }); - } - that.options.app.state.updateFilter(obj); - that.popup.destroy(); - }); - this.popup.render(); - }, - - onIssueCheck(e) { - e.preventDefault(); - e.stopPropagation(); - this.model.set({ selected: !this.model.get('selected') }); - const selected = this.model.collection.where({ selected: true }).length; - this.options.app.state.set({ selected }); - }, - - changeSelection() { - const selected = this.model.get('index') === this.options.app.state.get('selectedIndex'); - if (selected) { - this.select(); - } else { - this.unselect(); - } - }, - - selectCurrent() { - this.options.app.state.set({ selectedIndex: this.model.get('index') }); - }, - - resetIssue(options) { - const that = this; - const key = this.model.get('key'); - const componentUuid = this.model.get('componentUuid'); - const index = this.model.get('index'); - const selected = this.model.get('selected'); - this.model.reset( - { - key, - componentUuid, - index, - selected - }, - { silent: true } - ); - return this.model.fetch(options).done(() => that.trigger('reset')); - }, - - openComponentViewer() { - this.options.app.state.set({ selectedIndex: this.model.get('index') }); - if (this.options.app.state.has('component')) { - return this.options.app.controller.closeComponentViewer(); - } else { - return this.options.app.controller.showComponentViewer(this.model); - } - } -}); diff --git a/server/sonar-web/src/main/js/apps/issues/workspace-list-view.js b/server/sonar-web/src/main/js/apps/issues/workspace-list-view.js deleted file mode 100644 index f74b7c912b2..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/workspace-list-view.js +++ /dev/null @@ -1,125 +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 WorkspaceListView from '../../components/navigator/workspace-list-view'; -import IssueView from './workspace-list-item-view'; -import EmptyView from './workspace-list-empty-view'; -import Template from './templates/issues-workspace-list.hbs'; -import ComponentTemplate from './templates/issues-workspace-list-component.hbs'; -import { getOrganization, areThereCustomOrganizations } from '../../store/organizations/utils'; - -const COMPONENT_HEIGHT = 29; -const BOTTOM_OFFSET = 60; - -export default WorkspaceListView.extend({ - template: Template, - componentTemplate: ComponentTemplate, - childView: IssueView, - childViewContainer: '.js-list', - emptyView: EmptyView, - - bindShortcuts() { - const that = this; - WorkspaceListView.prototype.bindShortcuts.apply(this, arguments); - key('right', 'list', () => { - const selectedIssue = that.collection.at(that.options.app.state.get('selectedIndex')); - that.options.app.controller.showComponentViewer(selectedIssue); - return false; - }); - key('space', 'list', () => { - const selectedIssue = that.collection.at(that.options.app.state.get('selectedIndex')); - selectedIssue.set({ selected: !selectedIssue.get('selected') }); - return false; - }); - }, - - unbindShortcuts() { - WorkspaceListView.prototype.unbindShortcuts.apply(this, arguments); - key.unbind('right', 'list'); - key.unbind('space', 'list'); - }, - - scrollTo() { - const selectedIssue = this.collection.at(this.options.app.state.get('selectedIndex')); - if (selectedIssue == null) { - return; - } - const selectedIssueView = this.children.findByModel(selectedIssue); - const parentTopOffset = this.$el.offset().top; - let viewTop = selectedIssueView.$el.offset().top - parentTopOffset; - if (selectedIssueView.$el.prev().is('.issues-workspace-list-component')) { - viewTop -= COMPONENT_HEIGHT; - } - const viewBottom = selectedIssueView.$el.offset().top + - selectedIssueView.$el.outerHeight() + - BOTTOM_OFFSET; - const windowTop = $(window).scrollTop(); - const windowBottom = windowTop + $(window).height(); - if (viewTop < windowTop) { - $(window).scrollTop(viewTop); - } - if (viewBottom > windowBottom) { - $(window).scrollTop($(window).scrollTop() - windowBottom + viewBottom); - } - }, - - attachHtml(compositeView, childView, index) { - const container = this.getChildViewContainer(compositeView); - const model = this.collection.at(index); - if (model != null) { - const prev = index > 0 && this.collection.at(index - 1); - let putComponent = !prev; - if (prev) { - const fullComponent = [model.get('project'), model.get('component')].join(' '); - const fullPrevComponent = [prev.get('project'), prev.get('component')].join(' '); - if (fullComponent !== fullPrevComponent) { - putComponent = true; - } - } - if (putComponent) { - this.displayComponent(container, model); - } - } - container.append(childView.el); - }, - - displayComponent(container, model) { - const data = { ...model.toJSON() }; - const qualifier = this.options.app.state.get('contextComponentQualifier'); - if (qualifier === 'VW' || qualifier === 'SVW') { - Object.assign(data, { organization: undefined }); - } else if (qualifier === 'TRK') { - Object.assign(data, { organization: undefined, project: undefined }); - } else if (qualifier === 'BRC') { - Object.assign(data, { organization: undefined, project: undefined, subProject: undefined }); - } else { - const organization = areThereCustomOrganizations() - ? getOrganization(model.get('projectOrganization')) - : null; - Object.assign(data, { organization }); - } - container.append(this.componentTemplate(data)); - }, - - destroyChildren() { - WorkspaceListView.prototype.destroyChildren.apply(this, arguments); - this.$('.issues-workspace-list-component').remove(); - } -}); diff --git a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationFavoriteProjects.js b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationFavoriteProjects.js index bf1b7d89862..1fba6f72119 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationFavoriteProjects.js +++ b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationFavoriteProjects.js @@ -51,7 +51,7 @@ class OrganizationFavoriteProjects extends React.Component { render() { return ( - <div id="projects-page" className="page page-limited"> + <div id="projects-page"> <Helmet title={translate('projects.page')} titleTemplate="%s - SonarQube" /> <FavoriteProjectsContainer location={this.props.location} diff --git a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationProjects.js b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationProjects.js index 77eabf062da..67dbdf8bcd4 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationProjects.js +++ b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationProjects.js @@ -51,7 +51,7 @@ class OrganizationProjects extends React.Component { render() { return ( - <div id="projects-page" className="page page-limited"> + <div id="projects-page"> <Helmet title={translate('projects.page')} titleTemplate="%s - SonarQube" /> <AllProjectsContainer isFavorite={false} diff --git a/server/sonar-web/src/main/js/apps/overview/qualityGate/__tests__/__snapshots__/QualityGateCondition-test.js.snap b/server/sonar-web/src/main/js/apps/overview/qualityGate/__tests__/__snapshots__/QualityGateCondition-test.js.snap index d04badd1faf..b29c17b3a5c 100644 --- a/server/sonar-web/src/main/js/apps/overview/qualityGate/__tests__/__snapshots__/QualityGateCondition-test.js.snap +++ b/server/sonar-web/src/main/js/apps/overview/qualityGate/__tests__/__snapshots__/QualityGateCondition-test.js.snap @@ -47,7 +47,18 @@ exports[`test new_reliability_rating 1`] = ` className="overview-quality-gate-condition overview-quality-gate-condition-error" onlyActiveOnIndex={false} style={Object {}} - to="/component_issues?id=abcd-key#resolved=false|types=BUG|severities=BLOCKER%2CCRITICAL%2CMAJOR%2CMINOR|sinceLeakPeriod=true"> + to={ + Object { + "pathname": "/project/issues", + "query": Object { + "id": "abcd-key", + "resolved": "false", + "severities": "BLOCKER,CRITICAL,MAJOR,MINOR", + "sinceLeakPeriod": "true", + "types": "BUG", + }, + } + }> <div className="overview-quality-gate-condition-container"> <div @@ -91,7 +102,18 @@ exports[`test new_security_rating 1`] = ` className="overview-quality-gate-condition overview-quality-gate-condition-error" onlyActiveOnIndex={false} style={Object {}} - to="/component_issues?id=abcd-key#resolved=false|types=VULNERABILITY|severities=BLOCKER%2CCRITICAL%2CMAJOR%2CMINOR|sinceLeakPeriod=true"> + to={ + Object { + "pathname": "/project/issues", + "query": Object { + "id": "abcd-key", + "resolved": "false", + "severities": "BLOCKER,CRITICAL,MAJOR,MINOR", + "sinceLeakPeriod": "true", + "types": "VULNERABILITY", + }, + } + }> <div className="overview-quality-gate-condition-container"> <div @@ -135,7 +157,17 @@ exports[`test new_sqale_rating 1`] = ` className="overview-quality-gate-condition overview-quality-gate-condition-error" onlyActiveOnIndex={false} style={Object {}} - to="/component_issues?id=abcd-key#resolved=false|types=CODE_SMELL|sinceLeakPeriod=true"> + to={ + Object { + "pathname": "/project/issues", + "query": Object { + "id": "abcd-key", + "resolved": "false", + "sinceLeakPeriod": "true", + "types": "CODE_SMELL", + }, + } + }> <div className="overview-quality-gate-condition-container"> <div @@ -223,7 +255,17 @@ exports[`test reliability_rating 1`] = ` className="overview-quality-gate-condition overview-quality-gate-condition-error" onlyActiveOnIndex={false} style={Object {}} - to="/component_issues?id=abcd-key#resolved=false|types=BUG|severities=BLOCKER%2CCRITICAL%2CMAJOR%2CMINOR"> + to={ + Object { + "pathname": "/project/issues", + "query": Object { + "id": "abcd-key", + "resolved": "false", + "severities": "BLOCKER,CRITICAL,MAJOR,MINOR", + "types": "BUG", + }, + } + }> <div className="overview-quality-gate-condition-container"> <div @@ -267,7 +309,17 @@ exports[`test security_rating 1`] = ` className="overview-quality-gate-condition overview-quality-gate-condition-error" onlyActiveOnIndex={false} style={Object {}} - to="/component_issues?id=abcd-key#resolved=false|types=VULNERABILITY|severities=BLOCKER%2CCRITICAL%2CMAJOR%2CMINOR"> + to={ + Object { + "pathname": "/project/issues", + "query": Object { + "id": "abcd-key", + "resolved": "false", + "severities": "BLOCKER,CRITICAL,MAJOR,MINOR", + "types": "VULNERABILITY", + }, + } + }> <div className="overview-quality-gate-condition-container"> <div @@ -311,7 +363,16 @@ exports[`test sqale_rating 1`] = ` className="overview-quality-gate-condition overview-quality-gate-condition-error" onlyActiveOnIndex={false} style={Object {}} - to="/component_issues?id=abcd-key#resolved=false|types=CODE_SMELL"> + to={ + Object { + "pathname": "/project/issues", + "query": Object { + "id": "abcd-key", + "resolved": "false", + "types": "CODE_SMELL", + }, + } + }> <div className="overview-quality-gate-condition-container"> <div diff --git a/server/sonar-web/src/main/js/apps/permission-templates/components/ActionsCell.js b/server/sonar-web/src/main/js/apps/permission-templates/components/ActionsCell.js index 2d97ab9b996..9fd4ffc417e 100644 --- a/server/sonar-web/src/main/js/apps/permission-templates/components/ActionsCell.js +++ b/server/sonar-web/src/main/js/apps/permission-templates/components/ActionsCell.js @@ -22,7 +22,7 @@ import { Link } from 'react-router'; import { difference } from 'lodash'; import Backbone from 'backbone'; import { PermissionTemplateType, CallbackType } from '../propTypes'; -import QualifierIcon from '../../../components/shared/qualifier-icon'; +import QualifierIcon from '../../../components/shared/QualifierIcon'; import UpdateView from '../views/UpdateView'; import DeleteView from '../views/DeleteView'; import { translate } from '../../../helpers/l10n'; diff --git a/server/sonar-web/src/main/js/apps/project-admin/key/FineGrainedUpdate.js b/server/sonar-web/src/main/js/apps/project-admin/key/FineGrainedUpdate.js index 7928cb05f4c..bb722731c4f 100644 --- a/server/sonar-web/src/main/js/apps/project-admin/key/FineGrainedUpdate.js +++ b/server/sonar-web/src/main/js/apps/project-admin/key/FineGrainedUpdate.js @@ -19,7 +19,7 @@ */ import React from 'react'; import UpdateKeyForm from './UpdateKeyForm'; -import QualifierIcon from '../../../components/shared/qualifier-icon'; +import QualifierIcon from '../../../components/shared/QualifierIcon'; export default class FineGrainedUpdate extends React.Component { render() { diff --git a/server/sonar-web/src/main/js/apps/projects-admin/projects.js b/server/sonar-web/src/main/js/apps/projects-admin/projects.js index 88c0940368d..f3984b0cbdd 100644 --- a/server/sonar-web/src/main/js/apps/projects-admin/projects.js +++ b/server/sonar-web/src/main/js/apps/projects-admin/projects.js @@ -23,7 +23,7 @@ import { Link } from 'react-router'; import { getComponentPermissionsUrl } from '../../helpers/urls'; import ApplyTemplateView from '../permissions/project/views/ApplyTemplateView'; import Checkbox from '../../components/controls/Checkbox'; -import QualifierIcon from '../../components/shared/qualifier-icon'; +import QualifierIcon from '../../components/shared/QualifierIcon'; import { translate } from '../../helpers/l10n'; export default class Projects extends React.Component { diff --git a/server/sonar-web/src/main/js/apps/projects/components/AllProjects.js b/server/sonar-web/src/main/js/apps/projects/components/AllProjects.js index ec5299b31a8..c62d9e8c612 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/AllProjects.js +++ b/server/sonar-web/src/main/js/apps/projects/components/AllProjects.js @@ -24,6 +24,11 @@ import ProjectsListFooterContainer from './ProjectsListFooterContainer'; import PageSidebar from './PageSidebar'; import VisualizationsContainer from '../visualizations/VisualizationsContainer'; import { parseUrlQuery } from '../store/utils'; +import Page from '../../../components/layout/Page'; +import PageMain from '../../../components/layout/PageMain'; +import PageMainInner from '../../../components/layout/PageMainInner'; +import PageSide from '../../../components/layout/PageSide'; +import PageFilters from '../../../components/layout/PageFilters'; import '../styles.css'; export default class AllProjects extends React.Component { @@ -95,38 +100,41 @@ export default class AllProjects extends React.Component { const top = this.props.organization ? 95 : 30; return ( - <div className="page-with-sidebar page-with-left-sidebar projects-page"> - <aside className="page-sidebar-fixed page-sidebar-sticky projects-sidebar"> - <div className="page-sidebar-sticky-inner" style={{ top }}> + <Page className="projects-page"> + <PageSide top={top}> + <PageFilters> <PageSidebar query={query} isFavorite={this.props.isFavorite} organization={this.props.organization} /> - </div> - </aside> - <div className="page-main"> - <PageHeaderContainer onViewChange={this.handleViewChange} view={view} /> - {view === 'list' && - <ProjectsListContainer - isFavorite={this.props.isFavorite} - isFiltered={isFiltered} - organization={this.props.organization} - />} - {view === 'list' && - <ProjectsListFooterContainer - query={query} - isFavorite={this.props.isFavorite} - organization={this.props.organization} - />} - {view === 'visualizations' && - <VisualizationsContainer - onVisualizationChange={this.handleVisualizationChange} - sort={query.sort} - visualization={visualization} - />} - </div> - </div> + </PageFilters> + </PageSide> + + <PageMain> + <PageMainInner> + <PageHeaderContainer onViewChange={this.handleViewChange} view={view} /> + {view === 'list' && + <ProjectsListContainer + isFavorite={this.props.isFavorite} + isFiltered={isFiltered} + organization={this.props.organization} + />} + {view === 'list' && + <ProjectsListFooterContainer + query={query} + isFavorite={this.props.isFavorite} + organization={this.props.organization} + />} + {view === 'visualizations' && + <VisualizationsContainer + onVisualizationChange={this.handleVisualizationChange} + sort={query.sort} + visualization={visualization} + />} + </PageMainInner> + </PageMain> + </Page> ); } } diff --git a/server/sonar-web/src/main/js/apps/projects/components/App.js b/server/sonar-web/src/main/js/apps/projects/components/App.js index c5c3b94bda1..69c18380bb3 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/App.js +++ b/server/sonar-web/src/main/js/apps/projects/components/App.js @@ -32,7 +32,7 @@ export default class App extends React.Component { render() { return ( - <div id="projects-page" className="page page-limited"> + <div id="projects-page"> <Helmet title={translate('projects.page')} titleTemplate="%s - SonarQube" /> {this.props.children} </div> diff --git a/server/sonar-web/src/main/js/apps/projects/components/PageSidebar.js b/server/sonar-web/src/main/js/apps/projects/components/PageSidebar.js index ed924559a85..d102a9dcef4 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/PageSidebar.js +++ b/server/sonar-web/src/main/js/apps/projects/components/PageSidebar.js @@ -55,14 +55,14 @@ export default class PageSidebar extends React.PureComponent { : undefined; return ( - <div className="search-navigator-facets-list"> + <div> <FavoriteFilterContainer organization={this.props.organization} /> <div className="projects-facets-header clearfix"> {isFiltered && <div className="projects-facets-reset"> <Link to={{ pathname, query: linkQuery }} className="button button-red"> - {translate('projects.clear_all_filters')} + {translate('clear_all_filters')} </Link> </div>} diff --git a/server/sonar-web/src/main/js/apps/projects/components/ProjectsList.js b/server/sonar-web/src/main/js/apps/projects/components/ProjectsList.js index 1c4cae090f2..d692e2ca9ce 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/ProjectsList.js +++ b/server/sonar-web/src/main/js/apps/projects/components/ProjectsList.js @@ -19,9 +19,9 @@ */ import React from 'react'; import ProjectCardContainer from './ProjectCardContainer'; -import NoProjects from './NoProjects'; import NoFavoriteProjects from './NoFavoriteProjects'; import EmptyInstance from './EmptyInstance'; +import EmptySearch from '../../../components/common/EmptySearch'; export default class ProjectsList extends React.PureComponent { static propTypes = { @@ -37,7 +37,7 @@ export default class ProjectsList extends React.PureComponent { } else if (!this.props.isFiltered) { return <EmptyInstance />; } else { - return <NoProjects />; + return <EmptySearch />; } } diff --git a/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/PageSidebar-test.js.snap b/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/PageSidebar-test.js.snap index 211b0fbafb1..faf96ac7d0a 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/PageSidebar-test.js.snap +++ b/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/PageSidebar-test.js.snap @@ -16,7 +16,7 @@ exports[`test should handle \`view\` and \`visualization\` 2`] = ` }, } }> - projects.clear_all_filters + clear_all_filters </Link> </div> `; diff --git a/server/sonar-web/src/main/js/apps/projects/styles.css b/server/sonar-web/src/main/js/apps/projects/styles.css index 45f8cd2663b..99e3232f7d0 100644 --- a/server/sonar-web/src/main/js/apps/projects/styles.css +++ b/server/sonar-web/src/main/js/apps/projects/styles.css @@ -10,14 +10,6 @@ margin-bottom: 0; } -.projects-empty-list { - padding: 60px 0; - border: 1px solid #e6e6e6; - border-radius: 2px; - text-align: center; - color: #777; -} - .project-card { position: relative; min-height: 121px; diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileProjects.js b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileProjects.js index 6d877d785fa..171d4ab8e0d 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileProjects.js +++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileProjects.js @@ -21,7 +21,7 @@ import React from 'react'; import { Link } from 'react-router'; import ChangeProjectsView from '../views/ChangeProjectsView'; -import QualifierIcon from '../../../components/shared/qualifier-icon'; +import QualifierIcon from '../../../components/shared/QualifierIcon'; import { getProfileProjects } from '../../../api/quality-profiles'; import { translate } from '../../../helpers/l10n'; import type { Profile } from '../propTypes'; 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/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/DeleteCommentView.js b/server/sonar-web/src/main/js/components/issue/views/DeleteCommentView.js deleted file mode 100644 index 3360de2f416..00000000000 --- a/server/sonar-web/src/main/js/components/issue/views/DeleteCommentView.js +++ /dev/null @@ -1,34 +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 PopupView from '../../common/popup'; -import Template from '../templates/DeleteComment.hbs'; - -export default PopupView.extend({ - template: Template, - - events: { - 'click button': 'handleSubmit' - }, - - handleSubmit(e) { - e.preventDefault(); - this.options.onDelete(); - } -}); 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/changelog-view.js b/server/sonar-web/src/main/js/components/issue/views/changelog-view.js deleted file mode 100644 index b04b56abd6a..00000000000 --- a/server/sonar-web/src/main/js/components/issue/views/changelog-view.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. - */ -import PopupView from '../../common/popup'; -import Template from '../templates/issue-changelog.hbs'; - -export default PopupView.extend({ - template: Template, - - collectionEvents: { - sync: 'render' - }, - - serializeData() { - return { - ...PopupView.prototype.serializeData.apply(this, arguments), - issue: this.options.issue.toJSON() - }; - } -}); 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/issue-popup.js b/server/sonar-web/src/main/js/components/issue/views/issue-popup.js deleted file mode 100644 index 96488cd1e45..00000000000 --- a/server/sonar-web/src/main/js/components/issue/views/issue-popup.js +++ /dev/null @@ -1,46 +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 PopupView from '../../common/popup'; - -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(); - }, - - onDestroy() { - this.options.view.destroy(); - }, - - attachCloseEvents() {} -}); 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/helpers/handlebars/componentIssuesPermalink.js b/server/sonar-web/src/main/js/components/layout/PageFilters.js index cd9aaf55d66..f969366de69 100644 --- a/server/sonar-web/src/main/js/helpers/handlebars/componentIssuesPermalink.js +++ b/server/sonar-web/src/main/js/components/layout/PageFilters.js @@ -17,6 +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. */ -module.exports = function(componentKey) { - return window.baseUrl + '/component_issues/index?id=' + encodeURIComponent(componentKey); +// @flow +import React from 'react'; +import { css } from 'glamor'; + +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/layout/PageMain.js b/server/sonar-web/src/main/js/components/layout/PageMain.js new file mode 100644 index 00000000000..6195a1f651a --- /dev/null +++ b/server/sonar-web/src/main/js/components/layout/PageMain.js @@ -0,0 +1,34 @@ +/* + * 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'; + +type Props = { + children?: React.Element<*> +}; + +const PageMain = (props: Props) => ( + <div className={css({ flexGrow: 1, minWidth: 740, padding: 20 })}> + {props.children} + </div> +); + +export default PageMain; diff --git a/server/sonar-web/src/main/js/apps/issues/workspace-list-empty-view.js b/server/sonar-web/src/main/js/components/layout/PageMainInner.js index eea72dc3e28..41beed6518f 100644 --- a/server/sonar-web/src/main/js/apps/issues/workspace-list-empty-view.js +++ b/server/sonar-web/src/main/js/components/layout/PageMainInner.js @@ -17,13 +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 Marionette from 'backbone.marionette'; -import { translate } from '../../helpers/l10n'; +// @flow +import React from 'react'; +import { css } from 'glamor'; -export default Marionette.ItemView.extend({ - className: 'search-navigator-no-results', +type Props = { + children?: React.Element<*> +}; - template() { - return translate('issue_filter.no_issues'); - } -}); +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/apps/projects/components/NoProjects.js b/server/sonar-web/src/main/js/components/shared/QualifierIcon.js index c2194ad5409..82ed9f7e5e1 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/NoProjects.js +++ b/server/sonar-web/src/main/js/components/shared/QualifierIcon.js @@ -18,15 +18,26 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import React from 'react'; -import { translate } from '../../../helpers/l10n'; +import classNames from 'classnames'; + +type Props = { + className?: string, + qualifier: ?string +}; + +export default class QualifierIcon extends React.PureComponent { + props: Props; -export default class NoProjects extends React.Component { render() { - return ( - <div className="projects-empty-list"> - <h3>{translate('projects.no_projects.1')}</h3> - <p className="big-spacer-top">{translate('projects.no_projects.2')}</p> - </div> + 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/apps/issues/facets/mode-facet.js b/server/sonar-web/src/main/js/components/shared/__tests__/QualifierIcon-test.js index dd54e82c8d2..857ccbaf450 100644 --- a/server/sonar-web/src/main/js/apps/issues/facets/mode-facet.js +++ b/server/sonar-web/src/main/js/components/shared/__tests__/QualifierIcon-test.js @@ -17,24 +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 $ from 'jquery'; -import BaseFacet from './base-facet'; -import Template from '../templates/facets/issues-mode-facet.hbs'; +import React from 'react'; +import { shallow } from 'enzyme'; +import QualifierIcon from '../QualifierIcon'; -export default BaseFacet.extend({ - template: Template, +it('should render icon', () => { + expect(shallow(<QualifierIcon qualifier="TRK" />)).toMatchSnapshot(); + expect(shallow(<QualifierIcon qualifier="trk" />)).toMatchSnapshot(); +}); - toggleFacet(e) { - const isCount = $(e.currentTarget).is('[data-value="count"]'); - return this.options.app.state.updateFilter({ - facetMode: isCount ? 'count' : 'effort' - }); - }, +it('should not render icon', () => { + expect(shallow(<QualifierIcon qualifier={null} />)).toMatchSnapshot(); +}); - serializeData() { - return { - ...BaseFacet.prototype.serializeData.apply(this, arguments), - mode: this.options.app.state.getFacetMode() - }; - } +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" /> +`; diff --git a/server/sonar-web/src/main/js/helpers/__tests__/issues-test.js b/server/sonar-web/src/main/js/helpers/__tests__/issues-test.js new file mode 100644 index 00000000000..04654471d59 --- /dev/null +++ b/server/sonar-web/src/main/js/helpers/__tests__/issues-test.js @@ -0,0 +1,61 @@ +/* + * 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 { parseIssueFromResponse } from '../issues'; + +it('should populate comments data', () => { + const users = [ + { + active: true, + avatar: 'c1244e6857f7be3dc4549d9e9d51c631', + login: 'admin', + name: 'Admin Admin' + } + ]; + const issue = { + comments: [ + { + createdAt: '2017-04-11T10:38:09+0200', + htmlText: 'comment!', + key: 'AVtcKbZkQmGLa7yW8J71', + login: 'admin', + markdown: 'comment!', + updatable: true + } + ] + }; + expect(parseIssueFromResponse(issue, undefined, users, undefined)).toEqual({ + comments: [ + { + author: 'admin', + authorActive: true, + authorAvatar: 'c1244e6857f7be3dc4549d9e9d51c631', + authorLogin: 'admin', + authorName: 'Admin Admin', + createdAt: '2017-04-11T10:38:09+0200', + htmlText: 'comment!', + key: 'AVtcKbZkQmGLa7yW8J71', + login: undefined, + markdown: 'comment!', + updatable: true + } + ] + }); +}); diff --git a/server/sonar-web/src/main/js/helpers/__tests__/urls-test.js b/server/sonar-web/src/main/js/helpers/__tests__/urls-test.js index c6e54c559d5..c2047a572a4 100644 --- a/server/sonar-web/src/main/js/helpers/__tests__/urls-test.js +++ b/server/sonar-web/src/main/js/helpers/__tests__/urls-test.js @@ -55,32 +55,17 @@ describe('#getComponentUrl', () => { describe('#getComponentIssuesUrl', () => { it('should work without parameters', () => { - expect(getComponentIssuesUrl(SIMPLE_COMPONENT_KEY, {})).toBe( - '/component_issues?id=' + SIMPLE_COMPONENT_KEY + '#' - ); - }); - - it('should encode component key', () => { - expect(getComponentIssuesUrl(COMPLEX_COMPONENT_KEY, {})).toBe( - '/component_issues?id=' + COMPLEX_COMPONENT_KEY_ENCODED + '#' - ); + expect(getComponentIssuesUrl(SIMPLE_COMPONENT_KEY, {})).toEqual({ + pathname: '/project/issues', + query: { id: SIMPLE_COMPONENT_KEY } + }); }); it('should work with parameters', () => { - expect(getComponentIssuesUrl(SIMPLE_COMPONENT_KEY, { resolved: 'false' })).toBe( - '/component_issues?id=' + SIMPLE_COMPONENT_KEY + '#resolved=false' - ); - }); - - it('should encode parameters', () => { - expect( - getComponentIssuesUrl(SIMPLE_COMPONENT_KEY, { componentUuids: COMPLEX_COMPONENT_KEY }) - ).toBe( - '/component_issues?id=' + - SIMPLE_COMPONENT_KEY + - '#componentUuids=' + - COMPLEX_COMPONENT_KEY_ENCODED - ); + expect(getComponentIssuesUrl(SIMPLE_COMPONENT_KEY, { resolved: 'false' })).toEqual({ + pathname: '/project/issues', + query: { id: SIMPLE_COMPONENT_KEY, resolved: 'false' } + }); }); }); diff --git a/server/sonar-web/src/main/js/helpers/issues.js b/server/sonar-web/src/main/js/helpers/issues.js index e8ce3a697ec..013831fc1e6 100644 --- a/server/sonar-web/src/main/js/helpers/issues.js +++ b/server/sonar-web/src/main/js/helpers/issues.js @@ -20,6 +20,7 @@ // @flow import { sortBy } from 'lodash'; import { SEVERITIES } from './constants'; +import type { Issue } from '../components/issue/types'; type TextRange = { startLine: number, @@ -83,12 +84,13 @@ const injectCommentsRelational = (issue: RawIssue, users?: Array<User>) => { if (!issue.comments) { return {}; } - const comments = issue.comments.map(comment => ({ - ...comment, - author: comment.login, - login: undefined, - ...injectRelational(comment, users, 'author', 'login') - })); + const comments = issue.comments.map(comment => { + const commentWithAuthor = { ...comment, author: comment.login, login: undefined }; + return { + ...commentWithAuthor, + ...injectRelational(commentWithAuthor, users, 'author', 'login') + }; + }); return { comments }; }; @@ -110,11 +112,11 @@ const ensureTextRange = (issue: RawIssue) => { }; export const parseIssueFromResponse = ( - issue: RawIssue, + issue: Object, components?: Array<*>, users?: Array<*>, rules?: Array<*> -) => { +): Issue => { return { ...issue, ...injectRelational(issue, components, 'component', 'key'), diff --git a/server/sonar-web/src/main/js/helpers/path.js b/server/sonar-web/src/main/js/helpers/path.js index 63153bf6dc8..b65c049f3e4 100644 --- a/server/sonar-web/src/main/js/helpers/path.js +++ b/server/sonar-web/src/main/js/helpers/path.js @@ -102,3 +102,12 @@ export function splitPath(path) { return null; } } + +export function limitComponentName(str) { + if (typeof str === 'string') { + const LIMIT = 30; + return str.length > LIMIT ? str.substr(0, LIMIT) + '...' : str; + } else { + return ''; + } +} diff --git a/server/sonar-web/src/main/js/helpers/testUtils.js b/server/sonar-web/src/main/js/helpers/testUtils.js index 602eb3c5786..96b718cc64b 100644 --- a/server/sonar-web/src/main/js/helpers/testUtils.js +++ b/server/sonar-web/src/main/js/helpers/testUtils.js @@ -24,7 +24,7 @@ export const mockEvent = { stopPropagation() {} }; -export const click = element => element.simulate('click', mockEvent); +export const click = (element, event = {}) => element.simulate('click', { ...mockEvent, ...event }); export const submit = element => element.simulate('submit', { @@ -37,4 +37,7 @@ export const change = (element, value) => currentTarget: { value } }); -export const keydown = (element, keyCode) => element.simulate('keyDown', { ...mockEvent, keyCode }); +export const keydown = keyCode => { + const event = new KeyboardEvent('keydown', { keyCode }); + document.dispatchEvent(event); +}; diff --git a/server/sonar-web/src/main/js/helpers/urls.js b/server/sonar-web/src/main/js/helpers/urls.js index 34b3b06d89d..f8e127b89a0 100644 --- a/server/sonar-web/src/main/js/helpers/urls.js +++ b/server/sonar-web/src/main/js/helpers/urls.js @@ -37,40 +37,23 @@ export function getProjectUrl(key) { /** * Generate URL for a global issues page - * @param {object} query - * @returns {string} */ export function getIssuesUrl(query) { - const serializedQuery = Object.keys(query) - .map(criterion => `${encodeURIComponent(criterion)}=${encodeURIComponent(query[criterion])}`) - .join('|'); - - // return a string (not { pathname }) to help react-router's Link handle this properly - return '/issues#' + serializedQuery; + return { pathname: '/issues', query }; } /** * Generate URL for a component's issues page - * @param {string} componentKey - * @param {object} query - * @returns {string} */ export function getComponentIssuesUrl(componentKey, query) { - const serializedQuery = Object.keys(query) - .map(criterion => `${encodeURIComponent(criterion)}=${encodeURIComponent(query[criterion])}`) - .join('|'); - - // return a string (not { pathname }) to help react-router's Link handle this properly - return '/component_issues?id=' + encodeURIComponent(componentKey) + '#' + serializedQuery; + return { pathname: '/project/issues', query: { ...query, id: componentKey } }; } /** * Generate URL for a single issue - * @param {string} issueKey - * @returns {string} */ -export function getSingleIssueUrl(issueKey) { - return window.baseUrl + '/issues/search#issues=' + issueKey; +export function getSingleIssueUrl(issues) { + return { pathname: '/issues', query: { issues } }; } /** diff --git a/server/sonar-web/src/main/js/libs/third-party/keymaster.js b/server/sonar-web/src/main/js/libs/third-party/keymaster.js deleted file mode 100644 index 8ba7aad8487..00000000000 --- a/server/sonar-web/src/main/js/libs/third-party/keymaster.js +++ /dev/null @@ -1,314 +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. - */ -// keymaster.js -// version: 1.6.2 -// (c) 2011-2013 Thomas Fuchs -// keymaster.js may be freely distributed under the MIT license. - -;(function(global){ - var k, - _handlers = {}, - _mods = { 16: false, 18: false, 17: false, 91: false }, - _scope = 'all', - // modifier keys - _MODIFIERS = { - '⇧': 16, shift: 16, - '⌥': 18, alt: 18, option: 18, - '⌃': 17, ctrl: 17, control: 17, - '⌘': 91, command: 91 - }, - // special keys - _MAP = { - backspace: 8, tab: 9, clear: 12, - enter: 13, 'return': 13, - esc: 27, escape: 27, space: 32, - left: 37, up: 38, - right: 39, down: 40, - del: 46, 'delete': 46, - home: 36, end: 35, - pageup: 33, pagedown: 34, - ',': 188, '.': 190, '/': 191, - '`': 192, '-': 189, '=': 187, - ';': 186, '\'': 222, - '[': 219, ']': 221, '\\': 220 - }, - code = function(x){ - return _MAP[x] || x.toUpperCase().charCodeAt(0); - }, - _downKeys = []; - - for(k=1;k<20;k++) _MAP['f'+k] = 111+k; - - // IE doesn't support Array#indexOf, so have a simple replacement - function index(array, item){ - var i = array.length; - while(i--) if(array[i]===item) return i; - return -1; - } - - // for comparing mods before unassignment - function compareArray(a1, a2) { - if (a1.length != a2.length) return false; - for (var i = 0; i < a1.length; i++) { - if (a1[i] !== a2[i]) return false; - } - return true; - } - - var modifierMap = { - 16:'shiftKey', - 18:'altKey', - 17:'ctrlKey', - 91:'metaKey' - }; - function updateModifierKey(event) { - for(k in _mods) _mods[k] = event[modifierMap[k]]; - }; - - // handle keydown event - function dispatch(event) { - var key, handler, k, i, modifiersMatch, scope; - key = event.keyCode; - - if (index(_downKeys, key) == -1) { - _downKeys.push(key); - } - - // if a modifier key, set the key.<modifierkeyname> property to true and return - if(key == 93 || key == 224) key = 91; // right command on webkit, command on Gecko - if(key in _mods) { - _mods[key] = true; - // 'assignKey' from inside this closure is exported to window.key - for(k in _MODIFIERS) if(_MODIFIERS[k] == key) assignKey[k] = true; - return; - } - updateModifierKey(event); - - // see if we need to ignore the keypress (filter() can can be overridden) - // by default ignore key presses if a select, textarea, or input is focused - if(!assignKey.filter.call(this, event)) return; - - // abort if no potentially matching shortcuts found - if (!(key in _handlers)) return; - - scope = getScope(); - - // for each potential shortcut - for (i = 0; i < _handlers[key].length; i++) { - handler = _handlers[key][i]; - - // see if it's in the current scope - if(handler.scope == scope || handler.scope == 'all'){ - // check if modifiers match if any - modifiersMatch = handler.mods.length > 0; - for(k in _mods) - if((!_mods[k] && index(handler.mods, +k) > -1) || - (_mods[k] && index(handler.mods, +k) == -1)) modifiersMatch = false; - // call the handler and stop the event if neccessary - if((handler.mods.length == 0 && !_mods[16] && !_mods[18] && !_mods[17] && !_mods[91]) || modifiersMatch){ - if(handler.method(event, handler)===false){ - if(event.preventDefault) event.preventDefault(); - else event.returnValue = false; - if(event.stopPropagation) event.stopPropagation(); - if(event.cancelBubble) event.cancelBubble = true; - } - } - } - } - }; - - // unset modifier keys on keyup - function clearModifier(event){ - var key = event.keyCode, k, - i = index(_downKeys, key); - - // remove key from _downKeys - if (i >= 0) { - _downKeys.splice(i, 1); - } - - if(key == 93 || key == 224) key = 91; - if(key in _mods) { - _mods[key] = false; - for(k in _MODIFIERS) if(_MODIFIERS[k] == key) assignKey[k] = false; - } - }; - - function resetModifiers() { - for(k in _mods) _mods[k] = false; - for(k in _MODIFIERS) assignKey[k] = false; - }; - - // parse and assign shortcut - function assignKey(key, scope, method){ - var keys, mods; - keys = getKeys(key); - if (method === undefined) { - method = scope; - scope = 'all'; - } - - // for each shortcut - for (var i = 0; i < keys.length; i++) { - // set modifier keys if any - mods = []; - key = keys[i].split('+'); - if (key.length > 1){ - mods = getMods(key); - key = [key[key.length-1]]; - } - // convert to keycode and... - key = key[0] - key = code(key); - // ...store handler - if (!(key in _handlers)) _handlers[key] = []; - _handlers[key].push({ shortcut: keys[i], scope: scope, method: method, key: keys[i], mods: mods }); - } - }; - - // unbind all handlers for given key in current scope - function unbindKey(key, scope) { - var multipleKeys, keys, - mods = [], - i, j, obj; - - multipleKeys = getKeys(key); - - for (j = 0; j < multipleKeys.length; j++) { - keys = multipleKeys[j].split('+'); - - if (keys.length > 1) { - mods = getMods(keys); - key = keys[keys.length - 1]; - } - - key = code(key); - - if (scope === undefined) { - scope = getScope(); - } - if (!_handlers[key]) { - return; - } - for (i = 0; i < _handlers[key].length; i++) { - obj = _handlers[key][i]; - // only clear handlers if correct scope and mods match - if (obj.scope === scope && compareArray(obj.mods, mods)) { - _handlers[key][i] = {}; - } - } - } - }; - - // Returns true if the key with code 'keyCode' is currently down - // Converts strings into key codes. - function isPressed(keyCode) { - if (typeof(keyCode)=='string') { - keyCode = code(keyCode); - } - return index(_downKeys, keyCode) != -1; - } - - function getPressedKeyCodes() { - return _downKeys.slice(0); - } - - function filter(event){ - var tagName = (event.target || event.srcElement).tagName; - // ignore keypressed in any elements that support keyboard data input - return !(tagName == 'INPUT' || tagName == 'SELECT' || tagName == 'TEXTAREA'); - } - - // initialize key.<modifier> to false - for(k in _MODIFIERS) assignKey[k] = false; - - // set current scope (default 'all') - function setScope(scope){ _scope = scope || 'all' }; - function getScope(){ return _scope || 'all' }; - - // delete all handlers for a given scope - function deleteScope(scope){ - var key, handlers, i; - - for (key in _handlers) { - handlers = _handlers[key]; - for (i = 0; i < handlers.length; ) { - if (handlers[i].scope === scope) handlers.splice(i, 1); - else i++; - } - } - }; - - // abstract key logic for assign and unassign - function getKeys(key) { - var keys; - key = key.replace(/\s/g, ''); - keys = key.split(','); - if ((keys[keys.length - 1]) == '') { - keys[keys.length - 2] += ','; - } - return keys; - } - - // abstract mods logic for assign and unassign - function getMods(key) { - var mods = key.slice(0, key.length - 1); - for (var mi = 0; mi < mods.length; mi++) - mods[mi] = _MODIFIERS[mods[mi]]; - return mods; - } - - // cross-browser events - function addEvent(object, event, method) { - if (object.addEventListener) - object.addEventListener(event, method, false); - else if(object.attachEvent) - object.attachEvent('on'+event, function(){ method(window.event) }); - }; - - // set the handlers globally on document - addEvent(document, 'keydown', function(event) { dispatch(event) }); // Passing _scope to a callback to ensure it remains the same by execution. Fixes #48 - addEvent(document, 'keyup', clearModifier); - - // reset modifiers to false whenever the window is (re)focused. - addEvent(window, 'focus', resetModifiers); - - // store previously defined key - var previousKey = global.key; - - // restore previously defined key and return reference to our key object - function noConflict() { - var k = global.key; - global.key = previousKey; - return k; - } - - // set window.key and window.key.set/get/deleteScope, and the default filter - global.key = assignKey; - global.key.setScope = setScope; - global.key.getScope = getScope; - global.key.deleteScope = deleteScope; - global.key.filter = filter; - global.key.isPressed = isPressed; - global.key.getPressedKeyCodes = getPressedKeyCodes; - global.key.noConflict = noConflict; - global.key.unbind = unbindKey; - -})(window); diff --git a/server/sonar-web/src/main/js/store/issues/duck.js b/server/sonar-web/src/main/js/store/issues/duck.js deleted file mode 100644 index b559d8103c7..00000000000 --- a/server/sonar-web/src/main/js/store/issues/duck.js +++ /dev/null @@ -1,50 +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 { keyBy } from 'lodash'; - -type Issue = { key: string }; - -type ReceiveIssuesAction = { - type: 'RECEIVE_ISSUES', - issues: Array<Issue> -}; - -type Action = ReceiveIssuesAction; - -type State = { [key: string]: Issue }; - -export const receiveIssues = (issues: Array<Issue>): ReceiveIssuesAction => ({ - type: 'RECEIVE_ISSUES', - issues -}); - -const reducer = (state: State = {}, action: Action) => { - switch (action.type) { - case 'RECEIVE_ISSUES': - return { ...state, ...keyBy(action.issues, 'key') }; - default: - return state; - } -}; - -export default reducer; - -export const getIssueByKey = (state: State, key: string): ?Issue => state[key]; diff --git a/server/sonar-web/src/main/js/store/rootReducer.js b/server/sonar-web/src/main/js/store/rootReducer.js index 95db709a545..5404ef78f6a 100644 --- a/server/sonar-web/src/main/js/store/rootReducer.js +++ b/server/sonar-web/src/main/js/store/rootReducer.js @@ -22,7 +22,6 @@ import appState from './appState/duck'; import components, * as fromComponents from './components/reducer'; import users, * as fromUsers from './users/reducer'; import favorites, * as fromFavorites from './favorites/duck'; -import issues, * as fromIssues from './issues/duck'; import languages, * as fromLanguages from './languages/reducer'; import measures, * as fromMeasures from './measures/reducer'; import notifications, * as fromNotifications from './notifications/duck'; @@ -42,7 +41,6 @@ export default combineReducers({ components, globalMessages, favorites, - issues, languages, measures, notifications, @@ -85,8 +83,6 @@ export const getUsers = state => fromUsers.getUsers(state.users); export const isFavorite = (state, componentKey) => fromFavorites.isFavorite(state.favorites, componentKey); -export const getIssueByKey = (state, key) => fromIssues.getIssueByKey(state.issues, key); - export const getComponentMeasure = (state, componentKey, metricKey) => fromMeasures.getComponentMeasure(state.measures, componentKey, metricKey); diff --git a/server/sonar-web/src/main/less/components/issues.less b/server/sonar-web/src/main/less/components/issues.less index 658462e620c..ba09fadce81 100644 --- a/server/sonar-web/src/main/less/components/issues.less +++ b/server/sonar-web/src/main/less/components/issues.less @@ -43,7 +43,7 @@ .issue-list, .issue { - max-width: 920px; + max-width: 980px; } .issue.selected { @@ -91,6 +91,8 @@ } .issue-rule { + vertical-align: top; + margin-top: 2px; padding: 0 3px; background: fade(@blue, 30%); opacity: 0.5; @@ -346,7 +348,7 @@ input.issue-action-options-search { top: 0; bottom: 0; left: 0; - cursor: pointer; + border: none; &:hover { background-color: rgba(0, 0, 0, 0.05); diff --git a/server/sonar-web/src/main/less/components/page.less b/server/sonar-web/src/main/less/components/page.less index 9e24c26252f..cd284d7a546 100644 --- a/server/sonar-web/src/main/less/components/page.less +++ b/server/sonar-web/src/main/less/components/page.less @@ -153,10 +153,11 @@ } .page-sidebar-sticky { + width: 320px !important; padding-right: 0; .page-limited & { - margin: -20px 0; + margin: -20px 0 -20px -20px; padding-right: 0 !important; .page-sidebar-sticky-inner { @@ -166,16 +167,27 @@ .page-sidebar-sticky-inner { position: fixed; + z-index: 10; top: 30px; bottom: 0; + left: 0; overflow: auto; - width: 290px; + width: ~"calc(50vw - 640px + 280px + 3px)"; border-right: 1px solid #e6e6e6; box-sizing: border-box; background: #f3f3f3; + @media (max-width: 1335px) { + & { width: 310px; } + } + .search-navigator-facets-list { width: 260px; + margin-left: ~"calc(50vw - 640px + 290px - 260px - 37px)"; + + @media (max-width: 1335px) { + & { margin-left: 20px; } + } } } } diff --git a/server/sonar-web/src/main/less/components/react-select.less b/server/sonar-web/src/main/less/components/react-select.less index 0f496e61f58..a29d4113e52 100644 --- a/server/sonar-web/src/main/less/components/react-select.less +++ b/server/sonar-web/src/main/less/components/react-select.less @@ -101,6 +101,21 @@ white-space: nowrap; } +.Select-value svg, +.Select-value [class^="icon-"] { + padding-top: 4px; +} + +.Select-value img { + padding-top: 3px; +} + +.Select-option svg, +.Select-option img, +.Select-option [class^="icon-"] { + padding-top: 2px; +} + .has-value:not(.Select--multi) > .Select-control > .Select-value .Select-value-label, .has-value.is-pseudo-focused:not(.Select--multi) > .Select-control > .Select-value .Select-value-label { color: @baseFontColor; @@ -311,26 +326,15 @@ padding: 8px 10px; } -.Select--multi .Select-input { - vertical-align: middle; - margin-left: 10px; - padding: 0; -} - -.Select--multi.has-value .Select-input { - margin-left: 5px; -} - .Select--multi .Select-value { background-color: rgba(0, 126, 255, 0.08); border-radius: 2px; border: 1px solid rgba(0, 126, 255, 0.24); - color: #007eff; + color: @baseFontColor; display: inline-block; - font-size: 0.9em; - line-height: 1.4; - margin-left: 5px; - margin-top: 5px; + font-size: 12px; + line-height: 14px; + margin: 1px 4px 1px 1px; vertical-align: top; } @@ -400,6 +404,10 @@ background-color: #fcfcfc; } +.Select-aria-only { + display: none; +} + @keyframes Select-animation-spin { to { transform: rotate(1turn); diff --git a/server/sonar-web/src/main/less/components/search-navigator.less b/server/sonar-web/src/main/less/components/search-navigator.less index 08d9a90c69a..8b0203bfd2a 100644 --- a/server/sonar-web/src/main/less/components/search-navigator.less +++ b/server/sonar-web/src/main/less/components/search-navigator.less @@ -122,23 +122,30 @@ white-space: normal; overflow: hidden; font-size: 0; - cursor: pointer; transition: none; - &:hover { - border: 1px solid @blue; - padding: 3px 5px; + a& { + cursor: pointer; - .facet-stat { - top: -1px; - right: -1px; + .facet-name { + color: @baseFontColor; + } + + &:hover, &:focus { + border: 1px solid @blue; + padding: 3px 5px; + + .facet-stat { + top: -1px; + right: -1px; + } } } .facet-name { line-height: 16px; background-color: @barBackgroundColor; - color: @baseFontColor; + color: @secondFontColor; font-size: @smallFontSize; white-space: nowrap; } @@ -425,6 +432,7 @@ .search-navigator-date-facet-selection { .clearfix; position: relative; + padding: 0 10px; font-size: @smallFontSize; } diff --git a/server/sonar-web/src/main/less/pages/issues.less b/server/sonar-web/src/main/less/pages/issues.less index 3c25afc7d90..c6b9336153e 100644 --- a/server/sonar-web/src/main/less/pages/issues.less +++ b/server/sonar-web/src/main/less/pages/issues.less @@ -39,10 +39,29 @@ position: absolute; visibility: hidden; } + + .search-navigator-facet-header, + .search-navigator-facet-list { + padding-left: 0; + padding-right: 0; + } + + .search-navigator-facet-header { + padding-top: 8px; + padding-bottom: 8px; + } + + .search-navigator-facet-box:not(.hidden) + .search-navigator-facet-box { + border-top: none; + } + + .search-navigator-facet-footer { + padding: 0 0 10px 0; + } } .issues-workspace-list-component { - padding: 0 10px; + padding: 10px 10px 6px; } .issues-workspace-list-item + .issues-workspace-list-item { @@ -53,8 +72,12 @@ margin-top: 10px; } -.issues-workspace-list-item + .issues-workspace-list-component { - margin-top: 25px; +.issues-workspace-list-item:first-child .issues-workspace-list-component { + padding-top: 0; +} + +.issues-workspace-list-component + .issues-workspace-list-item { + margin-top: 0; } .issues-workspace-component-viewer { |