]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-18657 Add Filters for all, local and managed groups on groups list
authorguillaume-peoch-sonarsource <guillaume.peoch@sonarsource.com>
Tue, 14 Mar 2023 11:30:42 +0000 (12:30 +0100)
committersonartech <sonartech@sonarsource.com>
Wed, 22 Mar 2023 20:04:08 +0000 (20:04 +0000)
40 files changed:
server/sonar-web/src/main/js/api/mocks/GroupsServiceMock.ts [new file with mode: 0644]
server/sonar-web/src/main/js/api/user_groups.ts
server/sonar-web/src/main/js/apps/groups/components/App.tsx
server/sonar-web/src/main/js/apps/groups/components/EditMembers.tsx
server/sonar-web/src/main/js/apps/groups/components/Header.tsx
server/sonar-web/src/main/js/apps/groups/components/List.tsx
server/sonar-web/src/main/js/apps/groups/components/ListItem.tsx
server/sonar-web/src/main/js/apps/groups/components/__tests__/App-test.tsx [deleted file]
server/sonar-web/src/main/js/apps/groups/components/__tests__/DeleteForm-test.tsx [deleted file]
server/sonar-web/src/main/js/apps/groups/components/__tests__/EditMembers-test.tsx
server/sonar-web/src/main/js/apps/groups/components/__tests__/EditMembersModal-test.tsx
server/sonar-web/src/main/js/apps/groups/components/__tests__/Form-test.tsx [deleted file]
server/sonar-web/src/main/js/apps/groups/components/__tests__/GroupsApp-it.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/groups/components/__tests__/Header-test.tsx [deleted file]
server/sonar-web/src/main/js/apps/groups/components/__tests__/List-test.tsx
server/sonar-web/src/main/js/apps/groups/components/__tests__/ListItem-test.tsx
server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/App-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/DeleteForm-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembers-test.tsx.snap
server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/Form-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/Header-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/List-test.tsx.snap
server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/ListItem-test.tsx.snap
server/sonar-web/src/main/js/apps/users/Header.tsx
server/sonar-web/src/main/js/apps/users/UsersApp.tsx
server/sonar-web/src/main/js/apps/users/UsersList.tsx
server/sonar-web/src/main/js/apps/users/__tests__/Header-test.tsx
server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx
server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/Header-test.tsx.snap
server/sonar-web/src/main/js/apps/users/components/UserActions.tsx
server/sonar-web/src/main/js/apps/users/components/UserGroups.tsx
server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx
server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx
server/sonar-web/src/main/js/apps/users/components/__tests__/UserActions-test.tsx
server/sonar-web/src/main/js/apps/users/components/__tests__/UserGroups-test.tsx
server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserActions-test.tsx.snap
server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserGroups-test.tsx.snap
server/sonar-web/src/main/js/helpers/testMocks.ts
server/sonar-web/src/main/js/types/types.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

diff --git a/server/sonar-web/src/main/js/api/mocks/GroupsServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/GroupsServiceMock.ts
new file mode 100644 (file)
index 0000000..3965397
--- /dev/null
@@ -0,0 +1,195 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { cloneDeep } from 'lodash';
+import {
+  mockClusterSysInfo,
+  mockGroup,
+  mockIdentityProvider,
+  mockPaging,
+  mockUser,
+} from '../../helpers/testMocks';
+import { Group, IdentityProvider, Paging, SysInfoCluster, UserSelected } from '../../types/types';
+import { getSystemInfo } from '../system';
+import { getIdentityProviders } from '../users';
+import {
+  createGroup,
+  deleteGroup,
+  getUsersInGroup,
+  searchUsersGroups,
+  updateGroup,
+} from '../user_groups';
+
+export default class GroupsServiceMock {
+  isManaged = false;
+  paging: Paging;
+  groups: Group[];
+  readOnlyGroups = [
+    mockGroup({ name: 'managed-group', managed: true }),
+    mockGroup({ name: 'local-group', managed: false }),
+  ];
+
+  constructor() {
+    this.groups = cloneDeep(this.readOnlyGroups);
+    this.paging = mockPaging({
+      pageIndex: 1,
+      pageSize: 2,
+      total: 200,
+    });
+
+    jest.mocked(getSystemInfo).mockImplementation(this.handleGetSystemInfo);
+    jest.mocked(getIdentityProviders).mockImplementation(this.handleGetIdentityProviders);
+    jest.mocked(searchUsersGroups).mockImplementation((p) => this.handleSearchUsersGroups(p));
+    jest.mocked(createGroup).mockImplementation((g) => this.handleCreateGroup(g));
+    jest.mocked(deleteGroup).mockImplementation((g) => this.handleDeleteGroup(g));
+    jest.mocked(updateGroup).mockImplementation((g) => this.handleUpdateGroup(g));
+    jest.mocked(getUsersInGroup).mockImplementation(this.handlegetUsersInGroup);
+  }
+
+  reset() {
+    this.groups = cloneDeep(this.readOnlyGroups);
+  }
+
+  setIsManaged(managed: boolean) {
+    this.isManaged = managed;
+  }
+
+  setPaging(paging: Partial<Paging>) {
+    this.paging = { ...this.paging, ...paging };
+  }
+
+  handleCreateGroup = (group: { name: string; description?: string }): Promise<Group> => {
+    const newGroup = mockGroup(group);
+    this.groups.push(newGroup);
+    return this.reply(newGroup);
+  };
+
+  handleDeleteGroup = (group: { name: string }): Promise<Record<string, never>> => {
+    if (!this.groups.some((g) => g.name === group.name)) {
+      return Promise.reject();
+    }
+
+    const groupToDelete = this.groups.find((g) => g.name === group.name);
+    if (groupToDelete?.managed) {
+      return Promise.reject();
+    }
+
+    this.groups = this.groups.filter((g) => g.name !== group.name);
+    return this.reply({});
+  };
+
+  handleUpdateGroup = (group: {
+    currentName: string;
+    name?: string;
+    description?: string;
+  }): Promise<Record<string, never>> => {
+    if (!this.groups.some((g) => group.currentName === g.name)) {
+      return Promise.reject();
+    }
+
+    this.groups.map((g) => {
+      if (g.name === group.currentName) {
+        if (group.name !== undefined) {
+          g.name = group.name;
+        }
+        if (group.description !== undefined) {
+          g.description = group.description;
+        }
+      }
+    });
+    return this.reply({});
+  };
+
+  handlegetUsersInGroup = (): Promise<Paging & { users: UserSelected[] }> => {
+    return this.reply({
+      ...this.paging,
+      users: [
+        {
+          ...mockUser({ name: 'alice' }),
+          selected: true,
+        } as UserSelected,
+        {
+          ...mockUser({ name: 'bob' }),
+          selected: false,
+        } as UserSelected,
+      ],
+    });
+  };
+
+  handleSearchUsersGroups = (data: {
+    f?: string;
+    p?: number;
+    ps?: number;
+    q?: string;
+    managed: boolean | undefined;
+  }): Promise<{ groups: Group[]; paging: Paging }> => {
+    const { paging } = this;
+    if (data.p !== undefined && data.p !== paging.pageIndex) {
+      this.setPaging({ pageIndex: paging.pageIndex++ });
+      const groups = [
+        mockGroup({ name: `local-group ${this.groups.length + 4}` }),
+        mockGroup({ name: `local-group ${this.groups.length + 5}` }),
+      ];
+
+      return this.reply({ paging, groups });
+    }
+    if (this.isManaged) {
+      if (data.managed === undefined) {
+        return this.reply({
+          paging,
+          groups: this.groups.filter((g) => (data?.q ? g.name.includes(data.q) : true)),
+        });
+      }
+      const groups = this.groups.filter((group) => group.managed === data.managed);
+      return this.reply({
+        paging,
+        groups: groups.filter((g) => (data?.q ? g.name.includes(data.q) : true)),
+      });
+    }
+    return this.reply({
+      paging,
+      groups: this.groups.filter((g) => (data?.q ? g.name.includes(data.q) : true)),
+    });
+  };
+
+  handleGetIdentityProviders = (): Promise<{ identityProviders: IdentityProvider[] }> => {
+    return this.reply({ identityProviders: [mockIdentityProvider()] });
+  };
+
+  handleGetSystemInfo = (): Promise<SysInfoCluster> => {
+    return this.reply(
+      mockClusterSysInfo(
+        this.isManaged
+          ? {
+              System: {
+                'High Availability': true,
+                'Server ID': 'asd564-asd54a-5dsfg45',
+                'External Users and Groups Provisioning': 'Okta',
+              },
+            }
+          : {}
+      )
+    );
+  };
+
+  reply<T>(response: T): Promise<T> {
+    return Promise.resolve(cloneDeep(response));
+  }
+}
index 2093b210e7aa602be087ea5dafc5fa137709708b..9b2ebe32352ce2e318efba308ea3d9160616d930 100644 (file)
@@ -26,6 +26,7 @@ export function searchUsersGroups(data: {
   p?: number;
   ps?: number;
   q?: string;
+  managed: boolean | undefined;
 }): Promise<{ groups: Group[]; paging: Paging }> {
   return getJSON('/api/user_groups/search', data).catch(throwGlobalError);
 }
index 31cd060f6c9291925d6313914e04c6cda794fece..847d29b9fb1e807f9a6585e9a735f682bf0cfee2 100644 (file)
@@ -22,6 +22,7 @@ import * as React from 'react';
 import { Helmet } from 'react-helmet-async';
 import { getSystemInfo } from '../../../api/system';
 import { createGroup, deleteGroup, searchUsersGroups, updateGroup } from '../../../api/user_groups';
+import ButtonToggle from '../../../components/controls/ButtonToggle';
 import ListFooter from '../../../components/controls/ListFooter';
 import SearchBox from '../../../components/controls/SearchBox';
 import Suggestions from '../../../components/embed-docs-modal/Suggestions';
@@ -41,11 +42,17 @@ interface State {
   paging?: Paging;
   query: string;
   manageProvider?: string;
+  managed: boolean | undefined;
 }
 
 export default class App extends React.PureComponent<{}, State> {
   mounted = false;
-  state: State = { loading: true, query: '' };
+  state: State = {
+    loading: true,
+    query: '',
+    managed: undefined,
+    paging: { pageIndex: 1, pageSize: 100, total: 1000 },
+  };
 
   componentDidMount() {
     this.mounted = true;
@@ -53,18 +60,19 @@ export default class App extends React.PureComponent<{}, State> {
     this.fetchManageInstance();
   }
 
+  componentDidUpdate(_prevProps: {}, prevState: State) {
+    if (prevState.query !== this.state.query || prevState.managed !== this.state.managed) {
+      this.fetchGroups();
+    }
+    if (prevState !== undefined && prevState.paging?.pageIndex !== this.state.paging?.pageIndex) {
+      this.fetchMoreGroups();
+    }
+  }
+
   componentWillUnmount() {
     this.mounted = false;
   }
 
-  makeFetchGroupsRequest = (data?: { p?: number; q?: string }) => {
-    this.setState({ loading: true });
-    return searchUsersGroups({
-      q: this.state.query,
-      ...data,
-    });
-  };
-
   async fetchManageInstance() {
     const info = (await getSystemInfo()) as SysInfoCluster;
     if (this.mounted) {
@@ -80,9 +88,14 @@ export default class App extends React.PureComponent<{}, State> {
     }
   };
 
-  fetchGroups = async (data?: { p?: number; q?: string }) => {
+  fetchGroups = async () => {
+    const { query: q, managed } = this.state;
+    this.setState({ loading: true });
     try {
-      const { groups, paging } = await this.makeFetchGroupsRequest(data);
+      const { groups, paging } = await searchUsersGroups({
+        q,
+        managed,
+      });
       if (this.mounted) {
         this.setState({ groups, loading: false, paging });
       }
@@ -92,11 +105,13 @@ export default class App extends React.PureComponent<{}, State> {
   };
 
   fetchMoreGroups = async () => {
-    const { paging: currentPaging } = this.state;
+    const { query: q, managed, paging: currentPaging } = this.state;
     if (currentPaging && currentPaging.total > currentPaging.pageIndex * currentPaging.pageSize) {
       try {
-        const { groups, paging } = await this.makeFetchGroupsRequest({
-          p: currentPaging.pageIndex + 1,
+        const { groups, paging } = await searchUsersGroups({
+          p: currentPaging.pageIndex,
+          q,
+          managed,
         });
         if (this.mounted) {
           this.setState(({ groups: existingGroups = [] }) => ({
@@ -111,15 +126,10 @@ export default class App extends React.PureComponent<{}, State> {
     }
   };
 
-  search = (query: string) => {
-    this.fetchGroups({ q: query });
-    this.setState({ query });
-  };
-
   refresh = async () => {
-    const { paging, query } = this.state;
+    const { paging } = this.state;
 
-    await this.fetchGroups({ q: query });
+    await this.fetchGroups();
 
     // reload all pages in order
     if (paging && paging.pageIndex > 1) {
@@ -130,22 +140,6 @@ export default class App extends React.PureComponent<{}, State> {
     }
   };
 
-  closeDeleteForm = () => {
-    this.setState({ groupToBeDeleted: undefined });
-  };
-
-  closeEditForm = () => {
-    this.setState({ editedGroup: undefined });
-  };
-
-  openDeleteForm = (group: Group) => {
-    this.setState({ groupToBeDeleted: group });
-  };
-
-  openEditForm = (group: Group) => {
-    this.setState({ editedGroup: group });
-  };
-
   handleCreate = async (data: { description: string; name: string }) => {
     await createGroup({ ...data });
 
@@ -200,8 +194,16 @@ export default class App extends React.PureComponent<{}, State> {
   };
 
   render() {
-    const { editedGroup, groupToBeDeleted, groups, loading, paging, query, manageProvider } =
-      this.state;
+    const {
+      editedGroup,
+      groupToBeDeleted,
+      groups,
+      loading,
+      paging,
+      query,
+      manageProvider,
+      managed,
+    } = this.state;
 
     const showAnyone = 'anyone'.includes(query.toLowerCase());
 
@@ -212,22 +214,45 @@ export default class App extends React.PureComponent<{}, State> {
         <main className="page page-limited" id="groups-page">
           <Header onCreate={this.handleCreate} manageProvider={manageProvider} />
 
-          <SearchBox
-            className="big-spacer-bottom"
-            id="groups-search"
-            minLength={2}
-            onChange={this.search}
-            placeholder={translate('search.search_by_name')}
-            value={query}
-          />
+          <div className="display-flex-justify-start big-spacer-bottom big-spacer-top">
+            {manageProvider !== undefined && (
+              <div className="big-spacer-right">
+                <ButtonToggle
+                  value={managed === undefined ? 'all' : managed}
+                  disabled={loading}
+                  options={[
+                    { label: translate('all'), value: 'all' },
+                    { label: translate('managed'), value: true },
+                    { label: translate('local'), value: false },
+                  ]}
+                  onCheck={(filterOption) => {
+                    if (filterOption === 'all') {
+                      this.setState({ managed: undefined });
+                    } else {
+                      this.setState({ managed: filterOption as boolean });
+                    }
+                  }}
+                />
+              </div>
+            )}
+            <SearchBox
+              className="big-spacer-bottom"
+              id="groups-search"
+              minLength={2}
+              onChange={(q) => this.setState({ query: q })}
+              placeholder={translate('search.search_by_name')}
+              value={query}
+            />
+          </div>
 
           {groups !== undefined && (
             <List
               groups={groups}
-              onDelete={this.openDeleteForm}
-              onEdit={this.openEditForm}
+              onDelete={(groupToBeDeleted) => this.setState({ groupToBeDeleted })}
+              onEdit={(editedGroup) => this.setState({ editedGroup })}
               onEditMembers={this.refresh}
               showAnyone={showAnyone}
+              manageProvider={manageProvider}
             />
           )}
 
@@ -236,7 +261,11 @@ export default class App extends React.PureComponent<{}, State> {
               <ListFooter
                 count={showAnyone ? groups.length + 1 : groups.length}
                 loading={loading}
-                loadMore={this.fetchMoreGroups}
+                loadMore={() => {
+                  if (paging.total > paging.pageIndex * paging.pageSize) {
+                    this.setState({ paging: { ...paging, pageIndex: paging.pageIndex + 1 } });
+                  }
+                }}
                 ready={!loading}
                 total={showAnyone ? paging.total + 1 : paging.total}
               />
@@ -246,7 +275,7 @@ export default class App extends React.PureComponent<{}, State> {
           {groupToBeDeleted && (
             <DeleteForm
               group={groupToBeDeleted}
-              onClose={this.closeDeleteForm}
+              onClose={() => this.setState({ groupToBeDeleted: undefined })}
               onSubmit={this.handleDelete}
             />
           )}
@@ -256,7 +285,7 @@ export default class App extends React.PureComponent<{}, State> {
               confirmButtonText={translate('update_verb')}
               group={editedGroup}
               header={translate('groups.update_group')}
-              onClose={this.closeEditForm}
+              onClose={() => this.setState({ editedGroup: undefined })}
               onSubmit={this.handleEdit}
             />
           )}
index d22a55ee014f8cdebca8be58973c715fe1dcdd42..ca86ea9fa526ac80a2e6db87ac93137e50c7566c 100644 (file)
@@ -20,7 +20,7 @@
 import * as React from 'react';
 import { ButtonIcon } from '../../../components/controls/buttons';
 import BulletListIcon from '../../../components/icons/BulletListIcon';
-import { translate } from '../../../helpers/l10n';
+import { translateWithParameters } from '../../../helpers/l10n';
 import { Group } from '../../../types/types';
 import EditMembersModal from './EditMembersModal';
 
@@ -61,10 +61,10 @@ export default class EditMembers extends React.PureComponent<Props, State> {
     return (
       <>
         <ButtonIcon
-          aria-label={translate('groups.users.edit')}
+          aria-label={translateWithParameters('groups.users.edit', this.props.group.name)}
           className="button-small"
           onClick={this.handleMembersClick}
-          title={translate('groups.users.edit')}
+          title={translateWithParameters('groups.users.edit', this.props.group.name)}
         >
           <BulletListIcon />
         </ButtonIcon>
index 81e71d6cd8df7682f2e7055bf182e52d9d9d1fb9..8a681c42b5ed88533138bcaaf1bbf94429e8888e 100644 (file)
@@ -25,12 +25,12 @@ import { Alert } from '../../../components/ui/Alert';
 import { translate } from '../../../helpers/l10n';
 import Form from './Form';
 
-interface Props {
+interface HeaderProps {
   onCreate: (data: { description: string; name: string }) => Promise<void>;
   manageProvider?: string;
 }
 
-export default function Header(props: Props) {
+export default function Header(props: HeaderProps) {
   const { manageProvider } = props;
   const [createModal, setCreateModal] = React.useState(false);
 
index 74bd3440f5ecc47fd896feeb2e4c3fea49bff47c..06b658e57242bcd42d6f410b687182253699275f 100644 (file)
@@ -29,9 +29,12 @@ interface Props {
   onEdit: (group: Group) => void;
   onEditMembers: () => void;
   showAnyone: boolean;
+  manageProvider: string | undefined;
 }
 
 export default function List(props: Props) {
+  const { groups, manageProvider, showAnyone } = props;
+
   return (
     <div className="boxed-group boxed-group-inner">
       <table className="data zebra zebra-hover" id="groups-list">
@@ -46,7 +49,7 @@ export default function List(props: Props) {
           </tr>
         </thead>
         <tbody>
-          {props.showAnyone && (
+          {showAnyone && (
             <tr className="js-anyone" key="anyone">
               <td className="width-20">
                 <strong className="js-group-name">{translate('groups.anyone')}</strong>
@@ -61,13 +64,14 @@ export default function List(props: Props) {
             </tr>
           )}
 
-          {sortBy(props.groups, (group) => group.name.toLowerCase()).map((group) => (
+          {sortBy(groups, (group) => group.name.toLowerCase()).map((group) => (
             <ListItem
               group={group}
               key={group.name}
               onDelete={props.onDelete}
               onEdit={props.onEdit}
               onEditMembers={props.onEditMembers}
+              manageProvider={manageProvider}
             />
           ))}
         </tbody>
index f148746e94addd3cc85cb06e8758e141d6aa540b..c631103f75a1c2da2f0b7b4c4d644e29ac0fda50 100644 (file)
@@ -22,7 +22,7 @@ import ActionsDropdown, {
   ActionsDropdownDivider,
   ActionsDropdownItem,
 } from '../../../components/controls/ActionsDropdown';
-import { translate } from '../../../helpers/l10n';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { Group } from '../../../types/types';
 import EditMembers from './EditMembers';
 
@@ -31,41 +31,63 @@ export interface ListItemProps {
   onDelete: (group: Group) => void;
   onEdit: (group: Group) => void;
   onEditMembers: () => void;
+  manageProvider: string | undefined;
 }
 
 export default function ListItem(props: ListItemProps) {
-  const { group } = props;
+  const { manageProvider, group } = props;
+  const { name, managed, membersCount, description } = group;
+
+  const isManaged = () => {
+    return manageProvider !== undefined;
+  };
+
+  const isGroupLocal = () => {
+    return isManaged() && !managed;
+  };
 
   return (
-    <tr data-id={group.name}>
+    <tr data-id={name}>
       <td className="width-20">
-        <strong className="js-group-name">{group.name}</strong>
+        <strong className="js-group-name">{name}</strong>
         {group.default && <span className="little-spacer-left">({translate('default')})</span>}
+        {isGroupLocal() && <span className="little-spacer-left badge">{translate('local')}</span>}
       </td>
 
-      <td className="thin text-middle text-right little-padded-right">{group.membersCount}</td>
+      <td className="thin text-middle text-right little-padded-right">{membersCount}</td>
       <td className="little-padded-left">
-        {!group.default && <EditMembers group={group} onEdit={props.onEditMembers} />}
+        {!group.default && !isManaged() && (
+          <EditMembers group={group} onEdit={props.onEditMembers} />
+        )}
       </td>
 
       <td className="width-40">
-        <span className="js-group-description">{group.description}</span>
+        <span className="js-group-description">{description}</span>
       </td>
 
       <td className="thin nowrap text-right">
-        {!group.default && (
-          <ActionsDropdown>
-            <ActionsDropdownItem className="js-group-update" onClick={() => props.onEdit(group)}>
-              {translate('update_details')}
-            </ActionsDropdownItem>
-            <ActionsDropdownDivider />
-            <ActionsDropdownItem
-              className="js-group-delete"
-              destructive={true}
-              onClick={() => props.onDelete(group)}
-            >
-              {translate('delete')}
-            </ActionsDropdownItem>
+        {!group.default && (!isManaged() || isGroupLocal()) && (
+          <ActionsDropdown label={translateWithParameters('groups.edit', group.name)}>
+            {!isManaged() && (
+              <>
+                <ActionsDropdownItem
+                  className="js-group-update"
+                  onClick={() => props.onEdit(group)}
+                >
+                  {translate('update_details')}
+                </ActionsDropdownItem>
+                <ActionsDropdownDivider />
+              </>
+            )}
+            {(!isManaged() || isGroupLocal()) && (
+              <ActionsDropdownItem
+                className="js-group-delete"
+                destructive={true}
+                onClick={() => props.onDelete(group)}
+              >
+                {translate('delete')}
+              </ActionsDropdownItem>
+            )}
           </ActionsDropdown>
         )}
       </td>
diff --git a/server/sonar-web/src/main/js/apps/groups/components/__tests__/App-test.tsx b/server/sonar-web/src/main/js/apps/groups/components/__tests__/App-test.tsx
deleted file mode 100644 (file)
index 8858be2..0000000
+++ /dev/null
@@ -1,191 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { shallow } from 'enzyme';
-import * as React from 'react';
-import {
-  createGroup,
-  deleteGroup,
-  searchUsersGroups,
-  updateGroup,
-} from '../../../../api/user_groups';
-import { mockGroup } from '../../../../helpers/testMocks';
-import { waitAndUpdate } from '../../../../helpers/testUtils';
-import App from '../App';
-
-jest.mock('../../../../api/user_groups', () => ({
-  createGroup: jest.fn().mockResolvedValue({
-    default: false,
-    description: 'Desc foo',
-    membersCount: 0,
-    name: 'Foo',
-  }),
-  deleteGroup: jest.fn().mockResolvedValue({}),
-  searchUsersGroups: jest.fn().mockResolvedValue({
-    paging: { pageIndex: 1, pageSize: 2, total: 4 },
-    groups: [
-      {
-        default: false,
-        description: 'Owners of organization foo',
-        membersCount: 1,
-        name: 'Owners',
-      },
-      {
-        default: true,
-        description: 'Members of organization foo',
-        membersCount: 2,
-        name: 'Members',
-      },
-    ],
-  }),
-  updateGroup: jest.fn().mockResolvedValue({}),
-}));
-
-jest.mock('../../../../api/system', () => ({
-  getSystemInfo: jest.fn().mockResolvedValue({ System: {} }),
-}));
-
-beforeEach(() => {
-  jest.clearAllMocks();
-});
-
-it('should render correctly', async () => {
-  const wrapper = shallowRender();
-  expect(wrapper).toMatchSnapshot();
-  await waitAndUpdate(wrapper);
-  expect(searchUsersGroups).toHaveBeenCalledWith({ q: '' });
-  expect(wrapper).toMatchSnapshot();
-});
-
-it('should correctly handle creation', async () => {
-  const wrapper = shallowRender();
-  await waitAndUpdate(wrapper);
-  expect(wrapper.state('groups')).toHaveLength(2);
-  wrapper.instance().handleCreate({ description: 'Desc foo', name: 'foo' });
-  await waitAndUpdate(wrapper);
-  expect(createGroup).toHaveBeenCalled();
-});
-
-it('should correctly handle deletion', async () => {
-  const wrapper = shallowRender();
-  await waitAndUpdate(wrapper);
-  expect(wrapper.state('groups')).toHaveLength(2);
-  wrapper.setState({ groupToBeDeleted: mockGroup({ name: 'Members' }) });
-  wrapper.instance().handleDelete();
-  await waitAndUpdate(wrapper);
-  expect(deleteGroup).toHaveBeenCalled();
-  expect(wrapper.state().groupToBeDeleted).toBeUndefined();
-});
-
-it('should ignore deletion', async () => {
-  const wrapper = shallowRender();
-  await waitAndUpdate(wrapper);
-  wrapper.setState({ groupToBeDeleted: undefined });
-  wrapper.instance().handleDelete();
-  expect(deleteGroup).not.toHaveBeenCalled();
-});
-
-it('should correctly handle edition', async () => {
-  const wrapper = shallowRender();
-  await waitAndUpdate(wrapper);
-  wrapper.setState({ editedGroup: mockGroup({ name: 'Owners' }) });
-  wrapper.instance().handleEdit({ description: 'foo', name: 'bar' });
-  await waitAndUpdate(wrapper);
-  expect(updateGroup).toHaveBeenCalled();
-  expect(wrapper.state('groups')).toContainEqual({
-    default: false,
-    description: 'foo',
-    membersCount: 1,
-    name: 'bar',
-  });
-});
-
-it('should ignore edition', async () => {
-  const wrapper = shallowRender();
-  await waitAndUpdate(wrapper);
-  wrapper.setState({ editedGroup: undefined });
-  wrapper.instance().handleEdit({ description: 'nope', name: 'nuhuh' });
-  expect(updateGroup).not.toHaveBeenCalled();
-});
-
-it('should fetch more groups', async () => {
-  const wrapper = shallowRender();
-  await waitAndUpdate(wrapper);
-  wrapper.find('ListFooter').prop<Function>('loadMore')();
-  await waitAndUpdate(wrapper);
-  expect(searchUsersGroups).toHaveBeenCalledWith({ p: 2, q: '' });
-  expect(wrapper.state('groups')).toHaveLength(4);
-});
-
-it('should search for groups', async () => {
-  const wrapper = shallowRender();
-  await waitAndUpdate(wrapper);
-  wrapper.find('SearchBox').prop<Function>('onChange')('foo');
-  expect(searchUsersGroups).toHaveBeenCalledWith({ q: 'foo' });
-  expect(wrapper.state('query')).toBe('foo');
-});
-
-it('should handle edit modal', async () => {
-  const editedGroup = mockGroup();
-
-  const wrapper = shallowRender();
-  await waitAndUpdate(wrapper);
-  expect(wrapper.state().editedGroup).toBeUndefined();
-
-  wrapper.instance().openEditForm(editedGroup);
-  expect(wrapper.state().editedGroup).toEqual(editedGroup);
-
-  wrapper.instance().closeEditForm();
-  expect(wrapper.state().editedGroup).toBeUndefined();
-});
-
-it('should handle delete modal', async () => {
-  const groupToBeDeleted = mockGroup();
-
-  const wrapper = shallowRender();
-  await waitAndUpdate(wrapper);
-  expect(wrapper.state().groupToBeDeleted).toBeUndefined();
-
-  wrapper.instance().openDeleteForm(groupToBeDeleted);
-  expect(wrapper.state().groupToBeDeleted).toEqual(groupToBeDeleted);
-
-  wrapper.instance().closeDeleteForm();
-  expect(wrapper.state().groupToBeDeleted).toBeUndefined();
-});
-
-it('should refresh correctly', async () => {
-  const wrapper = shallowRender();
-
-  await waitAndUpdate(wrapper);
-
-  const query = 'preserve me';
-  wrapper.setState({ paging: { pageIndex: 2, pageSize: 2, total: 5 }, query });
-
-  (searchUsersGroups as jest.Mock).mockClear();
-
-  wrapper.instance().refresh();
-  await waitAndUpdate(wrapper);
-
-  expect(searchUsersGroups).toHaveBeenNthCalledWith(1, { q: query });
-  expect(searchUsersGroups).toHaveBeenNthCalledWith(2, { q: query, p: 2 });
-});
-
-function shallowRender(props: Partial<App['props']> = {}) {
-  return shallow<App>(<App {...props} />);
-}
diff --git a/server/sonar-web/src/main/js/apps/groups/components/__tests__/DeleteForm-test.tsx b/server/sonar-web/src/main/js/apps/groups/components/__tests__/DeleteForm-test.tsx
deleted file mode 100644 (file)
index 0ae92dc..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { shallow } from 'enzyme';
-import * as React from 'react';
-import DeleteForm from '../DeleteForm';
-
-it('should render', () => {
-  const group = { id: 3, name: 'Foo', membersCount: 5 };
-  expect(
-    shallow(<DeleteForm group={group} onClose={jest.fn()} onSubmit={jest.fn()} />).dive()
-  ).toMatchSnapshot();
-});
index 5c0e6a440b5d092e3c729ba87ba66c78e63dc50f..cce459746404dae0316f47fc522fee6d0a7a0a6a 100644 (file)
  */
 import { shallow } from 'enzyme';
 import * as React from 'react';
+import { mockGroup } from '../../../../helpers/testMocks';
 import { click } from '../../../../helpers/testUtils';
 import EditMembers from '../EditMembers';
 
 it('should edit members', () => {
-  const group = { id: 3, name: 'Foo', membersCount: 5 };
+  const group = mockGroup({ name: 'Foo', membersCount: 5 });
   const onEdit = jest.fn();
 
   const wrapper = shallow(<EditMembers group={group} onEdit={onEdit} />);
index a8b4cc49e0f36983a7c21cd0f1e8ca2a7924ee75..d0ba4a374124d8a232b5f4fba7e6a709f8663610 100644 (file)
@@ -21,10 +21,11 @@ import { shallow } from 'enzyme';
 import * as React from 'react';
 import { addUserToGroup, getUsersInGroup, removeUserFromGroup } from '../../../../api/user_groups';
 import SelectList, { SelectListFilter } from '../../../../components/controls/SelectList';
+import { mockGroup } from '../../../../helpers/testMocks';
 import { waitAndUpdate } from '../../../../helpers/testUtils';
 import EditMembersModal from '../EditMembersModal';
 
-const group = { id: 1, name: 'foo', membersCount: 1 };
+const group = mockGroup({ name: 'foo', membersCount: 1 });
 
 jest.mock('../../../../api/user_groups', () => ({
   getUsersInGroup: jest.fn().mockResolvedValue({
diff --git a/server/sonar-web/src/main/js/apps/groups/components/__tests__/Form-test.tsx b/server/sonar-web/src/main/js/apps/groups/components/__tests__/Form-test.tsx
deleted file mode 100644 (file)
index 31992d1..0000000
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { shallow } from 'enzyme';
-import * as React from 'react';
-import { change, click, submit } from '../../../../helpers/testUtils';
-import Form from '../Form';
-
-it('should render form', async () => {
-  const onClose = jest.fn();
-  const onSubmit = jest.fn(() => Promise.resolve());
-  const wrapper = shallow(
-    <Form
-      confirmButtonText="confirmButtonText"
-      header="header"
-      onClose={onClose}
-      onSubmit={onSubmit}
-    />
-  ).dive();
-  expect(wrapper).toMatchSnapshot();
-
-  change(wrapper.find('[name="name"]'), 'foo');
-  change(wrapper.find('[name="description"]'), 'bar');
-  submit(wrapper.find('form'));
-  expect(onSubmit).toHaveBeenCalledWith({ description: 'bar', name: 'foo' });
-
-  await new Promise(setImmediate);
-  expect(onClose).toHaveBeenCalled();
-
-  onClose.mockClear();
-  click(wrapper.find('ResetButtonLink'));
-  expect(onClose).toHaveBeenCalled();
-});
diff --git a/server/sonar-web/src/main/js/apps/groups/components/__tests__/GroupsApp-it.tsx b/server/sonar-web/src/main/js/apps/groups/components/__tests__/GroupsApp-it.tsx
new file mode 100644 (file)
index 0000000..d705163
--- /dev/null
@@ -0,0 +1,255 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import * as React from 'react';
+import { act } from 'react-dom/test-utils';
+import { byRole, byText } from 'testing-library-selector';
+import GroupsServiceMock from '../../../../api/mocks/GroupsServiceMock';
+import { renderApp } from '../../../../helpers/testReactTestingUtils';
+import App from '../App';
+
+jest.mock('../../../../api/users');
+jest.mock('../../../../api/system');
+jest.mock('../../../../api/user_groups');
+
+const handler = new GroupsServiceMock();
+
+const ui = {
+  createGroupButton: byRole('button', { name: 'groups.create_group' }),
+  infoManageMode: byText(/groups\.page\.managed_description/),
+  description: byText('user_groups.page.description'),
+  allFilter: byRole('button', { name: 'all' }),
+  managedFilter: byRole('button', { name: 'managed' }),
+  localFilter: byRole('button', { name: 'local' }),
+  searchInput: byRole('searchbox', { name: 'search.search_by_name' }),
+  updateButton: byRole('button', { name: 'update_details' }),
+  updateDialog: byRole('dialog', { name: 'groups.update_group' }),
+  updateDialogButton: byRole('button', { name: 'update_verb' }),
+  deleteButton: byRole('button', { name: 'delete' }),
+  deleteDialog: byRole('dialog', { name: 'groups.delete_group' }),
+  deleteDialogButton: byRole('button', { name: 'delete' }),
+  showMore: byRole('button', { name: 'show_more' }),
+  nameInput: byRole('textbox', { name: 'name field_required' }),
+  descriptionInput: byRole('textbox', { name: 'description' }),
+  createGroupDialogButton: byRole('button', { name: 'create' }),
+  editGroupDialogButton: byRole('button', { name: 'groups.create_group' }),
+
+  createGroupDialog: byRole('dialog', { name: 'groups.create_group' }),
+  membersDialog: byRole('dialog', { name: 'users.update' }),
+
+  managedGroupRow: byRole('row', { name: 'managed-group 1' }),
+  managedGroupEditMembersButton: byRole('button', { name: 'groups.users.edit.managed-group' }),
+  managedEditButton: byRole('button', { name: 'groups.edit.managed-group' }),
+
+  localGroupRow: byRole('row', { name: 'local-group 1' }),
+  localGroupEditMembersButton: byRole('button', { name: 'groups.users.edit.local-group' }),
+  localGroupRow2: byRole('row', { name: 'local-group 2 1 group 2 is loco!' }),
+  editedLocalGroupRow: byRole('row', { name: 'local-group 3 1 group 3 rocks!' }),
+  localEditButton: byRole('button', { name: 'groups.edit.local-group' }),
+  localGroupRowWithLocalBadge: byRole('row', {
+    name: 'local-group local 1',
+  }),
+};
+
+describe('in non managed mode', () => {
+  beforeEach(() => {
+    handler.setIsManaged(false);
+    handler.reset();
+  });
+
+  it('should render all groups', async () => {
+    renderGroupsApp();
+
+    expect(await ui.localGroupRow.find()).toBeInTheDocument();
+    expect(ui.managedGroupRow.get()).toBeInTheDocument();
+    expect(ui.localGroupRowWithLocalBadge.query()).not.toBeInTheDocument();
+  });
+
+  it('should be able to create a group', async () => {
+    const user = userEvent.setup();
+    renderGroupsApp();
+
+    expect(await ui.description.find()).toBeInTheDocument();
+
+    await user.click(ui.createGroupButton.get());
+    expect(ui.createGroupDialog.get()).toBeInTheDocument();
+
+    await user.type(ui.nameInput.get(), 'local-group 2');
+    await user.type(ui.descriptionInput.get(), 'group 2 is loco!');
+
+    await act(async () => {
+      await user.click(ui.createGroupDialogButton.get());
+    });
+
+    expect(await ui.localGroupRow2.find()).toBeInTheDocument();
+  });
+
+  it('should be able to delete a group', async () => {
+    const user = userEvent.setup();
+    renderGroupsApp();
+
+    await user.click(await ui.localEditButton.find());
+    await user.click(await ui.deleteButton.find());
+
+    expect(await ui.deleteDialog.find()).toBeInTheDocument();
+    await act(async () => {
+      await user.click(ui.deleteDialogButton.get());
+    });
+
+    expect(await ui.managedGroupRow.find()).toBeInTheDocument();
+    expect(ui.localGroupRow.query()).not.toBeInTheDocument();
+  });
+
+  it('should be able to edit a group', async () => {
+    const user = userEvent.setup();
+    renderGroupsApp();
+
+    await user.click(await ui.localEditButton.find());
+    await user.click(await ui.updateButton.find());
+
+    expect(ui.updateDialog.get()).toBeInTheDocument();
+
+    await user.clear(ui.nameInput.get());
+    await user.type(ui.nameInput.get(), 'local-group 3');
+    await user.clear(ui.descriptionInput.get());
+    await user.type(ui.descriptionInput.get(), 'group 3 rocks!');
+
+    expect(ui.updateDialog.get()).toBeInTheDocument();
+
+    await act(async () => {
+      await user.click(ui.updateDialogButton.get());
+    });
+
+    expect(await ui.managedGroupRow.find()).toBeInTheDocument();
+    expect(await ui.editedLocalGroupRow.find()).toBeInTheDocument();
+  });
+
+  it('should be able to edit the members of a group', async () => {
+    const user = userEvent.setup();
+    renderGroupsApp();
+
+    expect(await ui.localGroupRow.find()).toBeInTheDocument();
+    expect(await ui.localGroupEditMembersButton.find()).toBeInTheDocument();
+
+    await user.click(ui.localGroupEditMembersButton.get());
+    expect(await ui.membersDialog.find()).toBeInTheDocument();
+  });
+
+  it('should be able search a group', async () => {
+    const user = userEvent.setup();
+    renderGroupsApp();
+
+    expect(await ui.localGroupRow.find()).toBeInTheDocument();
+    expect(ui.managedGroupRow.get()).toBeInTheDocument();
+
+    await user.type(await ui.searchInput.find(), 'local');
+
+    expect(await ui.localGroupRow.find()).toBeInTheDocument();
+    expect(ui.managedGroupRow.query()).not.toBeInTheDocument();
+  });
+
+  it('should be able load more group', async () => {
+    const user = userEvent.setup();
+    renderGroupsApp();
+
+    // including the anyone (deprecated) group
+    expect(await screen.findAllByRole('row')).toHaveLength(4);
+
+    await user.click(await ui.showMore.find());
+
+    expect(await screen.findAllByRole('row')).toHaveLength(6);
+  });
+});
+
+describe('in manage mode', () => {
+  beforeEach(() => {
+    handler.setIsManaged(true);
+    handler.reset();
+  });
+
+  it('should not be able to create a group', async () => {
+    renderGroupsApp();
+    expect(await ui.createGroupButton.find()).toBeDisabled();
+    expect(ui.infoManageMode.get()).toBeInTheDocument();
+  });
+
+  it('should ONLY be able to delete a local group', async () => {
+    const user = userEvent.setup();
+    renderGroupsApp();
+
+    expect(await ui.localGroupRowWithLocalBadge.find()).toBeInTheDocument();
+
+    await user.click(await ui.localFilter.find());
+    await user.click(await ui.localEditButton.find());
+    expect(ui.updateButton.query()).not.toBeInTheDocument();
+
+    await user.click(await ui.deleteButton.find());
+
+    expect(await ui.deleteDialog.find()).toBeInTheDocument();
+    await act(async () => {
+      await user.click(ui.deleteDialogButton.get());
+    });
+    expect(ui.localGroupRowWithLocalBadge.query()).not.toBeInTheDocument();
+  });
+
+  it('should not be able to delete or edit a managed group', async () => {
+    renderGroupsApp();
+
+    expect(await ui.managedGroupRow.find()).toBeInTheDocument();
+    expect(ui.managedEditButton.query()).not.toBeInTheDocument();
+
+    expect(ui.managedGroupEditMembersButton.query()).not.toBeInTheDocument();
+  });
+
+  it('should render list of all groups', async () => {
+    renderGroupsApp();
+
+    expect(await ui.allFilter.find()).toBeInTheDocument();
+
+    expect(ui.localGroupRowWithLocalBadge.get()).toBeInTheDocument();
+    expect(ui.managedGroupRow.get()).toBeInTheDocument();
+  });
+
+  it('should render list of managed groups', async () => {
+    const user = userEvent.setup();
+    renderGroupsApp();
+
+    await user.click(await ui.managedFilter.find());
+
+    expect(ui.localGroupRow.query()).not.toBeInTheDocument();
+    expect(ui.managedGroupRow.get()).toBeInTheDocument();
+  });
+
+  it('should render list of local groups', async () => {
+    const user = userEvent.setup();
+    renderGroupsApp();
+
+    await user.click(await ui.localFilter.find());
+
+    expect(ui.localGroupRowWithLocalBadge.get()).toBeInTheDocument();
+    expect(ui.managedGroupRow.query()).not.toBeInTheDocument();
+  });
+});
+
+function renderGroupsApp() {
+  return renderApp('admin/groups', <App />);
+}
diff --git a/server/sonar-web/src/main/js/apps/groups/components/__tests__/Header-test.tsx b/server/sonar-web/src/main/js/apps/groups/components/__tests__/Header-test.tsx
deleted file mode 100644 (file)
index d0e25d5..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { shallow } from 'enzyme';
-import * as React from 'react';
-import { click } from '../../../../helpers/testUtils';
-import Header from '../Header';
-
-it('should create new group', () => {
-  const onCreate = jest.fn(() => Promise.resolve());
-  const wrapper = shallow(<Header onCreate={onCreate} />);
-  expect(wrapper).toMatchSnapshot();
-
-  click(wrapper.find('[id="groups-create"]'));
-  expect(wrapper).toMatchSnapshot();
-
-  wrapper.find('Form').prop<Function>('onSubmit')({ name: 'foo', description: 'bar' });
-  expect(onCreate).toHaveBeenCalledWith({ name: 'foo', description: 'bar' });
-});
index 625a816334ad7b754b596f03dd1d4f6022313655..a003332f77bcf4a2d51e3d1b43e1e304cbf5c3d2 100644 (file)
@@ -19,6 +19,7 @@
  */
 import { shallow } from 'enzyme';
 import * as React from 'react';
+import { mockGroup } from '../../../../helpers/testMocks';
 import List from '../List';
 
 it('should render', () => {
@@ -31,9 +32,9 @@ it('should not render "Anyone"', () => {
 
 function shallowRender(showAnyone = true) {
   const groups = [
-    { id: 1, name: 'sonar-users', description: '', membersCount: 55, default: true },
-    { id: 2, name: 'foo', description: 'foobar', membersCount: 0, default: false },
-    { id: 3, name: 'bar', description: 'barbar', membersCount: 1, default: false },
+    mockGroup({ name: 'sonar-users', description: '', membersCount: 55, default: true }),
+    mockGroup({ name: 'foo', description: 'foobar', membersCount: 0, default: false }),
+    mockGroup({ name: 'bar', description: 'barbar', membersCount: 1, default: false }),
   ];
   return shallow(
     <List
@@ -42,6 +43,7 @@ function shallowRender(showAnyone = true) {
       onEdit={jest.fn()}
       onEditMembers={jest.fn()}
       showAnyone={showAnyone}
+      manageProvider={undefined}
     />
   );
 }
index 7bcfafa6968c38278cd3d80057db0ef58f9f5cf5..206257c15cdf913533c56ba0fcb4485470cbf88b 100644 (file)
@@ -34,6 +34,7 @@ function shallowRender(overrides: Partial<ListItemProps> = {}) {
       onDelete={jest.fn()}
       onEdit={jest.fn()}
       onEditMembers={jest.fn()}
+      manageProvider={undefined}
       {...overrides}
     />
   );
diff --git a/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/App-test.tsx.snap b/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/App-test.tsx.snap
deleted file mode 100644 (file)
index a06d081..0000000
+++ /dev/null
@@ -1,94 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render correctly 1`] = `
-<Fragment>
-  <Suggestions
-    suggestions="user_groups"
-  />
-  <Helmet
-    defer={false}
-    encodeSpecialCharacters={true}
-    prioritizeSeoTags={false}
-    title="user_groups.page"
-  />
-  <main
-    className="page page-limited"
-    id="groups-page"
-  >
-    <Header
-      onCreate={[Function]}
-    />
-    <SearchBox
-      className="big-spacer-bottom"
-      id="groups-search"
-      minLength={2}
-      onChange={[Function]}
-      placeholder="search.search_by_name"
-      value=""
-    />
-  </main>
-</Fragment>
-`;
-
-exports[`should render correctly 2`] = `
-<Fragment>
-  <Suggestions
-    suggestions="user_groups"
-  />
-  <Helmet
-    defer={false}
-    encodeSpecialCharacters={true}
-    prioritizeSeoTags={false}
-    title="user_groups.page"
-  />
-  <main
-    className="page page-limited"
-    id="groups-page"
-  >
-    <Header
-      onCreate={[Function]}
-    />
-    <SearchBox
-      className="big-spacer-bottom"
-      id="groups-search"
-      minLength={2}
-      onChange={[Function]}
-      placeholder="search.search_by_name"
-      value=""
-    />
-    <List
-      groups={
-        [
-          {
-            "default": false,
-            "description": "Owners of organization foo",
-            "membersCount": 1,
-            "name": "Owners",
-          },
-          {
-            "default": true,
-            "description": "Members of organization foo",
-            "membersCount": 2,
-            "name": "Members",
-          },
-        ]
-      }
-      onDelete={[Function]}
-      onEdit={[Function]}
-      onEditMembers={[Function]}
-      showAnyone={true}
-    />
-    <div
-      id="groups-list-footer"
-    >
-      <ListFooter
-        count={3}
-        loadMore={[Function]}
-        loading={false}
-        ready={true}
-        total={5}
-      />
-    </div>
-  </main>
-</Fragment>
-`;
diff --git a/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/DeleteForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/DeleteForm-test.tsx.snap
deleted file mode 100644 (file)
index 6f7b612..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render 1`] = `
-<Modal
-  contentLabel="groups.delete_group"
-  onRequestClose={[MockFunction]}
->
-  <form
-    onSubmit={[Function]}
-  >
-    <header
-      className="modal-head"
-    >
-      <h2>
-        groups.delete_group
-      </h2>
-    </header>
-    <div
-      className="modal-body"
-    >
-      groups.delete_group.confirmation.Foo
-    </div>
-    <footer
-      className="modal-foot"
-    >
-      <DeferredSpinner
-        className="spacer-right"
-        loading={false}
-      />
-      <SubmitButton
-        className="button-red"
-        disabled={false}
-      >
-        delete
-      </SubmitButton>
-      <ResetButtonLink
-        disabled={false}
-        onClick={[Function]}
-      >
-        cancel
-      </ResetButtonLink>
-    </footer>
-  </form>
-</Modal>
-`;
index b074ac3da9b3242a4b6777016e3fd8b25fe1c1e0..044ea201e66d6a9c999babbac06e097009376d23 100644 (file)
@@ -3,10 +3,10 @@
 exports[`should edit members 1`] = `
 <Fragment>
   <ButtonIcon
-    aria-label="groups.users.edit"
+    aria-label="groups.users.edit.Foo"
     className="button-small"
     onClick={[Function]}
-    title="groups.users.edit"
+    title="groups.users.edit.Foo"
   >
     <BulletListIcon />
   </ButtonIcon>
@@ -16,17 +16,17 @@ exports[`should edit members 1`] = `
 exports[`should edit members 2`] = `
 <Fragment>
   <ButtonIcon
-    aria-label="groups.users.edit"
+    aria-label="groups.users.edit.Foo"
     className="button-small"
     onClick={[Function]}
-    title="groups.users.edit"
+    title="groups.users.edit.Foo"
   >
     <BulletListIcon />
   </ButtonIcon>
   <EditMembersModal
     group={
       {
-        "id": 3,
+        "managed": false,
         "membersCount": 5,
         "name": "Foo",
       }
@@ -39,10 +39,10 @@ exports[`should edit members 2`] = `
 exports[`should edit members 3`] = `
 <Fragment>
   <ButtonIcon
-    aria-label="groups.users.edit"
+    aria-label="groups.users.edit.Foo"
     className="button-small"
     onClick={[Function]}
-    title="groups.users.edit"
+    title="groups.users.edit.Foo"
   >
     <BulletListIcon />
   </ButtonIcon>
diff --git a/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/Form-test.tsx.snap b/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/Form-test.tsx.snap
deleted file mode 100644 (file)
index 1f5ac96..0000000
+++ /dev/null
@@ -1,82 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render form 1`] = `
-<Modal
-  contentLabel="header"
-  onRequestClose={[MockFunction]}
-  size="small"
->
-  <form
-    onSubmit={[Function]}
-  >
-    <header
-      className="modal-head"
-    >
-      <h2>
-        header
-      </h2>
-    </header>
-    <div
-      className="modal-body"
-    >
-      <MandatoryFieldsExplanation
-        className="modal-field"
-      />
-      <div
-        className="modal-field"
-      >
-        <label
-          htmlFor="create-group-name"
-        >
-          name
-          <MandatoryFieldMarker />
-        </label>
-        <input
-          autoFocus={true}
-          id="create-group-name"
-          maxLength={255}
-          name="name"
-          onChange={[Function]}
-          required={true}
-          size={50}
-          type="text"
-          value=""
-        />
-      </div>
-      <div
-        className="modal-field"
-      >
-        <label
-          htmlFor="create-group-description"
-        >
-          description
-        </label>
-        <textarea
-          id="create-group-description"
-          name="description"
-          onChange={[Function]}
-          value=""
-        />
-      </div>
-    </div>
-    <footer
-      className="modal-foot"
-    >
-      <DeferredSpinner
-        className="spacer-right"
-        loading={false}
-      />
-      <SubmitButton
-        disabled={false}
-      >
-        confirmButtonText
-      </SubmitButton>
-      <ResetButtonLink
-        onClick={[Function]}
-      >
-        cancel
-      </ResetButtonLink>
-    </footer>
-  </form>
-</Modal>
-`;
diff --git a/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/Header-test.tsx.snap b/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/Header-test.tsx.snap
deleted file mode 100644 (file)
index ba792ca..0000000
+++ /dev/null
@@ -1,69 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should create new group 1`] = `
-<Fragment>
-  <div
-    className="page-header"
-    id="groups-header"
-  >
-    <h2
-      className="page-title"
-    >
-      user_groups.page
-    </h2>
-    <div
-      className="page-actions"
-    >
-      <Button
-        disabled={false}
-        id="groups-create"
-        onClick={[Function]}
-      >
-        groups.create_group
-      </Button>
-    </div>
-    <p
-      className="page-description"
-    >
-      user_groups.page.description
-    </p>
-  </div>
-</Fragment>
-`;
-
-exports[`should create new group 2`] = `
-<Fragment>
-  <div
-    className="page-header"
-    id="groups-header"
-  >
-    <h2
-      className="page-title"
-    >
-      user_groups.page
-    </h2>
-    <div
-      className="page-actions"
-    >
-      <Button
-        disabled={false}
-        id="groups-create"
-        onClick={[Function]}
-      >
-        groups.create_group
-      </Button>
-    </div>
-    <p
-      className="page-description"
-    >
-      user_groups.page.description
-    </p>
-  </div>
-  <Form
-    confirmButtonText="create"
-    header="groups.create_group"
-    onClose={[Function]}
-    onSubmit={[MockFunction]}
-  />
-</Fragment>
-`;
index 5d3a39ef7ca4eee438292622e8919d58c3315009..19ddc35298e98d4239803b1e55f311cf8c030d8c 100644 (file)
@@ -64,7 +64,7 @@ exports[`should render 1`] = `
           {
             "default": false,
             "description": "barbar",
-            "id": 3,
+            "managed": false,
             "membersCount": 1,
             "name": "bar",
           }
@@ -79,7 +79,7 @@ exports[`should render 1`] = `
           {
             "default": false,
             "description": "foobar",
-            "id": 2,
+            "managed": false,
             "membersCount": 0,
             "name": "foo",
           }
@@ -94,7 +94,7 @@ exports[`should render 1`] = `
           {
             "default": true,
             "description": "",
-            "id": 1,
+            "managed": false,
             "membersCount": 55,
             "name": "sonar-users",
           }
index 6e22bae2cf07c7aaffcf1573ac8376b6d3f3b807..ca99c67a45f71dee5aaf62c4a32ed2cbca0e67dd 100644 (file)
@@ -24,6 +24,7 @@ exports[`should render correctly 1`] = `
     <EditMembers
       group={
         {
+          "managed": false,
           "membersCount": 1,
           "name": "Foo",
         }
@@ -41,7 +42,9 @@ exports[`should render correctly 1`] = `
   <td
     className="thin nowrap text-right"
   >
-    <ActionsDropdown>
+    <ActionsDropdown
+      label="groups.edit.Foo"
+    >
       <ActionsDropdownItem
         className="js-group-update"
         onClick={[Function]}
index a063aed87cd40d5b3f8de510287fd33138aebcaa..559e388b30610bda513cb14e527fc5d38c5b9c93 100644 (file)
@@ -22,12 +22,10 @@ import { FormattedMessage } from 'react-intl';
 import DocLink from '../../components/common/DocLink';
 import { Button } from '../../components/controls/buttons';
 import { Alert } from '../../components/ui/Alert';
-import DeferredSpinner from '../../components/ui/DeferredSpinner';
 import { translate } from '../../helpers/l10n';
 import UserForm from './components/UserForm';
 
 interface Props {
-  loading: boolean;
   onUpdateUsers: () => void;
   manageProvider?: string;
 }
@@ -35,11 +33,10 @@ interface Props {
 export default function Header(props: Props) {
   const [openUserForm, setOpenUserForm] = React.useState(false);
 
-  const { manageProvider, loading } = props;
+  const { manageProvider } = props;
   return (
     <div className="page-header null-spacer-bottom">
       <h2 className="page-title">{translate('users.page')}</h2>
-      <DeferredSpinner loading={loading} />
 
       <div className="page-actions">
         <Button
index 3eed4f2f5e433d78a8d9d81921b2c1351eef634f..36b1daac07cb51189a4f4e5ee0986896c1650c62 100644 (file)
@@ -138,21 +138,22 @@ export class UsersApp extends React.PureComponent<Props, State> {
   render() {
     const { search, managed } = parseQuery(this.props.location.query);
     const { loading, paging, users, manageProvider } = this.state;
-    // What if we have ONLY managed users? Should we not display the filter toggle?
+
     return (
       <main className="page page-limited" id="users-page">
         <Suggestions suggestions="users" />
         <Helmet defer={false} title={translate('users.page')} />
-        <Header loading={loading} onUpdateUsers={this.fetchUsers} manageProvider={manageProvider} />
+        <Header onUpdateUsers={this.fetchUsers} manageProvider={manageProvider} />
         <div className="display-flex-justify-start big-spacer-bottom big-spacer-top">
           {manageProvider !== undefined && (
             <div className="big-spacer-right">
               <ButtonToggle
                 value={managed === undefined ? 'all' : managed}
+                disabled={loading}
                 options={[
                   { label: translate('all'), value: 'all' },
-                  { label: translate('users.managed'), value: true },
-                  { label: translate('users.local'), value: false },
+                  { label: translate('managed'), value: true },
+                  { label: translate('local'), value: false },
                 ]}
                 onCheck={(filterOption) => {
                   if (filterOption === 'all') {
index 7dbb05134aad2cb3a2701f12d148b2f1b9ffe47e..d0c4e130803c20e7def6c5903602bf3c119409d8 100644 (file)
@@ -51,7 +51,9 @@ export default function UsersList({
             <th className="nowrap">{translate('users.last_connection')}</th>
             <th className="nowrap">{translate('my_profile.groups')}</th>
             <th className="nowrap">{translate('users.tokens')}</th>
-            <th className="nowrap">&nbsp;</th>
+            {(manageProvider === undefined || users.some((u) => !u.managed)) && (
+              <th className="nowrap">&nbsp;</th>
+            )}
           </tr>
         </thead>
         <tbody>
index 8119e8ea964090c167b809974a6fd976fb563477..cab66f21b2d1223f11ddbe6533eaa2c8873969f3 100644 (file)
@@ -33,5 +33,5 @@ it('should open the user creation form', () => {
 });
 
 function getWrapper(props = {}) {
-  return shallow(<Header loading={true} onUpdateUsers={jest.fn()} {...props} />);
+  return shallow(<Header onUpdateUsers={jest.fn()} {...props} />);
 }
index 1532ad653c039caab83116ffd052d47794acdb53..e2ad60141ded65cf6a14c5ddd265aceb918310db 100644 (file)
@@ -35,12 +35,17 @@ const ui = {
   infoManageMode: byText(/users\.page\.managed_description/),
   description: byText('users.page.description'),
   allFilter: byRole('button', { name: 'all' }),
-  managedFilter: byRole('button', { name: 'users.managed' }),
-  localFilter: byRole('button', { name: 'users.local' }),
+  managedFilter: byRole('button', { name: 'managed' }),
+  localFilter: byRole('button', { name: 'local' }),
   aliceRow: byRole('row', { name: 'AM Alice Merveille alice.merveille never' }),
   aliceRowWithLocalBadge: byRole('row', {
-    name: 'AM Alice Merveille alice.merveille users.local never',
+    name: 'AM Alice Merveille alice.merveille local never',
   }),
+  aliceUpdateGroupButton: byRole('button', { name: 'users.update_users_groups.alice.merveille' }),
+  aliceUpdateButton: byRole('button', { name: 'users.manage_user.alice.merveille' }),
+  alicedDeactivateButton: byRole('button', { name: 'users.deactivate' }),
+  bobUpdateGroupButton: byRole('button', { name: 'users.update_users_groups.bob.marley' }),
+  bobUpdateButton: byRole('button', { name: 'users.manage_user.bob.marley' }),
   bobRow: byRole('row', { name: 'BM Bob Marley bob.marley never' }),
 };
 
@@ -56,6 +61,20 @@ describe('in non managed mode', () => {
     expect(ui.createUserButton.get()).toBeEnabled();
   });
 
+  it("should be able to add/remove user's group", async () => {
+    renderUsersApp();
+
+    expect(await ui.aliceUpdateGroupButton.find()).toBeInTheDocument();
+    expect(await ui.bobUpdateGroupButton.find()).toBeInTheDocument();
+  });
+
+  it('should be able to update / change password / deactivate a user', async () => {
+    renderUsersApp();
+
+    expect(await ui.aliceUpdateButton.find()).toBeInTheDocument();
+    expect(await ui.bobUpdateButton.find()).toBeInTheDocument();
+  });
+
   it('should render all users', async () => {
     renderUsersApp();
 
@@ -76,6 +95,32 @@ describe('in manage mode', () => {
     expect(await ui.infoManageMode.find()).toBeInTheDocument();
   });
 
+  it("should not be able to add/remove a user's group", async () => {
+    renderUsersApp();
+
+    expect(await ui.aliceRowWithLocalBadge.find()).toBeInTheDocument();
+    expect(ui.aliceUpdateGroupButton.query()).not.toBeInTheDocument();
+
+    expect(await ui.bobRow.find()).toBeInTheDocument();
+    expect(ui.bobUpdateGroupButton.query()).not.toBeInTheDocument();
+  });
+
+  it('should not be able to update / change password / deactivate a managed user', async () => {
+    renderUsersApp();
+
+    expect(await ui.bobRow.find()).toBeInTheDocument();
+    expect(ui.bobUpdateButton.query()).not.toBeInTheDocument();
+  });
+
+  it('should ONLY be able to deactivate a local user', async () => {
+    const user = userEvent.setup();
+    renderUsersApp();
+
+    expect(await ui.aliceRowWithLocalBadge.find()).toBeInTheDocument();
+    await user.click(ui.aliceUpdateButton.get());
+    expect(await ui.alicedDeactivateButton.get()).toBeInTheDocument();
+  });
+
   it('should render list of all users', async () => {
     renderUsersApp();
 
@@ -89,9 +134,7 @@ describe('in manage mode', () => {
     const user = userEvent.setup();
     renderUsersApp();
 
-    // The click downs't work without this line
-    expect(await ui.managedFilter.find()).toBeInTheDocument();
-    await user.click(await ui.managedFilter.get());
+    await user.click(await ui.managedFilter.find());
 
     expect(ui.aliceRowWithLocalBadge.query()).not.toBeInTheDocument();
     expect(ui.bobRow.get()).toBeInTheDocument();
@@ -101,9 +144,7 @@ describe('in manage mode', () => {
     const user = userEvent.setup();
     renderUsersApp();
 
-    // The click downs't work without this line
-    expect(await ui.localFilter.find()).toBeInTheDocument();
-    await user.click(await ui.localFilter.get());
+    await user.click(await ui.localFilter.find());
 
     expect(ui.aliceRowWithLocalBadge.get()).toBeInTheDocument();
     expect(ui.bobRow.query()).not.toBeInTheDocument();
index 1e3f25b280fad2782ac0ac3ca79011eb1655df9d..88dc40cb905879b236ac6db47470be900ae2c3ff 100644 (file)
@@ -9,9 +9,6 @@ exports[`should render correctly 1`] = `
   >
     users.page
   </h2>
-  <DeferredSpinner
-    loading={true}
-  />
   <div
     className="page-actions"
   >
index 0b6795b6e2d33289ad6f448e6a757401947fe14d..19b85df1cf9e35203cb3fbfea2cbe1d16dd0bda8 100644 (file)
@@ -22,7 +22,7 @@ import ActionsDropdown, {
   ActionsDropdownDivider,
   ActionsDropdownItem,
 } from '../../../components/controls/ActionsDropdown';
-import { translate } from '../../../helpers/l10n';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { isUserActive, User } from '../../../types/users';
 import DeactivateForm from './DeactivateForm';
 import PasswordForm from './PasswordForm';
@@ -32,6 +32,7 @@ interface Props {
   isCurrentUser: boolean;
   onUpdateUsers: () => void;
   user: User;
+  manageProvider: string | undefined;
 }
 
 interface State {
@@ -57,23 +58,41 @@ export default class UserActions extends React.PureComponent<Props, State> {
     this.setState({ openForm: undefined });
   };
 
+  isInstanceManaged = () => {
+    return this.props.manageProvider !== undefined;
+  };
+
+  isUserLocal = () => {
+    return this.isInstanceManaged() && !this.props.user.managed;
+  };
+
+  isUserManaged = () => {
+    return this.isInstanceManaged() && this.props.user.managed;
+  };
+
   renderActions = () => {
     const { user } = this.props;
+
     return (
-      <ActionsDropdown>
-        <ActionsDropdownItem className="js-user-update" onClick={this.handleOpenUpdateForm}>
-          {translate('update_details')}
-        </ActionsDropdownItem>
-        {user.local && (
-          <ActionsDropdownItem
-            className="js-user-change-password"
-            onClick={this.handleOpenPasswordForm}
-          >
-            {translate('my_profile.password.title')}
-          </ActionsDropdownItem>
+      <ActionsDropdown label={translateWithParameters('users.manage_user', user.login)}>
+        {!this.isInstanceManaged() && (
+          <>
+            <ActionsDropdownItem className="js-user-update" onClick={this.handleOpenUpdateForm}>
+              {translate('update_details')}
+            </ActionsDropdownItem>
+            {user.local && (
+              <ActionsDropdownItem
+                className="js-user-change-password"
+                onClick={this.handleOpenPasswordForm}
+              >
+                {translate('my_profile.password.title')}
+              </ActionsDropdownItem>
+            )}
+          </>
         )}
-        <ActionsDropdownDivider />
-        {isUserActive(user) && (
+
+        {isUserActive(user) && !this.isInstanceManaged() && <ActionsDropdownDivider />}
+        {isUserActive(user) && (!this.isInstanceManaged() || this.isUserLocal()) && (
           <ActionsDropdownItem
             className="js-user-deactivate"
             destructive={true}
@@ -90,6 +109,10 @@ export default class UserActions extends React.PureComponent<Props, State> {
     const { openForm } = this.state;
     const { isCurrentUser, onUpdateUsers, user } = this.props;
 
+    if (this.isUserManaged()) {
+      return null;
+    }
+
     return (
       <>
         {this.renderActions()}
index df78ffc1bd2c9f01b81710946ae953e86da6cf86..f237f01f56b848823ab8cb464985cfaa8180928e 100644 (file)
@@ -28,6 +28,7 @@ interface Props {
   groups: string[];
   onUpdateUsers: () => void;
   user: User;
+  manageProvider: string | undefined;
 }
 
 interface State {
@@ -49,7 +50,8 @@ export default class UserGroups extends React.PureComponent<Props, State> {
   };
 
   render() {
-    const { groups } = this.props;
+    const { groups, user, manageProvider } = this.props;
+    const { showMore, openForm } = this.state;
     const limit = groups.length > GROUPS_LIMIT ? GROUPS_LIMIT - 1 : GROUPS_LIMIT;
     return (
       <ul>
@@ -59,31 +61,34 @@ export default class UserGroups extends React.PureComponent<Props, State> {
           </li>
         ))}
         {groups.length > GROUPS_LIMIT &&
-          this.state.showMore &&
+          showMore &&
           groups.slice(limit).map((group) => (
             <li className="little-spacer-bottom" key={group}>
               {group}
             </li>
           ))}
         <li className="little-spacer-bottom">
-          {groups.length > GROUPS_LIMIT && !this.state.showMore && (
+          {groups.length > GROUPS_LIMIT && !showMore && (
             <a className="js-user-more-groups spacer-right" href="#" onClick={this.toggleShowMore}>
               {translateWithParameters('more_x', groups.length - limit)}
             </a>
           )}
-          <ButtonIcon
-            className="js-user-groups button-small"
-            onClick={this.handleOpenForm}
-            tooltip={translate('users.update_groups')}
-          >
-            <BulletListIcon />
-          </ButtonIcon>
+          {manageProvider === undefined && (
+            <ButtonIcon
+              aria-label={translateWithParameters('users.update_users_groups', user.login)}
+              className="js-user-groups button-small"
+              onClick={this.handleOpenForm}
+              tooltip={translate('users.update_groups')}
+            >
+              <BulletListIcon />
+            </ButtonIcon>
+          )}
         </li>
-        {this.state.openForm && (
+        {openForm && (
           <GroupsForm
             onClose={this.handleCloseForm}
             onUpdateUsers={this.props.onUpdateUsers}
-            user={this.props.user}
+            user={user}
           />
         )}
       </ul>
index 4ec202012c5fb7ae6da4ec7cf95269b8acd38aa5..41da69c5f46ea601e575785020cb094bf437c565 100644 (file)
@@ -69,7 +69,12 @@ export default function UserListItem(props: UserListItemProps) {
         <DateFromNow date={user.lastConnectionDate} hourPrecision={true} />
       </td>
       <td className="thin nowrap text-middle">
-        <UserGroups groups={user.groups || []} onUpdateUsers={onUpdateUsers} user={user} />
+        <UserGroups
+          groups={user.groups || []}
+          manageProvider={manageProvider}
+          onUpdateUsers={onUpdateUsers}
+          user={user}
+        />
       </td>
       <td className="thin nowrap text-middle">
         {user.tokensCount}
@@ -81,9 +86,18 @@ export default function UserListItem(props: UserListItemProps) {
           <BulletListIcon />
         </ButtonIcon>
       </td>
-      <td className="thin nowrap text-right text-middle">
-        <UserActions isCurrentUser={isCurrentUser} onUpdateUsers={onUpdateUsers} user={user} />
-      </td>
+
+      {(manageProvider === undefined || !user.managed) && (
+        <td className="thin nowrap text-right text-middle">
+          <UserActions
+            isCurrentUser={isCurrentUser}
+            onUpdateUsers={onUpdateUsers}
+            user={user}
+            manageProvider={manageProvider}
+          />
+        </td>
+      )}
+
       {openTokenForm && (
         <TokensFormModal
           onClose={() => setOpenTokenForm(false)}
index f73eb688689fd0596d0cf420390082cd26341fd0..5a467e51f794f23340a3995b694cefe1e87a9184 100644 (file)
@@ -43,8 +43,8 @@ export default function UserListItemIdentity({ identityProvider, user, managePro
       {!user.local && user.externalProvider !== 'sonarqube' && (
         <ExternalProvider identityProvider={identityProvider} user={user} />
       )}
-      {user.managed === false && manageProvider !== undefined && (
-        <span className="badge">{translate('users.local')}</span>
+      {!user.managed && manageProvider !== undefined && (
+        <span className="badge">{translate('local')}</span>
       )}
     </td>
   );
index e4925c08c9a949872e38d2b1415fb0bb340fb04c..f040b6070ed6645891d0dc229ac87a9b37a1b14c 100644 (file)
@@ -55,6 +55,12 @@ it('should open the deactivate form', () => {
 
 function getWrapper(props = {}) {
   return shallow(
-    <UserActions isCurrentUser={false} onUpdateUsers={jest.fn()} user={user} {...props} />
+    <UserActions
+      isCurrentUser={false}
+      onUpdateUsers={jest.fn()}
+      user={user}
+      manageProvider={undefined}
+      {...props}
+    />
   );
 }
index 47ea651dbe466e60da52a5fa1db40f64363eac40..ae11594b78492b38ebef4f88160aeec27cae53ab 100644 (file)
@@ -51,5 +51,13 @@ it('should open the groups form', () => {
 });
 
 function getWrapper(props = {}) {
-  return shallow(<UserGroups groups={groups} onUpdateUsers={jest.fn()} user={user} {...props} />);
+  return shallow(
+    <UserGroups
+      groups={groups}
+      onUpdateUsers={jest.fn()}
+      user={user}
+      manageProvider={undefined}
+      {...props}
+    />
+  );
 }
index ad4bb9e465036caa2edc17edf6ba232a25fd78f4..b00cbcea9be26f5af7e451de916a598b2974c9c8 100644 (file)
@@ -2,7 +2,9 @@
 
 exports[`should render correctly 1`] = `
 <Fragment>
-  <ActionsDropdown>
+  <ActionsDropdown
+    label="users.manage_user.obi"
+  >
     <ActionsDropdownItem
       className="js-user-update"
       onClick={[Function]}
index cbc45cc9034b6042cc14a4edf6e2b506796c413e..27947eee208be8d33f3c5a18fb1a908c84e83d64 100644 (file)
@@ -25,6 +25,7 @@ exports[`should render correctly 1`] = `
       more_x.2
     </a>
     <ButtonIcon
+      aria-label="users.update_users_groups.obi"
       className="js-user-groups button-small"
       onClick={[Function]}
       tooltip="users.update_groups"
index 3cc53cbf2e64395c9641f4129b78d1f153c1273b..350205aa30ea1ce6711e5086cf28253686581107 100644 (file)
@@ -276,6 +276,7 @@ export function mockGroup(overrides: Partial<Group> = {}): Group {
   return {
     membersCount: 1,
     name: 'Foo',
+    managed: false,
     ...overrides,
   };
 }
index 9fbefdbeb0165fe23dcc82313a95ac7629955363..2f57f4a4f789a672a2e77cb09f9bfaf9e26707d6 100644 (file)
@@ -219,6 +219,7 @@ export interface Group {
   description?: string;
   membersCount: number;
   name: string;
+  managed: boolean;
 }
 
 export type HealthType = 'RED' | 'YELLOW' | 'GREEN';
index 43a618575dbbb9245791308fe7311e94435ed907..074a44418a625dbaed61f3539a1c7a35ae512f07 100644 (file)
@@ -271,8 +271,10 @@ false_positive=False positive
 go_back_to_homepage=Go back to the homepage
 last_analysis_before=Last analysis before
 less_than_1_hour_ago=< 1 hour ago
+local=Local
 logging_out=You're logging out, please wait...
 manage=Manage
+managed=Managed
 management=Management
 more_information=More information
 new_violations=New violations
@@ -2149,15 +2151,6 @@ unauthorized.message=You're not authorized to access this page. Please contact t
 unauthorized.reason=Reason:
 
 
-
-#------------------------------------------------------------------------------
-#
-# USERS & GROUPS PAGE
-#
-#------------------------------------------------------------------------------
-
-groups.users.edit=Change group members
-
 #------------------------------------------------------------------------------
 #
 # MY PROFILE & MY ACCOUNT
@@ -4340,12 +4333,13 @@ users.cannot_update_delegated_user=You cannot update the name and email of this
 users.minimum_x_characters=Minimum {0} characters
 users.email=Email
 users.last_connection=Last connection
+users.update_users_groups=Update {0}'s group membership
 users.update_groups=Update Groups
+users.manage_user=Update {0}
 users.update_tokens=Update Tokens
 users.add=Add user
 users.remove=Remove user
 users.search_description=Search users by login or name
-users.update=Update users
 users.tokens=Tokens
 users.user_X_tokens=Tokens of {user}
 users.tokens.sure=Sure?
@@ -4384,8 +4378,6 @@ users.change_admin_password.form.confirm=Confirm password for user 'admin'
 users.change_admin_password.form.cannot_use_default_password=You must choose a password that is different from the default password.
 users.change_admin_password.form.success=The admin user's password was successfully changed.
 users.change_admin_password.form.continue_to_app=Continue to SonarQube
-users.local=Local
-users.managed=Managed
 
 #------------------------------------------------------------------------------
 #
@@ -4400,7 +4392,9 @@ groups.delete_group=Delete Group
 groups.delete_group.confirmation=Are you sure you want to delete "{0}"?
 groups.create_group=Create Group
 groups.update_group=Update Group
+groups.users.edit=Change {0} members
 groups.anyone=Anyone
+groups.edit=Edit {0}
 
 
 #------------------------------------------------------------------------------