@@ -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); | |||
} |
@@ -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<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), | |||
}); | |||
}; | |||
@@ -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(); | |||
}; |
@@ -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 }> { |
@@ -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); | |||
} |
@@ -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); | |||
}); | |||
}); | |||
@@ -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(); |
@@ -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<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> | |||
} |
@@ -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> |
@@ -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]); | |||
}, | |||
}); | |||
} |
@@ -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<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(); | |||