]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-1330 add widget to manage quality profile permissions
authorStas Vilchik <stas.vilchik@sonarsource.com>
Mon, 25 Sep 2017 13:59:36 +0000 (15:59 +0200)
committerStas Vilchik <stas.vilchik@sonarsource.com>
Mon, 2 Oct 2017 15:18:15 +0000 (17:18 +0200)
45 files changed:
server/sonar-web/src/main/js/api/quality-profiles.ts
server/sonar-web/src/main/js/apps/permissions/shared/components/GroupHolder.js
server/sonar-web/src/main/js/apps/permissions/shared/components/GroupIcon.js [deleted file]
server/sonar-web/src/main/js/apps/projectQualityProfiles/App.tsx
server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/App-test.tsx
server/sonar-web/src/main/js/apps/quality-profiles/changelog/ChangelogContainer.tsx
server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonContainer.tsx
server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileDetails.tsx
server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileExporters.tsx
server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileInheritance.tsx
server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissions.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsFormSelect.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsGroup.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsUser.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileProjects.tsx
server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileRules.tsx
server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissions-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsForm-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsFormSelect-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsGroup-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsUser-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfileRules-test.tsx
server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfilePermissions-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfilePermissionsForm-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfilePermissionsFormSelect-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfilePermissionsGroup-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfilePermissionsUser-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfileRules-test.tsx.snap
server/sonar-web/src/main/js/apps/quality-profiles/home/EvolutionDeprecated.tsx
server/sonar-web/src/main/js/apps/quality-profiles/home/EvolutionRules.tsx
server/sonar-web/src/main/js/apps/quality-profiles/home/EvolutionStagnant.tsx
server/sonar-web/src/main/js/apps/quality-profiles/home/ProfilesList.tsx
server/sonar-web/src/main/js/apps/quality-profiles/styles.css
server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js
server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchValue.js
server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap
server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchValue-test.js.snap
server/sonar-web/src/main/js/components/controls/SimpleModal.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/controls/__tests__/SimpleModal-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/SimpleModal-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/components/icons-components/GroupIcon.tsx [new file with mode: 0644]
server/sonar-web/src/main/less/components/react-select.less
server/sonar-web/tsconfig.json
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 9ee43f3eabfdb9418b354a1312fce4838832fdb4..953c087579ed07dab4261371bd226c60696b2383 100644 (file)
@@ -26,6 +26,8 @@ import {
   postJSON,
   RequestData
 } from '../helpers/request';
+import { Paging } from '../app/types';
+import throwGlobalError from '../app/utils/throwGlobalError';
 
 export interface Profile {
   key: string;
@@ -46,14 +48,21 @@ export interface Profile {
   projectCount?: number;
 }
 
-export function searchQualityProfiles(data: {
+export interface SearchQualityProfilesParameters {
+  defaults?: boolean;
+  language?: string;
   organization?: string;
-  projectKey?: string;
-}): Promise<Profile[]> {
-  return getJSON('/api/qualityprofiles/search', data).then(r => r.profiles);
+  project?: string;
+  qualityProfile?: string;
 }
 
-export function getQualityProfiles(data: {
+export function searchQualityProfiles(
+  parameters: SearchQualityProfilesParameters
+): Promise<Profile[]> {
+  return getJSON('/api/qualityprofiles/search', parameters).then(r => r.profiles);
+}
+
+export function getQualityProfile(data: {
   compareToSonarWay?: boolean;
   profile: string;
 }): Promise<any> {
@@ -129,3 +138,66 @@ export function associateProject(profileKey: string, projectKey: string): Promis
 export function dissociateProject(profileKey: string, projectKey: string): Promise<void> {
   return post('/api/qualityprofiles/remove_project', { profileKey, projectKey });
 }
+
+export interface SearchUsersGroupsParameters {
+  language: string;
+  organization?: string;
+  qualityProfile: string;
+  q?: string;
+  selected?: 'all' | 'selected' | 'deselected';
+}
+
+export interface SearchUsersResponse {
+  users: Array<{
+    avatar?: string;
+    login: string;
+    name: string;
+    selected?: boolean;
+  }>;
+  paging: Paging;
+}
+
+export function searchUsers(parameters: SearchUsersGroupsParameters): Promise<SearchUsersResponse> {
+  return getJSON('/api/qualityprofiles/search_users', parameters).catch(throwGlobalError);
+}
+
+export interface SearchGroupsResponse {
+  groups: Array<{ name: string }>;
+  paging: Paging;
+}
+
+export function searchGroups(
+  parameters: SearchUsersGroupsParameters
+): Promise<SearchGroupsResponse> {
+  return getJSON('/api/qualityprofiles/search_groups', parameters).catch(throwGlobalError);
+}
+
+export interface AddRemoveUserParameters {
+  language: string;
+  login: string;
+  organization?: string;
+  qualityProfile: string;
+}
+
+export function addUser(parameters: AddRemoveUserParameters): Promise<void | Response> {
+  return post('/api/qualityprofiles/add_user', parameters).catch(throwGlobalError);
+}
+
+export function removeUser(parameters: AddRemoveUserParameters): Promise<void | Response> {
+  return post('/api/qualityprofiles/remove_user', parameters).catch(throwGlobalError);
+}
+
+export interface AddRemoveGroupParameters {
+  group: string;
+  language: string;
+  organization?: string;
+  qualityProfile: string;
+}
+
+export function addGroup(parameters: AddRemoveGroupParameters): Promise<void | Response> {
+  return post('/api/qualityprofiles/add_group', parameters).catch(throwGlobalError);
+}
+
+export function removeGroup(parameters: AddRemoveGroupParameters): Promise<void | Response> {
+  return post('/api/qualityprofiles/remove_group', parameters).catch(throwGlobalError);
+}
index bf2962ca4f3745e98a45a5da90bfa4a37359a34d..12a9c2af1bab44d00e3eab9dd3ae7b430c469ee5 100644 (file)
@@ -19,7 +19,7 @@
  */
 import React from 'react';
 import PropTypes from 'prop-types';
-import GroupIcon from './GroupIcon';
+import GroupIcon from '../../../../components/icons-components/GroupIcon';
 
 export default class GroupHolder extends React.PureComponent {
   static propTypes = {
diff --git a/server/sonar-web/src/main/js/apps/permissions/shared/components/GroupIcon.js b/server/sonar-web/src/main/js/apps/permissions/shared/components/GroupIcon.js
deleted file mode 100644 (file)
index 2e6a55f..0000000
+++ /dev/null
@@ -1,36 +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.
- */
-import React from 'react';
-
-const GroupIcon = () => {
-  /* eslint max-len: 0 */
-  return (
-    <div style={{ padding: '4px 3px 0' }}>
-      <svg xmlns="http://www.w3.org/2000/svg" width="30" height="28" viewBox="0 0 480 448">
-        <path
-          fill="#aaa"
-          d="M148.25 224q-40.5 1.25-66.25 32H48.5Q28 256 14 245.875T0 216.25Q0 128 31 128q1.5 0 10.875 5.25t24.375 10.625T96 149.25q16.75 0 33.25-5.75Q128 152.75 128 160q0 34.75 20.25 64zM416 383.25q0 30-18.25 47.375T349.25 448h-218.5q-30.25 0-48.5-17.375T64 383.25q0-13.25.875-25.875t3.5-27.25T75 303t10.75-24.375 15.5-20.25T122.625 245t27.875-5q2.5 0 10.75 5.375t18.25 12 26.75 12T240 274.75t33.75-5.375 26.75-12 18.25-12T329.5 240q15.25 0 27.875 5t21.375 13.375 15.5 20.25T405 303t6.625 27.125 3.5 27.25.875 25.875zM160 64q0 26.5-18.75 45.25T96 128t-45.25-18.75T32 64t18.75-45.25T96 0t45.25 18.75T160 64zm176 96q0 39.75-28.125 67.875T240 256t-67.875-28.125T144 160t28.125-67.875T240 64t67.875 28.125T336 160zm144 56.25q0 19.5-14 29.625T431.5 256H398q-25.75-30.75-66.25-32Q352 194.75 352 160q0-7.25-1.25-16.5 16.5 5.75 33.25 5.75 14.75 0 29.75-5.375t24.375-10.625T449 128q31 0 31 88.25zM448 64q0 26.5-18.75 45.25T384 128t-45.25-18.75T320 64t18.75-45.25T384 0t45.25 18.75T448 64z"
-        />
-      </svg>
-    </div>
-  );
-};
-
-export default GroupIcon;
index 25c725019b1073538f5fca877cfcbfab5fdb7467..48bf597e5979752798e275fa199574ec0581b351 100644 (file)
@@ -71,7 +71,7 @@ export default class QualityProfiles extends React.PureComponent<Props, State> {
     const organization = this.props.customOrganizations ? component.organization : undefined;
     Promise.all([
       searchQualityProfiles({ organization }),
-      searchQualityProfiles({ organization, projectKey: component.key })
+      searchQualityProfiles({ organization, project: component.key })
     ]).then(
       ([allProfiles, profiles]) => {
         if (this.mounted) {
index 4a68195eba521396b4a97da30c938fbb25da7bc0..9c027e6d4bcf921f2acb6208f0f4dee39f31ec7d 100644 (file)
@@ -74,7 +74,7 @@ it('fetches profiles', () => {
   mount(<App component={component} customOrganizations={false} />);
   expect(searchQualityProfiles.mock.calls).toHaveLength(2);
   expect(searchQualityProfiles).toBeCalledWith({ organization: undefined });
-  expect(searchQualityProfiles).toBeCalledWith({ organization: undefined, projectKey: 'foo' });
+  expect(searchQualityProfiles).toBeCalledWith({ organization: undefined, project: 'foo' });
 });
 
 it('fetches profiles with organization', () => {
@@ -82,7 +82,7 @@ it('fetches profiles with organization', () => {
   mount(<App component={component} customOrganizations={true} />);
   expect(searchQualityProfiles.mock.calls).toHaveLength(2);
   expect(searchQualityProfiles).toBeCalledWith({ organization: 'org' });
-  expect(searchQualityProfiles).toBeCalledWith({ organization: 'org', projectKey: 'foo' });
+  expect(searchQualityProfiles).toBeCalledWith({ organization: 'org', project: 'foo' });
 });
 
 it('changes profile', () => {
index 27cc49278d9cad2b741acea70aa9a6cf0c57a499..9d05395586e81023adf067b423bcd751bf060121 100644 (file)
@@ -169,7 +169,7 @@ export default class ChangelogContainer extends React.PureComponent<Props, State
       this.state.events.length < this.state.total;
 
     return (
-      <div className="quality-profile-box js-profile-changelog">
+      <div className="boxed-group boxed-group-inner js-profile-changelog">
         <header className="spacer-bottom">
           <ChangelogSearch
             fromDate={query.since}
index afa8646f50b4734d3092900c17a04ac1b0fccdd9..a71123b1927dbac3d33e96168d5c694aee11ce3f 100644 (file)
@@ -113,8 +113,8 @@ export default class ComparisonContainer extends React.PureComponent<Props, Stat
     const { left, right, inLeft, inRight, modified } = this.state;
 
     return (
-      <div className="quality-profile-box js-profile-comparison">
-        <header className="spacer-bottom">
+      <div className="boxed-group boxed-group-inner js-profile-comparison">
+        <header>
           <ComparisonForm
             withKey={withKey}
             profile={profile}
@@ -130,14 +130,16 @@ export default class ComparisonContainer extends React.PureComponent<Props, Stat
         right != null &&
         inRight != null &&
         modified != null && (
-          <ComparisonResults
-            left={left}
-            right={right}
-            inLeft={inLeft}
-            inRight={inRight}
-            modified={modified}
-            organization={this.props.organization}
-          />
+          <div className="spacer-top">
+            <ComparisonResults
+              left={left}
+              right={right}
+              inLeft={inLeft}
+              inRight={inRight}
+              modified={modified}
+              organization={this.props.organization}
+            />
+          </div>
         )}
       </div>
     );
index f1408a5277c61e449e67e765f94d0301d9977315..59566996434efe6c9b7fcbae2cc4d384799d2128 100644 (file)
@@ -22,6 +22,7 @@ import ProfileRules from './ProfileRules';
 import ProfileProjects from './ProfileProjects';
 import ProfileInheritance from './ProfileInheritance';
 import ProfileExporters from './ProfileExporters';
+import ProfilePermissions from './ProfilePermissions';
 import { Exporter, Profile } from '../types';
 
 interface Props {
@@ -41,6 +42,13 @@ export default function ProfileDetails(props: Props) {
         <div className="quality-profile-grid-left">
           <ProfileRules {...props} />
           <ProfileExporters {...props} />
+          {props.canAdmin &&
+          !props.profile.isBuiltIn && (
+            <ProfilePermissions
+              organization={props.organization || undefined}
+              profile={props.profile}
+            />
+          )}
         </div>
         <div className="quality-profile-grid-right">
           <ProfileInheritance {...props} />
index be7f0d8b4335be6bb755be413df91c0140b7d395..4c3f452af503cd4a5540aee62a56a967b366e8e1 100644 (file)
@@ -53,19 +53,22 @@ export default class ProfileExporters extends React.PureComponent<Props> {
     }
 
     return (
-      <div className="quality-profile-box quality-profile-exporters">
-        <header className="big-spacer-bottom">
-          <h2>{translate('quality_profiles.exporters')}</h2>
-        </header>
-        <ul>
-          {exportersForLanguage.map(exporter => (
-            <li key={exporter.key} data-key={exporter.key} className="spacer-top">
-              <a href={this.getExportUrl(exporter)} target="_blank">
-                {exporter.name}
-              </a>
-            </li>
-          ))}
-        </ul>
+      <div className="boxed-group quality-profile-exporters">
+        <h2>{translate('quality_profiles.exporters')}</h2>
+        <div className="boxed-group-inner">
+          <ul>
+            {exportersForLanguage.map((exporter, index) => (
+              <li
+                key={exporter.key}
+                data-key={exporter.key}
+                className={index > 0 ? 'spacer-top' : undefined}>
+                <a href={this.getExportUrl(exporter)} target="_blank">
+                  {exporter.name}
+                </a>
+              </li>
+            ))}
+          </ul>
+        </div>
       </div>
     );
   }
index 53a6333126a742b09ead9d983f024ab237349517..1e71f1bd6128bb312b25d2f473f66a4ff1fb2426 100644 (file)
@@ -119,58 +119,65 @@ export default class ProfileInheritance extends React.PureComponent<Props, State
     const extendsBuiltIn = ancestors != null && ancestors.some(profile => profile.isBuiltIn);
 
     return (
-      <div className="quality-profile-inheritance">
-        <header className="big-spacer-bottom clearfix">
-          <h2 className="pull-left">{translate('quality_profiles.profile_inheritance')}</h2>
-          {this.props.canAdmin &&
-          !this.props.profile.isBuiltIn && (
+      <div className="boxed-group quality-profile-inheritance">
+        {this.props.canAdmin &&
+        !this.props.profile.isBuiltIn && (
+          <div className="boxed-group-actions">
             <button className="pull-right js-change-parent" onClick={this.handleChangeParentClick}>
               {translate('quality_profiles.change_parent')}
             </button>
-          )}
+          </div>
+        )}
+
+        <header className="boxed-group-header">
+          <h2>{translate('quality_profiles.profile_inheritance')}</h2>
         </header>
 
-        {!this.state.loading && (
-          <table className="data zebra">
-            <tbody>
-              {ancestors != null &&
-                ancestors.map((ancestor, index) => (
+        <div className="boxed-group-inner">
+          {this.state.loading ? (
+            <i className="spinner" />
+          ) : (
+            <table className="data zebra">
+              <tbody>
+                {ancestors != null &&
+                  ancestors.map((ancestor, index) => (
+                    <ProfileInheritanceBox
+                      className="js-inheritance-ancestor"
+                      depth={index}
+                      key={ancestor.key}
+                      language={profile.language}
+                      organization={this.props.organization}
+                      profile={ancestor}
+                    />
+                  ))}
+
+                {this.state.profile != null && (
                   <ProfileInheritanceBox
-                    className="js-inheritance-ancestor"
-                    depth={index}
-                    key={ancestor.key}
+                    className={currentClassName}
+                    depth={ancestors ? ancestors.length : 0}
+                    displayLink={false}
+                    extendsBuiltIn={extendsBuiltIn}
                     language={profile.language}
                     organization={this.props.organization}
-                    profile={ancestor}
+                    profile={this.state.profile}
                   />
-                ))}
-
-              {this.state.profile != null && (
-                <ProfileInheritanceBox
-                  className={currentClassName}
-                  depth={ancestors ? ancestors.length : 0}
-                  displayLink={false}
-                  extendsBuiltIn={extendsBuiltIn}
-                  language={profile.language}
-                  organization={this.props.organization}
-                  profile={this.state.profile}
-                />
-              )}
-
-              {this.state.children != null &&
-                this.state.children.map(child => (
-                  <ProfileInheritanceBox
-                    className="js-inheritance-child"
-                    depth={ancestors ? ancestors.length + 1 : 0}
-                    key={child.key}
-                    language={profile.language}
-                    organization={this.props.organization}
-                    profile={child}
-                  />
-                ))}
-            </tbody>
-          </table>
-        )}
+                )}
+
+                {this.state.children != null &&
+                  this.state.children.map(child => (
+                    <ProfileInheritanceBox
+                      className="js-inheritance-child"
+                      depth={ancestors ? ancestors.length + 1 : 0}
+                      key={child.key}
+                      language={profile.language}
+                      organization={this.props.organization}
+                      profile={child}
+                    />
+                  ))}
+              </tbody>
+            </table>
+          )}
+        </div>
 
         {this.state.formOpen && (
           <ChangeParentForm
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissions.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissions.tsx
new file mode 100644 (file)
index 0000000..5e995b4
--- /dev/null
@@ -0,0 +1,203 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { sortBy, uniqBy } from 'lodash';
+import ProfilePermissionsUser from './ProfilePermissionsUser';
+import ProfilePermissionsGroup from './ProfilePermissionsGroup';
+import ProfilePermissionsForm from './ProfilePermissionsForm';
+import {
+  searchUsers,
+  searchGroups,
+  SearchUsersGroupsParameters
+} from '../../../api/quality-profiles';
+import { translate } from '../../../helpers/l10n';
+
+export interface User {
+  avatar?: string;
+  login: string;
+  name: string;
+}
+
+export interface Group {
+  name: string;
+}
+
+interface Props {
+  organization?: string;
+  profile: { language: string; name: string };
+}
+
+interface State {
+  addUserForm: boolean;
+  groups?: Group[];
+  loading: boolean;
+  users?: User[];
+}
+
+export default class ProfilePermissions extends React.PureComponent<Props, State> {
+  mounted: boolean;
+  state: State = { addUserForm: false, loading: true };
+
+  componentDidMount() {
+    this.mounted = true;
+    this.fetchUsersAndGroups();
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    if (
+      prevProps.organization !== this.props.organization ||
+      prevProps.profile !== this.props.profile
+    ) {
+      this.fetchUsersAndGroups();
+    }
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  fetchUsersAndGroups() {
+    this.setState({ loading: true });
+    const { organization, profile } = this.props;
+    const parameters: SearchUsersGroupsParameters = {
+      language: profile.language,
+      organization,
+      qualityProfile: profile.name,
+      selected: 'selected'
+    };
+    Promise.all([searchUsers(parameters), searchGroups(parameters)]).then(
+      ([usersResponse, groupsResponse]) => {
+        if (this.mounted) {
+          this.setState({
+            groups: groupsResponse.groups,
+            loading: false,
+            users: usersResponse.users
+          });
+        }
+      },
+      () => {
+        if (this.mounted) {
+          this.setState({ loading: false });
+        }
+      }
+    );
+  }
+
+  handleAddUserButtonClick = (event: React.SyntheticEvent<HTMLButtonElement>) => {
+    event.preventDefault();
+    event.currentTarget.blur();
+    this.setState({ addUserForm: true });
+  };
+
+  handleAddUserFormClose = () => {
+    if (this.mounted) {
+      this.setState({ addUserForm: false });
+    }
+  };
+
+  handleUserAdd = (addedUser: User) => {
+    if (this.mounted) {
+      this.setState((state: State) => ({
+        addUserForm: false,
+        users: state.users && uniqBy([...state.users, addedUser], user => user.login)
+      }));
+    }
+  };
+
+  handleUserDelete = (removedUser: User) => {
+    if (this.mounted) {
+      this.setState((state: State) => ({
+        users: state.users && state.users.filter(user => user !== removedUser)
+      }));
+    }
+  };
+
+  handleGroupAdd = (addedGroup: Group) => {
+    if (this.mounted) {
+      this.setState((state: State) => ({
+        addUserForm: false,
+        groups: state.groups && uniqBy([...state.groups, addedGroup], group => group.name)
+      }));
+    }
+  };
+
+  handleGroupDelete = (removedGroup: Group) => {
+    if (this.mounted) {
+      this.setState((state: State) => ({
+        groups: state.groups && state.groups.filter(group => group !== removedGroup)
+      }));
+    }
+  };
+
+  render() {
+    return (
+      <div className="boxed-group">
+        <h2>{translate('permissions.page')}</h2>
+        <div className="boxed-group-inner">
+          <p className="note">{translate('quality_profiles.default_permissions')}</p>
+
+          {this.state.loading ? (
+            <div className="big-spacer-top">
+              <i className="spinner" />
+            </div>
+          ) : (
+            <div className="big-spacer-top">
+              {this.state.users &&
+                sortBy(this.state.users, 'name').map(user => (
+                  <ProfilePermissionsUser
+                    key={user.login}
+                    onDelete={this.handleUserDelete}
+                    organization={this.props.organization}
+                    profile={this.props.profile}
+                    user={user}
+                  />
+                ))}
+              {this.state.groups &&
+                sortBy(this.state.groups, 'name').map(group => (
+                  <ProfilePermissionsGroup
+                    group={group}
+                    key={group.name}
+                    onDelete={this.handleGroupDelete}
+                    organization={this.props.organization}
+                    profile={this.props.profile}
+                  />
+                ))}
+              <div className="text-right">
+                <button onClick={this.handleAddUserButtonClick}>
+                  {translate('quality_profiles.grant_permissions_to_more_users')}
+                </button>
+              </div>
+            </div>
+          )}
+        </div>
+
+        {this.state.addUserForm && (
+          <ProfilePermissionsForm
+            onClose={this.handleAddUserFormClose}
+            onGroupAdd={this.handleGroupAdd}
+            onUserAdd={this.handleUserAdd}
+            profile={this.props.profile}
+            organization={this.props.organization}
+          />
+        )}
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsForm.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsForm.tsx
new file mode 100644 (file)
index 0000000..fe94b38
--- /dev/null
@@ -0,0 +1,152 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import Modal from 'react-modal';
+import ProfilePermissionsFormSelect from './ProfilePermissionsFormSelect';
+import {
+  searchUsers,
+  searchGroups,
+  addUser,
+  addGroup,
+  SearchUsersGroupsParameters
+} from '../../../api/quality-profiles';
+import { translate } from '../../../helpers/l10n';
+import { User, Group } from './ProfilePermissions';
+
+interface Props {
+  onClose: () => void;
+  onGroupAdd: (group: Group) => void;
+  onUserAdd: (user: User) => void;
+  organization?: string;
+  profile: { language: string; name: string };
+}
+
+interface State {
+  selected?: User | Group;
+  submitting: boolean;
+}
+
+export default class ProfilePermissionsForm extends React.PureComponent<Props, State> {
+  mounted: boolean;
+  state: State = { submitting: false };
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  stopSubmitting = () => {
+    if (this.mounted) {
+      this.setState({ submitting: false });
+    }
+  };
+
+  handleUserAdd = (user: User) =>
+    addUser({
+      language: this.props.profile.language,
+      login: user.login,
+      organization: this.props.organization,
+      qualityProfile: this.props.profile.name
+    }).then(() => this.props.onUserAdd(user), this.stopSubmitting);
+
+  handleGroupAdd = (group: Group) =>
+    addGroup({
+      group: group.name,
+      language: this.props.profile.language,
+      organization: this.props.organization,
+      qualityProfile: this.props.profile.name
+    }).then(() => this.props.onGroupAdd(group), this.stopSubmitting);
+
+  handleFormSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => {
+    event.preventDefault();
+    const { selected } = this.state;
+    if (selected) {
+      this.setState({ submitting: true });
+      if ((selected as User).login != undefined) {
+        this.handleUserAdd(selected as User);
+      } else {
+        this.handleGroupAdd(selected as Group);
+      }
+    }
+  };
+
+  handleSearch = (q: string) => {
+    const { organization, profile } = this.props;
+    const parameters: SearchUsersGroupsParameters = {
+      language: profile.language,
+      organization,
+      q,
+      qualityProfile: profile.name,
+      selected: 'deselected'
+    };
+    return Promise.all([
+      searchUsers(parameters),
+      searchGroups(parameters)
+    ]).then(([usersResponse, groupsResponse]) => [
+      ...usersResponse.users,
+      ...groupsResponse.groups
+    ]);
+  };
+
+  handleValueChange = (selected: User | Group) => {
+    this.setState({ selected });
+  };
+
+  render() {
+    const header = translate('quality_profiles.grant_permissions_to_user_or_group');
+    const submitDisabled = !this.state.selected || this.state.submitting;
+    return (
+      <Modal
+        isOpen={true}
+        contentLabel={header}
+        className="modal"
+        overlayClassName="modal-overlay"
+        onRequestClose={this.props.onClose}>
+        <header className="modal-head">
+          <h2>{header}</h2>
+        </header>
+        <form onSubmit={this.handleFormSubmit}>
+          <div className="modal-body">
+            <div className="modal-large-field">
+              <label>{translate('quality_profiles.search_description')}</label>
+              <ProfilePermissionsFormSelect
+                selected={this.state.selected}
+                onChange={this.handleValueChange}
+                onSearch={this.handleSearch}
+              />
+            </div>
+          </div>
+          <footer className="modal-foot">
+            {this.state.submitting && <i className="spinner spacer-right" />}
+            <button disabled={submitDisabled} type="submit">
+              {translate('add_verb')}
+            </button>
+            <button className="button-link" onClick={this.props.onClose} type="reset">
+              {translate('cancel')}
+            </button>
+          </footer>
+        </form>
+      </Modal>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsFormSelect.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsFormSelect.tsx
new file mode 100644 (file)
index 0000000..e265206
--- /dev/null
@@ -0,0 +1,136 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import * as Select from 'react-select';
+import { debounce, identity } from 'lodash';
+import { User, Group } from './ProfilePermissions';
+import Avatar from '../../../components/ui/Avatar';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+import GroupIcon from '../../../components/icons-components/GroupIcon';
+
+type Option = User | Group;
+type OptionWithValue = Option & { value: string };
+
+interface Props {
+  onChange: (option: OptionWithValue) => void;
+  onSearch: (query: string) => Promise<Option[]>;
+  selected?: Option;
+}
+
+interface State {
+  loading: boolean;
+  query: string;
+  searchResults: Option[];
+}
+
+export default class ProfilePermissionsFormSelect extends React.PureComponent<Props, State> {
+  mounted: boolean;
+
+  constructor(props: Props) {
+    super(props);
+    this.handleSearch = debounce(this.handleSearch, 250);
+    this.state = { loading: false, query: '', searchResults: [] };
+  }
+
+  componentDidMount() {
+    this.mounted = true;
+    this.handleSearch(this.state.query);
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  handleSearch = (query: string) => {
+    this.setState({ loading: true });
+    this.props.onSearch(query).then(
+      searchResults => {
+        if (this.mounted) {
+          this.setState({ loading: false, searchResults });
+        }
+      },
+      () => {
+        if (this.mounted) {
+          this.setState({ loading: false });
+        }
+      }
+    );
+  };
+
+  handleInputChange = (query: string) => {
+    this.setState({ query });
+    if (query.length > 1) {
+      this.handleSearch(query);
+    }
+  };
+
+  render() {
+    const noResultsText =
+      this.state.query.length === 1
+        ? translateWithParameters('select2.tooShort', 2)
+        : translate('no_results');
+
+    // create a uniq string both for users and groups
+    const options = this.state.searchResults.map(r => ({ ...r, value: getStringValue(r) }));
+
+    return (
+      <Select
+        autofocus={true}
+        className="Select-big"
+        clearable={false}
+        isLoading={this.state.loading}
+        // disable default react-select filtering
+        filterOptions={identity}
+        noResultsText={noResultsText}
+        optionRenderer={optionRenderer}
+        options={options}
+        onChange={this.props.onChange}
+        onInputChange={this.handleInputChange}
+        placeholder=""
+        searchable={true}
+        value={this.props.selected && getStringValue(this.props.selected)}
+        valueRenderer={optionRenderer}
+      />
+    );
+  }
+}
+
+function isUser(option: Option): option is User {
+  return (option as User).login != undefined;
+}
+
+function getStringValue(option: Option) {
+  return isUser(option) ? `user:${option.login}` : `group:${option.name}`;
+}
+
+function optionRenderer(option: OptionWithValue) {
+  return isUser(option) ? (
+    <div>
+      <Avatar hash={option.avatar} name={option.name} size={16} />
+      <strong className="spacer-left">{option.name}</strong>
+      <span className="note little-spacer-left">{option.login}</span>
+    </div>
+  ) : (
+    <div>
+      <GroupIcon size={16} />
+      <strong className="spacer-left">{option.name}</strong>
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsGroup.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsGroup.tsx
new file mode 100644 (file)
index 0000000..c2ae2c1
--- /dev/null
@@ -0,0 +1,133 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import { Group } from './ProfilePermissions';
+import { removeGroup } from '../../../api/quality-profiles';
+import SimpleModal, { ChildrenProps } from '../../../components/controls/SimpleModal';
+import DeleteIcon from '../../../components/icons-components/DeleteIcon';
+import GroupIcon from '../../../components/icons-components/GroupIcon';
+import { translate } from '../../../helpers/l10n';
+
+interface Props {
+  group: Group;
+  onDelete: (group: Group) => void;
+  organization?: string;
+  profile: { language: string; name: string };
+}
+
+interface State {
+  deleteModal: boolean;
+}
+
+export default class ProfilePermissionsGroup extends React.PureComponent<Props, State> {
+  mounted: boolean;
+  state: State = { deleteModal: false };
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  handleDeleteClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+    event.preventDefault();
+    event.currentTarget.blur();
+    this.setState({ deleteModal: true });
+  };
+
+  handleDeleteModalClose = () => {
+    if (this.mounted) {
+      this.setState({ deleteModal: false });
+    }
+  };
+
+  handleDelete = () => {
+    const { group, organization, profile } = this.props;
+
+    return removeGroup({
+      group: group.name,
+      language: profile.language,
+      organization,
+      qualityProfile: profile.name
+    }).then(() => {
+      this.handleDeleteModalClose();
+      this.props.onDelete(group);
+    });
+  };
+
+  renderDeleteModal = (props: ChildrenProps) => (
+    <div>
+      <header className="modal-head">
+        <h2>{translate('groups.remove')}</h2>
+      </header>
+
+      <div className="modal-body">
+        <FormattedMessage
+          defaultMessage={translate('groups.remove.confirmation')}
+          id="groups.remove.confirmation"
+          values={{
+            user: <strong>{this.props.group.name}</strong>
+          }}
+        />
+      </div>
+
+      <footer className="modal-foot">
+        {props.submitting && <i className="spinner spacer-right" />}
+        <button className="button-red" disabled={props.submitting} onClick={props.onSubmitClick}>
+          {translate('remove')}
+        </button>
+        <a href="#" onClick={props.onCloseClick}>
+          {translate('cancel')}
+        </a>
+      </footer>
+    </div>
+  );
+
+  render() {
+    const { group } = this.props;
+
+    return (
+      <div className="clearfix big-spacer-bottom">
+        <a
+          className="pull-right spacer-top spacer-left spacer-right button-icon"
+          href="#"
+          onClick={this.handleDeleteClick}>
+          <DeleteIcon />
+        </a>
+        <GroupIcon className="pull-left spacer-right" size={32} />
+        <div className="overflow-hidden" style={{ lineHeight: '32px' }}>
+          <strong>{group.name}</strong>
+        </div>
+
+        {this.state.deleteModal && (
+          <SimpleModal
+            header={translate('group.remove')}
+            onClose={this.handleDeleteModalClose}
+            onSubmit={this.handleDelete}>
+            {this.renderDeleteModal}
+          </SimpleModal>
+        )}
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsUser.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsUser.tsx
new file mode 100644 (file)
index 0000000..b2f74e1
--- /dev/null
@@ -0,0 +1,134 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import { User } from './ProfilePermissions';
+import { removeUser } from '../../../api/quality-profiles';
+import SimpleModal, { ChildrenProps } from '../../../components/controls/SimpleModal';
+import DeleteIcon from '../../../components/icons-components/DeleteIcon';
+import Avatar from '../../../components/ui/Avatar';
+import { translate } from '../../../helpers/l10n';
+
+interface Props {
+  onDelete: (user: User) => void;
+  organization?: string;
+  profile: { language: string; name: string };
+  user: User;
+}
+
+interface State {
+  deleteModal: boolean;
+}
+
+export default class ProfilePermissionsUser extends React.PureComponent<Props, State> {
+  mounted: boolean;
+  state: State = { deleteModal: false };
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  handleDeleteClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+    event.preventDefault();
+    event.currentTarget.blur();
+    this.setState({ deleteModal: true });
+  };
+
+  handleDeleteModalClose = () => {
+    if (this.mounted) {
+      this.setState({ deleteModal: false });
+    }
+  };
+
+  handleDelete = () => {
+    const { organization, profile, user } = this.props;
+
+    return removeUser({
+      language: profile.language,
+      login: user.login,
+      organization,
+      qualityProfile: profile.name
+    }).then(() => {
+      this.handleDeleteModalClose();
+      this.props.onDelete(user);
+    });
+  };
+
+  renderDeleteModal = (props: ChildrenProps) => (
+    <div>
+      <header className="modal-head">
+        <h2>{translate('users.remove')}</h2>
+      </header>
+
+      <div className="modal-body">
+        <FormattedMessage
+          defaultMessage={translate('users.remove.confirmation')}
+          id="users.remove.confirmation"
+          values={{
+            user: <strong>{this.props.user.name}</strong>
+          }}
+        />
+      </div>
+
+      <footer className="modal-foot">
+        {props.submitting && <i className="spinner spacer-right" />}
+        <button className="button-red" disabled={props.submitting} onClick={props.onSubmitClick}>
+          {translate('remove')}
+        </button>
+        <a href="#" onClick={props.onCloseClick}>
+          {translate('cancel')}
+        </a>
+      </footer>
+    </div>
+  );
+
+  render() {
+    const { user } = this.props;
+
+    return (
+      <div className="clearfix big-spacer-bottom">
+        <a
+          className="pull-right spacer-top spacer-left spacer-right button-icon"
+          href="#"
+          onClick={this.handleDeleteClick}>
+          <DeleteIcon />
+        </a>
+        <Avatar className="pull-left spacer-right" hash={user.avatar} name={user.name} size={32} />
+        <div className="overflow-hidden">
+          <strong>{user.name}</strong>
+          <div className="note">{user.login}</div>
+        </div>
+
+        {this.state.deleteModal && (
+          <SimpleModal
+            header={translate('users.remove')}
+            onClose={this.handleDeleteModalClose}
+            onSubmit={this.handleDelete}>
+            {this.renderDeleteModal}
+          </SimpleModal>
+        )}
+      </div>
+    );
+  }
+}
index 75171f440c9983652cdc03683fcd63af6cc72318..ca2845714416840f13a32f4d343743bcf417a75a 100644 (file)
@@ -65,6 +65,7 @@ export default class ProfileProjects extends React.PureComponent<Props, State> {
 
   loadProjects() {
     if (this.props.profile.isDefault) {
+      this.setState({ loading: false });
       return;
     }
 
@@ -127,21 +128,29 @@ export default class ProfileProjects extends React.PureComponent<Props, State> {
 
   render() {
     return (
-      <div className="quality-profile-projects">
-        <header className="page-header">
-          <h2 className="page-title">{translate('projects')}</h2>
-
-          {this.props.canAdmin &&
-          !this.props.profile.isDefault && (
-            <div className="pull-right">
-              <button className="js-change-projects" onClick={this.handleChangeClick}>
-                {translate('quality_profiles.change_projects')}
-              </button>
-            </div>
-          )}
+      <div className="boxed-group quality-profile-projects">
+        {this.props.canAdmin &&
+        !this.props.profile.isDefault && (
+          <div className="boxed-group-actions">
+            <button className="js-change-projects" onClick={this.handleChangeClick}>
+              {translate('quality_profiles.change_projects')}
+            </button>
+          </div>
+        )}
+
+        <header className="boxed-group-header">
+          <h2>{translate('projects')}</h2>
         </header>
 
-        {this.props.profile.isDefault ? this.renderDefault() : this.renderProjects()}
+        <div className="boxed-group-inner">
+          {this.state.loading ? (
+            <i className="spinner" />
+          ) : this.props.profile.isDefault ? (
+            this.renderDefault()
+          ) : (
+            this.renderProjects()
+          )}
+        </div>
 
         {this.state.formOpen && (
           <ChangeProjectsForm
index c4ec8b3dfa5bc8a6650b762be34e0f804744144d..b9ee6c91fac94395e53aa80d881027c7366b81c0 100644 (file)
@@ -25,7 +25,7 @@ import ProfileRulesRowTotal from './ProfileRulesRowTotal';
 import ProfileRulesDeprecatedWarning from './ProfileRulesDeprecatedWarning';
 import ProfileRulesSonarWayComparison from './ProfileRulesSonarWayComparison';
 import { searchRules, takeFacet } from '../../../api/rules';
-import { getQualityProfiles } from '../../../api/quality-profiles';
+import { getQualityProfile } from '../../../api/quality-profiles';
 import { getRulesUrl } from '../../../helpers/urls';
 import { translate } from '../../../helpers/l10n';
 import { Profile } from '../types';
@@ -83,7 +83,7 @@ export default class ProfileRules extends React.PureComponent<Props, State> {
     if (this.props.profile.isBuiltIn) {
       return Promise.resolve(null);
     }
-    return getQualityProfiles({
+    return getQualityProfile({
       compareToSonarWay: true,
       profile: this.props.profile.key
     });
@@ -148,7 +148,7 @@ export default class ProfileRules extends React.PureComponent<Props, State> {
     );
 
     return (
-      <div className="quality-profile-rules">
+      <div className="boxed-group quality-profile-rules">
         <div className="quality-profile-rules-distribution">
           <table className="data condensed">
             <thead>
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissions-test.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissions-test.tsx
new file mode 100644 (file)
index 0000000..c61a60d
--- /dev/null
@@ -0,0 +1,111 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+jest.mock('../../../../api/quality-profiles', () => ({
+  searchUsers: jest.fn(() => Promise.resolve([])),
+  searchGroups: jest.fn(() => Promise.resolve([]))
+}));
+
+import * as React from 'react';
+import { mount, shallow } from 'enzyme';
+import ProfilePermissions from '../ProfilePermissions';
+import { click } from '../../../../helpers/testUtils';
+
+const searchUsers = require('../../../../api/quality-profiles').searchUsers as jest.Mock<any>;
+const searchGroups = require('../../../../api/quality-profiles').searchGroups as jest.Mock<any>;
+
+const profile = { name: 'Sonar way', language: 'js' };
+
+beforeEach(() => {
+  searchUsers.mockClear();
+  searchGroups.mockClear();
+});
+
+it('renders', () => {
+  const wrapper = shallow(<ProfilePermissions profile={profile} />);
+  expect(wrapper).toMatchSnapshot();
+
+  wrapper.setState({
+    groups: [{ name: 'Lambda' }],
+    loading: false,
+    users: [{ login: 'luke', name: 'Luke Skywalker' }]
+  });
+  expect(wrapper).toMatchSnapshot();
+});
+
+it('opens add users form', () => {
+  const wrapper = shallow(<ProfilePermissions profile={profile} />);
+  (wrapper.instance() as ProfilePermissions).mounted = true;
+  wrapper.setState({ loading: false, users: [{ login: 'luke', name: 'Luke Skywalker' }] });
+  expect(wrapper.find('ProfilePermissionsForm').exists()).toBeFalsy();
+
+  click(wrapper.find('button'));
+  expect(wrapper.find('ProfilePermissionsForm').exists()).toBeTruthy();
+
+  wrapper.find('ProfilePermissionsForm').prop<Function>('onClose')();
+  expect(wrapper.find('ProfilePermissionsForm').exists()).toBeFalsy();
+});
+
+it('removes user', () => {
+  const wrapper = shallow(<ProfilePermissions profile={profile} />);
+  (wrapper.instance() as ProfilePermissions).mounted = true;
+
+  const joda = { login: 'joda', name: 'Joda' };
+  wrapper.setState({ loading: false, users: [{ login: 'luke', name: 'Luke Skywalker' }, joda] });
+  expect(wrapper.find('ProfilePermissionsUser')).toHaveLength(2);
+
+  wrapper
+    .find('ProfilePermissionsUser')
+    .first()
+    .prop<Function>('onDelete')(joda);
+  wrapper.update();
+  expect(wrapper.find('ProfilePermissionsUser')).toHaveLength(1);
+});
+
+it('removes group', () => {
+  const wrapper = shallow(<ProfilePermissions profile={profile} />);
+  (wrapper.instance() as ProfilePermissions).mounted = true;
+
+  const lambda = { name: 'Lambda' };
+  wrapper.setState({ loading: false, groups: [{ name: 'Atlas' }, lambda] });
+  expect(wrapper.find('ProfilePermissionsGroup')).toHaveLength(2);
+
+  wrapper
+    .find('ProfilePermissionsGroup')
+    .first()
+    .prop<Function>('onDelete')(lambda);
+  wrapper.update();
+  expect(wrapper.find('ProfilePermissionsGroup')).toHaveLength(1);
+});
+
+it('fetches users and groups on mount', () => {
+  mount(<ProfilePermissions organization="org" profile={profile} />);
+  expect(searchUsers).toBeCalledWith({
+    language: 'js',
+    organization: 'org',
+    qualityProfile: 'Sonar way',
+    selected: 'selected'
+  });
+  expect(searchGroups).toBeCalledWith({
+    language: 'js',
+    organization: 'org',
+    qualityProfile: 'Sonar way',
+    selected: 'selected'
+  });
+});
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsForm-test.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsForm-test.tsx
new file mode 100644 (file)
index 0000000..603967b
--- /dev/null
@@ -0,0 +1,91 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+jest.mock('../../../../api/quality-profiles', () => ({
+  addUser: jest.fn(() => Promise.resolve()),
+  addGroup: jest.fn(() => Promise.resolve())
+}));
+
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import ProfilePermissionsForm from '../ProfilePermissionsForm';
+import { submit } from '../../../../helpers/testUtils';
+
+const addUser = require('../../../../api/quality-profiles').addUser as jest.Mock<any>;
+const addGroup = require('../../../../api/quality-profiles').addGroup as jest.Mock<any>;
+
+const profile = { language: 'js', name: 'Sonar way' };
+
+it('adds user', async () => {
+  const onUserAdd = jest.fn();
+  const wrapper = shallow(
+    <ProfilePermissionsForm
+      onClose={jest.fn()}
+      onGroupAdd={jest.fn()}
+      onUserAdd={onUserAdd}
+      organization="org"
+      profile={profile}
+    />
+  );
+  expect(wrapper).toMatchSnapshot();
+
+  wrapper.setState({ selected: { login: 'luke' } });
+  expect(wrapper).toMatchSnapshot();
+
+  submit(wrapper.find('form'));
+  expect(wrapper).toMatchSnapshot();
+  expect(addUser).toBeCalledWith({
+    language: 'js',
+    login: 'luke',
+    organization: 'org',
+    qualityProfile: 'Sonar way'
+  });
+
+  await new Promise(setImmediate);
+  expect(onUserAdd).toBeCalledWith({ login: 'luke' });
+});
+
+it('adds group', async () => {
+  const onGroupAdd = jest.fn();
+  const wrapper = shallow(
+    <ProfilePermissionsForm
+      onClose={jest.fn()}
+      onGroupAdd={onGroupAdd}
+      onUserAdd={jest.fn()}
+      organization="org"
+      profile={profile}
+    />
+  );
+  expect(wrapper).toMatchSnapshot();
+
+  wrapper.setState({ selected: { name: 'lambda' } });
+  expect(wrapper).toMatchSnapshot();
+
+  submit(wrapper.find('form'));
+  expect(wrapper).toMatchSnapshot();
+  expect(addGroup).toBeCalledWith({
+    group: 'lambda',
+    language: 'js',
+    organization: 'org',
+    qualityProfile: 'Sonar way'
+  });
+
+  await new Promise(setImmediate);
+  expect(onGroupAdd).toBeCalledWith({ name: 'lambda' });
+});
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsFormSelect-test.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsFormSelect-test.tsx
new file mode 100644 (file)
index 0000000..3483c66
--- /dev/null
@@ -0,0 +1,57 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+jest.mock('lodash', () => {
+  const lodash = require.requireActual('lodash');
+  lodash.debounce = (fn: Function) => (...args: any[]) => fn(...args);
+  return lodash;
+});
+
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import ProfilePermissionsFormSelect from '../ProfilePermissionsFormSelect';
+
+it('renders', () => {
+  expect(
+    shallow(
+      <ProfilePermissionsFormSelect
+        onChange={jest.fn()}
+        onSearch={jest.fn()}
+        selected={{ name: 'lambda' }}
+      />
+    )
+  ).toMatchSnapshot();
+});
+
+it('searches', () => {
+  const onSearch = jest.fn(() => Promise.resolve([]));
+  const wrapper = shallow(
+    <ProfilePermissionsFormSelect
+      onChange={jest.fn()}
+      onSearch={onSearch}
+      selected={{ name: 'lambda' }}
+    />
+  );
+
+  wrapper.prop<Function>('onInputChange')('f');
+  expect(onSearch).not.toBeCalled();
+
+  wrapper.prop<Function>('onInputChange')('foo');
+  expect(onSearch).toBeCalledWith('foo');
+});
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsGroup-test.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsGroup-test.tsx
new file mode 100644 (file)
index 0000000..d792b2c
--- /dev/null
@@ -0,0 +1,70 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+jest.mock('../../../../api/quality-profiles', () => ({
+  removeGroup: jest.fn(() => Promise.resolve())
+}));
+
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import ProfilePermissionsGroup from '../ProfilePermissionsGroup';
+import { click } from '../../../../helpers/testUtils';
+
+const removeGroup = require('../../../../api/quality-profiles').removeGroup as jest.Mock<any>;
+
+const profile = { language: 'js', name: 'Sonar way' };
+const group = { name: 'lambda' };
+
+beforeEach(() => {
+  removeGroup.mockClear();
+});
+
+it('renders', () => {
+  expect(
+    shallow(<ProfilePermissionsGroup group={group} onDelete={jest.fn()} profile={profile} />)
+  ).toMatchSnapshot();
+});
+
+it('removes user', async () => {
+  const onDelete = jest.fn();
+  const wrapper = shallow(
+    <ProfilePermissionsGroup
+      group={group}
+      onDelete={onDelete}
+      organization="org"
+      profile={profile}
+    />
+  );
+  (wrapper.instance() as ProfilePermissionsGroup).mounted = true;
+  expect(wrapper.find('SimpleModal').exists()).toBeFalsy();
+
+  click(wrapper.find('a'));
+  expect(wrapper.find('SimpleModal').exists()).toBeTruthy();
+
+  wrapper.find('SimpleModal').prop<Function>('onSubmit')();
+  expect(removeGroup).toBeCalledWith({
+    group: 'lambda',
+    language: 'js',
+    organization: 'org',
+    qualityProfile: 'Sonar way'
+  });
+
+  await new Promise(setImmediate);
+  expect(onDelete).toBeCalledWith(group);
+});
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsUser-test.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsUser-test.tsx
new file mode 100644 (file)
index 0000000..6fbd6f8
--- /dev/null
@@ -0,0 +1,65 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+jest.mock('../../../../api/quality-profiles', () => ({
+  removeUser: jest.fn(() => Promise.resolve())
+}));
+
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import ProfilePermissionsUser from '../ProfilePermissionsUser';
+import { click } from '../../../../helpers/testUtils';
+
+const removeUser = require('../../../../api/quality-profiles').removeUser as jest.Mock<any>;
+
+const profile = { language: 'js', name: 'Sonar way' };
+const user = { login: 'luke', name: 'Luke Skywalker' };
+
+beforeEach(() => {
+  removeUser.mockClear();
+});
+
+it('renders', () => {
+  expect(
+    shallow(<ProfilePermissionsUser onDelete={jest.fn()} profile={profile} user={user} />)
+  ).toMatchSnapshot();
+});
+
+it('removes user', async () => {
+  const onDelete = jest.fn();
+  const wrapper = shallow(
+    <ProfilePermissionsUser onDelete={onDelete} organization="org" profile={profile} user={user} />
+  );
+  (wrapper.instance() as ProfilePermissionsUser).mounted = true;
+  expect(wrapper.find('SimpleModal').exists()).toBeFalsy();
+
+  click(wrapper.find('a'));
+  expect(wrapper.find('SimpleModal').exists()).toBeTruthy();
+
+  wrapper.find('SimpleModal').prop<Function>('onSubmit')();
+  expect(removeUser).toBeCalledWith({
+    language: 'js',
+    login: 'luke',
+    organization: 'org',
+    qualityProfile: 'Sonar way'
+  });
+
+  await new Promise(setImmediate);
+  expect(onDelete).toBeCalledWith(user);
+});
index df6462d30b0163fa1fccdb1f18827ade7fc15e71..9b282b2e2a19e69db77e740402eb95737399380d 100644 (file)
@@ -71,7 +71,7 @@ const apiResponseActive = {
 // Mock api some api functions
 (apiRules as any).searchRules = (data: any) =>
   Promise.resolve(data.activation === 'true' ? apiResponseActive : apiResponseAll);
-(apiQP as any).getQualityProfiles = () =>
+(apiQP as any).getQualityProfile = () =>
   Promise.resolve({
     compareToSonarWay: {
       profile: 'sonarway',
@@ -123,7 +123,7 @@ it('should not show a button to activate more rules on built in profiles', () =>
 });
 
 it('should not show sonarway comparison for built in profiles', () => {
-  (apiQP as any).getQualityProfiles = jest.fn(() => Promise.resolve());
+  (apiQP as any).getQualityProfile = jest.fn(() => Promise.resolve());
   const wrapper = shallow(
     <ProfileRules canAdmin={true} organization={null} profile={{ ...PROFILE, isBuiltIn: true }} />
   );
@@ -132,13 +132,13 @@ it('should not show sonarway comparison for built in profiles', () => {
   instance.loadRules();
   return doAsync(() => {
     wrapper.update();
-    expect(apiQP.getQualityProfiles).toHaveBeenCalledTimes(0);
+    expect(apiQP.getQualityProfile).toHaveBeenCalledTimes(0);
     expect(wrapper.find('ProfileRulesSonarWayComparison')).toHaveLength(0);
   });
 });
 
 it('should not show sonarway comparison if there is no missing rules', () => {
-  (apiQP as any).getQualityProfiles = jest.fn(() =>
+  (apiQP as any).getQualityProfile = jest.fn(() =>
     Promise.resolve({
       compareToSonarWay: {
         profile: 'sonarway',
@@ -153,7 +153,7 @@ it('should not show sonarway comparison if there is no missing rules', () => {
   instance.loadRules();
   return doAsync(() => {
     wrapper.update();
-    expect(apiQP.getQualityProfiles).toHaveBeenCalledTimes(1);
+    expect(apiQP.getQualityProfile).toHaveBeenCalledTimes(1);
     expect(wrapper.find('ProfileRulesSonarWayComparison')).toHaveLength(0);
   });
 });
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfilePermissions-test.tsx.snap b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfilePermissions-test.tsx.snap
new file mode 100644 (file)
index 0000000..806ca57
--- /dev/null
@@ -0,0 +1,88 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders 1`] = `
+<div
+  className="boxed-group"
+>
+  <h2>
+    permissions.page
+  </h2>
+  <div
+    className="boxed-group-inner"
+  >
+    <p
+      className="note"
+    >
+      quality_profiles.default_permissions
+    </p>
+    <div
+      className="big-spacer-top"
+    >
+      <i
+        className="spinner"
+      />
+    </div>
+  </div>
+</div>
+`;
+
+exports[`renders 2`] = `
+<div
+  className="boxed-group"
+>
+  <h2>
+    permissions.page
+  </h2>
+  <div
+    className="boxed-group-inner"
+  >
+    <p
+      className="note"
+    >
+      quality_profiles.default_permissions
+    </p>
+    <div
+      className="big-spacer-top"
+    >
+      <ProfilePermissionsUser
+        onDelete={[Function]}
+        profile={
+          Object {
+            "language": "js",
+            "name": "Sonar way",
+          }
+        }
+        user={
+          Object {
+            "login": "luke",
+            "name": "Luke Skywalker",
+          }
+        }
+      />
+      <ProfilePermissionsGroup
+        group={
+          Object {
+            "name": "Lambda",
+          }
+        }
+        onDelete={[Function]}
+        profile={
+          Object {
+            "language": "js",
+            "name": "Sonar way",
+          }
+        }
+      />
+      <div
+        className="text-right"
+      >
+        <button
+          onClick={[Function]}
+        >
+          quality_profiles.grant_permissions_to_more_users
+        </button>
+      </div>
+    </div>
+  </div>
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfilePermissionsForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfilePermissionsForm-test.tsx.snap
new file mode 100644 (file)
index 0000000..d8b0f23
--- /dev/null
@@ -0,0 +1,387 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`adds group 1`] = `
+<Modal
+  ariaHideApp={true}
+  bodyOpenClassName="ReactModal__Body--open"
+  className="modal"
+  closeTimeoutMS={0}
+  contentLabel="quality_profiles.grant_permissions_to_user_or_group"
+  isOpen={true}
+  onRequestClose={[Function]}
+  overlayClassName="modal-overlay"
+  parentSelector={[Function]}
+  portalClassName="ReactModalPortal"
+  shouldCloseOnOverlayClick={true}
+>
+  <header
+    className="modal-head"
+  >
+    <h2>
+      quality_profiles.grant_permissions_to_user_or_group
+    </h2>
+  </header>
+  <form
+    onSubmit={[Function]}
+  >
+    <div
+      className="modal-body"
+    >
+      <div
+        className="modal-large-field"
+      >
+        <label>
+          quality_profiles.search_description
+        </label>
+        <ProfilePermissionsFormSelect
+          onChange={[Function]}
+          onSearch={[Function]}
+        />
+      </div>
+    </div>
+    <footer
+      className="modal-foot"
+    >
+      <button
+        disabled={true}
+        type="submit"
+      >
+        add_verb
+      </button>
+      <button
+        className="button-link"
+        onClick={[Function]}
+        type="reset"
+      >
+        cancel
+      </button>
+    </footer>
+  </form>
+</Modal>
+`;
+
+exports[`adds group 2`] = `
+<Modal
+  ariaHideApp={true}
+  bodyOpenClassName="ReactModal__Body--open"
+  className="modal"
+  closeTimeoutMS={0}
+  contentLabel="quality_profiles.grant_permissions_to_user_or_group"
+  isOpen={true}
+  onRequestClose={[Function]}
+  overlayClassName="modal-overlay"
+  parentSelector={[Function]}
+  portalClassName="ReactModalPortal"
+  shouldCloseOnOverlayClick={true}
+>
+  <header
+    className="modal-head"
+  >
+    <h2>
+      quality_profiles.grant_permissions_to_user_or_group
+    </h2>
+  </header>
+  <form
+    onSubmit={[Function]}
+  >
+    <div
+      className="modal-body"
+    >
+      <div
+        className="modal-large-field"
+      >
+        <label>
+          quality_profiles.search_description
+        </label>
+        <ProfilePermissionsFormSelect
+          onChange={[Function]}
+          onSearch={[Function]}
+          selected={
+            Object {
+              "name": "lambda",
+            }
+          }
+        />
+      </div>
+    </div>
+    <footer
+      className="modal-foot"
+    >
+      <button
+        disabled={false}
+        type="submit"
+      >
+        add_verb
+      </button>
+      <button
+        className="button-link"
+        onClick={[Function]}
+        type="reset"
+      >
+        cancel
+      </button>
+    </footer>
+  </form>
+</Modal>
+`;
+
+exports[`adds group 3`] = `
+<Modal
+  ariaHideApp={true}
+  bodyOpenClassName="ReactModal__Body--open"
+  className="modal"
+  closeTimeoutMS={0}
+  contentLabel="quality_profiles.grant_permissions_to_user_or_group"
+  isOpen={true}
+  onRequestClose={[Function]}
+  overlayClassName="modal-overlay"
+  parentSelector={[Function]}
+  portalClassName="ReactModalPortal"
+  shouldCloseOnOverlayClick={true}
+>
+  <header
+    className="modal-head"
+  >
+    <h2>
+      quality_profiles.grant_permissions_to_user_or_group
+    </h2>
+  </header>
+  <form
+    onSubmit={[Function]}
+  >
+    <div
+      className="modal-body"
+    >
+      <div
+        className="modal-large-field"
+      >
+        <label>
+          quality_profiles.search_description
+        </label>
+        <ProfilePermissionsFormSelect
+          onChange={[Function]}
+          onSearch={[Function]}
+          selected={
+            Object {
+              "name": "lambda",
+            }
+          }
+        />
+      </div>
+    </div>
+    <footer
+      className="modal-foot"
+    >
+      <i
+        className="spinner spacer-right"
+      />
+      <button
+        disabled={true}
+        type="submit"
+      >
+        add_verb
+      </button>
+      <button
+        className="button-link"
+        onClick={[Function]}
+        type="reset"
+      >
+        cancel
+      </button>
+    </footer>
+  </form>
+</Modal>
+`;
+
+exports[`adds user 1`] = `
+<Modal
+  ariaHideApp={true}
+  bodyOpenClassName="ReactModal__Body--open"
+  className="modal"
+  closeTimeoutMS={0}
+  contentLabel="quality_profiles.grant_permissions_to_user_or_group"
+  isOpen={true}
+  onRequestClose={[Function]}
+  overlayClassName="modal-overlay"
+  parentSelector={[Function]}
+  portalClassName="ReactModalPortal"
+  shouldCloseOnOverlayClick={true}
+>
+  <header
+    className="modal-head"
+  >
+    <h2>
+      quality_profiles.grant_permissions_to_user_or_group
+    </h2>
+  </header>
+  <form
+    onSubmit={[Function]}
+  >
+    <div
+      className="modal-body"
+    >
+      <div
+        className="modal-large-field"
+      >
+        <label>
+          quality_profiles.search_description
+        </label>
+        <ProfilePermissionsFormSelect
+          onChange={[Function]}
+          onSearch={[Function]}
+        />
+      </div>
+    </div>
+    <footer
+      className="modal-foot"
+    >
+      <button
+        disabled={true}
+        type="submit"
+      >
+        add_verb
+      </button>
+      <button
+        className="button-link"
+        onClick={[Function]}
+        type="reset"
+      >
+        cancel
+      </button>
+    </footer>
+  </form>
+</Modal>
+`;
+
+exports[`adds user 2`] = `
+<Modal
+  ariaHideApp={true}
+  bodyOpenClassName="ReactModal__Body--open"
+  className="modal"
+  closeTimeoutMS={0}
+  contentLabel="quality_profiles.grant_permissions_to_user_or_group"
+  isOpen={true}
+  onRequestClose={[Function]}
+  overlayClassName="modal-overlay"
+  parentSelector={[Function]}
+  portalClassName="ReactModalPortal"
+  shouldCloseOnOverlayClick={true}
+>
+  <header
+    className="modal-head"
+  >
+    <h2>
+      quality_profiles.grant_permissions_to_user_or_group
+    </h2>
+  </header>
+  <form
+    onSubmit={[Function]}
+  >
+    <div
+      className="modal-body"
+    >
+      <div
+        className="modal-large-field"
+      >
+        <label>
+          quality_profiles.search_description
+        </label>
+        <ProfilePermissionsFormSelect
+          onChange={[Function]}
+          onSearch={[Function]}
+          selected={
+            Object {
+              "login": "luke",
+            }
+          }
+        />
+      </div>
+    </div>
+    <footer
+      className="modal-foot"
+    >
+      <button
+        disabled={false}
+        type="submit"
+      >
+        add_verb
+      </button>
+      <button
+        className="button-link"
+        onClick={[Function]}
+        type="reset"
+      >
+        cancel
+      </button>
+    </footer>
+  </form>
+</Modal>
+`;
+
+exports[`adds user 3`] = `
+<Modal
+  ariaHideApp={true}
+  bodyOpenClassName="ReactModal__Body--open"
+  className="modal"
+  closeTimeoutMS={0}
+  contentLabel="quality_profiles.grant_permissions_to_user_or_group"
+  isOpen={true}
+  onRequestClose={[Function]}
+  overlayClassName="modal-overlay"
+  parentSelector={[Function]}
+  portalClassName="ReactModalPortal"
+  shouldCloseOnOverlayClick={true}
+>
+  <header
+    className="modal-head"
+  >
+    <h2>
+      quality_profiles.grant_permissions_to_user_or_group
+    </h2>
+  </header>
+  <form
+    onSubmit={[Function]}
+  >
+    <div
+      className="modal-body"
+    >
+      <div
+        className="modal-large-field"
+      >
+        <label>
+          quality_profiles.search_description
+        </label>
+        <ProfilePermissionsFormSelect
+          onChange={[Function]}
+          onSearch={[Function]}
+          selected={
+            Object {
+              "login": "luke",
+            }
+          }
+        />
+      </div>
+    </div>
+    <footer
+      className="modal-foot"
+    >
+      <i
+        className="spinner spacer-right"
+      />
+      <button
+        disabled={true}
+        type="submit"
+      >
+        add_verb
+      </button>
+      <button
+        className="button-link"
+        onClick={[Function]}
+        type="reset"
+      >
+        cancel
+      </button>
+    </footer>
+  </form>
+</Modal>
+`;
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfilePermissionsFormSelect-test.tsx.snap b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfilePermissionsFormSelect-test.tsx.snap
new file mode 100644 (file)
index 0000000..10e1ea5
--- /dev/null
@@ -0,0 +1,52 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders 1`] = `
+<Select
+  addLabelText="Add \\"{label}\\"?"
+  arrowRenderer={[Function]}
+  autofocus={true}
+  autosize={true}
+  backspaceRemoves={true}
+  backspaceToRemoveMessage="Press backspace to remove {label}"
+  className="Select-big"
+  clearAllText="Clear all"
+  clearRenderer={[Function]}
+  clearValueText="Clear value"
+  clearable={false}
+  deleteRemoves={true}
+  delimiter=","
+  disabled={false}
+  escapeClearsValue={true}
+  filterOptions={[Function]}
+  ignoreAccents={true}
+  ignoreCase={true}
+  inputProps={Object {}}
+  isLoading={false}
+  joinValues={false}
+  labelKey="label"
+  matchPos="any"
+  matchProp="any"
+  menuBuffer={0}
+  menuRenderer={[Function]}
+  multi={false}
+  noResultsText="no_results"
+  onBlurResetsInput={true}
+  onChange={[Function]}
+  onCloseResetsInput={true}
+  onInputChange={[Function]}
+  optionComponent={[Function]}
+  optionRenderer={[Function]}
+  options={Array []}
+  pageSize={5}
+  placeholder=""
+  required={false}
+  scrollMenuIntoView={true}
+  searchable={true}
+  simpleValue={false}
+  tabSelectsValue={true}
+  value="group:lambda"
+  valueComponent={[Function]}
+  valueKey="value"
+  valueRenderer={[Function]}
+/>
+`;
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfilePermissionsGroup-test.tsx.snap b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfilePermissionsGroup-test.tsx.snap
new file mode 100644 (file)
index 0000000..b72e86d
--- /dev/null
@@ -0,0 +1,31 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders 1`] = `
+<div
+  className="clearfix big-spacer-bottom"
+>
+  <a
+    className="pull-right spacer-top spacer-left spacer-right button-icon"
+    href="#"
+    onClick={[Function]}
+  >
+    <DeleteIcon />
+  </a>
+  <GroupIcon
+    className="pull-left spacer-right"
+    size={32}
+  />
+  <div
+    className="overflow-hidden"
+    style={
+      Object {
+        "lineHeight": "32px",
+      }
+    }
+  >
+    <strong>
+      lambda
+    </strong>
+  </div>
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfilePermissionsUser-test.tsx.snap b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfilePermissionsUser-test.tsx.snap
new file mode 100644 (file)
index 0000000..f606faf
--- /dev/null
@@ -0,0 +1,32 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders 1`] = `
+<div
+  className="clearfix big-spacer-bottom"
+>
+  <a
+    className="pull-right spacer-top spacer-left spacer-right button-icon"
+    href="#"
+    onClick={[Function]}
+  >
+    <DeleteIcon />
+  </a>
+  <Connect(Avatar)
+    className="pull-left spacer-right"
+    name="Luke Skywalker"
+    size={32}
+  />
+  <div
+    className="overflow-hidden"
+  >
+    <strong>
+      Luke Skywalker
+    </strong>
+    <div
+      className="note"
+    >
+      luke
+    </div>
+  </div>
+</div>
+`;
index 22be4264e2a94d3726ad192428e44aec09396301..315e117ffbad4f242799a2a16fd91f70c0af45fd 100644 (file)
@@ -2,7 +2,7 @@
 
 exports[`should render the quality profiles rules with sonarway comparison 1`] = `
 <div
-  className="quality-profile-rules"
+  className="boxed-group quality-profile-rules"
 >
   <div
     className="quality-profile-rules-distribution"
index 1183addbd8920b9c8dd365b64f30c5e236e278eb..d198030a1486b7f372811ffa5dec028d6e99383f 100644 (file)
@@ -42,7 +42,7 @@ export default function EvolutionDeprecated(props: Props) {
   const sortedProfiles = sortBy(profilesWithDeprecations, p => -p.activeDeprecatedRuleCount);
 
   return (
-    <div className="quality-profile-box quality-profiles-evolution-deprecated">
+    <div className="boxed-group boxed-group-inner quality-profiles-evolution-deprecated">
       <div className="spacer-bottom">
         <strong>{translate('quality_profiles.deprecated_rules')}</strong>
       </div>
index 0d526ee00631f9a2462fb617b0aa9d705b1205ee..03a9aed243ecc7059ba5fb17cfc844dfde482b9e 100644 (file)
@@ -103,7 +103,7 @@ export default class EvolutionRules extends React.PureComponent<Props, State> {
     );
 
     return (
-      <div className="quality-profile-box quality-profiles-evolution-rules">
+      <div className="boxed-group boxed-group-inner quality-profiles-evolution-rules">
         <div className="clearfix">
           <strong className="pull-left">{translate('quality_profiles.latest_new_rules')}</strong>
         </div>
index eaf2ab8658901ffbbe96d93e7ebdc93c27d9f580..3ef0e4167e9f3ae3a15450bb5d95fa57d3e39741 100644 (file)
@@ -39,7 +39,7 @@ export default function EvolutionStagnant(props: Props) {
   }
 
   return (
-    <div className="quality-profile-box quality-profiles-evolution-stagnant">
+    <div className="boxed-group boxed-group-inner quality-profiles-evolution-stagnant">
       <div className="spacer-bottom">
         <strong>{translate('quality_profiles.stagnant_profiles')}</strong>
       </div>
index bdc506df3a90d46c8b82f7ddd2a6a9ecd70aab1e..e1d7b4d55b538629a7915110e25a4b3767c345bf 100644 (file)
@@ -101,7 +101,7 @@ export default class ProfilesList extends React.PureComponent<Props> {
         )}
 
         {languagesToShow.map(languageKey => (
-          <div key={languageKey} className="quality-profile-box quality-profiles-table">
+          <div key={languageKey} className="boxed-group boxed-group-inner quality-profiles-table">
             <table data-language={languageKey} className="data zebra zebra-hover">
               {profilesToShow[languageKey] != null &&
                 this.renderHeader(languageKey, profilesToShow[languageKey].length)}
index 9320f748773dff4e4e33aed9acf2b701a9ffe033..84993d5134f2c193b929fc2b9a064c5e42066b31 100644 (file)
@@ -1,14 +1,5 @@
-.quality-profile-box {
-  padding: 20px;
-  border: 1px solid #e6e6e6;
-  border-radius: 2px;
-  background-color: #fff;
-}
-
 .quality-profiles-table {
-  margin-top: 20px;
-  padding-top: 10px;
-  padding-bottom: 10px;
+  padding-top: 7px;
 }
 
 .quality-profiles-table-name {
@@ -26,6 +17,7 @@
 
 .quality-profiles-list-header {
   line-height: 24px;
+  margin-bottom: 20px;
   padding: 5px 10px;
   border-bottom: 1px solid #e6e6e6;
 }
   margin-left: 20px;
 }
 
-.quality-profile-rules,
-.quality-profile-projects,
-.quality-profile-inheritance,
-.quality-profile-evolution {
-  border: 1px solid #e6e6e6;
-  border-radius: 2px;
-  background-color: #fff;
-}
-
-.quality-profile-evolution {
-  padding: 20px;
-}
-
-.quality-profile-projects,
-.quality-profile-inheritance {
-  padding: 15px 20px 20px;
-}
-
-.quality-profile-rules {
-  min-height: 182px;
-}
-
-.quality-profile-rules > header {
-  padding: 15px 20px;
-}
-
 .quality-profile-rules-distribution {
-  margin-bottom: 20px;
-  padding: 5px 20px 0;
+  margin-bottom: 15px;
+  padding: 7px 20px 0;
 }
 
 .quality-profile-rules-deprecated {
+  margin-top: 20px;
   padding: 15px 20px;
   background-color: #f2dede;
 }
 
 .quality-profile-rules-sonarway-missing {
+  margin-top: 20px;
   padding: 15px 20px;
   background-color: #fcf8e3;
 }
 
-.quality-profile-exporters {
-  margin-top: 20px;
-}
-
-.quality-profile-evolution {
-  display: flex;
-  margin-top: 20px;
-}
-
-.quality-profile-evolution > div {
-  width: 50%;
-  text-align: center;
-}
-
-.quality-profile-projects {
-  margin-top: 20px;
-}
-
-.quality-profile-inheritance {
-}
-
 .quality-profile-not-found {
   padding-top: 100px;
   text-align: center;
 }
 
 .quality-profiles-evolution-deprecated {
-  margin-bottom: 20px;
   border-color: #ebccd1;
   background-color: #f2dede;
 }
 
 .quality-profiles-evolution-stagnant {
-  margin-bottom: 20px;
   border-color: #faebcc;
   background-color: #fcf8e3;
 }
 
-.quality-profiles-evolution-rules {
-  border: 1px solid #e6e6e6;
-  background-color: #fff;
-}
-
 .quality-profile-comparison-table {
   table-layout: fixed;
 }
index f1f0dfe3014fba9a1997449dc28b2b0899a6d21b..46f0794ddac5b3a3b6f2cd7c1ee8905d419ac0a0 100644 (file)
@@ -33,7 +33,7 @@ type Props = {
 };
 */
 
-const AVATAR_SIZE /*: number */ = 20;
+const AVATAR_SIZE /*: number */ = 16;
 
 export default class UsersSelectSearchOption extends React.PureComponent {
   /*:: props: Props; */
index 70643cb34a818e842a756aad808df8dabb9a55fd..a420dad822ca5cb6a49956b4c2aca39bee32264a 100644 (file)
@@ -29,7 +29,7 @@ type Props = {
 };
 */
 
-const AVATAR_SIZE /*: number */ = 20;
+const AVATAR_SIZE /*: number */ = 16;
 
 export default class UsersSelectSearchValue extends React.PureComponent {
   /*:: props: Props; */
index 56c7a055882378dc490b6f2bd3eb250c6f32fd1f..b3e752014e7c077541d829b99b30508bc13d22a0 100644 (file)
@@ -10,7 +10,7 @@ exports[`should render correctly with email instead of hash 1`] = `
   <Connect(Avatar)
     email="admin@admin.ch"
     name="Administrator"
-    size={20}
+    size={16}
   />
   <strong
     className="spacer-left"
@@ -35,7 +35,7 @@ exports[`should render correctly without all parameters 1`] = `
   <Connect(Avatar)
     hash="7daf6c79d4802916d83f6266e24850af"
     name="Administrator"
-    size={20}
+    size={16}
   />
   <strong
     className="spacer-left"
index 7771b377fb2c932415d25d36568997961b3ba752..1387a28aec296b483b5a26e41e37376a67d0b06e 100644 (file)
@@ -11,7 +11,7 @@ exports[`should render correctly with a user 1`] = `
     <Connect(Avatar)
       hash="7daf6c79d4802916d83f6266e24850af"
       name="Administrator"
-      size={20}
+      size={16}
     />
     <strong
       className="spacer-left"
@@ -38,7 +38,7 @@ exports[`should render correctly with email instead of hash 1`] = `
     <Connect(Avatar)
       email="admin@admin.ch"
       name="Administrator"
-      size={20}
+      size={16}
     />
     <strong
       className="spacer-left"
diff --git a/server/sonar-web/src/main/js/components/controls/SimpleModal.tsx b/server/sonar-web/src/main/js/components/controls/SimpleModal.tsx
new file mode 100644 (file)
index 0000000..825ace8
--- /dev/null
@@ -0,0 +1,90 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import Modal from 'react-modal';
+
+export interface ChildrenProps {
+  onCloseClick: (event: React.SyntheticEvent<HTMLElement>) => void;
+  onSubmitClick: (event: React.SyntheticEvent<HTMLElement>) => void;
+  submitting: boolean;
+}
+
+interface Props {
+  children: (props: ChildrenProps) => React.ReactNode;
+  header: string;
+  onClose: () => void;
+  onSubmit: () => void | Promise<void>;
+}
+
+interface State {
+  submitting: boolean;
+}
+
+export default class SimpleModal extends React.PureComponent<Props, State> {
+  mounted: boolean;
+  state: State = { submitting: false };
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  stopSubmitting = () => {
+    if (this.mounted) {
+      this.setState({ submitting: false });
+    }
+  };
+
+  handleCloseClick = (event: React.SyntheticEvent<HTMLElement>) => {
+    event.preventDefault();
+    event.currentTarget.blur();
+    this.props.onClose();
+  };
+
+  handleSubmitClick = (event: React.SyntheticEvent<HTMLElement>) => {
+    event.preventDefault();
+    event.currentTarget.blur();
+    const result = this.props.onSubmit();
+    if (result) {
+      this.setState({ submitting: true });
+      result.then(this.stopSubmitting, this.stopSubmitting);
+    }
+  };
+
+  render() {
+    return (
+      <Modal
+        isOpen={true}
+        contentLabel={this.props.header}
+        className="modal"
+        overlayClassName="modal-overlay"
+        onRequestClose={this.props.onClose}>
+        {this.props.children({
+          onCloseClick: this.handleCloseClick,
+          onSubmitClick: this.handleSubmitClick,
+          submitting: this.state.submitting
+        })}
+      </Modal>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/SimpleModal-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/SimpleModal-test.tsx
new file mode 100644 (file)
index 0000000..42c3c99
--- /dev/null
@@ -0,0 +1,70 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import SimpleModal, { ChildrenProps } from '../SimpleModal';
+import { click } from '../../../helpers/testUtils';
+
+it('renders', () => {
+  const inner = () => <div />;
+  expect(
+    shallow(
+      <SimpleModal header="" onClose={jest.fn()} onSubmit={jest.fn()}>
+        {inner}
+      </SimpleModal>
+    )
+  ).toMatchSnapshot();
+});
+
+it('closes', () => {
+  const onClose = jest.fn();
+  const inner = ({ onCloseClick }: ChildrenProps) => <button onClick={onCloseClick}>close</button>;
+  const wrapper = shallow(
+    <SimpleModal header="" onClose={onClose} onSubmit={jest.fn()}>
+      {inner}
+    </SimpleModal>
+  );
+  click(wrapper.find('button'));
+  expect(onClose).toBeCalled();
+});
+
+it('submits', async () => {
+  const onSubmit = jest.fn(() => Promise.resolve());
+  const inner = ({ onSubmitClick, submitting }: ChildrenProps) => (
+    <button disabled={submitting} onClick={onSubmitClick}>
+      close
+    </button>
+  );
+  const wrapper = shallow(
+    <SimpleModal header="" onClose={jest.fn()} onSubmit={onSubmit}>
+      {inner}
+    </SimpleModal>
+  );
+  (wrapper.instance() as SimpleModal).mounted = true;
+  expect(wrapper).toMatchSnapshot();
+
+  click(wrapper.find('button'));
+  expect(onSubmit).toBeCalled();
+  expect(wrapper).toMatchSnapshot();
+
+  await new Promise(setImmediate);
+  wrapper.update();
+  expect(wrapper).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/SimpleModal-test.tsx.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/SimpleModal-test.tsx.snap
new file mode 100644 (file)
index 0000000..4778ef4
--- /dev/null
@@ -0,0 +1,88 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders 1`] = `
+<Modal
+  ariaHideApp={true}
+  bodyOpenClassName="ReactModal__Body--open"
+  className="modal"
+  closeTimeoutMS={0}
+  contentLabel=""
+  isOpen={true}
+  onRequestClose={[Function]}
+  overlayClassName="modal-overlay"
+  parentSelector={[Function]}
+  portalClassName="ReactModalPortal"
+  shouldCloseOnOverlayClick={true}
+>
+  <div />
+</Modal>
+`;
+
+exports[`submits 1`] = `
+<Modal
+  ariaHideApp={true}
+  bodyOpenClassName="ReactModal__Body--open"
+  className="modal"
+  closeTimeoutMS={0}
+  contentLabel=""
+  isOpen={true}
+  onRequestClose={[Function]}
+  overlayClassName="modal-overlay"
+  parentSelector={[Function]}
+  portalClassName="ReactModalPortal"
+  shouldCloseOnOverlayClick={true}
+>
+  <button
+    disabled={false}
+    onClick={[Function]}
+  >
+    close
+  </button>
+</Modal>
+`;
+
+exports[`submits 2`] = `
+<Modal
+  ariaHideApp={true}
+  bodyOpenClassName="ReactModal__Body--open"
+  className="modal"
+  closeTimeoutMS={0}
+  contentLabel=""
+  isOpen={true}
+  onRequestClose={[Function]}
+  overlayClassName="modal-overlay"
+  parentSelector={[Function]}
+  portalClassName="ReactModalPortal"
+  shouldCloseOnOverlayClick={true}
+>
+  <button
+    disabled={true}
+    onClick={[Function]}
+  >
+    close
+  </button>
+</Modal>
+`;
+
+exports[`submits 3`] = `
+<Modal
+  ariaHideApp={true}
+  bodyOpenClassName="ReactModal__Body--open"
+  className="modal"
+  closeTimeoutMS={0}
+  contentLabel=""
+  isOpen={true}
+  onRequestClose={[Function]}
+  overlayClassName="modal-overlay"
+  parentSelector={[Function]}
+  portalClassName="ReactModalPortal"
+  shouldCloseOnOverlayClick={true}
+>
+  <button
+    disabled={false}
+    onClick={[Function]}
+  >
+    close
+  </button>
+</Modal>
+`;
diff --git a/server/sonar-web/src/main/js/components/icons-components/GroupIcon.tsx b/server/sonar-web/src/main/js/components/icons-components/GroupIcon.tsx
new file mode 100644 (file)
index 0000000..cb971f1
--- /dev/null
@@ -0,0 +1,39 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+
+interface Props {
+  className?: string;
+  fill?: string;
+  size?: number;
+}
+
+export default function GroupIcon({ className, fill = '#aaa', size = 36 }: Props) {
+  return (
+    <svg className={className} width={size} height={size} viewBox="0 0 36 36">
+      <g transform="matrix(0.0625,0,0,0.0625,3,4)">
+        <path
+          fill={fill}
+          d="M148.25,224C121.25,224.833 99.167,235.5 82,256L48.5,256C34.833,256 23.333,252.625 14,245.875C4.667,239.125 0,229.25 0,216.25C0,157.417 10.333,128 31,128C32,128 35.625,129.75 41.875,133.25C48.125,136.75 56.25,140.292 66.25,143.875C76.25,147.458 86.167,149.25 96,149.25C107.167,149.25 118.25,147.333 129.25,143.5C128.417,149.667 128,155.167 128,160C128,183.167 134.75,204.5 148.25,224ZM416,383.25C416,403.25 409.917,419.042 397.75,430.625C385.583,442.208 369.417,448 349.25,448L130.75,448C110.583,448 94.417,442.208 82.25,430.625C70.083,419.042 64,403.25 64,383.25C64,374.417 64.292,365.792 64.875,357.375C65.458,348.958 66.625,339.875 68.375,330.125C70.125,320.375 72.333,311.333 75,303C77.667,294.667 81.25,286.542 85.75,278.625C90.25,270.708 95.417,263.958 101.25,258.375C107.083,252.792 114.208,248.333 122.625,245C131.042,241.667 140.333,240 150.5,240C152.167,240 155.75,241.792 161.25,245.375C166.75,248.958 172.833,252.958 179.5,257.375C186.167,261.792 195.083,265.792 206.25,269.375C217.417,272.958 228.667,274.75 240,274.75C251.333,274.75 262.583,272.958 273.75,269.375C284.917,265.792 293.833,261.792 300.5,257.375C307.167,252.958 313.25,248.958 318.75,245.375C324.25,241.792 327.833,240 329.5,240C339.667,240 348.958,241.667 357.375,245C365.792,248.333 372.917,252.792 378.75,258.375C384.583,263.958 389.75,270.708 394.25,278.625C398.75,286.542 402.333,294.667 405,303C407.667,311.333 409.875,320.375 411.625,330.125C413.375,339.875 414.542,348.958 415.125,357.375C415.708,365.792 416,374.417 416,383.25ZM160,64C160,81.667 153.75,96.75 141.25,109.25C128.75,121.75 113.667,128 96,128C78.333,128 63.25,121.75 50.75,109.25C38.25,96.75 32,81.667 32,64C32,46.333 38.25,31.25 50.75,18.75C63.25,6.25 78.333,0 96,0C113.667,0 128.75,6.25 141.25,18.75C153.75,31.25 160,46.333 160,64ZM336,160C336,186.5 326.625,209.125 307.875,227.875C289.125,246.625 266.5,256 240,256C213.5,256 190.875,246.625 172.125,227.875C153.375,209.125 144,186.5 144,160C144,133.5 153.375,110.875 172.125,92.125C190.875,73.375 213.5,64 240,64C266.5,64 289.125,73.375 307.875,92.125C326.625,110.875 336,133.5 336,160ZM480,216.25C480,229.25 475.333,239.125 466,245.875C456.667,252.625 445.167,256 431.5,256L398,256C380.833,235.5 358.75,224.833 331.75,224C345.25,204.5 352,183.167 352,160C352,155.167 351.583,149.667 350.75,143.5C361.75,147.333 372.833,149.25 384,149.25C393.833,149.25 403.75,147.458 413.75,143.875C423.75,140.292 431.875,136.75 438.125,133.25C444.375,129.75 448,128 449,128C469.667,128 480,157.417 480,216.25ZM448,64C448,81.667 441.75,96.75 429.25,109.25C416.75,121.75 401.667,128 384,128C366.333,128 351.25,121.75 338.75,109.25C326.25,96.75 320,81.667 320,64C320,46.333 326.25,31.25 338.75,18.75C351.25,6.25 366.333,0 384,0C401.667,0 416.75,6.25 429.25,18.75C441.75,31.25 448,46.333 448,64Z"
+        />
+      </g>
+    </svg>
+  );
+}
index 33649781706d0d2571dbfe17617b6cff1a2a5acf..c3d9e98a579635e33dc218ad11482828e555eb85 100644 (file)
 
 .Select-big .Select-value-label {
   display: inline-block;
-  margin-top: 5px;
+  margin-top: 7px;
+  line-height: 16px;
 }
 
 .Select-big .Select-option {
-  padding: 4px 8px;
+  padding: 7px 8px;
+  line-height: 16px;
 }
 
-.Select-big img {
+.Select-big img,
+.Select-big svg {
   padding-top: 0;
 }
 
index 427192d12e754db536c580000009eb0adc5ed893..8186016873dee81713d198a9eddb912cacb49f15 100644 (file)
@@ -8,7 +8,9 @@
     "strict": true,
     "target": "es5",
     "jsx": "react",
-    "lib": ["es2017", "dom"],
+    // remove "es2015.promise", "es2015.iterable" when upgrading to TypeScript 2.5
+    // see https://github.com/Microsoft/TypeScript/issues/16017
+    "lib": ["es2015.promise", "es2015.iterable", "es2017", "dom"],
     "module": "esnext",
     "moduleResolution": "node",
     "typeRoots": ["./src/main/js/typings", "./node_modules/@types"]
index 6ed28db4edbe947a4aca7eb01df900f1aa6c117d..973400b7713d47fe6bf21cb23ff84cd9fc3702c1 100644 (file)
@@ -1573,6 +1573,11 @@ quality_profiles.built_in=Built-in
 quality_profiles.built_in.description.1=This quality profile is provided by a plugin.
 quality_profiles.built_in.description.2=It will automatically be updated when a new version of the supplying plugin changes its definition.
 quality_profiles.extends_built_in=Because it inherits from a built-in quality profile, this quality profile can be automatically updated when a new version of the corresponding plugin is deployed.
+quality_profiles.default_permissions=Users with the global "Manage Quality Profile" permission can manage this quality profile.
+quality_profiles.grant_permissions_to_more_users=Grant permissions to more users
+quality_profiles.grant_permissions_to_user_or_group=Grant permissions to a user or a group
+quality_profiles.additional_user_groups=Additional users / groups:
+quality_profiles.search_description=Search users by login or name, and groups by name
 
 
 
@@ -1911,15 +1916,19 @@ login.login_with_x=Log in with {0}
 
 #------------------------------------------------------------------------------
 #
-# USERS PAGE
+# USERS & GROUPS PAGE
 #
 #------------------------------------------------------------------------------
 users.add=Add user
 users.remove=Remove user
+users.remove.confirmation=Are you sure you want to remove user "{user}"?
 users.search_description=Search users by login or name
 users.update=Update users
 users.update_details=Update details
 
+groups.remove=Remove group
+groups.remove.confirmation=Are you sure you want to remove group "{user}"?
+
 #------------------------------------------------------------------------------
 #
 # MY PROFILE & MY ACCOUNT