From 0893f59871443630e1da50c180cb0b091ae14a0c Mon Sep 17 00:00:00 2001 From: Viktor Vorona Date: Mon, 22 Jan 2024 17:33:45 +0100 Subject: [PATCH] SONAR-21114 Migrate groups of a user dialog to Web API v2 --- .../main/js/api/legacy-group-membership.ts | 29 ----- .../main/js/api/mocks/GroupsServiceMock.ts | 53 ++------- .../src/main/js/api/mocks/UsersServiceMock.ts | 90 --------------- .../sonar-web/src/main/js/api/user_groups.ts | 2 +- server/sonar-web/src/main/js/api/users.ts | 18 --- .../js/apps/groups/__tests__/GroupsApp-it.tsx | 11 +- .../js/apps/users/__tests__/UsersApp-it.tsx | 48 ++++++-- .../js/apps/users/components/GroupsForm.tsx | 105 +++++++++--------- .../js/apps/users/components/UserListItem.tsx | 8 +- .../src/main/js/queries/group-memberships.ts | 78 +++++++++++++ server/sonar-web/src/main/js/queries/users.ts | 41 +------ 11 files changed, 195 insertions(+), 288 deletions(-) delete mode 100644 server/sonar-web/src/main/js/api/legacy-group-membership.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 index 37e302a4245..00000000000 --- a/server/sonar-web/src/main/js/api/legacy-group-membership.ts +++ /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); -} diff --git a/server/sonar-web/src/main/js/api/mocks/GroupsServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/GroupsServiceMock.ts index e1fd6ca3750..b2267028033 100644 --- a/server/sonar-web/src/main/js/api/mocks/GroupsServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/GroupsServiceMock.ts @@ -18,12 +18,7 @@ * 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) { - this.paging = { ...this.paging, ...paging }; - } - handleCreateGroup = (group: { name: string; description?: string }): Promise => { const newGroup = mockGroup(group); this.groups.push(newGroup); @@ -106,26 +85,18 @@ export default class GroupsServiceMock { handleSearchUsersGroups = ( params: Parameters[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), }); }; diff --git a/server/sonar-web/src/main/js/api/mocks/UsersServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/UsersServiceMock.ts index 1d0e13dac7c..92b87cf1b01 100644 --- a/server/sonar-web/src/main/js/api/mocks/UsersServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/UsersServiceMock.ts @@ -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(); }; 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 771591eacfa..543d9b13dc4 100644 --- a/server/sonar-web/src/main/js/api/user_groups.ts +++ b/server/sonar-web/src/main/js/api/user_groups.ts @@ -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 }> { diff --git a/server/sonar-web/src/main/js/api/users.ts b/server/sonar-web/src/main/js/api/users.ts index 34f98e68710..b4c75e57bf7 100644 --- a/server/sonar-web/src/main/js/api/users.ts +++ b/server/sonar-web/src/main/js/api/users.ts @@ -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); } diff --git a/server/sonar-web/src/main/js/apps/groups/__tests__/GroupsApp-it.tsx b/server/sonar-web/src/main/js/apps/groups/__tests__/GroupsApp-it.tsx index ef44a583be6..4ef85a29f56 100644 --- a/server/sonar-web/src/main/js/apps/groups/__tests__/GroupsApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/groups/__tests__/GroupsApp-it.tsx @@ -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); }); }); diff --git a/server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx b/server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx index 103e0836228..9bddc2e7728 100644 --- a/server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx @@ -17,17 +17,25 @@ * 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(); 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 7b6bd4594bb..2a38f116bbf 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 @@ -19,15 +19,18 @@ */ 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(false); - const [lastSearchParams, setLastSearchParams] = React.useState< - SelectListSearchParams | undefined - >(undefined); - const [groups, setGroups] = React.useState([]); - const [groupsTotalCount, setGroupsTotalCount] = React.useState(undefined); - const [selectedGroups, setSelectedGroups] = React.useState([]); - const { mutateAsync: addUserToGroup } = useAddUserToGroupMutation(); - const { mutateAsync: removeUserFromGroup } = useRemoveUserToGroupMutation(); + const [query, setQuery] = React.useState(''); + const [filter, setFilter] = React.useState(SelectListFilter.Selected); + const [changedGroups, setChangedGroups] = React.useState>(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 (
{group === undefined ? ( - {name} + {groupId} ) : ( <> {group.name} @@ -110,17 +111,19 @@ export default function GroupsForm(props: Props) { body={
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} />
} diff --git a/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx b/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx index ac890d1816f..22a28cdb0f2 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx @@ -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) { 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 ( diff --git a/server/sonar-web/src/main/js/queries/group-memberships.ts b/server/sonar-web/src/main/js/queries/group-memberships.ts index 0b5017d8c20..a513895ca7e 100644 --- a/server/sonar-web/src/main/js/queries/group-memberships.ts +++ b/server/sonar-web/src/main/js/queries/group-memberships.ts @@ -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( + [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( + [DOMAIN, USER_SUB_DOMAIN, 'count', data.userId], + (oldData) => (oldData !== undefined ? oldData - 1 : undefined), + ); + queryClient.invalidateQueries([DOMAIN, USER_SUB_DOMAIN, 'memberships', data.userId]); }, }); } diff --git a/server/sonar-web/src/main/js/queries/users.ts b/server/sonar-web/src/main/js/queries/users.ts index 1a97eaeb45e..3991572059d 100644 --- a/server/sonar-web/src/main/js/queries/users.ts +++ b/server/sonar-web/src/main/js/queries/users.ts @@ -18,16 +18,8 @@ * 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[0]) => addUserToGroup(data), - onSuccess(_, data) { - queryClient.setQueryData(['user', data.login, 'groups', 'total'], (oldData) => - oldData !== undefined ? oldData + 1 : undefined, - ); - }, - }); -} - -export function useRemoveUserToGroupMutation() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (data: Parameters[0]) => removeUserFromGroup(data), - onSuccess(_, data) { - queryClient.setQueryData(['user', data.login, 'groups', 'total'], (oldData) => - oldData !== undefined ? oldData - 1 : undefined, - ); - }, - }); -} - export function useDismissNoticeMutation() { const { updateDismissedNotices } = useCurrentUser(); -- 2.39.5