aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src/main
diff options
context:
space:
mode:
authorStas Vilchik <stas.vilchik@sonarsource.com>2017-12-07 14:27:05 +0100
committerStas Vilchik <stas.vilchik@sonarsource.com>2018-01-02 10:38:10 +0100
commit7260d343ea6a7289695a8c509860cbcf726e433c (patch)
tree0a635089cf3d2ca61d902af9202d8d467be4328d /server/sonar-web/src/main
parent49f29073c935c97e52d4f7d2a8e02e79391e3ff2 (diff)
downloadsonarqube-7260d343ea6a7289695a8c509860cbcf726e433c.tar.gz
sonarqube-7260d343ea6a7289695a8c509860cbcf726e433c.zip
SONAR-9554 Make "Analyze a project" and "Create an org" more discoverable
Diffstat (limited to 'server/sonar-web/src/main')
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.css11
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js6
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx101
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavPlus-test.tsx38
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavPlus-test.tsx.snap40
-rw-r--r--server/sonar-web/src/main/js/apps/account/organizations/CreateOrganizationForm.tsx (renamed from server/sonar-web/src/main/js/apps/account/organizations/CreateOrganizationForm.js)128
-rw-r--r--server/sonar-web/src/main/js/apps/account/organizations/UserOrganizations.tsx34
-rw-r--r--server/sonar-web/src/main/js/apps/account/routes.ts10
-rw-r--r--server/sonar-web/src/main/js/apps/organizations/actions.js1
-rw-r--r--server/sonar-web/src/main/js/components/controls/Dropdown.tsx92
-rw-r--r--server/sonar-web/src/main/js/components/controls/__tests__/Dropdown-test.tsx44
-rw-r--r--server/sonar-web/src/main/js/components/icons-components/PlusIcon.tsx36
12 files changed, 458 insertions, 83 deletions
diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.css b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.css
index ac02282d585..48a78705251 100644
--- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.css
+++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.css
@@ -27,13 +27,19 @@
border: none !important;
}
-.navbar-help {
+.navbar-help,
+.navbar-plus {
+ display: inline-block;
height: var(--globalNavHeight);
padding: calc(var(--globalNavHeight) - var(--globalNavContentHeight)) 12px !important;
border-bottom: none !important;
color: #fff !important;
}
+.navbar-plus {
+ margin-right: calc(-1 * var(--gridSize));
+}
+
.global-navbar-menu {
display: flex;
align-items: center;
@@ -56,7 +62,8 @@
.navbar-brand:focus,
.global-navbar-menu > li > a.active,
.global-navbar-menu > li > a:hover,
-.global-navbar-menu > li > a:focus {
+.global-navbar-menu > li > a:focus,
+.global-navbar-menu > .dropdown.open > a {
background-color: #020202;
}
diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js
index 317077eb1af..564775c0179 100644
--- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js
+++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js
@@ -24,9 +24,11 @@ import GlobalNavBranding from './GlobalNavBranding';
import GlobalNavMenu from './GlobalNavMenu';
import GlobalNavExplore from './GlobalNavExplore';
import GlobalNavUserContainer from './GlobalNavUserContainer';
+import GlobalNavPlus from './GlobalNavPlus';
import Search from '../../search/Search';
import GlobalHelp from '../../help/GlobalHelp';
import * as theme from '../../../theme';
+import { isLoggedIn } from '../../../types';
import NavBar from '../../../../components/nav/NavBar';
import Tooltip from '../../../../components/controls/Tooltip';
import HelpIcon from '../../../../components/icons-components/HelpIcon';
@@ -130,6 +132,10 @@ class GlobalNav extends React.PureComponent {
</a>
</li>
<Search appState={this.props.appState} currentUser={this.props.currentUser} />
+ {isLoggedIn(this.props.currentUser) &&
+ this.props.onSonarCloud && (
+ <GlobalNavPlus openOnboardingTutorial={this.openOnboardingTutorial} />
+ )}
<GlobalNavUserContainer {...this.props} />
</ul>
diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx
new file mode 100644
index 00000000000..12e1d701ba2
--- /dev/null
+++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx
@@ -0,0 +1,101 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 * as classNames from 'classnames';
+import * as PropTypes from 'prop-types';
+import CreateOrganizationForm from '../../../../apps/account/organizations/CreateOrganizationForm';
+import PlusIcon from '../../../../components/icons-components/PlusIcon';
+import Dropdown from '../../../../components/controls/Dropdown';
+import { translate } from '../../../../helpers/l10n';
+
+interface Props {
+ openOnboardingTutorial: () => void;
+}
+
+interface State {
+ createOrganization: boolean;
+}
+
+export default class GlobalNavPlus extends React.PureComponent<Props, State> {
+ static contextTypes = {
+ router: PropTypes.object
+ };
+
+ constructor(props: Props) {
+ super(props);
+ this.state = { createOrganization: false };
+ }
+
+ handleNewProjectClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+ event.preventDefault();
+ this.props.openOnboardingTutorial();
+ };
+
+ openCreateOrganizationForm = () => this.setState({ createOrganization: true });
+
+ closeCreateOrganizationForm = () => this.setState({ createOrganization: false });
+
+ handleNewOrganizationClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+ event.preventDefault();
+ event.currentTarget.blur();
+ this.openCreateOrganizationForm();
+ };
+
+ handleCreateOrganization = ({ key }: { key: string }) => {
+ this.closeCreateOrganizationForm();
+ this.context.router.push(`/organizations/${key}`);
+ };
+
+ render() {
+ return (
+ <Dropdown>
+ {({ onToggleClick, open }) => (
+ <li className={classNames('dropdown', { open })}>
+ <a className="navbar-plus" href="#" onClick={onToggleClick}>
+ <PlusIcon />
+ </a>
+ <ul className="dropdown-menu dropdown-menu-right">
+ <li>
+ <a className="js-new-project" href="#" onClick={this.handleNewProjectClick}>
+ {translate('my_account.analyze_new_project')}
+ </a>
+ </li>
+ <li className="divider" />
+ <li>
+ <a
+ className="js-new-organization"
+ href="#"
+ onClick={this.handleNewOrganizationClick}>
+ {translate('my_account.create_new_organization')}
+ </a>
+ </li>
+ </ul>
+ {this.state.createOrganization && (
+ <CreateOrganizationForm
+ onClose={this.closeCreateOrganizationForm}
+ onCreate={this.handleCreateOrganization}
+ />
+ )}
+ </li>
+ )}
+ </Dropdown>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavPlus-test.tsx b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavPlus-test.tsx
new file mode 100644
index 00000000000..e315be5676f
--- /dev/null
+++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavPlus-test.tsx
@@ -0,0 +1,38 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 GlobalNavPlus from '../GlobalNavPlus';
+import { click } from '../../../../../helpers/testUtils';
+
+it('render', () => {
+ const wrapper = shallow(<GlobalNavPlus openOnboardingTutorial={jest.fn()} />);
+ expect(wrapper.is('Dropdown')).toBe(true);
+ expect(wrapper.find('Dropdown').shallow()).toMatchSnapshot();
+});
+
+it('opens onboarding', () => {
+ const openOnboardingTutorial = jest.fn();
+ const wrapper = shallow(<GlobalNavPlus openOnboardingTutorial={openOnboardingTutorial} />)
+ .find('Dropdown')
+ .shallow();
+ click(wrapper.find('.js-new-project'));
+ expect(openOnboardingTutorial).toBeCalled();
+});
diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavPlus-test.tsx.snap b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavPlus-test.tsx.snap
new file mode 100644
index 00000000000..4400a02b250
--- /dev/null
+++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavPlus-test.tsx.snap
@@ -0,0 +1,40 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`render 1`] = `
+<li
+ className="dropdown"
+>
+ <a
+ className="navbar-plus"
+ href="#"
+ onClick={[Function]}
+ >
+ <PlusIcon />
+ </a>
+ <ul
+ className="dropdown-menu dropdown-menu-right"
+ >
+ <li>
+ <a
+ className="js-new-project"
+ href="#"
+ onClick={[Function]}
+ >
+ my_account.analyze_new_project
+ </a>
+ </li>
+ <li
+ className="divider"
+ />
+ <li>
+ <a
+ className="js-new-organization"
+ href="#"
+ onClick={[Function]}
+ >
+ my_account.create_new_organization
+ </a>
+ </li>
+ </ul>
+</li>
+`;
diff --git a/server/sonar-web/src/main/js/apps/account/organizations/CreateOrganizationForm.js b/server/sonar-web/src/main/js/apps/account/organizations/CreateOrganizationForm.tsx
index bd6b0c854fa..66087e08cb4 100644
--- a/server/sonar-web/src/main/js/apps/account/organizations/CreateOrganizationForm.js
+++ b/server/sonar-web/src/main/js/apps/account/organizations/CreateOrganizationForm.tsx
@@ -17,44 +17,49 @@
* 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';
import { debounce } from 'lodash';
import { connect } from 'react-redux';
-import { withRouter } from 'react-router';
+import * as PropTypes from 'prop-types';
+import { createOrganization } from '../../organizations/actions';
+import { Organization } from '../../../app/types';
import Modal from '../../../components/controls/Modal';
import { translate } from '../../../helpers/l10n';
-import { createOrganization } from '../../organizations/actions';
-/*::
-type State = {
- loading: boolean,
- avatar: string,
- avatarImage: string,
- description: string,
- key: string,
- name: string,
- url: string
-};
-*/
-
-class CreateOrganizationForm extends React.PureComponent {
- /*:: mounted: boolean; */
- /*:: state: State; */
- /*:: props: {
- createOrganization: (fields: {}) => Promise<*>,
- router: { push: string => void }
+interface DispatchProps {
+ createOrganization: (fields: Partial<Organization>) => Promise<{ key: string }>;
+}
+
+interface Props extends DispatchProps {
+ onClose: () => void;
+ onCreate: (organization: { key: string }) => void;
+}
+
+interface State {
+ avatar: string;
+ avatarImage: string;
+ description: string;
+ key: string;
+ loading: boolean;
+ name: string;
+ url: string;
+}
+
+class CreateOrganizationForm extends React.PureComponent<Props, State> {
+ mounted: boolean;
+
+ static contextTypes = {
+ router: PropTypes.object
};
-*/
- constructor(props) {
+ constructor(props: Props) {
super(props);
this.state = {
- loading: false,
avatar: '',
avatarImage: '',
description: '',
key: '',
+ loading: false,
name: '',
url: ''
};
@@ -69,36 +74,37 @@ class CreateOrganizationForm extends React.PureComponent {
this.mounted = false;
}
- closeForm = () => {
- this.props.router.push('/account/organizations');
- };
-
stopProcessing = () => {
if (this.mounted) {
this.setState({ loading: false });
}
};
- stopProcessingAndClose = () => {
- if (this.mounted) {
- this.setState({ loading: false });
- }
- this.closeForm();
- };
-
- handleAvatarInputChange = (e /*: Object */) => {
- const { value } = e.target;
+ handleAvatarInputChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
+ const { value } = event.currentTarget;
this.setState({ avatar: value });
this.changeAvatarImage(value);
};
- changeAvatarImage = (value /*: string */) => {
+ changeAvatarImage = (value: string) => {
this.setState({ avatarImage: value });
};
- handleSubmit = (e /*: Object */) => {
- e.preventDefault();
- const organization /*: Object */ = { name: this.state.name };
+ handleNameChange = (event: React.SyntheticEvent<HTMLInputElement>) =>
+ this.setState({ name: event.currentTarget.value });
+
+ handleKeyChange = (event: React.SyntheticEvent<HTMLInputElement>) =>
+ this.setState({ key: event.currentTarget.value });
+
+ handleDescriptionChange = (event: React.SyntheticEvent<HTMLTextAreaElement>) =>
+ this.setState({ description: event.currentTarget.value });
+
+ handleUrlChange = (event: React.SyntheticEvent<HTMLInputElement>) =>
+ this.setState({ url: event.currentTarget.value });
+
+ handleSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => {
+ event.preventDefault();
+ const organization = { name: this.state.name };
if (this.state.avatar) {
Object.assign(organization, { avatar: this.state.avatar });
}
@@ -112,14 +118,12 @@ class CreateOrganizationForm extends React.PureComponent {
Object.assign(organization, { url: this.state.url });
}
this.setState({ loading: true });
- this.props
- .createOrganization(organization)
- .then(this.stopProcessingAndClose, this.stopProcessing);
+ this.props.createOrganization(organization).then(this.props.onCreate, this.stopProcessing);
};
render() {
return (
- <Modal contentLabel="modal form" onRequestClose={this.closeForm}>
+ <Modal contentLabel="modal form" onRequestClose={this.props.onClose}>
<header className="modal-head">
<h2>{translate('my_account.create_organization')}</h2>
</header>
@@ -137,11 +141,11 @@ class CreateOrganizationForm extends React.PureComponent {
name="name"
required={true}
type="text"
- minLength="2"
- maxLength="64"
+ minLength={2}
+ maxLength={64}
value={this.state.name}
disabled={this.state.loading}
- onChange={e => this.setState({ name: e.target.value })}
+ onChange={this.handleNameChange}
/>
<div className="modal-field-description">
{translate('organization.name.description')}
@@ -153,11 +157,11 @@ class CreateOrganizationForm extends React.PureComponent {
id="organization-key"
name="key"
type="text"
- minLength="2"
- maxLength="64"
+ minLength={2}
+ maxLength={64}
value={this.state.key}
disabled={this.state.loading}
- onChange={e => this.setState({ key: e.target.value })}
+ onChange={this.handleKeyChange}
/>
<div className="modal-field-description">
{translate('organization.key.description')}
@@ -169,7 +173,7 @@ class CreateOrganizationForm extends React.PureComponent {
id="organization-avatar"
name="avatar"
type="text"
- maxLength="256"
+ maxLength={256}
value={this.state.avatar}
disabled={this.state.loading}
onChange={this.handleAvatarInputChange}
@@ -192,11 +196,11 @@ class CreateOrganizationForm extends React.PureComponent {
<textarea
id="organization-description"
name="description"
- rows="3"
- maxLength="256"
+ rows={3}
+ maxLength={256}
value={this.state.description}
disabled={this.state.loading}
- onChange={e => this.setState({ description: e.target.value })}
+ onChange={this.handleDescriptionChange}
/>
<div className="modal-field-description">
{translate('organization.description.description')}
@@ -208,10 +212,10 @@ class CreateOrganizationForm extends React.PureComponent {
id="organization-url"
name="url"
type="text"
- maxLength="256"
+ maxLength={256}
value={this.state.url}
disabled={this.state.loading}
- onChange={e => this.setState({ url: e.target.value })}
+ onChange={this.handleUrlChange}
/>
<div className="modal-field-description">
{translate('organization.url.description')}
@@ -225,7 +229,7 @@ class CreateOrganizationForm extends React.PureComponent {
<button disabled={this.state.loading} type="submit">
{translate('create')}
</button>
- <button type="reset" className="button-link" onClick={this.closeForm}>
+ <button className="button-link" onClick={this.props.onClose} type="reset">
{translate('cancel')}
</button>
</div>
@@ -236,8 +240,6 @@ class CreateOrganizationForm extends React.PureComponent {
}
}
-const mapStateToProps = null;
-
-const mapDispatchToProps = { createOrganization };
+const mapDispatchToProps: DispatchProps = { createOrganization: createOrganization as any };
-export default connect(mapStateToProps, mapDispatchToProps)(withRouter(CreateOrganizationForm));
+export default connect(null, mapDispatchToProps)(CreateOrganizationForm);
diff --git a/server/sonar-web/src/main/js/apps/account/organizations/UserOrganizations.tsx b/server/sonar-web/src/main/js/apps/account/organizations/UserOrganizations.tsx
index 75a48b2bed9..c33f2be07eb 100644
--- a/server/sonar-web/src/main/js/apps/account/organizations/UserOrganizations.tsx
+++ b/server/sonar-web/src/main/js/apps/account/organizations/UserOrganizations.tsx
@@ -20,8 +20,8 @@
import * as React from 'react';
import Helmet from 'react-helmet';
import { connect } from 'react-redux';
-import { Link } from 'react-router';
import OrganizationsList from './OrganizationsList';
+import CreateOrganizationForm from './CreateOrganizationForm';
import { translate } from '../../../helpers/l10n';
import { fetchIfAnyoneCanCreateOrganizations, fetchMyOrganizations } from './actions';
import { getAppState, getMyOrganizations, getGlobalSettingValue } from '../../../store/rootReducer';
@@ -38,17 +38,16 @@ interface DispatchProps {
fetchMyOrganizations: () => Promise<void>;
}
-interface Props extends StateProps, DispatchProps {
- children?: React.ReactNode;
-}
+interface Props extends StateProps, DispatchProps {}
interface State {
+ createOrganization: boolean;
loading: boolean;
}
class UserOrganizations extends React.PureComponent<Props, State> {
mounted: boolean;
- state: State = { loading: true };
+ state: State = { createOrganization: false, loading: true };
componentDidMount() {
this.mounted = true;
@@ -68,6 +67,20 @@ class UserOrganizations extends React.PureComponent<Props, State> {
}
};
+ openCreateOrganizationForm = () => this.setState({ createOrganization: true });
+
+ closeCreateOrganizationForm = () => this.setState({ createOrganization: false });
+
+ handleCreateClick = (event: React.SyntheticEvent<HTMLButtonElement>) => {
+ event.preventDefault();
+ event.currentTarget.blur();
+ this.openCreateOrganizationForm();
+ };
+
+ handleCreate = () => {
+ this.closeCreateOrganizationForm();
+ };
+
render() {
const anyoneCanCreate =
this.props.anyoneCanCreate != null && this.props.anyoneCanCreate.value === 'true';
@@ -82,9 +95,7 @@ class UserOrganizations extends React.PureComponent<Props, State> {
<h2 className="page-title">{translate('my_account.organizations')}</h2>
{canCreateOrganizations && (
<div className="page-actions">
- <Link to="/account/organizations/create" className="button">
- {translate('create')}
- </Link>
+ <button onClick={this.handleCreateClick}>{translate('create')}</button>
</div>
)}
{this.props.organizations.length > 0 ? (
@@ -104,7 +115,12 @@ class UserOrganizations extends React.PureComponent<Props, State> {
<OrganizationsList organizations={this.props.organizations} />
)}
- {this.props.children}
+ {this.state.createOrganization && (
+ <CreateOrganizationForm
+ onClose={this.closeCreateOrganizationForm}
+ onCreate={this.handleCreate}
+ />
+ )}
</div>
);
}
diff --git a/server/sonar-web/src/main/js/apps/account/routes.ts b/server/sonar-web/src/main/js/apps/account/routes.ts
index 90e66149504..3346b234050 100644
--- a/server/sonar-web/src/main/js/apps/account/routes.ts
+++ b/server/sonar-web/src/main/js/apps/account/routes.ts
@@ -52,15 +52,7 @@ const routes = [
path: 'organizations',
getComponent(_: RouterState, callback: (err: any, component: RouteComponent) => any) {
import('./organizations/UserOrganizations').then(i => callback(null, i.default));
- },
- childRoutes: [
- {
- path: 'create',
- getComponent(_: RouterState, callback: (err: any, component: RouteComponent) => any) {
- import('./organizations/CreateOrganizationForm').then(i => callback(null, i.default));
- }
- }
- ]
+ }
}
]
}
diff --git a/server/sonar-web/src/main/js/apps/organizations/actions.js b/server/sonar-web/src/main/js/apps/organizations/actions.js
index 9050442d5e8..b8484243b70 100644
--- a/server/sonar-web/src/main/js/apps/organizations/actions.js
+++ b/server/sonar-web/src/main/js/apps/organizations/actions.js
@@ -72,6 +72,7 @@ export const createOrganization = (fields /*: Object */) => (dispatch /*: Functi
dispatch(
addGlobalSuccessMessage(translateWithParameters('organization.created', organization.name))
);
+ return organization;
};
return api.createOrganization(fields).then(onFulfilled, onRejected(dispatch));
diff --git a/server/sonar-web/src/main/js/components/controls/Dropdown.tsx b/server/sonar-web/src/main/js/components/controls/Dropdown.tsx
new file mode 100644
index 00000000000..ba419195739
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/controls/Dropdown.tsx
@@ -0,0 +1,92 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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';
+
+interface RenderProps {
+ closeDropdown: () => void;
+ onToggleClick: (event: React.SyntheticEvent<HTMLElement>) => void;
+ open: boolean;
+}
+
+interface Props {
+ children: (renderProps: RenderProps) => JSX.Element;
+ onOpen?: () => void;
+}
+
+interface State {
+ open: boolean;
+}
+
+export default class Dropdown extends React.PureComponent<Props, State> {
+ toggleNode?: HTMLElement;
+
+ constructor(props: Props) {
+ super(props);
+ this.state = { open: false };
+ }
+
+ componentDidUpdate(_: Props, prevState: State) {
+ if (!prevState.open && this.state.open) {
+ this.addClickHandler();
+ if (this.props.onOpen) {
+ this.props.onOpen();
+ }
+ }
+
+ if (prevState.open && !this.state.open) {
+ this.removeClickHandler();
+ }
+ }
+
+ componentWillUnmount() {
+ this.removeClickHandler();
+ }
+
+ addClickHandler = () => {
+ window.addEventListener('click', this.handleWindowClick);
+ };
+
+ removeClickHandler = () => {
+ window.removeEventListener('click', this.handleWindowClick);
+ };
+
+ handleWindowClick = (event: MouseEvent) => {
+ if (!this.toggleNode || !this.toggleNode.contains(event.target as Node)) {
+ this.closeDropdown();
+ }
+ };
+
+ closeDropdown = () => this.setState({ open: false });
+
+ handleToggleClick = (event: React.SyntheticEvent<HTMLElement>) => {
+ this.toggleNode = event.currentTarget;
+ event.preventDefault();
+ event.currentTarget.blur();
+ this.setState(state => ({ open: !state.open }));
+ };
+
+ render() {
+ return this.props.children({
+ closeDropdown: this.closeDropdown,
+ onToggleClick: this.handleToggleClick,
+ open: this.state.open
+ });
+ }
+}
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/Dropdown-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/Dropdown-test.tsx
new file mode 100644
index 00000000000..82048afd2f7
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/controls/__tests__/Dropdown-test.tsx
@@ -0,0 +1,44 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 Dropdown from '../Dropdown';
+import { click } from '../../../helpers/testUtils';
+
+it('renders', () => {
+ expect(
+ shallow(<Dropdown>{() => <div />}</Dropdown>)
+ .find('div')
+ .exists()
+ ).toBeTruthy();
+});
+
+it('toggles', () => {
+ const wrapper = shallow(
+ <Dropdown>{({ onToggleClick }) => <button onClick={onToggleClick} />}</Dropdown>
+ );
+ expect(wrapper.state()).toEqual({ open: false });
+
+ click(wrapper.find('button'));
+ expect(wrapper.state()).toEqual({ open: true });
+
+ click(wrapper.find('button'));
+ expect(wrapper.state()).toEqual({ open: false });
+});
diff --git a/server/sonar-web/src/main/js/components/icons-components/PlusIcon.tsx b/server/sonar-web/src/main/js/components/icons-components/PlusIcon.tsx
new file mode 100644
index 00000000000..1a0738f546b
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/icons-components/PlusIcon.tsx
@@ -0,0 +1,36 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import * as React from 'react';
+import { IconProps } from './types';
+
+export default function PlusIcon({ className, fill = 'currentColor', size = 16 }: IconProps) {
+ return (
+ <svg
+ className={className}
+ width={size}
+ height={size}
+ viewBox="0 0 16 16"
+ version="1.1"
+ xmlnsXlink="http://www.w3.org/1999/xlink"
+ xmlSpace="preserve">
+ <path style={{ fill }} d="M1,7L7,7L7,1L9,1L9,7L15,7L15,9L9,9L9,15L7,15L7,9L1,9L1,7Z" />
+ </svg>
+ );
+}