]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-12210 Handle pagination in user & group list
authorphilippe-perrin-sonarsource <philippe.perrin@sonarsource.com>
Thu, 20 Jun 2019 15:11:01 +0000 (17:11 +0200)
committersonartech <sonartech@sonarsource.com>
Fri, 28 Jun 2019 06:45:50 +0000 (08:45 +0200)
server/sonar-web/src/main/js/api/user_groups.ts
server/sonar-web/src/main/js/api/users.ts
server/sonar-web/src/main/js/apps/groups/components/EditMembersModal.tsx
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__/__snapshots__/EditMembers-test.tsx.snap
server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembersModal-test.tsx.snap
server/sonar-web/src/main/js/apps/organizationMembers/ManageMemberGroupsForm.tsx
server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx
server/sonar-web/src/main/js/apps/users/components/__tests__/GroupsForm-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/GroupsForm-test.tsx.snap [new file with mode: 0644]

index a84be34b5eaf1a7114018606f59173d6145ea302..eae50459ee164c0c5cc8ce6937b7960cbfcc5f61 100644 (file)
@@ -44,7 +44,7 @@ export function getUsersInGroup(data: {
   ps?: number;
   q?: string;
   selected?: string;
-}): Promise<{ paging: T.Paging; users: GroupUser[] }> {
+}): Promise<T.Paging & { users: GroupUser[] }> {
   return getJSON('/api/user_groups/users', data).catch(throwGlobalError);
 }
 
index 884a057da0831df56df5f1c9270e8a9934480c98..8daacb90b25d3c71644a86a89bb59ff8eda0ea74 100644 (file)
@@ -17,7 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { getJSON, post, postJSON, RequestData } from '../helpers/request';
+import { getJSON, post, postJSON } from '../helpers/request';
 import throwGlobalError from '../app/utils/throwGlobalError';
 
 export function getCurrentUser(): Promise<T.CurrentUser> {
@@ -40,22 +40,14 @@ export interface UserGroup {
   selected: boolean;
 }
 
-export function getUserGroups(
-  login: string,
-  organization?: string,
-  query?: string,
-  selected?: string
-): Promise<{ paging: T.Paging; groups: UserGroup[] }> {
-  const data: RequestData = { login };
-  if (organization) {
-    data.organization = organization;
-  }
-  if (query) {
-    data.q = query;
-  }
-  if (selected) {
-    data.selected = selected;
-  }
+export function getUserGroups(data: {
+  login: string;
+  organization?: string;
+  p?: number;
+  ps?: number;
+  q?: string;
+  selected?: string;
+}): Promise<{ paging: T.Paging; groups: UserGroup[] }> {
   return getJSON('/api/users/groups', data);
 }
 
index 14b4c813d14d8b269dd4d01d4ada12499f2cf5ea..9b475b87c6af8063b71edccdd50d3eddb99068c3 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import * as React from 'react';
 import { find, without } from 'lodash';
-import Modal from '../../../components/controls/Modal';
-import SelectList, { Filter } from '../../../components/SelectList/SelectList';
-import { ResetButtonLink } from '../../../components/ui/buttons';
-import { translate } from '../../../helpers/l10n';
+import * as React from 'react';
 import {
-  GroupUser,
-  removeUserFromGroup,
   addUserToGroup,
-  getUsersInGroup
+  getUsersInGroup,
+  GroupUser,
+  removeUserFromGroup
 } from '../../../api/user_groups';
 import DeferredSpinner from '../../../components/common/DeferredSpinner';
+import Modal from '../../../components/controls/Modal';
+import SelectList, { Filter } from '../../../components/SelectList/SelectList';
+import { ResetButtonLink } from '../../../components/ui/buttons';
+import { translate } from '../../../helpers/l10n';
 
 interface Props {
   group: T.Group;
@@ -37,39 +37,83 @@ interface Props {
   organization: string | undefined;
 }
 
+export interface SearchParams {
+  name: string;
+  organization?: string;
+  page: number;
+  pageSize: number;
+  query?: string;
+  selected: string;
+}
+
 interface State {
+  lastSearchParams: SearchParams;
+  listHasBeenTouched: boolean;
   loading: boolean;
   users: GroupUser[];
+  usersTotalCount?: number;
   selectedUsers: string[];
 }
 
+const PAGE_SIZE = 100;
+
 export default class EditMembers extends React.PureComponent<Props, State> {
   mounted = false;
-  state: State = { loading: true, users: [], selectedUsers: [] };
+
+  constructor(props: Props) {
+    super(props);
+
+    this.state = {
+      lastSearchParams: {
+        name: props.group.name,
+        organization: props.organization,
+        page: 1,
+        pageSize: PAGE_SIZE,
+        query: '',
+        selected: Filter.Selected
+      },
+      listHasBeenTouched: false,
+      loading: true,
+      users: [],
+      selectedUsers: []
+    };
+  }
 
   componentDidMount() {
     this.mounted = true;
-    this.handleSearch('', Filter.Selected);
+    this.fetchUsers(this.state.lastSearchParams);
   }
 
   componentWillUnmount() {
     this.mounted = false;
   }
 
-  handleSearch = (query: string, selected: Filter) => {
-    return getUsersInGroup({
-      name: this.props.group.name,
-      organization: this.props.organization,
-      ps: 100,
-      q: query !== '' ? query : undefined,
-      selected
+  fetchUsers = (searchParams: SearchParams, more?: boolean) =>
+    getUsersInGroup({
+      ...searchParams,
+      p: searchParams.page,
+      ps: searchParams.pageSize,
+      q: searchParams.query !== '' ? searchParams.query : undefined
     }).then(
       data => {
         if (this.mounted) {
-          this.setState({
-            loading: false,
-            users: data.users,
-            selectedUsers: data.users.filter(user => user.selected).map(user => user.login)
+          this.setState(prevState => {
+            const users = more ? [...prevState.users, ...data.users] : data.users;
+            const newSelectedUsers = data.users
+              .filter(user => user.selected)
+              .map(user => user.login);
+            const selectedUsers = more
+              ? [...prevState.selectedUsers, ...newSelectedUsers]
+              : newSelectedUsers;
+
+            return {
+              lastSearchParams: searchParams,
+              listHasBeenTouched: false,
+              loading: false,
+              users,
+              usersTotalCount: data.total,
+              selectedUsers
+            };
           });
         }
       },
@@ -79,35 +123,57 @@ export default class EditMembers extends React.PureComponent<Props, State> {
         }
       }
     );
-  };
 
-  handleSelect = (login: string) => {
-    return addUserToGroup({
+  handleLoadMore = () =>
+    this.fetchUsers(
+      {
+        ...this.state.lastSearchParams,
+        page: this.state.lastSearchParams.page + 1
+      },
+      true
+    );
+
+  handleReload = () =>
+    this.fetchUsers({
+      ...this.state.lastSearchParams,
+      page: 1
+    });
+
+  handleSearch = (query: string, selected: Filter) =>
+    this.fetchUsers({
+      ...this.state.lastSearchParams,
+      page: 1,
+      query,
+      selected
+    });
+
+  handleSelect = (login: string) =>
+    addUserToGroup({
       name: this.props.group.name,
       login,
       organization: this.props.organization
     }).then(() => {
       if (this.mounted) {
         this.setState((state: State) => ({
+          listHasBeenTouched: true,
           selectedUsers: [...state.selectedUsers, login]
         }));
       }
     });
-  };
 
-  handleUnselect = (login: string) => {
-    return removeUserFromGroup({
+  handleUnselect = (login: string) =>
+    removeUserFromGroup({
       name: this.props.group.name,
       login,
       organization: this.props.organization
     }).then(() => {
       if (this.mounted) {
         this.setState((state: State) => ({
+          listHasBeenTouched: true,
           selectedUsers: without(state.selectedUsers, login)
         }));
       }
     });
-  };
 
   renderElement = (login: string): React.ReactNode => {
     const user = find(this.state.users, { login });
@@ -138,6 +204,12 @@ export default class EditMembers extends React.PureComponent<Props, State> {
           <DeferredSpinner loading={this.state.loading}>
             <SelectList
               elements={this.state.users.map(user => user.login)}
+              elementsTotalCount={this.state.usersTotalCount}
+              needReload={
+                this.state.listHasBeenTouched && this.state.lastSearchParams.selected !== Filter.All
+              }
+              onLoadMore={this.handleLoadMore}
+              onReload={this.handleReload}
               onSearch={this.handleSearch}
               onSelect={this.handleSelect}
               onUnselect={this.handleUnselect}
index 1331cff110c688f54f82aabbe0444a5d4bab401f..82505385a2c98503b701214ed9a911732cffc6e8 100644 (file)
@@ -36,6 +36,7 @@ it('should edit members', async () => {
   expect(wrapper).toMatchSnapshot();
 
   await waitAndUpdate(wrapper);
+
   click(wrapper.find('ResetButtonLink'));
   expect(onEdit).toBeCalled();
   expect(wrapper).toMatchSnapshot();
index 6ee23a8ecb986f2403dc21b58a835f90ce2b93c2..88209b8e0995d336a1baecfe477a6a0cb33caa65 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-/* eslint-disable import/first, import/order */
-import * as React from 'react';
 import { shallow } from 'enzyme';
-import EditMembersModal from '../EditMembersModal';
+import * as React from 'react';
+import EditMembersModal, { SearchParams } from '../EditMembersModal';
+import SelectList, { Filter } from '../../../../components/SelectList/SelectList';
 import { waitAndUpdate } from '../../../../helpers/testUtils';
+import { getUsersInGroup, addUserToGroup, removeUserFromGroup } from '../../../../api/user_groups';
 
 jest.mock('../../../../api/user_groups', () => ({
   getUsersInGroup: jest.fn().mockResolvedValue({
-    paging: { pageIndex: 0, pageSize: 10, total: 0 },
+    paging: { pageIndex: 1, pageSize: 10, total: 1 },
     users: [
       {
         login: 'foo',
@@ -33,20 +34,109 @@ jest.mock('../../../../api/user_groups', () => ({
         selected: true
       }
     ]
-  })
+  }),
+  addUserToGroup: jest.fn().mockResolvedValue({}),
+  removeUserFromGroup: jest.fn().mockResolvedValue({})
 }));
 
-const getUsersInGroup = require('../../../../api/user_groups').getUsersInGroup as jest.Mock<any>;
-
-const group = { id: 1, name: 'foo', membersCount: 1 };
+beforeEach(() => {
+  jest.clearAllMocks();
+});
 
-it('should render modal', async () => {
-  getUsersInGroup.mockClear();
+it('should render modal properly', async () => {
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
 
-  const wrapper = shallow(<EditMembersModal group={group} onClose={() => {}} organization="bar" />);
   expect(wrapper).toMatchSnapshot();
+  expect(getUsersInGroup).toHaveBeenCalledWith(
+    expect.objectContaining({
+      p: 1
+    })
+  );
+
+  wrapper.setState({ listHasBeenTouched: true });
+  expect(wrapper.find(SelectList).props().needReload).toBe(true);
+
+  wrapper.setState({ lastSearchParams: { selected: Filter.All } as SearchParams });
+  expect(wrapper.find(SelectList).props().needReload).toBe(false);
+});
 
+it('should handle reload properly', async () => {
+  const wrapper = shallowRender();
   await waitAndUpdate(wrapper);
-  expect(getUsersInGroup).toHaveBeenCalledTimes(1);
-  expect(wrapper).toMatchSnapshot();
+
+  wrapper.instance().handleReload();
+  expect(getUsersInGroup).toHaveBeenCalledWith(
+    expect.objectContaining({
+      p: 1
+    })
+  );
+  expect(wrapper.state().listHasBeenTouched).toBe(false);
 });
+
+it('should handle search reload properly', async () => {
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+
+  wrapper.instance().handleSearch('foo', Filter.Selected);
+  expect(getUsersInGroup).toHaveBeenCalledWith(
+    expect.objectContaining({
+      p: 1,
+      q: 'foo',
+      selected: Filter.Selected
+    })
+  );
+  expect(wrapper.state().listHasBeenTouched).toBe(false);
+});
+
+it('should handle load more properly', async () => {
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+
+  wrapper.instance().handleLoadMore();
+  expect(getUsersInGroup).toHaveBeenCalledWith(
+    expect.objectContaining({
+      p: 2
+    })
+  );
+  expect(wrapper.state().listHasBeenTouched).toBe(false);
+});
+
+it('should handle selection properly', async () => {
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+
+  wrapper.instance().handleSelect('toto');
+  await waitAndUpdate(wrapper);
+  expect(addUserToGroup).toHaveBeenCalledWith(
+    expect.objectContaining({
+      login: 'toto'
+    })
+  );
+  expect(wrapper.state().listHasBeenTouched).toBe(true);
+});
+
+it('should handle deselection properly', async () => {
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+
+  wrapper.instance().handleUnselect('tata');
+  await waitAndUpdate(wrapper);
+  expect(removeUserFromGroup).toHaveBeenCalledWith(
+    expect.objectContaining({
+      login: 'tata'
+    })
+  );
+  expect(wrapper.state().listHasBeenTouched).toBe(true);
+});
+
+function shallowRender(props: Partial<EditMembersModal['props']> = {}) {
+  return shallow<EditMembersModal>(
+    <EditMembersModal
+      group={{ id: 1, name: 'foo', membersCount: 1 }}
+      onClose={jest.fn()}
+      organization={'bar'}
+      {...props}
+    />
+  );
+}
index 98241e10f0ca4bb10bb77f5480c61b38f1e9b206..a2651397affa0ee70e737cc49e05342eb24df3aa 100644 (file)
@@ -311,6 +311,9 @@ exports[`should edit members 2`] = `
                   >
                     <SelectList
                       elements={Array []}
+                      needReload={false}
+                      onLoadMore={[Function]}
+                      onReload={[Function]}
                       onSearch={[Function]}
                       onSelect={[Function]}
                       onUnselect={[Function]}
index b9156bda2370e26482a334ac069e44b1975c6cfa..faff753d49bc59ebaf10957f8c25507452f6b781 100644 (file)
@@ -1,50 +1,9 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`should render modal 1`] = `
+exports[`should render modal properly 1`] = `
 <Modal
   contentLabel="users.update"
-  onRequestClose={[Function]}
->
-  <header
-    className="modal-head"
-  >
-    <h2>
-      users.update
-    </h2>
-  </header>
-  <div
-    className="modal-body"
-  >
-    <DeferredSpinner
-      loading={true}
-      timeout={100}
-    >
-      <SelectList
-        elements={Array []}
-        onSearch={[Function]}
-        onSelect={[Function]}
-        onUnselect={[Function]}
-        renderElement={[Function]}
-        selectedElements={Array []}
-      />
-    </DeferredSpinner>
-  </div>
-  <footer
-    className="modal-foot"
-  >
-    <ResetButtonLink
-      onClick={[Function]}
-    >
-      Done
-    </ResetButtonLink>
-  </footer>
-</Modal>
-`;
-
-exports[`should render modal 2`] = `
-<Modal
-  contentLabel="users.update"
-  onRequestClose={[Function]}
+  onRequestClose={[MockFunction]}
 >
   <header
     className="modal-head"
@@ -66,6 +25,9 @@ exports[`should render modal 2`] = `
             "foo",
           ]
         }
+        needReload={false}
+        onLoadMore={[Function]}
+        onReload={[Function]}
         onSearch={[Function]}
         onSelect={[Function]}
         onUnselect={[Function]}
@@ -82,7 +44,7 @@ exports[`should render modal 2`] = `
     className="modal-foot"
   >
     <ResetButtonLink
-      onClick={[Function]}
+      onClick={[MockFunction]}
     >
       Done
     </ResetButtonLink>
index fbc68ce2d8f2edb88c025342e2fa46fc76937b49..4355c2ab8826ef2c8081adcb21c19ccdb334338c 100644 (file)
@@ -58,7 +58,10 @@ export default class ManageMemberGroupsForm extends React.PureComponent<Props, S
 
   loadUserGroups = () => {
     this.setState({ loading: true });
-    getUserGroups(this.props.member.login, this.props.organization.key).then(
+    getUserGroups({
+      login: this.props.member.login,
+      organization: this.props.organization.key
+    }).then(
       response => {
         if (this.mounted) {
           this.setState({ loading: false, userGroups: keyBy(response.groups, 'name') });
index 3e4b4554f4342e0c5e464e6907aced031b096f41..b1cbfbd4f695bf84be256e710d9e1856a9850a3c 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import * as React from 'react';
 import { find, without } from 'lodash';
+import * as React from 'react';
+import { getUserGroups, UserGroup } from '../../../api/users';
+import { addUserToGroup, removeUserFromGroup } from '../../../api/user_groups';
 import Modal from '../../../components/controls/Modal';
 import SelectList, { Filter } from '../../../components/SelectList/SelectList';
 import { translate } from '../../../helpers/l10n';
-import { getUserGroups, UserGroup } from '../../../api/users';
-import { addUserToGroup, removeUserFromGroup } from '../../../api/user_groups';
 
 interface Props {
   onClose: () => void;
@@ -31,47 +31,130 @@ interface Props {
   user: T.User;
 }
 
+export interface SearchParams {
+  login: string;
+  organization?: string;
+  page: number;
+  pageSize: number;
+  query?: string;
+  selected: string;
+}
+
 interface State {
   groups: UserGroup[];
+  groupsTotalCount?: number;
+  lastSearchParams: SearchParams;
+  listHasBeenTouched: boolean;
   selectedGroups: string[];
 }
 
-export default class GroupsForm extends React.PureComponent<Props> {
-  container?: HTMLDivElement | null;
-  state: State = { groups: [], selectedGroups: [] };
+const PAGE_SIZE = 100;
+
+export default class GroupsForm extends React.PureComponent<Props, State> {
+  mounted = false;
+
+  constructor(props: Props) {
+    super(props);
+
+    this.state = {
+      groups: [],
+      lastSearchParams: {
+        login: props.user.login,
+        page: 1,
+        pageSize: PAGE_SIZE,
+        query: '',
+        selected: Filter.Selected
+      },
+      listHasBeenTouched: false,
+      selectedGroups: []
+    };
+  }
 
   componentDidMount() {
-    this.handleSearch('', Filter.Selected);
+    this.mounted = true;
+    this.fetchUsers(this.state.lastSearchParams);
   }
 
-  handleSearch = (query: string, selected: Filter) => {
-    return getUserGroups(this.props.user.login, undefined, query, selected).then(data => {
-      this.setState({
-        groups: data.groups,
-        selectedGroups: data.groups.filter(group => group.selected).map(group => group.name)
-      });
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  fetchUsers = (searchParams: SearchParams, more?: boolean) =>
+    getUserGroups({
+      login: searchParams.login,
+      organization: searchParams.organization !== '' ? searchParams.organization : undefined,
+      p: searchParams.page,
+      ps: searchParams.pageSize,
+      q: searchParams.query !== '' ? searchParams.query : undefined,
+      selected: searchParams.selected
+    }).then(data => {
+      if (this.mounted) {
+        this.setState(prevState => {
+          const groups = more ? [...prevState.groups, ...data.groups] : data.groups;
+          const newSeletedGroups = data.groups.filter(gp => gp.selected).map(gp => gp.name);
+          const selectedGroups = more
+            ? [...prevState.selectedGroups, ...newSeletedGroups]
+            : newSeletedGroups;
+
+          return {
+            lastSearchParams: searchParams,
+            listHasBeenTouched: false,
+            groups,
+            groupsTotalCount: data.paging.total,
+            selectedGroups
+          };
+        });
+      }
     });
-  };
 
-  handleSelect = (name: string) => {
-    return addUserToGroup({
+  handleLoadMore = () =>
+    this.fetchUsers(
+      {
+        ...this.state.lastSearchParams,
+        page: this.state.lastSearchParams.page + 1
+      },
+      true
+    );
+
+  handleReload = () =>
+    this.fetchUsers({
+      ...this.state.lastSearchParams,
+      page: 1
+    });
+
+  handleSearch = (query: string, selected: Filter) =>
+    this.fetchUsers({
+      ...this.state.lastSearchParams,
+      page: 1,
+      query,
+      selected
+    });
+
+  handleSelect = (name: string) =>
+    addUserToGroup({
       name,
       login: this.props.user.login
     }).then(() => {
-      this.setState((state: State) => ({ selectedGroups: [...state.selectedGroups, name] }));
+      if (this.mounted) {
+        this.setState((state: State) => ({
+          listHasBeenTouched: true,
+          selectedGroups: [...state.selectedGroups, name]
+        }));
+      }
     });
-  };
 
-  handleUnselect = (name: string) => {
-    return removeUserFromGroup({
+  handleUnselect = (name: string) =>
+    removeUserFromGroup({
       name,
       login: this.props.user.login
     }).then(() => {
-      this.setState((state: State) => ({
-        selectedGroups: without(state.selectedGroups, name)
-      }));
+      if (this.mounted) {
+        this.setState((state: State) => ({
+          listHasBeenTouched: true,
+          selectedGroups: without(state.selectedGroups, name)
+        }));
+      }
     });
-  };
 
   handleCloseClick = (event: React.SyntheticEvent<HTMLElement>) => {
     event.preventDefault();
@@ -112,6 +195,12 @@ export default class GroupsForm extends React.PureComponent<Props> {
         <div className="modal-body">
           <SelectList
             elements={this.state.groups.map(group => group.name)}
+            elementsTotalCount={this.state.groupsTotalCount}
+            needReload={
+              this.state.listHasBeenTouched && this.state.lastSearchParams.selected !== Filter.All
+            }
+            onLoadMore={this.handleLoadMore}
+            onReload={this.handleReload}
             onSearch={this.handleSearch}
             onSelect={this.handleSelect}
             onUnselect={this.handleUnselect}
diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/GroupsForm-test.tsx b/server/sonar-web/src/main/js/apps/users/components/__tests__/GroupsForm-test.tsx
new file mode 100644 (file)
index 0000000..6c86567
--- /dev/null
@@ -0,0 +1,155 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 GroupsForm, { SearchParams } from '../GroupsForm';
+import SelectList, { Filter } from '../../../../components/SelectList/SelectList';
+import { waitAndUpdate } from '../../../../helpers/testUtils';
+import { getUserGroups } from '../../../../api/users';
+import { addUserToGroup, removeUserFromGroup } from '../../../../api/user_groups';
+import { mockUser } from '../../../../helpers/testMocks';
+
+jest.mock('../../../../api/users', () => ({
+  getUserGroups: jest.fn().mockResolvedValue({
+    paging: { pageIndex: 1, pageSize: 10, total: 1 },
+    groups: [
+      {
+        id: 1001,
+        name: 'test1',
+        description: 'test1',
+        selected: true
+      },
+      {
+        id: 1002,
+        name: 'test2',
+        description: 'test2',
+        selected: true
+      },
+      {
+        id: 1003,
+        name: 'test3',
+        description: 'test3',
+        selected: false
+      }
+    ]
+  })
+}));
+
+jest.mock('../../../../api/user_groups', () => ({
+  addUserToGroup: jest.fn().mockResolvedValue({}),
+  removeUserFromGroup: jest.fn().mockResolvedValue({})
+}));
+
+beforeEach(() => {
+  jest.clearAllMocks();
+});
+
+it('should render correctly', async () => {
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+
+  expect(wrapper).toMatchSnapshot();
+  expect(getUserGroups).toHaveBeenCalledWith(
+    expect.objectContaining({
+      p: 1
+    })
+  );
+
+  wrapper.setState({ listHasBeenTouched: true });
+  expect(wrapper.find(SelectList).props().needReload).toBe(true);
+
+  wrapper.setState({ lastSearchParams: { selected: Filter.All } as SearchParams });
+  expect(wrapper.find(SelectList).props().needReload).toBe(false);
+});
+
+it('should handle reload properly', async () => {
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+
+  wrapper.instance().handleReload();
+  expect(getUserGroups).toHaveBeenCalledWith(
+    expect.objectContaining({
+      p: 1
+    })
+  );
+  expect(wrapper.state().listHasBeenTouched).toBe(false);
+});
+
+it('should handle search reload properly', async () => {
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+
+  wrapper.instance().handleSearch('foo', Filter.Selected);
+  expect(getUserGroups).toHaveBeenCalledWith(
+    expect.objectContaining({
+      p: 1,
+      q: 'foo',
+      selected: Filter.Selected
+    })
+  );
+  expect(wrapper.state().listHasBeenTouched).toBe(false);
+});
+
+it('should handle load more properly', async () => {
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+
+  wrapper.instance().handleLoadMore();
+  expect(getUserGroups).toHaveBeenCalledWith(
+    expect.objectContaining({
+      p: 2
+    })
+  );
+  expect(wrapper.state().listHasBeenTouched).toBe(false);
+});
+
+it('should handle selection properly', async () => {
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+
+  wrapper.instance().handleSelect('toto');
+  await waitAndUpdate(wrapper);
+  expect(addUserToGroup).toHaveBeenCalledWith(
+    expect.objectContaining({
+      name: 'toto'
+    })
+  );
+  expect(wrapper.state().listHasBeenTouched).toBe(true);
+});
+
+it('should handle deselection properly', async () => {
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+
+  wrapper.instance().handleUnselect('tata');
+  await waitAndUpdate(wrapper);
+  expect(removeUserFromGroup).toHaveBeenCalledWith(
+    expect.objectContaining({
+      name: 'tata'
+    })
+  );
+  expect(wrapper.state().listHasBeenTouched).toBe(true);
+});
+
+function shallowRender(props: Partial<GroupsForm['props']> = {}) {
+  return shallow<GroupsForm>(
+    <GroupsForm onClose={jest.fn()} onUpdateUsers={jest.fn()} user={mockUser()} {...props} />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/GroupsForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/GroupsForm-test.tsx.snap
new file mode 100644 (file)
index 0000000..a544c91
--- /dev/null
@@ -0,0 +1,54 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<Modal
+  contentLabel="users.update_groups"
+  onRequestClose={[Function]}
+>
+  <div
+    className="modal-head"
+  >
+    <h2>
+      users.update_groups
+    </h2>
+  </div>
+  <div
+    className="modal-body"
+  >
+    <SelectList
+      elements={
+        Array [
+          "test1",
+          "test2",
+          "test3",
+        ]
+      }
+      elementsTotalCount={1}
+      needReload={false}
+      onLoadMore={[Function]}
+      onReload={[Function]}
+      onSearch={[Function]}
+      onSelect={[Function]}
+      onUnselect={[Function]}
+      renderElement={[Function]}
+      selectedElements={
+        Array [
+          "test1",
+          "test2",
+        ]
+      }
+    />
+  </div>
+  <footer
+    className="modal-foot"
+  >
+    <a
+      className="js-modal-close"
+      href="#"
+      onClick={[Function]}
+    >
+      Done
+    </a>
+  </footer>
+</Modal>
+`;