]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-21114 Migrate groups of a user dialog to Web API v2
authorViktor Vorona <viktor.vorona@sonarsource.com>
Mon, 22 Jan 2024 16:33:45 +0000 (17:33 +0100)
committersonartech <sonartech@sonarsource.com>
Fri, 26 Jan 2024 20:02:46 +0000 (20:02 +0000)
server/sonar-web/src/main/js/api/legacy-group-membership.ts [deleted file]
server/sonar-web/src/main/js/api/mocks/GroupsServiceMock.ts
server/sonar-web/src/main/js/api/mocks/UsersServiceMock.ts
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/__tests__/GroupsApp-it.tsx
server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx
server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx
server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx
server/sonar-web/src/main/js/queries/group-memberships.ts
server/sonar-web/src/main/js/queries/users.ts

diff --git a/server/sonar-web/src/main/js/api/legacy-group-membership.ts b/server/sonar-web/src/main/js/api/legacy-group-membership.ts
deleted file mode 100644 (file)
index 37e302a..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2024 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 { throwGlobalError } from '../helpers/error';
-import { post } from '../helpers/request';
-
-export function addUserToGroup(data: { name: string; login?: string }) {
-  return post('/api/user_groups/add_user', data).catch(throwGlobalError);
-}
-
-export function removeUserFromGroup(data: { name: string; login?: string }) {
-  return post('/api/user_groups/remove_user', data).catch(throwGlobalError);
-}
index e1fd6ca37504cc4574de969d2e1806f002a7a84e..b2267028033414440d3e934bf99ac8e1a33e2ff9 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import { cloneDeep } from 'lodash';
-import {
-  mockGroup,
-  mockIdentityProvider,
-  mockPaging,
-  mockUserGroupMember,
-} from '../../helpers/testMocks';
+import { mockGroup, mockIdentityProvider } from '../../helpers/testMocks';
 import { Group, IdentityProvider, Paging, Provider } from '../../types/types';
 import { createGroup, deleteGroup, getUsersGroups, updateGroup } from '../user_groups';
 
@@ -31,26 +26,14 @@ jest.mock('../user_groups');
 
 export default class GroupsServiceMock {
   provider: Provider | undefined;
-  paging: Paging;
   groups: Group[];
   readOnlyGroups = [
     mockGroup({ name: 'managed-group', managed: true, id: '1' }),
     mockGroup({ name: 'local-group', managed: false, id: '2' }),
   ];
 
-  defaultUsers = [
-    mockUserGroupMember({ name: 'alice', login: 'alice.dev' }),
-    mockUserGroupMember({ name: 'bob', login: 'bob.dev' }),
-    mockUserGroupMember({ selected: false }),
-  ];
-
   constructor() {
     this.groups = cloneDeep(this.readOnlyGroups);
-    this.paging = mockPaging({
-      pageIndex: 1,
-      pageSize: 2,
-      total: 200,
-    });
 
     jest.mocked(getUsersGroups).mockImplementation((p) => this.handleSearchUsersGroups(p));
     jest.mocked(createGroup).mockImplementation((g) => this.handleCreateGroup(g));
@@ -62,10 +45,6 @@ export default class GroupsServiceMock {
     this.groups = cloneDeep(this.readOnlyGroups);
   }
 
-  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);
@@ -106,26 +85,18 @@ export default class GroupsServiceMock {
   handleSearchUsersGroups = (
     params: Parameters<typeof getUsersGroups>[0],
   ): Promise<{ groups: Group[]; page: Paging }> => {
-    const { paging: page } = this;
-    if (params.pageIndex !== undefined && params.pageIndex !== page.pageIndex) {
-      this.setPaging({ pageIndex: page.pageIndex++ });
-      const groups = [
-        mockGroup({ name: `local-group ${this.groups.length + 4}` }),
-        mockGroup({ name: `local-group ${this.groups.length + 5}` }),
-      ];
-
-      return this.reply({ page, groups });
-    }
-    if (params.managed === undefined) {
-      return this.reply({
-        page,
-        groups: this.groups.filter((g) => (params?.q ? g.name.includes(params.q) : true)),
-      });
-    }
-    const groups = this.groups.filter((group) => group.managed === params.managed);
+    const pageIndex = params.pageIndex ?? 1;
+    const pageSize = params.pageSize ?? 10;
+    const groups = this.groups
+      .filter((g) => !params.q || g.name.includes(params.q))
+      .filter((g) => params.managed === undefined || g.managed === params.managed);
     return this.reply({
-      page,
-      groups: groups.filter((g) => (params?.q ? g.name.includes(params.q) : true)),
+      page: {
+        pageIndex,
+        pageSize,
+        total: groups.length,
+      },
+      groups: groups.slice((pageIndex - 1) * pageSize, pageIndex * pageSize),
     });
   };
 
index 1d0e13dac7c9c8d05b2044ebed1978dcb8bb2e2c..92b87cf1b01511495e2338bea8e1945de818e9c8 100644 (file)
@@ -28,15 +28,12 @@ import {
   NoticeType,
   RestUserDetailed,
 } from '../../types/users';
-import { addUserToGroup, removeUserFromGroup } from '../legacy-group-membership';
 import {
-  UserGroup,
   changePassword,
   deleteUser,
   dismissNotice,
   getCurrentUser,
   getIdentityProviders,
-  getUserGroups,
   getUsers,
   postUser,
   updateUser,
@@ -44,7 +41,6 @@ import {
 import GroupMembershipsServiceMock from './GroupMembersipsServiceMock';
 
 jest.mock('../users');
-jest.mock('../legacy-group-membership');
 
 const DEFAULT_USERS = [
   mockRestUser({
@@ -101,44 +97,12 @@ const DEFAULT_USERS = [
   }),
 ];
 
-const DEFAULT_GROUPS: UserGroup[] = [
-  {
-    id: 1001,
-    name: 'test1',
-    description: 'test1',
-    selected: true,
-    default: true,
-  },
-  {
-    id: 1002,
-    name: 'test2',
-    description: 'test2',
-    selected: true,
-    default: false,
-  },
-  {
-    id: 1003,
-    name: 'test3',
-    description: 'test3',
-    selected: true,
-    default: false,
-  },
-  {
-    id: 1004,
-    name: 'test4',
-    description: 'test4',
-    selected: false,
-    default: false,
-  },
-];
-
 const DEFAULT_PASSWORD = 'test';
 
 export default class UsersServiceMock {
   isManaged = true;
   users = cloneDeep(DEFAULT_USERS);
   currentUser = mockLoggedInUser();
-  groups = cloneDeep(DEFAULT_GROUPS);
   password = DEFAULT_PASSWORD;
   groupMembershipsServiceMock?: GroupMembershipsServiceMock = undefined;
   constructor(groupMembershipsServiceMock?: GroupMembershipsServiceMock) {
@@ -147,9 +111,6 @@ export default class UsersServiceMock {
     jest.mocked(getUsers).mockImplementation(this.handleGetUsers);
     jest.mocked(postUser).mockImplementation(this.handlePostUser);
     jest.mocked(updateUser).mockImplementation(this.handleUpdateUser);
-    jest.mocked(getUserGroups).mockImplementation(this.handleGetUserGroups);
-    jest.mocked(addUserToGroup).mockImplementation(this.handleAddUserToGroup);
-    jest.mocked(removeUserFromGroup).mockImplementation(this.handleRemoveUserFromGroup);
     jest.mocked(changePassword).mockImplementation(this.handleChangePassword);
     jest.mocked(deleteUser).mockImplementation(this.handleDeactivateUser);
     jest.mocked(dismissNotice).mockImplementation(this.handleDismissNotification);
@@ -294,56 +255,6 @@ export default class UsersServiceMock {
     });
   };
 
-  handleGetUserGroups: typeof getUserGroups = (data) => {
-    if (data.login !== 'alice.merveille') {
-      return this.reply({
-        paging: { pageIndex: 1, pageSize: 10, total: 0 },
-        groups: [],
-      });
-    }
-    const filteredGroups = this.groups
-      .filter((g) => g.name.includes(data.q ?? ''))
-      .filter((g) => {
-        switch (data.selected) {
-          case 'all':
-            return true;
-          case 'deselected':
-            return !g.selected;
-          default:
-            return g.selected;
-        }
-      });
-
-    return this.reply({
-      paging: { pageIndex: 1, pageSize: 10, total: filteredGroups.length },
-      groups: filteredGroups,
-    });
-  };
-
-  handleAddUserToGroup: typeof addUserToGroup = ({ name }) => {
-    this.groups = this.groups.map((g) => (g.name === name ? { ...g, selected: true } : g));
-    return this.reply({});
-  };
-
-  handleRemoveUserFromGroup: typeof removeUserFromGroup = ({ name }) => {
-    let isDefault = false;
-    this.groups = this.groups.map((g) => {
-      if (g.name === name) {
-        if (g.default) {
-          isDefault = true;
-          return g;
-        }
-        return { ...g, selected: false };
-      }
-      return g;
-    });
-    return isDefault
-      ? Promise.reject({
-          errors: [{ msg: 'Cannot remove Default group' }],
-        })
-      : this.reply({});
-  };
-
   handleChangePassword: typeof changePassword = (data) => {
     if (data.previousPassword !== this.password) {
       return Promise.reject(ChangePasswordResults.OldPasswordIncorrect);
@@ -381,7 +292,6 @@ export default class UsersServiceMock {
   reset = () => {
     this.isManaged = true;
     this.users = cloneDeep(DEFAULT_USERS);
-    this.groups = cloneDeep(DEFAULT_GROUPS);
     this.password = DEFAULT_PASSWORD;
     this.currentUser = mockLoggedInUser();
   };
index 771591eacfa5d5a98e8fb78cc041913fd1df8dc8..543d9b13dc4122aba229f41a6b0e53b327b4b85a 100644 (file)
@@ -24,7 +24,7 @@ const GROUPS_ENDPOINT = '/api/v2/authorizations/groups';
 
 export function getUsersGroups(params: {
   q?: string;
-  managed: boolean | undefined;
+  managed?: boolean;
   pageIndex?: number;
   pageSize?: number;
 }): Promise<{ groups: Group[]; page: Paging }> {
index 34f98e68710e891fb8356d1c53df4a15e3671d4c..b4c75e57bf78bfabeffd6e74a8a5a2d47935b801 100644 (file)
@@ -55,24 +55,6 @@ export function changePassword(data: {
   });
 }
 
-export interface UserGroup {
-  default: boolean;
-  description: string;
-  id: number;
-  name: string;
-  selected: boolean;
-}
-
-export function getUserGroups(data: {
-  login: string;
-  p?: number;
-  ps?: number;
-  q?: string;
-  selected?: string;
-}): Promise<{ paging: Paging; groups: UserGroup[] }> {
-  return getJSON('/api/users/groups', data);
-}
-
 export function getIdentityProviders(): Promise<{ identityProviders: IdentityProvider[] }> {
   return getJSON('/api/users/identity_providers').catch(throwGlobalError);
 }
index ef44a583be6ac7343d147525aa737b42c8d82e10..4ef85a29f561c0accbf5461a36c0b69ee5976dd7 100644 (file)
@@ -25,7 +25,7 @@ import GroupMembershipsServiceMock from '../../../api/mocks/GroupMembersipsServi
 import GroupsServiceMock from '../../../api/mocks/GroupsServiceMock';
 import SystemServiceMock from '../../../api/mocks/SystemServiceMock';
 import UsersServiceMock from '../../../api/mocks/UsersServiceMock';
-import { mockGroupMembership, mockRestUser } from '../../../helpers/testMocks';
+import { mockGroup, mockGroupMembership, mockRestUser } from '../../../helpers/testMocks';
 import { renderApp } from '../../../helpers/testReactTestingUtils';
 import { byRole, byText } from '../../../helpers/testSelector';
 import { Feature } from '../../../types/features';
@@ -257,14 +257,17 @@ describe('in non managed mode', () => {
 
   it('should be able load more group', async () => {
     const user = userEvent.setup();
+    handler.groups = new Array(15)
+      .fill(null)
+      .map((_, index) => mockGroup({ id: index.toString(), name: `group${index}` }));
     renderGroupsApp();
 
-    expect(await ui.localGroupRow.find()).toBeInTheDocument();
-    expect(await screen.findAllByRole('row')).toHaveLength(3);
+    expect(await ui.showMore.find()).toBeInTheDocument();
+    expect(await screen.findAllByRole('row')).toHaveLength(11);
 
     await user.click(await ui.showMore.find());
 
-    expect(await screen.findAllByRole('row')).toHaveLength(5);
+    expect(await screen.findAllByRole('row')).toHaveLength(16);
   });
 });
 
index 103e08362286cb05fc160e3f364eedf27b15965e..9bddc2e7728f5e8dd2bde70f0e6fac00ae172f15 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 { screen, waitFor, within } from '@testing-library/react';
+import { screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import * as React from 'react';
 import selectEvent from 'react-select-event';
 import ComponentsServiceMock from '../../../api/mocks/ComponentsServiceMock';
 import GithubProvisioningServiceMock from '../../../api/mocks/GithubProvisioningServiceMock';
+import GroupMembershipsServiceMock from '../../../api/mocks/GroupMembersipsServiceMock';
+import GroupsServiceMock from '../../../api/mocks/GroupsServiceMock';
 import SettingsServiceMock from '../../../api/mocks/SettingsServiceMock';
 import SystemServiceMock from '../../../api/mocks/SystemServiceMock';
 import UserTokensMock from '../../../api/mocks/UserTokensMock';
 import UsersServiceMock from '../../../api/mocks/UsersServiceMock';
-import { mockCurrentUser, mockLoggedInUser, mockRestUser } from '../../../helpers/testMocks';
+import {
+  mockCurrentUser,
+  mockGroup,
+  mockGroupMembership,
+  mockLoggedInUser,
+  mockRestUser,
+} from '../../../helpers/testMocks';
 import { renderApp } from '../../../helpers/testReactTestingUtils';
 import { byLabelText, byRole, byText } from '../../../helpers/testSelector';
 import { Feature } from '../../../types/features';
@@ -42,6 +50,8 @@ const systemHandler = new SystemServiceMock();
 const componentsHandler = new ComponentsServiceMock();
 const settingsHandler = new SettingsServiceMock();
 const githubHandler = new GithubProvisioningServiceMock();
+const membershipHandler = new GroupMembershipsServiceMock();
+const groupsHandler = new GroupsServiceMock();
 
 const ui = {
   createUserButton: byRole('button', { name: 'users.create_user' }),
@@ -107,7 +117,7 @@ const ui = {
   selectedFilter: byRole('radio', { name: 'selected' }),
   unselectedFilter: byRole('radio', { name: 'unselected' }),
 
-  getGroups: () => within(ui.dialogGroups.get()).getAllByRole('checkbox'),
+  groups: byRole('dialog', { name: 'users.update_groups' }).byRole('checkbox'),
   dialogTokens: byRole('dialog', { name: /users.user_X_tokens/ }),
   dialogPasswords: byRole('dialog', { name: 'my_profile.password.title' }),
   dialogUpdateUser: byRole('dialog', { name: 'users.update_user' }),
@@ -147,6 +157,8 @@ beforeEach(() => {
   settingsHandler.reset();
   systemHandler.reset();
   githubHandler.reset();
+  membershipHandler.reset();
+  groupsHandler.reset();
 });
 
 describe('different filters combinations', () => {
@@ -297,24 +309,38 @@ describe('in non managed mode', () => {
 
   it('should be able to edit the groups of a user', async () => {
     const user = userEvent.setup();
+    groupsHandler.groups = new Array(105).fill(null).map((_, index) =>
+      mockGroup({
+        id: index.toString(),
+        name: `group${index}`,
+        // eslint-disable-next-line jest/no-conditional-in-test
+        description: index === 0 ? 'description99' : undefined,
+      }),
+    );
+    membershipHandler.memberships = [
+      mockGroupMembership({ userId: '2', groupId: '1' }),
+      mockGroupMembership({ userId: '2', groupId: '2' }),
+      mockGroupMembership({ userId: '2', groupId: '3' }),
+    ];
     renderUsersApp();
     expect(await ui.aliceRow.byText('3').find()).toBeInTheDocument();
 
     await user.click(await ui.aliceUpdateGroupButton.find());
     expect(await ui.dialogGroups.find()).toBeInTheDocument();
 
-    expect(ui.getGroups()).toHaveLength(3);
+    expect(await ui.groups.findAll()).toHaveLength(3);
 
     await user.click(await ui.allFilter.find());
-    expect(ui.getGroups()).toHaveLength(4);
+    expect(ui.groups.getAll()).toHaveLength(105);
 
     await user.click(ui.unselectedFilter.get());
+    expect(ui.groups.getAll()).toHaveLength(102);
     expect(ui.reloadButton.query()).not.toBeInTheDocument();
-    await user.click(ui.getGroups()[0]);
+    await user.click(ui.groups.getAt(0));
     expect(await ui.reloadButton.find()).toBeInTheDocument();
 
     await user.click(ui.selectedFilter.get());
-    expect(ui.getGroups()).toHaveLength(4);
+    expect(ui.groups.getAll()).toHaveLength(4);
 
     await user.click(ui.doneButton.get());
     expect(ui.dialogGroups.query()).not.toBeInTheDocument();
@@ -324,14 +350,14 @@ describe('in non managed mode', () => {
 
     await user.click(ui.selectedFilter.get());
 
-    await user.click(ui.getGroups()[1]);
+    await user.click(ui.groups.getAt(1));
     expect(await ui.reloadButton.find()).toBeInTheDocument();
     await user.click(ui.reloadButton.get());
-    expect(ui.getGroups()).toHaveLength(3);
+    expect(ui.groups.getAll()).toHaveLength(3);
 
-    await user.type(ui.dialogGroups.byRole('searchbox').get(), '4');
+    await user.type(ui.dialogGroups.byRole('searchbox').get(), '99');
 
-    expect(ui.getGroups()).toHaveLength(1);
+    expect(ui.groups.getAll()).toHaveLength(2);
 
     await user.click(ui.doneButton.get());
     expect(ui.dialogGroups.query()).not.toBeInTheDocument();
index 7b6bd4594bb3cf0210b4ae5da97cc5f15ada95b4..2a38f116bbf7d9323fcc7eeec126a64d8427f33a 100644 (file)
  */
 
 import { LightPrimary, Modal, Note } from 'design-system';
-import { find, without } from 'lodash';
+import { find } from 'lodash';
 import * as React from 'react';
-import { UserGroup, getUserGroups } from '../../../api/users';
 import SelectList, {
   SelectListFilter,
   SelectListSearchParams,
 } from '../../../components/controls/SelectList';
 import { translate } from '../../../helpers/l10n';
-import { useAddUserToGroupMutation, useRemoveUserToGroupMutation } from '../../../queries/users';
+import {
+  useAddGroupMembershipMutation,
+  useRemoveGroupMembershipMutation,
+  useUserGroupsQuery,
+} from '../../../queries/group-memberships';
 import { RestUserDetailed } from '../../../types/users';
 
 interface Props {
@@ -37,60 +40,58 @@ interface Props {
 
 export default function GroupsForm(props: Props) {
   const { user } = props;
-  const [needToReload, setNeedToReload] = React.useState<boolean>(false);
-  const [lastSearchParams, setLastSearchParams] = React.useState<
-    SelectListSearchParams | undefined
-  >(undefined);
-  const [groups, setGroups] = React.useState<UserGroup[]>([]);
-  const [groupsTotalCount, setGroupsTotalCount] = React.useState<number | undefined>(undefined);
-  const [selectedGroups, setSelectedGroups] = React.useState<string[]>([]);
-  const { mutateAsync: addUserToGroup } = useAddUserToGroupMutation();
-  const { mutateAsync: removeUserFromGroup } = useRemoveUserToGroupMutation();
+  const [query, setQuery] = React.useState<string>('');
+  const [filter, setFilter] = React.useState<SelectListFilter>(SelectListFilter.Selected);
+  const [changedGroups, setChangedGroups] = React.useState<Map<string, boolean>>(new Map());
+  const {
+    data: groups,
+    isLoading,
+    refetch,
+  } = useUserGroupsQuery({
+    q: query,
+    filter,
+    userId: user.id,
+  });
+  const { mutateAsync: addUserToGroup } = useAddGroupMembershipMutation();
+  const { mutateAsync: removeUserFromGroup } = useRemoveGroupMembershipMutation();
 
-  const fetchUsers = (searchParams: SelectListSearchParams) =>
-    getUserGroups({
-      login: user.login,
-      p: searchParams.page,
-      ps: searchParams.pageSize,
-      q: searchParams.query !== '' ? searchParams.query : undefined,
-      selected: searchParams.filter,
-    }).then((data) => {
-      const more = searchParams.page != null && searchParams.page > 1;
-      const allGroups = more ? [...groups, ...data.groups] : data.groups;
-      const newSeletedGroups = data.groups.filter((gp) => gp.selected).map((gp) => gp.name);
-      const allSelectedGroups = more ? [...selectedGroups, ...newSeletedGroups] : newSeletedGroups;
+  const onSearch = (searchParams: SelectListSearchParams) => {
+    if (query === searchParams.query && filter === searchParams.filter) {
+      refetch();
+    } else {
+      setQuery(searchParams.query);
+      setFilter(searchParams.filter);
+    }
 
-      setLastSearchParams(searchParams);
-      setNeedToReload(false);
-      setGroups(allGroups);
-      setGroupsTotalCount(data.paging.total);
-      setSelectedGroups(allSelectedGroups);
-    });
+    setChangedGroups(new Map());
+  };
 
-  const handleSelect = (name: string) =>
+  const handleSelect = (groupId: string) =>
     addUserToGroup({
-      name,
-      login: user.login,
+      userId: user.id,
+      groupId,
     }).then(() => {
-      setNeedToReload(true);
-      setSelectedGroups([...selectedGroups, name]);
+      const newChangedGroups = new Map(changedGroups);
+      newChangedGroups.set(groupId, true);
+      setChangedGroups(newChangedGroups);
     });
 
-  const handleUnselect = (name: string) =>
+  const handleUnselect = (groupId: string) =>
     removeUserFromGroup({
-      name,
-      login: user.login,
+      groupId,
+      userId: user.id,
     }).then(() => {
-      setNeedToReload(true);
-      setSelectedGroups(without(selectedGroups, name));
+      const newChangedGroups = new Map(changedGroups);
+      newChangedGroups.set(groupId, false);
+      setChangedGroups(newChangedGroups);
     });
 
-  const renderElement = (name: string): React.ReactNode => {
-    const group = find(groups, { name });
+  const renderElement = (groupId: string): React.ReactNode => {
+    const group = find(groups, { id: groupId });
     return (
       <div>
         {group === undefined ? (
-          <LightPrimary>{name}</LightPrimary>
+          <LightPrimary>{groupId}</LightPrimary>
         ) : (
           <>
             <LightPrimary>{group.name}</LightPrimary>
@@ -110,17 +111,19 @@ export default function GroupsForm(props: Props) {
       body={
         <div className="sw-pt-1">
           <SelectList
-            elements={groups.map((group) => group.name)}
-            elementsTotalCount={groupsTotalCount}
-            needToReload={
-              needToReload && lastSearchParams && lastSearchParams.filter !== SelectListFilter.All
-            }
-            onSearch={fetchUsers}
+            elements={groups?.map((group) => group.id.toString()) ?? []}
+            elementsTotalCount={groups?.length}
+            needToReload={changedGroups.size > 0 && filter !== SelectListFilter.All}
+            onSearch={onSearch}
             onSelect={handleSelect}
             onUnselect={handleUnselect}
             renderElement={renderElement}
-            selectedElements={selectedGroups}
-            withPaging
+            selectedElements={
+              groups
+                ?.filter((g) => (changedGroups.has(g.id) ? changedGroups.get(g.id) : g.selected))
+                .map((g) => g.id) ?? []
+            }
+            loading={isLoading}
           />
         </div>
       }
index ac890d1816f0019079051f3b5345cb781cf8d50c..22a28cdb0f299ff03595ae63fff246557e6cb3bc 100644 (file)
@@ -30,7 +30,8 @@ import {
 import * as React from 'react';
 import DateFromNow from '../../../components/intl/DateFromNow';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
-import { useUserGroupsCountQuery, useUserTokensQuery } from '../../../queries/users';
+import { useUserGroupsCountQuery } from '../../../queries/group-memberships';
+import { useUserTokensQuery } from '../../../queries/users';
 import { IdentityProvider, Provider } from '../../../types/types';
 import { RestUserDetailed } from '../../../types/users';
 import GroupsForm from './GroupsForm';
@@ -45,9 +46,10 @@ export interface UserListItemProps {
   manageProvider: Provider | undefined;
 }
 
-export default function UserListItem(props: UserListItemProps) {
+export default function UserListItem(props: Readonly<UserListItemProps>) {
   const { identityProvider, user, manageProvider } = props;
   const {
+    id,
     name,
     login,
     avatar,
@@ -59,7 +61,7 @@ export default function UserListItem(props: UserListItemProps) {
   const [openTokenForm, setOpenTokenForm] = React.useState(false);
   const [openGroupForm, setOpenGroupForm] = React.useState(false);
   const { data: tokens, isLoading: tokensAreLoading } = useUserTokensQuery(login);
-  const { data: groupsCount, isLoading: groupsAreLoading } = useUserGroupsCountQuery(login);
+  const { data: groupsCount, isLoading: groupsAreLoading } = useUserGroupsCountQuery(id);
 
   return (
     <TableRow>
index 0b5017d8c203bdde389d97bda9d26a84e930120d..a513895ca7ec3e6a059764e9109f25e113ecafad 100644 (file)
@@ -28,9 +28,11 @@ import { SelectListFilter } from '../components/controls/SelectList';
 import { translateWithParameters } from '../helpers/l10n';
 import { getNextPageParam, getPreviousPageParam } from '../helpers/react-query';
 import { RestUserDetailed } from '../types/users';
+import { useGroupsQueries } from './groups';
 
 const DOMAIN = 'group-memberships';
 const GROUP_SUB_DOMAIN = 'users-of-group';
+const USER_SUB_DOMAIN = 'groups-of-user';
 
 export function useGroupMembersQuery(params: {
   filter?: SelectListFilter;
@@ -78,6 +80,65 @@ export function useGroupMembersQuery(params: {
   });
 }
 
+export function useUserGroupsQuery(params: {
+  filter?: SelectListFilter;
+  q?: string;
+  userId: string;
+}) {
+  const { q, filter, userId } = params;
+  const {
+    data: groupsPages,
+    isLoading: loadingGroups,
+    fetchNextPage: fetchNextPageGroups,
+    hasNextPage: hasNextPageGroups,
+  } = useGroupsQueries({});
+  const {
+    data: membershipsPages,
+    isLoading: loadingMemberships,
+    fetchNextPage: fetchNextPageMemberships,
+    hasNextPage: hasNextPageMemberships,
+  } = useInfiniteQuery({
+    queryKey: [DOMAIN, USER_SUB_DOMAIN, 'memberships', userId],
+    queryFn: ({ pageParam = 1 }) =>
+      getGroupMemberships({ userId, pageSize: 100, pageIndex: pageParam }),
+    getNextPageParam,
+    getPreviousPageParam,
+  });
+  if (hasNextPageGroups) {
+    fetchNextPageGroups();
+  }
+  if (hasNextPageMemberships) {
+    fetchNextPageMemberships();
+  }
+  return useQuery({
+    queryKey: [DOMAIN, USER_SUB_DOMAIN, params],
+    queryFn: () => {
+      const memberships =
+        membershipsPages?.pages.flatMap((page) => page.groupMemberships).flat() ?? [];
+      const groups = (groupsPages?.pages.flatMap((page) => page.groups).flat() ?? [])
+        .filter(
+          (group) =>
+            q === undefined ||
+            group.name.toLowerCase().includes(q.toLowerCase()) ||
+            group.description?.toLowerCase().includes(q.toLowerCase()),
+        )
+        .map((group) => ({
+          ...group,
+          selected: memberships.some((membership) => membership.groupId === group.id),
+        }));
+      switch (filter) {
+        case SelectListFilter.All:
+          return groups;
+        case SelectListFilter.Unselected:
+          return groups.filter((group) => !group.selected);
+        default:
+          return groups.filter((group) => group.selected);
+      }
+    },
+    enabled: !loadingGroups && !hasNextPageGroups && !loadingMemberships && !hasNextPageMemberships,
+  });
+}
+
 export function useGroupMembersCountQuery(groupId: string) {
   return useQuery({
     queryKey: [DOMAIN, GROUP_SUB_DOMAIN, 'count', groupId],
@@ -85,6 +146,13 @@ export function useGroupMembersCountQuery(groupId: string) {
   });
 }
 
+export function useUserGroupsCountQuery(userId: string) {
+  return useQuery({
+    queryKey: [DOMAIN, USER_SUB_DOMAIN, 'count', userId],
+    queryFn: () => getGroupMemberships({ userId, pageSize: 0 }).then((r) => r.page.total),
+  });
+}
+
 export function useAddGroupMembershipMutation() {
   const queryClient = useQueryClient();
 
@@ -95,6 +163,11 @@ export function useAddGroupMembershipMutation() {
         [DOMAIN, GROUP_SUB_DOMAIN, 'count', data.groupId],
         (oldData) => (oldData !== undefined ? oldData + 1 : undefined),
       );
+      queryClient.setQueryData<number>(
+        [DOMAIN, USER_SUB_DOMAIN, 'count', data.userId],
+        (oldData) => (oldData !== undefined ? oldData + 1 : undefined),
+      );
+      queryClient.invalidateQueries([DOMAIN, USER_SUB_DOMAIN, 'memberships', data.userId]);
     },
   });
 }
@@ -117,6 +190,11 @@ export function useRemoveGroupMembershipMutation() {
         [DOMAIN, GROUP_SUB_DOMAIN, 'count', data.groupId],
         (oldData) => (oldData !== undefined ? oldData - 1 : undefined),
       );
+      queryClient.setQueryData<number>(
+        [DOMAIN, USER_SUB_DOMAIN, 'count', data.userId],
+        (oldData) => (oldData !== undefined ? oldData - 1 : undefined),
+      );
+      queryClient.invalidateQueries([DOMAIN, USER_SUB_DOMAIN, 'memberships', data.userId]);
     },
   });
 }
index 1a97eaeb45efa8f7a7a01dc07f7c68549b485529..3991572059db6515ff15aa1fd5da938fbeeb1b6d 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { addUserToGroup, removeUserFromGroup } from '../api/legacy-group-membership';
 import { generateToken, getTokens, revokeToken } from '../api/user-tokens';
-import {
-  deleteUser,
-  dismissNotice,
-  getUserGroups,
-  getUsers,
-  postUser,
-  updateUser,
-} from '../api/users';
+import { deleteUser, dismissNotice, getUsers, postUser, updateUser } from '../api/users';
 import { useCurrentUser } from '../app/components/current-user/CurrentUserContext';
 import { getNextPageParam, getPreviousPageParam } from '../helpers/react-query';
 import { UserToken } from '../types/token';
@@ -56,13 +48,6 @@ export function useUserTokensQuery(login: string) {
   });
 }
 
-export function useUserGroupsCountQuery(login: string) {
-  return useQuery({
-    queryKey: ['user', login, 'groups', 'total'],
-    queryFn: () => getUserGroups({ login, ps: 1 }).then((r) => r.paging.total),
-  });
-}
-
 export function usePostUserMutation() {
   const queryClient = useQueryClient();
 
@@ -136,30 +121,6 @@ export function useRevokeTokenMutation() {
   });
 }
 
-export function useAddUserToGroupMutation() {
-  const queryClient = useQueryClient();
-  return useMutation({
-    mutationFn: (data: Parameters<typeof addUserToGroup>[0]) => addUserToGroup(data),
-    onSuccess(_, data) {
-      queryClient.setQueryData<number>(['user', data.login, 'groups', 'total'], (oldData) =>
-        oldData !== undefined ? oldData + 1 : undefined,
-      );
-    },
-  });
-}
-
-export function useRemoveUserToGroupMutation() {
-  const queryClient = useQueryClient();
-  return useMutation({
-    mutationFn: (data: Parameters<typeof removeUserFromGroup>[0]) => removeUserFromGroup(data),
-    onSuccess(_, data) {
-      queryClient.setQueryData<number>(['user', data.login, 'groups', 'total'], (oldData) =>
-        oldData !== undefined ? oldData - 1 : undefined,
-      );
-    },
-  });
-}
-
 export function useDismissNoticeMutation() {
   const { updateDismissedNotices } = useCurrentUser();