aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web
diff options
context:
space:
mode:
authorStas Vilchik <stas.vilchik@sonarsource.com>2017-08-28 11:43:35 +0200
committerJanos Gyerik <janos.gyerik@sonarsource.com>2017-09-12 11:34:54 +0200
commit25140ec8ed74cc5ea5f50f05325f0d4a7f8753fd (patch)
tree3bfcb2944b2a6150eef40638db25004322cbfd98 /server/sonar-web
parent030370cf6255d1185ecb3deeaab7de1fe76e058b (diff)
downloadsonarqube-25140ec8ed74cc5ea5f50f05325f0d4a7f8753fd.tar.gz
sonarqube-25140ec8ed74cc5ea5f50f05325f0d4a7f8753fd.zip
SONAR-9756 Build UI for branch management (#2433)
Diffstat (limited to 'server/sonar-web')
-rw-r--r--server/sonar-web/package.json3
-rw-r--r--server/sonar-web/src/main/js/api/branches.ts10
-rw-r--r--server/sonar-web/src/main/js/app/components/ProjectContainer.tsx24
-rw-r--r--server/sonar-web/src/main/js/app/components/__tests__/ProjectContainer-test.tsx18
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.tsx4
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranch.tsx4
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranchesMenu.tsx15
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranchesMenuItem.tsx2
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.tsx23
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMeta.tsx2
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavMenu-test.tsx18
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavBranchesMenu-test.tsx.snap4
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavMenu-test.tsx.snap34
-rw-r--r--server/sonar-web/src/main/js/app/components/search/Search.css14
-rw-r--r--server/sonar-web/src/main/js/app/components/search/Search.js2
-rw-r--r--server/sonar-web/src/main/js/app/utils/startReactApp.js2
-rw-r--r--server/sonar-web/src/main/js/apps/projectBranches/components/App.tsx68
-rw-r--r--server/sonar-web/src/main/js/apps/projectBranches/components/BranchRow.tsx138
-rw-r--r--server/sonar-web/src/main/js/apps/projectBranches/components/DeleteBranchModal.tsx105
-rw-r--r--server/sonar-web/src/main/js/apps/projectBranches/components/RenameBranchModal.tsx131
-rw-r--r--server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/App-test.tsx34
-rw-r--r--server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/BranchRow-test.tsx65
-rw-r--r--server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/DeleteBranchModal-test.tsx98
-rw-r--r--server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/RenameBranchModal-test.tsx95
-rw-r--r--server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/App-test.tsx.snap73
-rw-r--r--server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/BranchRow-test.tsx.snap95
-rw-r--r--server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/DeleteBranchModal-test.tsx.snap104
-rw-r--r--server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/RenameBranchModal-test.tsx.snap223
-rw-r--r--server/sonar-web/src/main/js/apps/projectBranches/routes.ts30
-rw-r--r--server/sonar-web/src/main/js/components/common/BranchStatus.css (renamed from server/sonar-web/src/main/js/app/components/nav/component/BranchStatus.css)0
-rw-r--r--server/sonar-web/src/main/js/components/common/BranchStatus.tsx (renamed from server/sonar-web/src/main/js/app/components/nav/component/BranchStatus.tsx)12
-rw-r--r--server/sonar-web/src/main/js/components/common/__tests__/BranchStatus-test.tsx (renamed from server/sonar-web/src/main/js/app/components/nav/component/__tests__/BranchStatus-test.tsx)2
-rw-r--r--server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/BranchStatus-test.tsx.snap (renamed from server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/BranchStatus-test.tsx.snap)0
-rw-r--r--server/sonar-web/src/main/js/components/icons-components/ChangeIcon.tsx (renamed from server/sonar-web/src/main/js/components/icons-components/ChangeIcon.js)13
-rw-r--r--server/sonar-web/src/main/js/components/icons-components/DeleteIcon.tsx (renamed from server/sonar-web/src/main/js/components/icons-components/DeleteIcon.js)13
-rw-r--r--server/sonar-web/src/main/js/helpers/request.ts4
-rw-r--r--server/sonar-web/src/main/less/components/dropdowns.less11
-rw-r--r--server/sonar-web/src/main/less/components/menu.less5
38 files changed, 1449 insertions, 49 deletions
diff --git a/server/sonar-web/package.json b/server/sonar-web/package.json
index 679b0c8538a..26755ecd3c8 100644
--- a/server/sonar-web/package.json
+++ b/server/sonar-web/package.json
@@ -26,6 +26,7 @@
"keymaster": "1.6.2",
"lodash": "4.17.4",
"numeral": "1.5.3",
+ "prop-types": "15.5.10",
"rc-tooltip": "3.4.7",
"react": "15.6.1",
"react-dom": "15.6.1",
@@ -96,7 +97,6 @@
"less-loader": "4.0.4",
"postcss-loader": "2.0.6",
"prettier": "1.5.2",
- "prop-types": "15.5.10",
"react-dev-utils": "3.0.0",
"react-error-overlay": "1.0.7",
"react-test-renderer": "15.6.1",
@@ -132,6 +132,7 @@
"jest": {
"coverageDirectory": "<rootDir>/target/coverage",
"coveragePathIgnorePatterns": ["<rootDir>/node_modules", "<rootDir>/tests"],
+ "mapCoverage": true,
"moduleFileExtensions": ["ts", "tsx", "js", "json"],
"moduleNameMapper": {
"^.+\\.(hbs|jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/config/jest/FileStub.js",
diff --git a/server/sonar-web/src/main/js/api/branches.ts b/server/sonar-web/src/main/js/api/branches.ts
index 6435c575aae..ec3e79e0932 100644
--- a/server/sonar-web/src/main/js/api/branches.ts
+++ b/server/sonar-web/src/main/js/api/branches.ts
@@ -17,9 +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.
*/
-import { getJSON } from '../helpers/request';
+import { getJSON, post } from '../helpers/request';
import throwGlobalError from '../app/utils/throwGlobalError';
export function getBranches(project: string): Promise<any> {
return getJSON('/api/project_branches/list', { project }).then(r => r.branches, throwGlobalError);
}
+
+export function deleteBranch(project: string, branch: string): Promise<void | Response> {
+ return post('/api/project_branches/delete', { project, branch }).catch(throwGlobalError);
+}
+
+export function renameBranch(project: string, branch: string): Promise<void | Response> {
+ return post('/api/project_branches/rename', { project, branch }).catch(throwGlobalError);
+}
diff --git a/server/sonar-web/src/main/js/app/components/ProjectContainer.tsx b/server/sonar-web/src/main/js/app/components/ProjectContainer.tsx
index f7ae6453557..804c420ee88 100644
--- a/server/sonar-web/src/main/js/app/components/ProjectContainer.tsx
+++ b/server/sonar-web/src/main/js/app/components/ProjectContainer.tsx
@@ -83,9 +83,7 @@ export default class ProjectContainer extends React.PureComponent<Props, State>
Promise.all([getComponentNavigation(id), getComponentData(id, branch)]).then(([nav, data]) => {
const component = this.addQualifier({ ...nav, ...data });
- const project = component.breadcrumbs.find((c: Component) => c.qualifier === 'TRK');
- const branchesRequest = project ? getBranches(project.key) : Promise.resolve([]);
- branchesRequest.then(branches => {
+ this.fetchBranches(component).then(branches => {
if (this.mounted) {
this.setState({ loading: false, branches, component });
}
@@ -93,12 +91,30 @@ export default class ProjectContainer extends React.PureComponent<Props, State>
}, onError);
}
+ fetchBranches = (component: Component) => {
+ const project = component.breadcrumbs.find((c: Component) => c.qualifier === 'TRK');
+ return project ? getBranches(project.key) : Promise.resolve([]);
+ };
+
handleProjectChange = (changes: {}) => {
if (this.mounted) {
this.setState(state => ({ component: { ...state.component, ...changes } }));
}
};
+ handleBranchesChange = () => {
+ if (this.mounted && this.state.component) {
+ this.fetchBranches(this.state.component).then(
+ branches => {
+ if (this.mounted) {
+ this.setState({ branches });
+ }
+ },
+ () => {}
+ );
+ }
+ };
+
render() {
const { query } = this.props.location;
const { branches, component, loading } = this.state;
@@ -128,7 +144,9 @@ export default class ProjectContainer extends React.PureComponent<Props, State>
/>}
{React.cloneElement(this.props.children, {
branch,
+ branches,
component: component,
+ onBranchesChange: this.handleBranchesChange,
onComponentChange: this.handleProjectChange
})}
</div>
diff --git a/server/sonar-web/src/main/js/app/components/__tests__/ProjectContainer-test.tsx b/server/sonar-web/src/main/js/app/components/__tests__/ProjectContainer-test.tsx
index beb6580feb8..ee31dea0b0c 100644
--- a/server/sonar-web/src/main/js/app/components/__tests__/ProjectContainer-test.tsx
+++ b/server/sonar-web/src/main/js/app/components/__tests__/ProjectContainer-test.tsx
@@ -100,3 +100,21 @@ it("doesn't load branches portfolio", () => {
expect(getComponentNavigation).toBeCalledWith('portfolioKey');
});
});
+
+it('updates branches on change', () => {
+ (getBranches as jest.Mock<any>).mockImplementation(() => Promise.resolve([]));
+ const Inner = () => <div />;
+ const wrapper = shallow(
+ <ProjectContainer location={{ query: { id: 'portfolioKey' } }}>
+ <Inner />
+ </ProjectContainer>
+ );
+ (wrapper.instance() as ProjectContainer).mounted = true;
+ wrapper.setState({
+ branches: [{ isMain: true }],
+ component: { breadcrumbs: [{ key: 'projectKey', name: 'project', qualifier: 'TRK' }] },
+ loading: false
+ });
+ (wrapper.find(Inner).prop('onBranchesChange') as Function)();
+ expect(getBranches).toBeCalledWith('projectKey');
+});
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.tsx b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.tsx
index 189ace21d11..d1b95c1a06f 100644
--- a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.tsx
+++ b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.tsx
@@ -102,6 +102,8 @@ export default class ComponentNav extends React.PureComponent<Props, State> {
<ComponentNavBranch
branches={this.props.branches}
currentBranch={this.props.currentBranch}
+ // to close dropdown on any location change
+ location={this.props.location}
project={this.props.component}
/>
@@ -116,6 +118,8 @@ export default class ComponentNav extends React.PureComponent<Props, State> {
branch={this.props.currentBranch}
component={this.props.component}
conf={this.props.conf}
+ // to re-render selected menu item
+ location={this.props.location}
/>
</ContextNavBar>
);
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranch.tsx b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranch.tsx
index 1a99483710f..cf293b64b4a 100644
--- a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranch.tsx
+++ b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranch.tsx
@@ -33,6 +33,7 @@ import BubblePopupHelper from '../../../../components/common/BubblePopupHelper';
interface Props {
branches: Branch[];
currentBranch: Branch;
+ location?: any;
project: Component;
}
@@ -61,7 +62,8 @@ export default class ComponentNavBranch extends React.PureComponent<Props, State
componentWillReceiveProps(nextProps: Props) {
if (
nextProps.project !== this.props.project ||
- nextProps.currentBranch !== this.props.currentBranch
+ nextProps.currentBranch !== this.props.currentBranch ||
+ nextProps.location !== this.props.location
) {
this.setState({ dropdownOpen: false, singleBranchPopupOpen: false });
}
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranchesMenu.tsx b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranchesMenu.tsx
index 261abe6dd2b..e58e796e9a0 100644
--- a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranchesMenu.tsx
+++ b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranchesMenu.tsx
@@ -28,6 +28,7 @@ import {
} from '../../../../helpers/branches';
import { translate } from '../../../../helpers/l10n';
import { getProjectBranchUrl } from '../../../../helpers/urls';
+import { Link } from 'react-router';
interface Props {
branches: Branch[];
@@ -179,17 +180,29 @@ export default class ComponentNavBranchesMenu extends React.PureComponent<Props,
});
return (
- <ul className="menu">
+ <ul className="menu menu-vertically-limited">
{menu}
</ul>
);
};
render() {
+ const { project } = this.props;
+ const showManageLink =
+ project.qualifier === 'TRK' && project.configuration && project.configuration.showSettings;
+
return (
<div className="dropdown-menu dropdown-menu-shadow" ref={node => (this.node = node)}>
{this.renderSearch()}
{this.renderBranchesList()}
+ {showManageLink &&
+ <div className="dropdown-bottom-hint text-right">
+ <Link
+ className="text-muted"
+ to={{ pathname: '/project/branches', query: { id: project.key } }}>
+ {translate('branches.manage')}
+ </Link>
+ </div>}
</div>
);
}
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranchesMenuItem.tsx b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranchesMenuItem.tsx
index 9d218df274f..ece4360a4dc 100644
--- a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranchesMenuItem.tsx
+++ b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranchesMenuItem.tsx
@@ -20,7 +20,7 @@
import * as React from 'react';
import { Link } from 'react-router';
import * as classNames from 'classnames';
-import BranchStatus from './BranchStatus';
+import BranchStatus from '../../../../components/common/BranchStatus';
import { Branch, Component } from '../../../types';
import BranchIcon from '../../../../components/icons-components/BranchIcon';
import { isShortLivingBranch } from '../../../../helpers/branches';
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.tsx b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.tsx
index f0edfde9eab..3c1a4f512b7 100644
--- a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.tsx
+++ b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.tsx
@@ -20,6 +20,7 @@
import * as React from 'react';
import { Link } from 'react-router';
import * as classNames from 'classnames';
+import * as PropTypes from 'prop-types';
import { Branch, Component, ComponentExtension, ComponentConfiguration } from '../../../types';
import NavBarTabs from '../../../../components/nav/NavBarTabs';
import { isShortLivingBranch, getBranchName } from '../../../../helpers/branches';
@@ -27,6 +28,7 @@ import { translate } from '../../../../helpers/l10n';
const SETTINGS_URLS = [
'/project/admin',
+ '/project/branches',
'/project/settings',
'/project/quality_profiles',
'/project/quality_gate',
@@ -43,9 +45,14 @@ interface Props {
branch: Branch;
component: Component;
conf: ComponentConfiguration;
+ location?: any;
}
export default class ComponentNavMenu extends React.PureComponent<Props> {
+ static contextTypes = {
+ branchesEnabled: PropTypes.bool.isRequired
+ };
+
isProject() {
return this.props.component.qualifier === 'TRK';
}
@@ -196,6 +203,7 @@ export default class ComponentNavMenu extends React.PureComponent<Props> {
renderAdministrationLinks() {
return [
this.renderSettingsLink(),
+ this.renderBranchesLink(),
this.renderProfilesLink(),
this.renderQualityGateLink(),
this.renderCustomMeasuresLink(),
@@ -223,6 +231,21 @@ export default class ComponentNavMenu extends React.PureComponent<Props> {
);
}
+ renderBranchesLink() {
+ if (!this.context.branchesEnabled || !this.isProject() || !this.props.conf.showSettings) {
+ return null;
+ }
+ return (
+ <li key="branches">
+ <Link
+ to={{ pathname: '/project/branches', query: { id: this.props.component.key } }}
+ activeClassName="active">
+ {translate('project_branches.page')}
+ </Link>
+ </li>
+ );
+ }
+
renderProfilesLink() {
if (!this.props.conf.showQualityProfiles) {
return null;
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMeta.tsx b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMeta.tsx
index 00c4db25561..07d5cbc5bbf 100644
--- a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMeta.tsx
+++ b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMeta.tsx
@@ -19,7 +19,7 @@
*/
import * as React from 'react';
import IncrementalBadge from './IncrementalBadge';
-import BranchStatus from './BranchStatus';
+import BranchStatus from '../../../../components/common/BranchStatus';
import { Branch, Component, ComponentConfiguration } from '../../../types';
import Tooltip from '../../../../components/controls/Tooltip';
import PendingIcon from '../../../../components/icons-components/PendingIcon';
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavMenu-test.tsx b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavMenu-test.tsx
index 2b6574a85ed..c69eda2ef7a 100644
--- a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavMenu-test.tsx
+++ b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavMenu-test.tsx
@@ -44,7 +44,10 @@ it('should work with extensions', () => {
extensions: [{ key: 'foo', name: 'Foo' }]
};
expect(
- shallow(<ComponentNavMenu branch={mainBranch} component={component as Component} conf={conf} />)
+ shallow(
+ <ComponentNavMenu branch={mainBranch} component={component as Component} conf={conf} />,
+ { context: { branchesEnabled: true } }
+ )
).toMatchSnapshot();
});
@@ -62,7 +65,10 @@ it('should work with multiple extensions', () => {
extensions: [{ key: 'foo', name: 'Foo' }, { key: 'bar', name: 'Bar' }]
};
expect(
- shallow(<ComponentNavMenu branch={mainBranch} component={component as Component} conf={conf} />)
+ shallow(
+ <ComponentNavMenu branch={mainBranch} component={component as Component} conf={conf} />,
+ { context: { branchesEnabled: true } }
+ )
).toMatchSnapshot();
});
@@ -77,7 +83,9 @@ it('should work for short-living branches', () => {
const component = { key: 'foo', qualifier: 'TRK' } as Component;
const conf = { showSettings: true };
expect(
- shallow(<ComponentNavMenu branch={branch} component={component} conf={conf} />)
+ shallow(<ComponentNavMenu branch={branch} component={component} conf={conf} />, {
+ context: { branchesEnabled: true }
+ })
).toMatchSnapshot();
});
@@ -86,6 +94,8 @@ it('should work for long-living branches', () => {
const component = { key: 'foo', qualifier: 'TRK' } as Component;
const conf = { showSettings: true };
expect(
- shallow(<ComponentNavMenu branch={branch} component={component} conf={conf} />)
+ shallow(<ComponentNavMenu branch={branch} component={component} conf={conf} />, {
+ context: { branchesEnabled: true }
+ })
).toMatchSnapshot();
});
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavBranchesMenu-test.tsx.snap b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavBranchesMenu-test.tsx.snap
index d80344beadd..2974d68eb61 100644
--- a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavBranchesMenu-test.tsx.snap
+++ b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavBranchesMenu-test.tsx.snap
@@ -25,7 +25,7 @@ exports[`renders list 1`] = `
/>
</div>
<ul
- className="menu"
+ className="menu menu-vertically-limited"
>
<ComponentNavBranchesMenuItem
branch={
@@ -165,7 +165,7 @@ exports[`searches 1`] = `
/>
</div>
<ul
- className="menu"
+ className="menu menu-vertically-limited"
>
<ComponentNavBranchesMenuItem
branch={
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavMenu-test.tsx.snap b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavMenu-test.tsx.snap
index ab9cc70c6b1..f406307cd81 100644
--- a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavMenu-test.tsx.snap
+++ b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavMenu-test.tsx.snap
@@ -273,6 +273,23 @@ exports[`should work with extensions 1`] = `
style={Object {}}
to={
Object {
+ "pathname": "/project/branches",
+ "query": Object {
+ "id": "foo",
+ },
+ }
+ }
+ >
+ project_branches.page
+ </Link>
+ </li>
+ <li>
+ <Link
+ activeClassName="active"
+ onlyActiveOnIndex={false}
+ style={Object {}}
+ to={
+ Object {
"pathname": "/project/admin/extension/foo",
"query": Object {
"id": "foo",
@@ -477,6 +494,23 @@ exports[`should work with multiple extensions 1`] = `
style={Object {}}
to={
Object {
+ "pathname": "/project/branches",
+ "query": Object {
+ "id": "foo",
+ },
+ }
+ }
+ >
+ project_branches.page
+ </Link>
+ </li>
+ <li>
+ <Link
+ activeClassName="active"
+ onlyActiveOnIndex={false}
+ style={Object {}}
+ to={
+ Object {
"pathname": "/project/admin/extension/foo",
"query": Object {
"id": "foo",
diff --git a/server/sonar-web/src/main/js/app/components/search/Search.css b/server/sonar-web/src/main/js/app/components/search/Search.css
index 7cdd0a20035..3799637ce66 100644
--- a/server/sonar-web/src/main/js/app/components/search/Search.css
+++ b/server/sonar-web/src/main/js/app/components/search/Search.css
@@ -72,16 +72,6 @@
left: -5px;
}
-.navbar-search-shortcut-hint {
- line-height: 16px;
- margin-top: 5px;
- padding: 5px 10px;
- border-top: 1px solid #e6e6e6;
- background-color: #f3f3f3;
- color: #777;
- font-size: 11px;
-}
-
.navbar-search-no-results {
margin-top: 4px;
padding: 5px 10px;
@@ -94,3 +84,7 @@
overflow-y: auto;
overflow-x: hidden;
}
+
+.global-navbar-search-dropdown .dropdown-bottom-hint {
+ margin-bottom: 0;
+}
diff --git a/server/sonar-web/src/main/js/app/components/search/Search.js b/server/sonar-web/src/main/js/app/components/search/Search.js
index ea4592d3992..e28606b0d7d 100644
--- a/server/sonar-web/src/main/js/app/components/search/Search.js
+++ b/server/sonar-web/src/main/js/app/components/search/Search.js
@@ -367,7 +367,7 @@ export default class Search extends React.PureComponent {
results={this.state.results}
selected={this.state.selected}
/>
- <div className="navbar-search-shortcut-hint">
+ <div className="dropdown-bottom-hint">
<div className="pull-right">
<ClockIcon className="little-spacer-right" size={12} />
{translate('recently_browsed')}
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 910761f150c..af23e36912b 100644
--- a/server/sonar-web/src/main/js/app/utils/startReactApp.js
+++ b/server/sonar-web/src/main/js/app/utils/startReactApp.js
@@ -55,6 +55,7 @@ import organizationsRoutes from '../../apps/organizations/routes';
import permissionTemplatesRoutes from '../../apps/permission-templates/routes';
import projectActivityRoutes from '../../apps/projectActivity/routes';
import projectAdminRoutes from '../../apps/project-admin/routes';
+import projectBranchesRoutes from '../../apps/projectBranches/routes';
import projectsRoutes from '../../apps/projects/routes';
import projectsManagementRoutes from '../../apps/projectsManagement/routes';
import qualityGatesRoutes from '../../apps/quality-gates/routes';
@@ -187,6 +188,7 @@ const startReactApp = () => {
component={ProjectPageExtension}
/>
<Route path="background_tasks" childRoutes={backgroundTasksRoutes} />
+ <Route path="branches" childRoutes={projectBranchesRoutes} />
<Route path="issues" childRoutes={issuesRoutes} />
<Route path="settings" childRoutes={settingsRoutes} />
{projectAdminRoutes}
diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/App.tsx b/server/sonar-web/src/main/js/apps/projectBranches/components/App.tsx
new file mode 100644
index 00000000000..442f2f2bc97
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectBranches/components/App.tsx
@@ -0,0 +1,68 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 * as React from 'react';
+import BranchRow from './BranchRow';
+import { Branch } from '../../../app/types';
+import { sortBranchesAsTree } from '../../../helpers/branches';
+import { translate } from '../../../helpers/l10n';
+
+interface Props {
+ branches: Branch[];
+ component: { key: string };
+ onBranchesChange: () => void;
+}
+
+export default function App({ branches, component, onBranchesChange }: Props) {
+ return (
+ <div className="page page-limited">
+ <header className="page-header">
+ <h1 className="page-title">
+ {translate('project_branches.page')}
+ </h1>
+ </header>
+
+ <table className="data zebra zebra-hover">
+ <thead>
+ <tr>
+ <th>
+ {translate('branch')}
+ </th>
+ <th className="text-right">
+ {translate('status')}
+ </th>
+ <th className="text-right">
+ {translate('actions')}
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ {sortBranchesAsTree(branches).map(branch =>
+ <BranchRow
+ branch={branch}
+ component={component.key}
+ key={branch.name}
+ onChange={onBranchesChange}
+ />
+ )}
+ </tbody>
+ </table>
+ </div>
+ );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/BranchRow.tsx b/server/sonar-web/src/main/js/apps/projectBranches/components/BranchRow.tsx
new file mode 100644
index 00000000000..b8bba666f10
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectBranches/components/BranchRow.tsx
@@ -0,0 +1,138 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 * as React from 'react';
+import { Branch } from '../../../app/types';
+import * as classNames from 'classnames';
+import DeleteBranchModal from './DeleteBranchModal';
+import BranchStatus from '../../../components/common/BranchStatus';
+import BranchIcon from '../../../components/icons-components/BranchIcon';
+import { isShortLivingBranch } from '../../../helpers/branches';
+import ChangeIcon from '../../../components/icons-components/ChangeIcon';
+import DeleteIcon from '../../../components/icons-components/DeleteIcon';
+import { translate } from '../../../helpers/l10n';
+import Tooltip from '../../../components/controls/Tooltip';
+import RenameBranchModal from './RenameBranchModal';
+
+interface Props {
+ branch: Branch;
+ component: string;
+ onChange: () => void;
+}
+
+interface State {
+ deleting: boolean;
+ renaming: boolean;
+}
+
+export default class BranchRow extends React.PureComponent<Props, State> {
+ mounted: boolean;
+ state: State = { deleting: false, renaming: false };
+
+ componentDidMount() {
+ this.mounted = true;
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ handleDeleteClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+ event.preventDefault();
+ event.currentTarget.blur();
+ this.setState({ deleting: true });
+ };
+
+ handleDeletingStop = () => {
+ this.setState({ deleting: false });
+ };
+
+ handleRenameClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+ event.preventDefault();
+ event.currentTarget.blur();
+ this.setState({ renaming: true });
+ };
+
+ handleChange = () => {
+ if (this.mounted) {
+ this.setState({ deleting: false, renaming: false });
+ this.props.onChange();
+ }
+ };
+
+ handleRenamingStop = () => {
+ this.setState({ renaming: false });
+ };
+
+ render() {
+ const { branch, component } = this.props;
+
+ return (
+ <tr>
+ <td>
+ <BranchIcon
+ branch={branch}
+ className={classNames('little-spacer-right', {
+ 'big-spacer-left': isShortLivingBranch(branch) && !branch.isOrphan
+ })}
+ />
+ {branch.name}
+ </td>
+ <td className="thin nowrap text-right">
+ <BranchStatus branch={branch} />
+ </td>
+ <td className="thin nowrap text-right">
+ {branch.isMain
+ ? <Tooltip overlay={translate('branches.rename')}>
+ <a
+ className="js-rename link-no-underline"
+ href="#"
+ onClick={this.handleRenameClick}>
+ <ChangeIcon />
+ </a>
+ </Tooltip>
+ : <Tooltip overlay={translate('branches.delete')}>
+ <a
+ className="js-delete link-no-underline"
+ href="#"
+ onClick={this.handleDeleteClick}>
+ <DeleteIcon />
+ </a>
+ </Tooltip>}
+ </td>
+
+ {this.state.deleting &&
+ <DeleteBranchModal
+ branch={branch}
+ component={component}
+ onClose={this.handleDeletingStop}
+ onDelete={this.handleChange}
+ />}
+
+ {this.state.renaming &&
+ <RenameBranchModal
+ branch={branch}
+ component={component}
+ onClose={this.handleRenamingStop}
+ onRename={this.handleChange}
+ />}
+ </tr>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/DeleteBranchModal.tsx b/server/sonar-web/src/main/js/apps/projectBranches/components/DeleteBranchModal.tsx
new file mode 100644
index 00000000000..2e51b553c9a
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectBranches/components/DeleteBranchModal.tsx
@@ -0,0 +1,105 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 * as React from 'react';
+import Modal from 'react-modal';
+import { deleteBranch } from '../../../api/branches';
+import { Branch } from '../../../app/types';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+
+interface Props {
+ branch: Branch;
+ component: string;
+ onClose: () => void;
+ onDelete: () => void;
+}
+
+interface State {
+ loading: boolean;
+}
+
+export default class DeleteBranchModal extends React.PureComponent<Props, State> {
+ mounted: boolean;
+ state: State = { loading: false };
+
+ componentDidMount() {
+ this.mounted = true;
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ handleSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => {
+ event.preventDefault();
+ this.setState({ loading: true });
+ deleteBranch(this.props.component, this.props.branch.name).then(
+ () => {
+ if (this.mounted) {
+ this.setState({ loading: false });
+ this.props.onDelete();
+ }
+ },
+ () => {
+ if (this.mounted) {
+ this.setState({ loading: false });
+ }
+ }
+ );
+ };
+
+ handleCancelClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+ event.preventDefault();
+ this.props.onClose();
+ };
+
+ render() {
+ const { branch } = this.props;
+ const header = translate('branches.delete');
+
+ return (
+ <Modal
+ isOpen={true}
+ contentLabel={header}
+ className="modal"
+ overlayClassName="modal-overlay"
+ onRequestClose={this.props.onClose}>
+ <header className="modal-head">
+ <h2>
+ {header}
+ </h2>
+ </header>
+ <form onSubmit={this.handleSubmit}>
+ <div className="modal-body">
+ {translateWithParameters('branches.delete.are_you_sure', branch.name)}
+ </div>
+ <footer className="modal-foot">
+ {this.state.loading && <i className="spinner spacer-right" />}
+ <button className="button-red" disabled={this.state.loading} type="submit">
+ {translate('delete')}
+ </button>
+ <a href="#" onClick={this.handleCancelClick}>
+ {translate('cancel')}
+ </a>
+ </footer>
+ </form>
+ </Modal>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/RenameBranchModal.tsx b/server/sonar-web/src/main/js/apps/projectBranches/components/RenameBranchModal.tsx
new file mode 100644
index 00000000000..bcc8eedceeb
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectBranches/components/RenameBranchModal.tsx
@@ -0,0 +1,131 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 * as React from 'react';
+import Modal from 'react-modal';
+import { renameBranch } from '../../../api/branches';
+import { Branch } from '../../../app/types';
+import { translate } from '../../../helpers/l10n';
+
+interface Props {
+ branch: Branch;
+ component: string;
+ onClose: () => void;
+ onRename: () => void;
+}
+
+interface State {
+ loading: boolean;
+ name?: string;
+}
+
+export default class RenameBranchModal extends React.PureComponent<Props, State> {
+ mounted: boolean;
+ state: State = { loading: false };
+
+ componentDidMount() {
+ this.mounted = true;
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ handleSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => {
+ event.preventDefault();
+ if (!this.state.name) {
+ return;
+ }
+ this.setState({ loading: true });
+ renameBranch(this.props.component, this.state.name).then(
+ () => {
+ if (this.mounted) {
+ this.setState({ loading: false });
+ this.props.onRename();
+ }
+ },
+ () => {
+ if (this.mounted) {
+ this.setState({ loading: false });
+ }
+ }
+ );
+ };
+
+ handleCancelClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+ event.preventDefault();
+ this.props.onClose();
+ };
+
+ handleNameChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
+ this.setState({ name: event.currentTarget.value });
+ };
+
+ render() {
+ const { branch } = this.props;
+ const header = translate('branches.rename');
+ const submitDisabled =
+ this.state.loading || !this.state.name || this.state.name === branch.name;
+
+ return (
+ <Modal
+ isOpen={true}
+ contentLabel={header}
+ className="modal"
+ overlayClassName="modal-overlay"
+ onRequestClose={this.props.onClose}>
+ <header className="modal-head">
+ <h2>
+ {header}
+ </h2>
+ </header>
+ <form onSubmit={this.handleSubmit}>
+ <div className="modal-body">
+ <div className="modal-field">
+ <label htmlFor="rename-branch-name">
+ {translate('new_name')}
+ <em className="mandatory">*</em>
+ </label>
+ <input
+ autoFocus={true}
+ id="rename-branch-name"
+ maxLength={100}
+ name="name"
+ onChange={this.handleNameChange}
+ required={true}
+ size={50}
+ type="text"
+ value={this.state.name != undefined ? this.state.name : branch.name}
+ />
+ </div>
+ </div>
+ <footer className="modal-foot">
+ {this.state.loading && <i className="spinner spacer-right" />}
+ <button disabled={submitDisabled} type="submit">
+ {translate('rename')}
+ </button>
+ <a href="#" onClick={this.handleCancelClick}>
+ {translate('cancel')}
+ </a>
+ </footer>
+ </form>
+ </Modal>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/App-test.tsx b/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/App-test.tsx
new file mode 100644
index 00000000000..4288105f79a
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/App-test.tsx
@@ -0,0 +1,34 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 * as React from 'react';
+import { shallow } from 'enzyme';
+import App from '../App';
+import { Branch, BranchType } from '../../../../app/types';
+
+it('renders sorted list of branches', () => {
+ const branches: Branch[] = [
+ { isMain: true, name: 'master' },
+ { isMain: false, name: 'branch-1.0', type: BranchType.LONG },
+ { isMain: false, name: 'branch-1.0', mergeBranch: 'master', type: BranchType.SHORT }
+ ];
+ expect(
+ shallow(<App branches={branches} component={{ key: 'foo' }} onBranchesChange={jest.fn()} />)
+ ).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/BranchRow-test.tsx b/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/BranchRow-test.tsx
new file mode 100644
index 00000000000..4edc3ce70d6
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/BranchRow-test.tsx
@@ -0,0 +1,65 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 * as React from 'react';
+import { shallow } from 'enzyme';
+import BranchRow from '../BranchRow';
+import { MainBranch, ShortLivingBranch, BranchType } from '../../../../app/types';
+import { click } from '../../../../helpers/testUtils';
+
+const mainBranch: MainBranch = { isMain: true, name: 'master' };
+
+const shortBranch: ShortLivingBranch = {
+ isMain: false,
+ name: 'feature',
+ mergeBranch: 'foo',
+ type: BranchType.SHORT
+};
+
+it('renders main branch', () => {
+ expect(shallowRender(mainBranch)).toMatchSnapshot();
+});
+
+it('renders short-living branch', () => {
+ expect(shallowRender(shortBranch)).toMatchSnapshot();
+});
+
+it('renames main branch', () => {
+ const onChange = jest.fn();
+ const wrapper = shallowRender(mainBranch, onChange);
+
+ click(wrapper.find('.js-rename'));
+ (wrapper.find('RenameBranchModal').prop('onRename') as Function)();
+ expect(onChange).toBeCalled();
+});
+
+it('deletes short-living branch', () => {
+ const onChange = jest.fn();
+ const wrapper = shallowRender(shortBranch, onChange);
+
+ click(wrapper.find('.js-delete'));
+ (wrapper.find('DeleteBranchModal').prop('onDelete') as Function)();
+ expect(onChange).toBeCalled();
+});
+
+function shallowRender(branch: MainBranch | ShortLivingBranch, onChange: () => void = jest.fn()) {
+ const wrapper = shallow(<BranchRow branch={branch} component="foo" onChange={onChange} />);
+ (wrapper.instance() as any).mounted = true;
+ return wrapper;
+}
diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/DeleteBranchModal-test.tsx b/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/DeleteBranchModal-test.tsx
new file mode 100644
index 00000000000..b2870587114
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/DeleteBranchModal-test.tsx
@@ -0,0 +1,98 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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.
+ */
+jest.mock('../../../../api/branches', () => ({ deleteBranch: jest.fn() }));
+
+import * as React from 'react';
+import { shallow, ShallowWrapper } from 'enzyme';
+import DeleteBranchModal from '../DeleteBranchModal';
+import { ShortLivingBranch, BranchType } from '../../../../app/types';
+import { submit, doAsync, click } from '../../../../helpers/testUtils';
+import { deleteBranch } from '../../../../api/branches';
+
+beforeEach(() => {
+ (deleteBranch as jest.Mock<any>).mockClear();
+});
+
+it('renders', () => {
+ const wrapper = shallowRender();
+ expect(wrapper).toMatchSnapshot();
+ wrapper.setState({ loading: true });
+ expect(wrapper).toMatchSnapshot();
+});
+
+it('deletes branch', () => {
+ (deleteBranch as jest.Mock<any>).mockImplementation(() => Promise.resolve());
+ const onDelete = jest.fn();
+ const wrapper = shallowRender(onDelete);
+
+ submitForm(wrapper);
+
+ return doAsync().then(() => {
+ wrapper.update();
+ expect(wrapper.state().loading).toBe(false);
+ expect(onDelete).toBeCalled();
+ expect(deleteBranch).toBeCalledWith('foo', 'feature');
+ });
+});
+
+it('cancels', () => {
+ const onClose = jest.fn();
+ const wrapper = shallowRender(jest.fn(), onClose);
+
+ click(wrapper.find('a'));
+
+ return doAsync().then(() => {
+ expect(onClose).toBeCalled();
+ });
+});
+
+it('stops loading on WS error', () => {
+ (deleteBranch as jest.Mock<any>).mockImplementation(() => Promise.reject(null));
+ const onDelete = jest.fn();
+ const wrapper = shallowRender(onDelete);
+
+ submitForm(wrapper);
+
+ return doAsync().then(() => {
+ wrapper.update();
+ expect(wrapper.state().loading).toBe(false);
+ expect(onDelete).not.toBeCalled();
+ expect(deleteBranch).toBeCalledWith('foo', 'feature');
+ });
+});
+
+function shallowRender(onDelete: () => void = jest.fn(), onClose: () => void = jest.fn()) {
+ const branch: ShortLivingBranch = {
+ isMain: false,
+ name: 'feature',
+ mergeBranch: 'master',
+ type: BranchType.SHORT
+ };
+ const wrapper = shallow(
+ <DeleteBranchModal branch={branch} component="foo" onClose={onClose} onDelete={onDelete} />
+ );
+ (wrapper.instance() as any).mounted = true;
+ return wrapper;
+}
+
+function submitForm(wrapper: ShallowWrapper<any, any>) {
+ submit(wrapper.find('form'));
+ expect(wrapper.state().loading).toBe(true);
+}
diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/RenameBranchModal-test.tsx b/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/RenameBranchModal-test.tsx
new file mode 100644
index 00000000000..3a1c962d68e
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/RenameBranchModal-test.tsx
@@ -0,0 +1,95 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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.
+ */
+jest.mock('../../../../api/branches', () => ({ renameBranch: jest.fn() }));
+
+import * as React from 'react';
+import { shallow, ShallowWrapper } from 'enzyme';
+import RenameBranchModal from '../RenameBranchModal';
+import { MainBranch } from '../../../../app/types';
+import { submit, doAsync, click, change } from '../../../../helpers/testUtils';
+import { renameBranch } from '../../../../api/branches';
+
+beforeEach(() => {
+ (renameBranch as jest.Mock<any>).mockClear();
+});
+
+it('renders', () => {
+ const wrapper = shallowRender();
+ expect(wrapper).toMatchSnapshot();
+ wrapper.setState({ name: 'dev' });
+ expect(wrapper).toMatchSnapshot();
+ wrapper.setState({ loading: true });
+ expect(wrapper).toMatchSnapshot();
+});
+
+it('renames branch', () => {
+ (renameBranch as jest.Mock<any>).mockImplementation(() => Promise.resolve());
+ const onRename = jest.fn();
+ const wrapper = shallowRender(onRename);
+
+ fillAndSubmit(wrapper);
+
+ return doAsync().then(() => {
+ wrapper.update();
+ expect(wrapper.state().loading).toBe(false);
+ expect(onRename).toBeCalled();
+ expect(renameBranch).toBeCalledWith('foo', 'dev');
+ });
+});
+
+it('cancels', () => {
+ const onClose = jest.fn();
+ const wrapper = shallowRender(jest.fn(), onClose);
+
+ click(wrapper.find('a'));
+
+ return doAsync().then(() => {
+ expect(onClose).toBeCalled();
+ });
+});
+
+it('stops loading on WS error', () => {
+ (renameBranch as jest.Mock<any>).mockImplementation(() => Promise.reject(null));
+ const onRename = jest.fn();
+ const wrapper = shallowRender(onRename);
+
+ fillAndSubmit(wrapper);
+
+ return doAsync().then(() => {
+ wrapper.update();
+ expect(wrapper.state().loading).toBe(false);
+ expect(onRename).not.toBeCalled();
+ });
+});
+
+function shallowRender(onRename: () => void = jest.fn(), onClose: () => void = jest.fn()) {
+ const branch: MainBranch = { isMain: true, name: 'master' };
+ const wrapper = shallow(
+ <RenameBranchModal branch={branch} component="foo" onClose={onClose} onRename={onRename} />
+ );
+ (wrapper.instance() as any).mounted = true;
+ return wrapper;
+}
+
+function fillAndSubmit(wrapper: ShallowWrapper<any, any>) {
+ change(wrapper.find('input'), 'dev');
+ submit(wrapper.find('form'));
+ expect(wrapper.state().loading).toBe(true);
+}
diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/App-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/App-test.tsx.snap
new file mode 100644
index 00000000000..6f983e33df8
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/App-test.tsx.snap
@@ -0,0 +1,73 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders sorted list of branches 1`] = `
+<div
+ className="page page-limited"
+>
+ <header
+ className="page-header"
+ >
+ <h1
+ className="page-title"
+ >
+ project_branches.page
+ </h1>
+ </header>
+ <table
+ className="data zebra zebra-hover"
+ >
+ <thead>
+ <tr>
+ <th>
+ branch
+ </th>
+ <th
+ className="text-right"
+ >
+ status
+ </th>
+ <th
+ className="text-right"
+ >
+ actions
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <BranchRow
+ branch={
+ Object {
+ "isMain": true,
+ "name": "master",
+ }
+ }
+ component="foo"
+ onChange={[Function]}
+ />
+ <BranchRow
+ branch={
+ Object {
+ "isMain": false,
+ "mergeBranch": "master",
+ "name": "branch-1.0",
+ "type": "SHORT",
+ }
+ }
+ component="foo"
+ onChange={[Function]}
+ />
+ <BranchRow
+ branch={
+ Object {
+ "isMain": false,
+ "name": "branch-1.0",
+ "type": "LONG",
+ }
+ }
+ component="foo"
+ onChange={[Function]}
+ />
+ </tbody>
+ </table>
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/BranchRow-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/BranchRow-test.tsx.snap
new file mode 100644
index 00000000000..f31097d6911
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/BranchRow-test.tsx.snap
@@ -0,0 +1,95 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders main branch 1`] = `
+<tr>
+ <td>
+ <BranchIcon
+ branch={
+ Object {
+ "isMain": true,
+ "name": "master",
+ }
+ }
+ className="little-spacer-right"
+ />
+ master
+ </td>
+ <td
+ className="thin nowrap text-right"
+ >
+ <BranchStatus
+ branch={
+ Object {
+ "isMain": true,
+ "name": "master",
+ }
+ }
+ />
+ </td>
+ <td
+ className="thin nowrap text-right"
+ >
+ <Tooltip
+ overlay="branches.rename"
+ placement="bottom"
+ >
+ <a
+ className="js-rename link-no-underline"
+ href="#"
+ onClick={[Function]}
+ >
+ <ChangeIcon />
+ </a>
+ </Tooltip>
+ </td>
+</tr>
+`;
+
+exports[`renders short-living branch 1`] = `
+<tr>
+ <td>
+ <BranchIcon
+ branch={
+ Object {
+ "isMain": false,
+ "mergeBranch": "foo",
+ "name": "feature",
+ "type": "SHORT",
+ }
+ }
+ className="little-spacer-right big-spacer-left"
+ />
+ feature
+ </td>
+ <td
+ className="thin nowrap text-right"
+ >
+ <BranchStatus
+ branch={
+ Object {
+ "isMain": false,
+ "mergeBranch": "foo",
+ "name": "feature",
+ "type": "SHORT",
+ }
+ }
+ />
+ </td>
+ <td
+ className="thin nowrap text-right"
+ >
+ <Tooltip
+ overlay="branches.delete"
+ placement="bottom"
+ >
+ <a
+ className="js-delete link-no-underline"
+ href="#"
+ onClick={[Function]}
+ >
+ <DeleteIcon />
+ </a>
+ </Tooltip>
+ </td>
+</tr>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/DeleteBranchModal-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/DeleteBranchModal-test.tsx.snap
new file mode 100644
index 00000000000..934f8ed2d7d
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/DeleteBranchModal-test.tsx.snap
@@ -0,0 +1,104 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders 1`] = `
+<Modal
+ ariaHideApp={true}
+ bodyOpenClassName="ReactModal__Body--open"
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="branches.delete"
+ isOpen={true}
+ onRequestClose={[Function]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ shouldCloseOnOverlayClick={true}
+>
+ <header
+ className="modal-head"
+ >
+ <h2>
+ branches.delete
+ </h2>
+ </header>
+ <form
+ onSubmit={[Function]}
+ >
+ <div
+ className="modal-body"
+ >
+ branches.delete.are_you_sure.feature
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <button
+ className="button-red"
+ disabled={false}
+ type="submit"
+ >
+ delete
+ </button>
+ <a
+ href="#"
+ onClick={[Function]}
+ >
+ cancel
+ </a>
+ </footer>
+ </form>
+</Modal>
+`;
+
+exports[`renders 2`] = `
+<Modal
+ ariaHideApp={true}
+ bodyOpenClassName="ReactModal__Body--open"
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="branches.delete"
+ isOpen={true}
+ onRequestClose={[Function]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ shouldCloseOnOverlayClick={true}
+>
+ <header
+ className="modal-head"
+ >
+ <h2>
+ branches.delete
+ </h2>
+ </header>
+ <form
+ onSubmit={[Function]}
+ >
+ <div
+ className="modal-body"
+ >
+ branches.delete.are_you_sure.feature
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <i
+ className="spinner spacer-right"
+ />
+ <button
+ className="button-red"
+ disabled={true}
+ type="submit"
+ >
+ delete
+ </button>
+ <a
+ href="#"
+ onClick={[Function]}
+ >
+ cancel
+ </a>
+ </footer>
+ </form>
+</Modal>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/RenameBranchModal-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/RenameBranchModal-test.tsx.snap
new file mode 100644
index 00000000000..7867fa4785a
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/RenameBranchModal-test.tsx.snap
@@ -0,0 +1,223 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders 1`] = `
+<Modal
+ ariaHideApp={true}
+ bodyOpenClassName="ReactModal__Body--open"
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="branches.rename"
+ isOpen={true}
+ onRequestClose={[Function]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ shouldCloseOnOverlayClick={true}
+>
+ <header
+ className="modal-head"
+ >
+ <h2>
+ branches.rename
+ </h2>
+ </header>
+ <form
+ onSubmit={[Function]}
+ >
+ <div
+ className="modal-body"
+ >
+ <div
+ className="modal-field"
+ >
+ <label
+ htmlFor="rename-branch-name"
+ >
+ new_name
+ <em
+ className="mandatory"
+ >
+ *
+ </em>
+ </label>
+ <input
+ autoFocus={true}
+ id="rename-branch-name"
+ maxLength={100}
+ name="name"
+ onChange={[Function]}
+ required={true}
+ size={50}
+ type="text"
+ value="master"
+ />
+ </div>
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <button
+ disabled={true}
+ type="submit"
+ >
+ rename
+ </button>
+ <a
+ href="#"
+ onClick={[Function]}
+ >
+ cancel
+ </a>
+ </footer>
+ </form>
+</Modal>
+`;
+
+exports[`renders 2`] = `
+<Modal
+ ariaHideApp={true}
+ bodyOpenClassName="ReactModal__Body--open"
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="branches.rename"
+ isOpen={true}
+ onRequestClose={[Function]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ shouldCloseOnOverlayClick={true}
+>
+ <header
+ className="modal-head"
+ >
+ <h2>
+ branches.rename
+ </h2>
+ </header>
+ <form
+ onSubmit={[Function]}
+ >
+ <div
+ className="modal-body"
+ >
+ <div
+ className="modal-field"
+ >
+ <label
+ htmlFor="rename-branch-name"
+ >
+ new_name
+ <em
+ className="mandatory"
+ >
+ *
+ </em>
+ </label>
+ <input
+ autoFocus={true}
+ id="rename-branch-name"
+ maxLength={100}
+ name="name"
+ onChange={[Function]}
+ required={true}
+ size={50}
+ type="text"
+ value="dev"
+ />
+ </div>
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <button
+ disabled={false}
+ type="submit"
+ >
+ rename
+ </button>
+ <a
+ href="#"
+ onClick={[Function]}
+ >
+ cancel
+ </a>
+ </footer>
+ </form>
+</Modal>
+`;
+
+exports[`renders 3`] = `
+<Modal
+ ariaHideApp={true}
+ bodyOpenClassName="ReactModal__Body--open"
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="branches.rename"
+ isOpen={true}
+ onRequestClose={[Function]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ shouldCloseOnOverlayClick={true}
+>
+ <header
+ className="modal-head"
+ >
+ <h2>
+ branches.rename
+ </h2>
+ </header>
+ <form
+ onSubmit={[Function]}
+ >
+ <div
+ className="modal-body"
+ >
+ <div
+ className="modal-field"
+ >
+ <label
+ htmlFor="rename-branch-name"
+ >
+ new_name
+ <em
+ className="mandatory"
+ >
+ *
+ </em>
+ </label>
+ <input
+ autoFocus={true}
+ id="rename-branch-name"
+ maxLength={100}
+ name="name"
+ onChange={[Function]}
+ required={true}
+ size={50}
+ type="text"
+ value="dev"
+ />
+ </div>
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <i
+ className="spinner spacer-right"
+ />
+ <button
+ disabled={true}
+ type="submit"
+ >
+ rename
+ </button>
+ <a
+ href="#"
+ onClick={[Function]}
+ >
+ cancel
+ </a>
+ </footer>
+ </form>
+</Modal>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectBranches/routes.ts b/server/sonar-web/src/main/js/apps/projectBranches/routes.ts
new file mode 100644
index 00000000000..520805ebac5
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectBranches/routes.ts
@@ -0,0 +1,30 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 { RouterState, IndexRouteProps } from 'react-router';
+
+const routes = [
+ {
+ getIndexRoute(_: RouterState, callback: (err: any, route: IndexRouteProps) => any) {
+ import('./components/App').then(i => callback(null, { component: (i as any).default }));
+ }
+ }
+];
+
+export default routes;
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/BranchStatus.css b/server/sonar-web/src/main/js/components/common/BranchStatus.css
index 74278d67573..74278d67573 100644
--- a/server/sonar-web/src/main/js/app/components/nav/component/BranchStatus.css
+++ b/server/sonar-web/src/main/js/components/common/BranchStatus.css
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/BranchStatus.tsx b/server/sonar-web/src/main/js/components/common/BranchStatus.tsx
index 9a7937deaba..ae36462f6f5 100644
--- a/server/sonar-web/src/main/js/app/components/nav/component/BranchStatus.tsx
+++ b/server/sonar-web/src/main/js/components/common/BranchStatus.tsx
@@ -19,12 +19,12 @@
*/
import * as React from 'react';
import * as classNames from 'classnames';
-import { Branch } from '../../../types';
-import Level from '../../../../components/ui/Level';
-import BugIcon from '../../../../components/icons-components/BugIcon';
-import CodeSmellIcon from '../../../../components/icons-components/CodeSmellIcon';
-import VulnerabilityIcon from '../../../../components/icons-components/VulnerabilityIcon';
-import { isShortLivingBranch } from '../../../../helpers/branches';
+import { Branch } from '../../app/types';
+import Level from '../ui/Level';
+import BugIcon from '../icons-components/BugIcon';
+import CodeSmellIcon from '../icons-components/CodeSmellIcon';
+import VulnerabilityIcon from '../icons-components/VulnerabilityIcon';
+import { isShortLivingBranch } from '../../helpers/branches';
import './BranchStatus.css';
interface Props {
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/BranchStatus-test.tsx b/server/sonar-web/src/main/js/components/common/__tests__/BranchStatus-test.tsx
index be779854e94..7b2de5d01d7 100644
--- a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/BranchStatus-test.tsx
+++ b/server/sonar-web/src/main/js/components/common/__tests__/BranchStatus-test.tsx
@@ -20,7 +20,7 @@
import * as React from 'react';
import { shallow } from 'enzyme';
import BranchStatus from '../BranchStatus';
-import { BranchType, LongLivingBranch } from '../../../../types';
+import { BranchType, LongLivingBranch } from '../../../app/types';
it('renders status of short-living branches', () => {
checkShort(0, 0, 0);
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/BranchStatus-test.tsx.snap b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/BranchStatus-test.tsx.snap
index 1f4ccfc4484..1f4ccfc4484 100644
--- a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/BranchStatus-test.tsx.snap
+++ b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/BranchStatus-test.tsx.snap
diff --git a/server/sonar-web/src/main/js/components/icons-components/ChangeIcon.js b/server/sonar-web/src/main/js/components/icons-components/ChangeIcon.tsx
index e6b7498ab33..59918140bc6 100644
--- a/server/sonar-web/src/main/js/components/icons-components/ChangeIcon.js
+++ b/server/sonar-web/src/main/js/components/icons-components/ChangeIcon.tsx
@@ -17,15 +17,14 @@
* 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 * as React from 'react';
-/*::
-type Props = { className?: string, size?: number };
-*/
+interface Props {
+ className?: string;
+ size?: number;
+}
-export default function ChangeIcon({ className, size = 12 } /*: Props */) {
- /* eslint-disable max-len */
+export default function ChangeIcon({ className, size = 12 }: Props) {
return (
<svg
className={className}
diff --git a/server/sonar-web/src/main/js/components/icons-components/DeleteIcon.js b/server/sonar-web/src/main/js/components/icons-components/DeleteIcon.tsx
index 9c90a1a9511..08a43811da1 100644
--- a/server/sonar-web/src/main/js/components/icons-components/DeleteIcon.js
+++ b/server/sonar-web/src/main/js/components/icons-components/DeleteIcon.tsx
@@ -17,15 +17,14 @@
* 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 * as React from 'react';
-/*::
-type Props = { className?: string, size?: number };
-*/
+interface Props {
+ className?: string;
+ size?: number;
+}
-export default function DeleteIcon({ className, size = 12 } /*: Props */) {
- /* eslint-disable max-len */
+export default function DeleteIcon({ className, size = 12 }: Props) {
return (
<svg
className={className}
diff --git a/server/sonar-web/src/main/js/helpers/request.ts b/server/sonar-web/src/main/js/helpers/request.ts
index 4bac03dadd6..1acb519e000 100644
--- a/server/sonar-web/src/main/js/helpers/request.ts
+++ b/server/sonar-web/src/main/js/helpers/request.ts
@@ -170,10 +170,10 @@ export function postJSON(url: string, data?: RequestData): Promise<any> {
* Shortcut to do a POST request
*/
export function post(url: string, data?: RequestData): Promise<void> {
- return new Promise(resolve => {
+ return new Promise((resolve, reject) => {
request(url).setMethod('POST').setData(data).submit().then(checkStatus).then(() => {
resolve();
- });
+ }, reject);
});
}
diff --git a/server/sonar-web/src/main/less/components/dropdowns.less b/server/sonar-web/src/main/less/components/dropdowns.less
index 6213f652aa4..53371a95b8f 100644
--- a/server/sonar-web/src/main/less/components/dropdowns.less
+++ b/server/sonar-web/src/main/less/components/dropdowns.less
@@ -99,3 +99,14 @@
top: 0;
z-index: (1000 - 10);
}
+
+.dropdown-bottom-hint {
+ line-height: 16px;
+ margin-top: 5px;
+ margin-bottom: -5px;
+ padding: 5px 10px;
+ border-top: 1px solid #e6e6e6;
+ background-color: #f3f3f3;
+ color: #777;
+ font-size: 11px;
+}
diff --git a/server/sonar-web/src/main/less/components/menu.less b/server/sonar-web/src/main/less/components/menu.less
index bf751939d5d..ba566680624 100644
--- a/server/sonar-web/src/main/less/components/menu.less
+++ b/server/sonar-web/src/main/less/components/menu.less
@@ -80,6 +80,11 @@
}
}
+ .menu-vertically-limited {
+ max-height: 300px;
+ overflow-y: auto;
+ }
+
.menu-footer > a > span {
border-bottom: 1px solid @darkGrey;
color: @secondFontColor;