]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-8665 Create the "Organizations" page in the "My Account" space
authorStas Vilchik <vilchiks@gmail.com>
Mon, 6 Feb 2017 17:44:08 +0000 (18:44 +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/organizations/OrganizationCard.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/organizations/OrganizationsList.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/organizations/UserOrganizations.js
server/sonar-web/src/main/js/apps/account/organizations/actions.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/organizations/actions.js
server/sonar-web/src/main/js/store/organizations/__tests__/__snapshots__/duck-test.js.snap
server/sonar-web/src/main/js/store/organizations/duck.js
server/sonar-web/src/main/js/store/rootReducer.js
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index ef998da81c3b48a234ae02cc2ce473174a4e8ab1..bc8873b80bf1ee0a3759a090d53883571ec065ef 100644 (file)
@@ -29,6 +29,10 @@ export const getOrganizations = (organizations?: Array<string>) => {
   return getJSON('/api/organizations/search', data);
 };
 
+export const getMyOrganizations = () => (
+    getJSON('/api/organizations/search_my_organizations').then(r => r.organizations)
+);
+
 type GetOrganizationType = null | Organization;
 
 type GetOrganizationNavigation = {
diff --git a/server/sonar-web/src/main/js/apps/account/organizations/OrganizationCard.js b/server/sonar-web/src/main/js/apps/account/organizations/OrganizationCard.js
new file mode 100644 (file)
index 0000000..d1cbea1
--- /dev/null
@@ -0,0 +1,66 @@
+/*
+ * 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 OrganizationLink from '../../../components/ui/OrganizationLink';
+import type { Organization } from '../../../store/organizations/duck';
+
+export default class OrganizationCard extends React.Component {
+  props: {
+    organization: Organization
+  };
+
+  render () {
+    const { organization } = this.props;
+
+    return (
+        <div className="account-project-card clearfix">
+          <aside className="account-project-side">
+            {!!organization.avatar && (
+                <div className="spacer-bottom">
+                  <img src={organization.avatar} height={30} alt={organization.name}/>
+                </div>
+            )}
+            {!!organization.url && (
+                <div className="text-limited text-top spacer-bottom">
+                  <a className="small" href={organization.url} title={organization.url} rel="nofollow">
+                    {organization.url}
+                  </a>
+                </div>
+            )}
+          </aside>
+
+          <h3 className="account-project-name">
+            <OrganizationLink organization={organization}>
+              {organization.name}
+            </OrganizationLink>
+          </h3>
+
+          <div className="account-project-key">{organization.key}</div>
+
+          {!!organization.description && (
+              <div className="account-project-description">
+                {organization.description}
+              </div>
+          )}
+        </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/account/organizations/OrganizationsList.js b/server/sonar-web/src/main/js/apps/account/organizations/OrganizationsList.js
new file mode 100644 (file)
index 0000000..3c0c019
--- /dev/null
@@ -0,0 +1,41 @@
+/*
+ * 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 OrganizationCard from './OrganizationCard';
+import type { Organization } from '../../../store/organizations/duck';
+
+export default class OrganizationsList extends React.Component {
+  props: {
+    organizations: Array<Organization>
+  };
+
+  render () {
+    return (
+        <ul className="account-projects-list">
+          {this.props.organizations.map(organization => (
+              <li key={organization.key}>
+                <OrganizationCard organization={organization}/>
+              </li>
+          ))}
+        </ul>
+    );
+  }
+}
index ca97abba72daac279633a1a1086b4feed3155ab3..cd8e2b831c0fd584e233f1a4c655765bde21ae94 100644 (file)
 // @flow
 import React from 'react';
 import Helmet from 'react-helmet';
+import { connect } from 'react-redux';
 import { Link } from 'react-router';
+import OrganizationsList from './OrganizationsList';
 import { translate } from '../../../helpers/l10n';
+import { fetchIfAnyoneCanCreateOrganizations, fetchMyOrganizations } from './actions';
+import { getMyOrganizations, getSettingValue, getCurrentUser } from '../../../store/rootReducer';
+import type { Organization } from '../../../store/organizations/duck';
+import { isUserAdmin } from '../../../helpers/users';
+
+class UserOrganizations extends React.Component {
+  mounted: boolean;
+
+  props: {
+    anyoneCanCreate: boolean,
+    currentUser: Object,
+    children: Object,
+    organizations: Array<Organization>,
+    fetchIfAnyoneCanCreateOrganizations: () => Promise<*>,
+    fetchMyOrganizations: () => Promise<*>
+  };
+
+  state: { loading: boolean } = {
+    loading: true
+  };
+
+  componentDidMount () {
+    this.mounted = true;
+    Promise.all([this.props.fetchMyOrganizations(), this.props.fetchIfAnyoneCanCreateOrganizations()]).then(() => {
+      if (this.mounted) {
+        this.setState({ loading: false });
+      }
+    });
+  }
+
+  componentWillUnmount () {
+    this.mounted = false;
+  }
 
-export default class UserOrganizations extends React.Component {
   render () {
     const title = translate('my_account.organizations') + ' - ' + translate('my_account.page');
 
+    const canCreateOrganizations = !this.state.loading &&
+        this.props.anyoneCanCreate ||
+        isUserAdmin(this.props.currentUser);
+
     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>
+            {canCreateOrganizations && (
+                <div className="page-actions">
+                  <Link to="/account/organizations/create" className="button">{translate('create')}</Link>
+                </div>
+            )}
+            {this.props.organizations.length > 0 ? (
+                <div className="page-description">
+                  {translate('my_account.organizations.description')}
+                </div>
+            ) : (
+                <div className="page-description">
+                  {translate('my_account.organizations.no_results')}
+                </div>
+            )}
           </header>
 
+          {this.state.loading ? (
+              <i className="spinner"/>
+          ) : (
+              <OrganizationsList organizations={this.props.organizations}/>
+          )}
+
           {this.props.children}
         </div>
     );
   }
 }
+
+const mapStateToProps = state => ({
+  anyoneCanCreate: getSettingValue(state, 'sonar.organizations.anyoneCanCreate') === 'true',
+  currentUser: getCurrentUser(state),
+  organizations: getMyOrganizations(state)
+});
+
+const mapDispatchToProps = {
+  fetchMyOrganizations,
+  fetchIfAnyoneCanCreateOrganizations
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(UserOrganizations);
diff --git a/server/sonar-web/src/main/js/apps/account/organizations/actions.js b/server/sonar-web/src/main/js/apps/account/organizations/actions.js
new file mode 100644 (file)
index 0000000..0971b33
--- /dev/null
@@ -0,0 +1,42 @@
+/*
+ * 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 * as api from '../../../api/organizations';
+import { receiveMyOrganizations } from '../../../store/organizations/duck';
+import { getValues } from '../../../api/settings';
+import { receiveValues } from '../../settings/store/values/actions';
+
+export const fetchMyOrganizations = (): Function => (dispatch: Function): Promise<*> => {
+  return api.getMyOrganizations().then(keys => {
+    if (keys.length > 0) {
+      return api.getOrganizations(keys).then(({ organizations }) => {
+        return dispatch(receiveMyOrganizations(organizations));
+      });
+    } else {
+      return dispatch(receiveMyOrganizations([]));
+    }
+  });
+};
+
+export const fetchIfAnyoneCanCreateOrganizations = (): Function => (dispatch: Function): Promise<*> => {
+  return getValues('sonar.organizations.anyoneCanCreate').then(values => {
+    dispatch(receiveValues(values));
+  });
+};
index cd8ee61d9a67a3d9a2cbebd67756a3dfed1b221a..14ccf6a5c56e85697f5e6f33108fa09839bea7da 100644 (file)
@@ -46,7 +46,7 @@ export const fetchOrganization = (key: string): Function => (dispatch: Function)
 
 export const createOrganization = (fields: {}): Function => (dispatch: Function): Promise<*> => {
   const onFulfilled = (organization: Organization) => {
-    dispatch(actions.receiveOrganizations([organization]));
+    dispatch(actions.createOrganization(organization));
     dispatch(addGlobalSuccessMessage(translateWithParameters('organization.created', organization.name)));
   };
 
index 158cccb9ddef160d3369c96b1469ef39b24df76c..7225fb41f886235380fa225f2096b02b51fc42e3 100644 (file)
@@ -1,6 +1,7 @@
 exports[`Reducer should have initial state 1`] = `
 Object {
   "byKey": Object {},
+  "my": Array [],
 }
 `;
 
@@ -16,6 +17,7 @@ Object {
       "name": "Foo",
     },
   },
+  "my": Array [],
 }
 `;
 
@@ -31,5 +33,6 @@ Object {
       "name": "Qwe",
     },
   },
+  "my": Array [],
 }
 `;
index 2d42f78795df3187d1f78adb6b93b7793661f6d1..7df169b1dd35bd369b329e59d46da5a45dc33692 100644 (file)
@@ -20,6 +20,8 @@
 // @flow
 import { combineReducers } from 'redux';
 import omit from 'lodash/omit';
+import uniq from 'lodash/uniq';
+import without from 'lodash/without';
 
 export type Organization = {
   avatar?: string,
@@ -36,6 +38,16 @@ type ReceiveOrganizationsAction = {
   organizations: Array<Organization>
 };
 
+type ReceiveMyOrganizationsAction = {
+  type: 'RECEIVE_MY_ORGANIZATIONS',
+  organizations: Array<Organization>
+};
+
+type CreateOrganizationAction = {
+  type: 'CREATE_ORGANIZATION',
+  organization: Organization
+};
+
 type UpdateOrganizationAction = {
   type: 'UPDATE_ORGANIZATION',
   key: string,
@@ -47,14 +59,22 @@ type DeleteOrganizationAction = {
   key: string
 };
 
-type Action = ReceiveOrganizationsAction | UpdateOrganizationAction | DeleteOrganizationAction;
+type Action =
+    ReceiveOrganizationsAction |
+        ReceiveMyOrganizationsAction |
+        CreateOrganizationAction |
+        UpdateOrganizationAction |
+        DeleteOrganizationAction;
 
 type ByKeyState = {
   [key: string]: Organization
 };
 
+type MyState = Array<string>;
+
 type State = {
-  byKey: ByKeyState
+  byKey: ByKeyState,
+  my: MyState
 };
 
 export const receiveOrganizations = (organizations: Array<Organization>): ReceiveOrganizationsAction => ({
@@ -62,6 +82,16 @@ export const receiveOrganizations = (organizations: Array<Organization>): Receiv
   organizations
 });
 
+export const receiveMyOrganizations = (organizations: Array<Organization>): ReceiveMyOrganizationsAction => ({
+  type: 'RECEIVE_MY_ORGANIZATIONS',
+  organizations
+});
+
+export const createOrganization = (organization: Organization): CreateOrganizationAction => ({
+  type: 'CREATE_ORGANIZATION',
+  organization
+});
+
 export const updateOrganization = (key: string, changes: {}): UpdateOrganizationAction => ({
   type: 'UPDATE_ORGANIZATION',
   key,
@@ -73,7 +103,10 @@ export const deleteOrganization = (key: string): DeleteOrganizationAction => ({
   key
 });
 
-const onReceiveOrganizations = (state: ByKeyState, action: ReceiveOrganizationsAction): ByKeyState => {
+const onReceiveOrganizations = (
+  state: ByKeyState,
+  action: ReceiveOrganizationsAction | ReceiveMyOrganizationsAction
+): ByKeyState => {
   const nextState = { ...state };
   action.organizations.forEach(organization => {
     nextState[organization.key] = { ...state[organization.key], ...organization };
@@ -84,7 +117,10 @@ const onReceiveOrganizations = (state: ByKeyState, action: ReceiveOrganizationsA
 const byKey = (state: ByKeyState = {}, action: Action) => {
   switch (action.type) {
     case 'RECEIVE_ORGANIZATIONS':
+    case 'RECEIVE_MY_ORGANIZATIONS':
       return onReceiveOrganizations(state, action);
+    case 'CREATE_ORGANIZATION':
+      return { ...state, [action.organization.key]: action.organization };
     case 'UPDATE_ORGANIZATION':
       return {
         ...state,
@@ -101,12 +137,29 @@ const byKey = (state: ByKeyState = {}, action: Action) => {
   }
 };
 
-export default combineReducers({ byKey });
+const my = (state: MyState = [], action: Action) => {
+  switch (action.type) {
+    case 'RECEIVE_MY_ORGANIZATIONS':
+      return uniq([...state, ...action.organizations.map(o => o.key)]);
+    case 'CREATE_ORGANIZATION':
+      return uniq([...state, action.organization.key]);
+    case 'DELETE_ORGANIZATION':
+      return without(state, action.key);
+    default:
+      return state;
+  }
+};
+
+export default combineReducers({ byKey, my });
 
 export const getOrganizationByKey = (state: State, key: string): Organization => (
     state.byKey[key]
 );
 
+export const getMyOrganizations = (state: State): Array<Organization> => (
+    state.my.map(key => getOrganizationByKey(state, key))
+);
+
 export const areThereCustomOrganizations = (state: State): boolean => (
     Object.keys(state.byKey).length > 1
 );
index e3a26024c09219ad34cd5ed64c19bdeac26f737e..9403962fdebf485bb1740dd70b514f1dd0125d03 100644 (file)
@@ -116,6 +116,10 @@ export const getOrganizationByKey = (state, key) => (
     fromOrganizations.getOrganizationByKey(state.organizations, key)
 );
 
+export const getMyOrganizations = state => (
+    fromOrganizations.getMyOrganizations(state.organizations)
+);
+
 export const areThereCustomOrganizations = state => (
     fromOrganizations.areThereCustomOrganizations(state.organizations)
 );
index 5ea428ad4ac360cc357c9d38280fd2b81d339444..a963f404ac86c7aab29a77b64170252b0dea4d15 100644 (file)
@@ -1786,6 +1786,7 @@ 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.organizations.no_results=You are not administering any organizations yet.
 my_account.create_organization=Create Organization