]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-8666 Make it possible to create a new organization
authorStas Vilchik <vilchiks@gmail.com>
Mon, 6 Feb 2017 16:45:29 +0000 (17:45 +0100)
committerStas Vilchik <stas-vilchik@users.noreply.github.com>
Tue, 7 Feb 2017 12:13:58 +0000 (13:13 +0100)
server/sonar-web/src/main/js/api/organizations.js
server/sonar-web/src/main/js/apps/account/components/Account.js
server/sonar-web/src/main/js/apps/account/components/Nav.js
server/sonar-web/src/main/js/apps/account/organizations/CreateOrganizationForm.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/organizations/UserOrganizations.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/routes.js
server/sonar-web/src/main/js/apps/organizations/actions.js
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 4685c4ee2c9b49fd9aa702e026bc104e0dc6aa91..ef998da81c3b48a234ae02cc2ce473174a4e8ab1 100644 (file)
@@ -18,7 +18,8 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 // @flow
-import { getJSON, post } from '../helpers/request';
+import { getJSON, post, postJSON } from '../helpers/request';
+import type { Organization } from '../store/organizations/duck';
 
 export const getOrganizations = (organizations?: Array<string>) => {
   const data = {};
@@ -28,13 +29,7 @@ export const getOrganizations = (organizations?: Array<string>) => {
   return getJSON('/api/organizations/search', data);
 };
 
-type GetOrganizationType = null | {
-  avatar?: string,
-  description?: string,
-  key: string,
-  name: string,
-  url?: string
-};
+type GetOrganizationType = null | Organization;
 
 type GetOrganizationNavigation = {
   canAdmin: boolean,
@@ -49,6 +44,10 @@ export const getOrganizationNavigation = (key: string): Promise<GetOrganizationN
   return getJSON('/api/navigation/organization', { organization: key }).then(r => r.organization);
 };
 
+export const createOrganization = (fields: {}): Promise<Organization> => (
+    postJSON('/api/organizations/create', fields).then(r => r.organization)
+);
+
 export const updateOrganization = (key: string, changes: {}) => (
     post('/api/organizations/update', { key, ...changes })
 );
index ec2f895d076a4332c0037818d5f027f142d2fa6d..e87d7911149de0302c7dc5b23ffb1de31fba9906 100644 (file)
@@ -21,7 +21,7 @@ import React from 'react';
 import { connect } from 'react-redux';
 import Nav from './Nav';
 import UserCard from './UserCard';
-import { getCurrentUser } from '../../../store/rootReducer';
+import { getCurrentUser, areThereCustomOrganizations } from '../../../store/rootReducer';
 import handleRequiredAuthentication from '../../../app/utils/handleRequiredAuthentication';
 import '../account.css';
 
@@ -44,7 +44,7 @@ class Account extends React.Component {
           <header className="account-header">
             <div className="account-container clearfix">
               <UserCard user={currentUser}/>
-              <Nav user={currentUser}/>
+              <Nav user={currentUser} customOrganizations={this.props.customOrganizations}/>
             </div>
           </header>
 
@@ -54,8 +54,9 @@ class Account extends React.Component {
   }
 }
 
-export default connect(
-    state => ({
-      currentUser: getCurrentUser(state)
-    })
-)(Account);
+const mapStateToProps = state => ({
+  currentUser: getCurrentUser(state),
+  customOrganizations: areThereCustomOrganizations(state)
+});
+
+export default connect(mapStateToProps)(Account);
index f02b4f2db8981446f7fa8e6efa201319d9eedee9..2889e7c259eaa1dc4cec9636daa21c686413a6fe 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+// @flow
 import React from 'react';
 import { Link, IndexLink } from 'react-router';
 import { translate } from '../../../helpers/l10n';
 
-const Nav = () => (
+type Props = {
+  customOrganizations: boolean
+};
+
+const Nav = ({ customOrganizations }: Props) => (
     <nav className="account-nav clearfix">
       <ul className="nav navbar-nav nav-tabs">
         <li>
@@ -39,11 +44,20 @@ const Nav = () => (
             {translate('my_account.notifications')}
           </Link>
         </li>
-        <li>
-          <Link to="/account/projects/" activeClassName="active">
-            {translate('my_account.projects')}
-          </Link>
-        </li>
+        {!customOrganizations && (
+            <li>
+              <Link to="/account/projects/" activeClassName="active">
+                {translate('my_account.projects')}
+              </Link>
+            </li>
+        )}
+        {customOrganizations && (
+            <li>
+              <Link to="/account/organizations" activeClassName="active">
+                {translate('my_account.organizations')}
+              </Link>
+            </li>
+        )}
       </ul>
     </nav>
 );
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
new file mode 100644 (file)
index 0000000..4196449
--- /dev/null
@@ -0,0 +1,240 @@
+/*
+ * 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.
+ */
+// @flow
+import React from 'react';
+import Modal from 'react-modal';
+import debounce from 'lodash/debounce';
+import { connect } from 'react-redux';
+import { withRouter } from 'react-router';
+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.Component {
+  mounted: boolean;
+
+  props: {
+    createOrganization: () => Promise<*>,
+    router: { push: (string) => void }
+  };
+
+  state: State = {
+    loading: false,
+    avatar: '',
+    avatarImage: '',
+    description: '',
+    key: '',
+    name: '',
+    url: ''
+  };
+
+  constructor (props) {
+    super(props);
+    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 isOpen={true}
+               contentLabel="modal form"
+               className="modal"
+               overlayClassName="modal-overlay"
+               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-key">
+                  {translate('organization.key')}
+                </label>
+                <input id="organization-key"
+                       autoFocus={true}
+                       name="key"
+                       type="text"
+                       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-name">
+                  {translate('organization.name')}
+                  <em className="mandatory">*</em>
+                </label>
+                <input id="organization-name"
+                       name="name"
+                       required={true}
+                       type="text"
+                       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-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">
+              {this.state.processing ? (
+                      <i className="spinner"/>
+                  ) : (
+                      <div>
+                        <button 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/UserOrganizations.js b/server/sonar-web/src/main/js/apps/account/organizations/UserOrganizations.js
new file mode 100644 (file)
index 0000000..ca97abb
--- /dev/null
@@ -0,0 +1,48 @@
+/*
+ * 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.
+ */
+// @flow
+import React from 'react';
+import Helmet from 'react-helmet';
+import { Link } from 'react-router';
+import { translate } from '../../../helpers/l10n';
+
+export default class UserOrganizations extends React.Component {
+  render () {
+    const title = translate('my_account.organizations') + ' - ' + translate('my_account.page');
+
+    return (
+        <div className="account-body account-container">
+          <Helmet title={title} titleTemplate="%s - SonarQube"/>
+
+          <header className="page-header">
+            <h2 className="page-title">{translate('my_account.organizations')}</h2>
+            <div className="page-actions">
+              <Link to="/account/organizations/create" className="button">{translate('create')}</Link>
+            </div>
+            <div className="page-description">
+              {translate('my_account.organizations.description')}
+            </div>
+          </header>
+
+          {this.props.children}
+        </div>
+    );
+  }
+}
index 2cf1a59c8b41dedbfcba011c72b37ac2e293a6b4..f5a3590906f0558e2b13a1113b6dc7ed2f56e87b 100644 (file)
@@ -24,6 +24,8 @@ import ProjectsContainer from './projects/ProjectsContainer';
 import Security from './components/Security';
 import Profile from './profile/Profile';
 import Notifications from './notifications/Notifications';
+import UserOrganizations from './organizations/UserOrganizations';
+import CreateOrganizationForm from './organizations/CreateOrganizationForm';
 
 export default (
     <Route component={Account}>
@@ -31,6 +33,9 @@ export default (
       <Route path="security" component={Security}/>
       <Route path="projects" component={ProjectsContainer}/>
       <Route path="notifications" component={Notifications}/>
+      <Route path="organizations" component={UserOrganizations}>
+        <Route path="create" component={CreateOrganizationForm}/>
+      </Route>
 
       <Route path="issues" onEnter={() => {
         window.location = window.baseUrl + '/issues' + window.location.hash + '|assigned_to_me=true';
index 51266527fc95a56658c3c878d9ec634f8b4a5cf4..cd8ee61d9a67a3d9a2cbebd67756a3dfed1b221a 100644 (file)
@@ -22,7 +22,13 @@ import * as api from '../../api/organizations';
 import { onFail } from '../../store/rootActions';
 import * as actions from '../../store/organizations/duck';
 import { addGlobalSuccessMessage } from '../../store/globalMessages/duck';
-import { translate } from '../../helpers/l10n';
+import { translate, translateWithParameters } from '../../helpers/l10n';
+import type { Organization } from '../../store/organizations/duck';
+
+const onRejected = (dispatch: Function) => (error: Object) => {
+  onFail(dispatch)(error);
+  return Promise.reject();
+};
 
 export const fetchOrganization = (key: string): Function => (dispatch: Function): Promise<*> => {
   const onFulfilled = ([organization, navigation]) => {
@@ -38,6 +44,15 @@ export const fetchOrganization = (key: string): Function => (dispatch: Function)
   ]).then(onFulfilled, onFail(dispatch));
 };
 
+export const createOrganization = (fields: {}): Function => (dispatch: Function): Promise<*> => {
+  const onFulfilled = (organization: Organization) => {
+    dispatch(actions.receiveOrganizations([organization]));
+    dispatch(addGlobalSuccessMessage(translateWithParameters('organization.created', organization.name)));
+  };
+
+  return api.createOrganization(fields).then(onFulfilled, onRejected(dispatch));
+};
+
 export const updateOrganization = (key: string, changes: {}): Function => (dispatch: Function): Promise<*> => {
   const onFulfilled = () => {
     dispatch(actions.updateOrganization(key, changes));
index 8f7ac899613eddc01ad103713b06cb1afaf611f6..5ea428ad4ac360cc357c9d38280fd2b81d339444 100644 (file)
@@ -1784,6 +1784,9 @@ my_account.projects.no_results=You are not administering any project yet.
 my_account.projects.analyzed_x=Analyzed {0}
 my_account.projects.never_analyzed=Never analyzed
 my_account.projects.x_characters_min=({0} characters min)
+my_account.organizations=Organizations
+my_account.organizations.description=Those organizations are the ones you are administering.
+my_account.create_organization=Create Organization
 
 
 
@@ -2759,6 +2762,7 @@ about_page.scanners.ant.text=The SonarQube Scanner for Ant lets you start an ana
 organization.avatar=Avatar
 organization.avatar.description=Url of a small image that represents the organization (preferably 30px height).
 organization.avatar.preview=Preview
+organization.created=Organization "{0}" has been created.
 organization.delete=Delete Organization
 organization.delete.description=Delete this organization from SonarQube. All projects belonging to the organization will be deleted as well. The operation cannot be undone.
 organization.delete.question=Are you sure you want to delete this organization?
@@ -2767,6 +2771,7 @@ organization.description=Description
 organization.description.description=Description of the organization (256 characters max).
 organization.edit=Edit Organization
 organization.key=Key
+organization.key.description=Key of the organization (2 to 32 characters). The key is unique to the whole SonarQube. When not specified, the key is computed from the name.
 organization.name=Name
 organization.name.description=Name of the organization (2 to 64 characters).
 organization.updated=Organization details have been updated.