aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorphilippe-perrin-sonarsource <philippe.perrin@sonarsource.com>2019-06-20 17:11:01 +0200
committersonartech <sonartech@sonarsource.com>2019-06-28 08:45:50 +0200
commitc8aad7e3ccdb94828808d7b2780be2ad8ead3436 (patch)
tree1f06e7ef510d192ba1bf8de59e1a3de71e677bce
parent72d3203e113f99ac3aca1be5d2998343d61dbb53 (diff)
downloadsonarqube-c8aad7e3ccdb94828808d7b2780be2ad8ead3436.tar.gz
sonarqube-c8aad7e3ccdb94828808d7b2780be2ad8ead3436.zip
SONAR-12210 Handle pagination in user & group list
-rw-r--r--server/sonar-web/src/main/js/api/user_groups.ts2
-rw-r--r--server/sonar-web/src/main/js/api/users.ts26
-rw-r--r--server/sonar-web/src/main/js/apps/groups/components/EditMembersModal.tsx128
-rw-r--r--server/sonar-web/src/main/js/apps/groups/components/__tests__/EditMembers-test.tsx1
-rw-r--r--server/sonar-web/src/main/js/apps/groups/components/__tests__/EditMembersModal-test.tsx116
-rw-r--r--server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembers-test.tsx.snap3
-rw-r--r--server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembersModal-test.tsx.snap50
-rw-r--r--server/sonar-web/src/main/js/apps/organizationMembers/ManageMemberGroupsForm.tsx5
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx137
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/__tests__/GroupsForm-test.tsx155
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/GroupsForm-test.tsx.snap54
11 files changed, 549 insertions, 128 deletions
diff --git a/server/sonar-web/src/main/js/api/user_groups.ts b/server/sonar-web/src/main/js/api/user_groups.ts
index a84be34b5ea..eae50459ee1 100644
--- a/server/sonar-web/src/main/js/api/user_groups.ts
+++ b/server/sonar-web/src/main/js/api/user_groups.ts
@@ -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);
}
diff --git a/server/sonar-web/src/main/js/api/users.ts b/server/sonar-web/src/main/js/api/users.ts
index 884a057da08..8daacb90b25 100644
--- a/server/sonar-web/src/main/js/api/users.ts
+++ b/server/sonar-web/src/main/js/api/users.ts
@@ -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);
}
diff --git a/server/sonar-web/src/main/js/apps/groups/components/EditMembersModal.tsx b/server/sonar-web/src/main/js/apps/groups/components/EditMembersModal.tsx
index 14b4c813d14..9b475b87c6a 100644
--- a/server/sonar-web/src/main/js/apps/groups/components/EditMembersModal.tsx
+++ b/server/sonar-web/src/main/js/apps/groups/components/EditMembersModal.tsx
@@ -17,19 +17,19 @@
* 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}
diff --git a/server/sonar-web/src/main/js/apps/groups/components/__tests__/EditMembers-test.tsx b/server/sonar-web/src/main/js/apps/groups/components/__tests__/EditMembers-test.tsx
index 1331cff110c..82505385a2c 100644
--- a/server/sonar-web/src/main/js/apps/groups/components/__tests__/EditMembers-test.tsx
+++ b/server/sonar-web/src/main/js/apps/groups/components/__tests__/EditMembers-test.tsx
@@ -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();
diff --git a/server/sonar-web/src/main/js/apps/groups/components/__tests__/EditMembersModal-test.tsx b/server/sonar-web/src/main/js/apps/groups/components/__tests__/EditMembersModal-test.tsx
index 6ee23a8ecb9..88209b8e099 100644
--- a/server/sonar-web/src/main/js/apps/groups/components/__tests__/EditMembersModal-test.tsx
+++ b/server/sonar-web/src/main/js/apps/groups/components/__tests__/EditMembersModal-test.tsx
@@ -17,15 +17,16 @@
* 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}
+ />
+ );
+}
diff --git a/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembers-test.tsx.snap b/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembers-test.tsx.snap
index 98241e10f0c..a2651397aff 100644
--- a/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembers-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembers-test.tsx.snap
@@ -311,6 +311,9 @@ exports[`should edit members 2`] = `
>
<SelectList
elements={Array []}
+ needReload={false}
+ onLoadMore={[Function]}
+ onReload={[Function]}
onSearch={[Function]}
onSelect={[Function]}
onUnselect={[Function]}
diff --git a/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembersModal-test.tsx.snap b/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembersModal-test.tsx.snap
index b9156bda237..faff753d49b 100644
--- a/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembersModal-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembersModal-test.tsx.snap
@@ -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>
diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/ManageMemberGroupsForm.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/ManageMemberGroupsForm.tsx
index fbc68ce2d8f..4355c2ab882 100644
--- a/server/sonar-web/src/main/js/apps/organizationMembers/ManageMemberGroupsForm.tsx
+++ b/server/sonar-web/src/main/js/apps/organizationMembers/ManageMemberGroupsForm.tsx
@@ -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') });
diff --git a/server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx b/server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx
index 3e4b4554f43..b1cbfbd4f69 100644
--- a/server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx
+++ b/server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx
@@ -17,13 +17,13 @@
* 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
index 00000000000..6c86567a743
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/GroupsForm-test.tsx
@@ -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
index 00000000000..a544c913d55
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/GroupsForm-test.tsx.snap
@@ -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>
+`;