]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-9554 Make "Analyze a project" and "Create an org" more discoverable
authorStas Vilchik <stas.vilchik@sonarsource.com>
Thu, 7 Dec 2017 13:27:05 +0000 (14:27 +0100)
committerStas Vilchik <stas.vilchik@sonarsource.com>
Tue, 2 Jan 2018 09:38:10 +0000 (10:38 +0100)
14 files changed:
server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.css
server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavPlus-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavPlus-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/organizations/CreateOrganizationForm.js [deleted file]
server/sonar-web/src/main/js/apps/account/organizations/CreateOrganizationForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/organizations/UserOrganizations.tsx
server/sonar-web/src/main/js/apps/account/routes.ts
server/sonar-web/src/main/js/apps/organizations/actions.js
server/sonar-web/src/main/js/components/controls/Dropdown.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/controls/__tests__/Dropdown-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/icons-components/PlusIcon.tsx [new file with mode: 0644]
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index ac02282d58528e58588d0e4b3fb326c1fbc241c7..48a78705251b7eea1dcd9b7a91a051492acc4435 100644 (file)
   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;
 }
 
index 317077eb1af9170a75d4b8bdb24671cc884fc63d..564775c01795fefef47a7fc259fa83df8a00435f 100644 (file)
@@ -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 (file)
index 0000000..12e1d70
--- /dev/null
@@ -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 (file)
index 0000000..e315be5
--- /dev/null
@@ -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 (file)
index 0000000..4400a02
--- /dev/null
@@ -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.js
deleted file mode 100644 (file)
index bd6b0c8..0000000
+++ /dev/null
@@ -1,243 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2017 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-// @flow
-import React from 'react';
-import { debounce } from 'lodash';
-import { connect } from 'react-redux';
-import { withRouter } from 'react-router';
-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 }
-  };
-*/
-
-  constructor(props) {
-    super(props);
-    this.state = {
-      loading: false,
-      avatar: '',
-      avatarImage: '',
-      description: '',
-      key: '',
-      name: '',
-      url: ''
-    };
-    this.changeAvatarImage = debounce(this.changeAvatarImage, 500);
-  }
-
-  componentDidMount() {
-    this.mounted = true;
-  }
-
-  componentWillUnmount() {
-    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;
-    this.setState({ avatar: value });
-    this.changeAvatarImage(value);
-  };
-
-  changeAvatarImage = (value /*: string */) => {
-    this.setState({ avatarImage: value });
-  };
-
-  handleSubmit = (e /*: Object */) => {
-    e.preventDefault();
-    const organization /*: Object */ = { name: this.state.name };
-    if (this.state.avatar) {
-      Object.assign(organization, { avatar: this.state.avatar });
-    }
-    if (this.state.description) {
-      Object.assign(organization, { description: this.state.description });
-    }
-    if (this.state.key) {
-      Object.assign(organization, { key: this.state.key });
-    }
-    if (this.state.url) {
-      Object.assign(organization, { url: this.state.url });
-    }
-    this.setState({ loading: true });
-    this.props
-      .createOrganization(organization)
-      .then(this.stopProcessingAndClose, this.stopProcessing);
-  };
-
-  render() {
-    return (
-      <Modal contentLabel="modal form" onRequestClose={this.closeForm}>
-        <header className="modal-head">
-          <h2>{translate('my_account.create_organization')}</h2>
-        </header>
-
-        <form onSubmit={this.handleSubmit}>
-          <div className="modal-body">
-            <div className="modal-field">
-              <label htmlFor="organization-name">
-                {translate('organization.name')}
-                <em className="mandatory">*</em>
-              </label>
-              <input
-                id="organization-name"
-                autoFocus={true}
-                name="name"
-                required={true}
-                type="text"
-                minLength="2"
-                maxLength="64"
-                value={this.state.name}
-                disabled={this.state.loading}
-                onChange={e => this.setState({ name: e.target.value })}
-              />
-              <div className="modal-field-description">
-                {translate('organization.name.description')}
-              </div>
-            </div>
-            <div className="modal-field">
-              <label htmlFor="organization-key">{translate('organization.key')}</label>
-              <input
-                id="organization-key"
-                name="key"
-                type="text"
-                minLength="2"
-                maxLength="64"
-                value={this.state.key}
-                disabled={this.state.loading}
-                onChange={e => this.setState({ key: e.target.value })}
-              />
-              <div className="modal-field-description">
-                {translate('organization.key.description')}
-              </div>
-            </div>
-            <div className="modal-field">
-              <label htmlFor="organization-avatar">{translate('organization.avatar')}</label>
-              <input
-                id="organization-avatar"
-                name="avatar"
-                type="text"
-                maxLength="256"
-                value={this.state.avatar}
-                disabled={this.state.loading}
-                onChange={this.handleAvatarInputChange}
-              />
-              <div className="modal-field-description">
-                {translate('organization.avatar.description')}
-              </div>
-              {!!this.state.avatarImage && (
-                <div className="spacer-top spacer-bottom">
-                  <div className="little-spacer-bottom">
-                    {translate('organization.avatar.preview')}
-                    {':'}
-                  </div>
-                  <img src={this.state.avatarImage} alt="" height={30} />
-                </div>
-              )}
-            </div>
-            <div className="modal-field">
-              <label htmlFor="organization-description">{translate('description')}</label>
-              <textarea
-                id="organization-description"
-                name="description"
-                rows="3"
-                maxLength="256"
-                value={this.state.description}
-                disabled={this.state.loading}
-                onChange={e => this.setState({ description: e.target.value })}
-              />
-              <div className="modal-field-description">
-                {translate('organization.description.description')}
-              </div>
-            </div>
-            <div className="modal-field">
-              <label htmlFor="organization-url">{translate('organization.url')}</label>
-              <input
-                id="organization-url"
-                name="url"
-                type="text"
-                maxLength="256"
-                value={this.state.url}
-                disabled={this.state.loading}
-                onChange={e => this.setState({ url: e.target.value })}
-              />
-              <div className="modal-field-description">
-                {translate('organization.url.description')}
-              </div>
-            </div>
-          </div>
-
-          <footer className="modal-foot">
-            <div>
-              {this.state.loading && <i className="spinner spacer-right" />}
-              <button disabled={this.state.loading} type="submit">
-                {translate('create')}
-              </button>
-              <button type="reset" className="button-link" onClick={this.closeForm}>
-                {translate('cancel')}
-              </button>
-            </div>
-          </footer>
-        </form>
-      </Modal>
-    );
-  }
-}
-
-const mapStateToProps = null;
-
-const mapDispatchToProps = { createOrganization };
-
-export default connect(mapStateToProps, mapDispatchToProps)(withRouter(CreateOrganizationForm));
diff --git a/server/sonar-web/src/main/js/apps/account/organizations/CreateOrganizationForm.tsx b/server/sonar-web/src/main/js/apps/account/organizations/CreateOrganizationForm.tsx
new file mode 100644 (file)
index 0000000..66087e0
--- /dev/null
@@ -0,0 +1,245 @@
+/*
+ * 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 { debounce } from 'lodash';
+import { connect } from 'react-redux';
+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';
+
+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: Props) {
+    super(props);
+    this.state = {
+      avatar: '',
+      avatarImage: '',
+      description: '',
+      key: '',
+      loading: false,
+      name: '',
+      url: ''
+    };
+    this.changeAvatarImage = debounce(this.changeAvatarImage, 500);
+  }
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  stopProcessing = () => {
+    if (this.mounted) {
+      this.setState({ loading: false });
+    }
+  };
+
+  handleAvatarInputChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
+    const { value } = event.currentTarget;
+    this.setState({ avatar: value });
+    this.changeAvatarImage(value);
+  };
+
+  changeAvatarImage = (value: string) => {
+    this.setState({ avatarImage: value });
+  };
+
+  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 });
+    }
+    if (this.state.description) {
+      Object.assign(organization, { description: this.state.description });
+    }
+    if (this.state.key) {
+      Object.assign(organization, { key: this.state.key });
+    }
+    if (this.state.url) {
+      Object.assign(organization, { url: this.state.url });
+    }
+    this.setState({ loading: true });
+    this.props.createOrganization(organization).then(this.props.onCreate, this.stopProcessing);
+  };
+
+  render() {
+    return (
+      <Modal contentLabel="modal form" onRequestClose={this.props.onClose}>
+        <header className="modal-head">
+          <h2>{translate('my_account.create_organization')}</h2>
+        </header>
+
+        <form onSubmit={this.handleSubmit}>
+          <div className="modal-body">
+            <div className="modal-field">
+              <label htmlFor="organization-name">
+                {translate('organization.name')}
+                <em className="mandatory">*</em>
+              </label>
+              <input
+                id="organization-name"
+                autoFocus={true}
+                name="name"
+                required={true}
+                type="text"
+                minLength={2}
+                maxLength={64}
+                value={this.state.name}
+                disabled={this.state.loading}
+                onChange={this.handleNameChange}
+              />
+              <div className="modal-field-description">
+                {translate('organization.name.description')}
+              </div>
+            </div>
+            <div className="modal-field">
+              <label htmlFor="organization-key">{translate('organization.key')}</label>
+              <input
+                id="organization-key"
+                name="key"
+                type="text"
+                minLength={2}
+                maxLength={64}
+                value={this.state.key}
+                disabled={this.state.loading}
+                onChange={this.handleKeyChange}
+              />
+              <div className="modal-field-description">
+                {translate('organization.key.description')}
+              </div>
+            </div>
+            <div className="modal-field">
+              <label htmlFor="organization-avatar">{translate('organization.avatar')}</label>
+              <input
+                id="organization-avatar"
+                name="avatar"
+                type="text"
+                maxLength={256}
+                value={this.state.avatar}
+                disabled={this.state.loading}
+                onChange={this.handleAvatarInputChange}
+              />
+              <div className="modal-field-description">
+                {translate('organization.avatar.description')}
+              </div>
+              {!!this.state.avatarImage && (
+                <div className="spacer-top spacer-bottom">
+                  <div className="little-spacer-bottom">
+                    {translate('organization.avatar.preview')}
+                    {':'}
+                  </div>
+                  <img src={this.state.avatarImage} alt="" height={30} />
+                </div>
+              )}
+            </div>
+            <div className="modal-field">
+              <label htmlFor="organization-description">{translate('description')}</label>
+              <textarea
+                id="organization-description"
+                name="description"
+                rows={3}
+                maxLength={256}
+                value={this.state.description}
+                disabled={this.state.loading}
+                onChange={this.handleDescriptionChange}
+              />
+              <div className="modal-field-description">
+                {translate('organization.description.description')}
+              </div>
+            </div>
+            <div className="modal-field">
+              <label htmlFor="organization-url">{translate('organization.url')}</label>
+              <input
+                id="organization-url"
+                name="url"
+                type="text"
+                maxLength={256}
+                value={this.state.url}
+                disabled={this.state.loading}
+                onChange={this.handleUrlChange}
+              />
+              <div className="modal-field-description">
+                {translate('organization.url.description')}
+              </div>
+            </div>
+          </div>
+
+          <footer className="modal-foot">
+            <div>
+              {this.state.loading && <i className="spinner spacer-right" />}
+              <button disabled={this.state.loading} type="submit">
+                {translate('create')}
+              </button>
+              <button className="button-link" onClick={this.props.onClose} type="reset">
+                {translate('cancel')}
+              </button>
+            </div>
+          </footer>
+        </form>
+      </Modal>
+    );
+  }
+}
+
+const mapDispatchToProps: DispatchProps = { createOrganization: createOrganization as any };
+
+export default connect(null, mapDispatchToProps)(CreateOrganizationForm);
index 75a48b2bed91d93f378683eefb670a2e5e04fd5a..c33f2be07eb865b9085a899ef005bb924bc91f32 100644 (file)
@@ -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>
     );
   }
index 90e6614950414384f6c231735e623b419a8ce686..3346b234050400161e8838841e0e1eb14473621c 100644 (file)
@@ -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));
-            }
-          }
-        ]
+        }
       }
     ]
   }
index 9050442d5e83a4e4120c162d9d25b7d1100e63b0..b8484243b70aee4f858193e29e1f2bc50356f864 100644 (file)
@@ -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 (file)
index 0000000..ba41919
--- /dev/null
@@ -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 (file)
index 0000000..82048af
--- /dev/null
@@ -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 (file)
index 0000000..1a0738f
--- /dev/null
@@ -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>
+  );
+}
index c055ad8d0a5b612a54ac62ec81ac2e1af84124ea..030f1b305bf3c63a707d6253e58b712506087b46 100644 (file)
@@ -1405,6 +1405,8 @@ my_account.organizations.no_results=You are not a member of any organizations ye
 my_account.create_organization=Create Organization
 my_account.search_project=Search Project
 my_account.set_notifications_for=Set notifications for
+my_account.analyze_new_project=Analyze new project
+my_account.create_new_organization=Create new organization
 
 
 #------------------------------------------------------------------------------