diff options
author | guillaume-peoch-sonarsource <guillaume.peoch@sonarsource.com> | 2023-07-24 12:16:13 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2023-07-31 20:03:31 +0000 |
commit | a0bade28a6c8a387afbe78a8fd3bc90bb00aa267 (patch) | |
tree | 2b2f33b7ae73f3fc459559cdae16075fd710c8bb | |
parent | 5f30a8ab495390c57c12ccab5fc228b4591a23ed (diff) | |
download | sonarqube-a0bade28a6c8a387afbe78a8fd3bc90bb00aa267.tar.gz sonarqube-a0bade28a6c8a387afbe78a8fd3bc90bb00aa267.zip |
SONAR-19967 Refactor User call using React Query
15 files changed, 534 insertions, 638 deletions
diff --git a/server/sonar-web/src/main/js/api/queries/users.ts b/server/sonar-web/src/main/js/api/queries/users.ts new file mode 100644 index 00000000000..916c86bd2ba --- /dev/null +++ b/server/sonar-web/src/main/js/api/queries/users.ts @@ -0,0 +1,106 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { + QueryFunctionContext, + useMutation, + useQueries, + useQueryClient, +} from '@tanstack/react-query'; +import { range } from 'lodash'; +import { User } from '../../types/users'; +import { + CreateUserParams, + DeactivateUserParams, + SearchUsersParams, + UpdateUserParams, + createUser, + deactivateUser, + searchUsers, + updateUser, +} from '../users'; + +export function useUsersQueries( + searchParam: Omit<SearchUsersParams, 'p' | 'ps'>, + numberOfPages: number +) { + type QueryKey = ['user', 'list', number, Omit<SearchUsersParams, 'p' | 'ps'>]; + const results = useQueries({ + queries: range(1, numberOfPages + 1).map((page: number) => ({ + queryKey: ['user', 'list', page, searchParam], + queryFn: ({ queryKey: [_u, _l, page, searchParam] }: QueryFunctionContext<QueryKey>) => + searchUsers({ ...searchParam, p: page }), + })), + }); + + return results.reduce( + (acc, { data, isLoading }) => ({ + users: acc.users.concat(data?.users ?? []), + total: data?.paging.total, + isLoading: acc.isLoading || isLoading, + }), + { users: [] as User[], total: 0, isLoading: false } + ); +} + +export function useInvalidateUsersList() { + const queryClient = useQueryClient(); + + return () => queryClient.invalidateQueries({ queryKey: ['user', 'list'] }); +} + +export function useCreateUserMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: CreateUserParams) => { + await createUser(data); + }, + onSuccess() { + queryClient.invalidateQueries({ queryKey: ['user', 'list'] }); + }, + }); +} + +export function useUpdateUserMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: UpdateUserParams) => { + await updateUser(data); + }, + onSuccess() { + queryClient.invalidateQueries({ queryKey: ['user', 'list'] }); + }, + }); +} + +export function useDeactivateUserMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: DeactivateUserParams) => { + await deactivateUser(data); + }, + onSuccess() { + queryClient.invalidateQueries({ queryKey: ['user', 'list'] }); + }, + }); +} diff --git a/server/sonar-web/src/main/js/api/users.ts b/server/sonar-web/src/main/js/api/users.ts index 5afe097a7bb..d7f33159095 100644 --- a/server/sonar-web/src/main/js/api/users.ts +++ b/server/sonar-web/src/main/js/api/users.ts @@ -53,13 +53,17 @@ export interface UserGroup { selected: boolean; } -export function getUserGroups(data: { +export interface UserGroupsParams { login: string; p?: number; ps?: number; q?: string; selected?: string; -}): Promise<{ paging: Paging; groups: UserGroup[] }> { +} + +export function getUserGroups( + data: UserGroupsParams +): Promise<{ paging: Paging; groups: UserGroup[] }> { return getJSON('/api/users/groups', data); } @@ -67,7 +71,7 @@ export function getIdentityProviders(): Promise<{ identityProviders: IdentityPro return getJSON('/api/users/identity_providers').catch(throwGlobalError); } -export function searchUsers(data: { +export interface SearchUsersParams { p?: number; ps?: number; q?: string; @@ -76,38 +80,46 @@ export function searchUsers(data: { lastConnectedBefore?: string; slLastConnectedAfter?: string; slLastConnectedBefore?: string; -}): Promise<{ paging: Paging; users: User[] }> { +} + +export function searchUsers(data: SearchUsersParams): Promise<{ paging: Paging; users: User[] }> { data.q = data.q || undefined; return getJSON('/api/users/search', data).catch(throwGlobalError); } -export function createUser(data: { +export interface CreateUserParams { email?: string; local?: boolean; login: string; name: string; password?: string; scmAccount: string[]; -}): Promise<void | Response> { +} + +export function createUser(data: CreateUserParams): Promise<void | Response> { return post('/api/users/create', data); } -export function updateUser(data: { +export interface UpdateUserParams { email?: string; login: string; name?: string; scmAccount: string[]; -}): Promise<{ user: User }> { +} + +export function updateUser(data: UpdateUserParams): Promise<{ user: User }> { return postJSON('/api/users/update', { ...data, scmAccount: data.scmAccount.length > 0 ? data.scmAccount : '', }); } -export function deactivateUser(data: { +export interface DeactivateUserParams { login: string; anonymize?: boolean; -}): Promise<{ user: User }> { +} + +export function deactivateUser(data: DeactivateUserParams): Promise<{ user: User }> { return postJSON('/api/users/deactivate', data).catch(throwGlobalError); } diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/GithubAuthenticationTab.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/GithubAuthenticationTab.tsx index 7eb384a9bb0..1d8acf00f16 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/authentication/GithubAuthenticationTab.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/GithubAuthenticationTab.tsx @@ -37,7 +37,7 @@ import AuthenticationFormField from './AuthenticationFormField'; import ConfigurationForm from './ConfigurationForm'; import GitHubConfigurationValidity from './GitHubConfigurationValidity'; import useGithubConfiguration, { GITHUB_JIT_FIELDS } from './hook/useGithubConfiguration'; -import { useCheckGitHubConfigQuery, useIdentityProvierQuery } from './queries/identity-provider'; +import { useCheckGitHubConfigQuery, useIdentityProviderQuery } from './queries/identity-provider'; interface GithubAuthenticationProps { definitions: ExtendedSettingDefinition[]; @@ -51,7 +51,7 @@ const GITHUB_EXCLUDED_FIELD = [ export default function GithubAuthenticationTab(props: GithubAuthenticationProps) { const { definitions } = props; - const { data } = useIdentityProvierQuery(); + const { data } = useIdentityProviderQuery(); const [showEditModal, setShowEditModal] = useState(false); const [showConfirmProvisioningModal, setShowConfirmProvisioningModal] = useState(false); diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthenticationTab.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthenticationTab.tsx index edd22a7a4d6..48448523693 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthenticationTab.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthenticationTab.tsx @@ -40,7 +40,7 @@ import useSamlConfiguration, { SAML_GROUP_NAME, SAML_SCIM_DEPRECATED, } from './hook/useSamlConfiguration'; -import { useIdentityProvierQuery, useToggleScimMutation } from './queries/identity-provider'; +import { useIdentityProviderQuery, useToggleScimMutation } from './queries/identity-provider'; interface SamlAuthenticationProps { definitions: ExtendedSettingDefinition[]; @@ -76,7 +76,7 @@ export default function SamlAuthenticationTab(props: SamlAuthenticationProps) { } = useSamlConfiguration(definitions); const toggleScim = useToggleScimMutation(); - const { data } = useIdentityProvierQuery(); + const { data } = useIdentityProviderQuery(); const { mutate: saveSetting } = useSaveValueMutation(); const hasDifferentProvider = data?.provider !== undefined && data.provider !== Provider.Scim; diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/queries/identity-provider.ts b/server/sonar-web/src/main/js/apps/settings/components/authentication/queries/identity-provider.ts index b42d4bd4f50..9c02a99a80c 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/authentication/queries/identity-provider.ts +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/queries/identity-provider.ts @@ -35,7 +35,7 @@ import { useSyncStatusQuery } from '../../../../../queries/github-sync'; import { Feature } from '../../../../../types/features'; import { SysInfoCluster } from '../../../../../types/types'; -export function useIdentityProvierQuery() { +export function useIdentityProviderQuery() { return useQuery(['identity_provider'], async () => { const info = (await getSystemInfo()) as SysInfoCluster; return { provider: info.System['External Users and Groups Provisioning'] }; diff --git a/server/sonar-web/src/main/js/apps/users/Header.tsx b/server/sonar-web/src/main/js/apps/users/Header.tsx index 559e388b306..4dc19d14ff4 100644 --- a/server/sonar-web/src/main/js/apps/users/Header.tsx +++ b/server/sonar-web/src/main/js/apps/users/Header.tsx @@ -26,7 +26,6 @@ import { translate } from '../../helpers/l10n'; import UserForm from './components/UserForm'; interface Props { - onUpdateUsers: () => void; manageProvider?: string; } @@ -66,9 +65,7 @@ export default function Header(props: Props) { /> </Alert> )} - {openUserForm && ( - <UserForm onClose={() => setOpenUserForm(false)} onUpdateUsers={props.onUpdateUsers} /> - )} + {openUserForm && <UserForm onClose={() => setOpenUserForm(false)} />} </div> ); } diff --git a/server/sonar-web/src/main/js/apps/users/UsersApp.tsx b/server/sonar-web/src/main/js/apps/users/UsersApp.tsx index 50f5b15c847..348a19e2278 100644 --- a/server/sonar-web/src/main/js/apps/users/UsersApp.tsx +++ b/server/sonar-web/src/main/js/apps/users/UsersApp.tsx @@ -18,9 +18,10 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import { subDays, subSeconds } from 'date-fns'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Helmet } from 'react-helmet-async'; -import { getIdentityProviders, searchUsers } from '../../api/users'; +import { useUsersQueries } from '../../api/queries/users'; +import { getIdentityProviders } from '../../api/users'; import GitHubSynchronisationWarning from '../../app/components/GitHubSynchronisationWarning'; import HelpTooltip from '../../components/controls/HelpTooltip'; import ListFooter from '../../components/controls/ListFooter'; @@ -32,8 +33,7 @@ import { Provider, useManageProvider } from '../../components/hooks/useManagePro import DeferredSpinner from '../../components/ui/DeferredSpinner'; import { now, toISO8601WithOffsetString } from '../../helpers/dates'; import { translate } from '../../helpers/l10n'; -import { IdentityProvider, Paging } from '../../types/types'; -import { User } from '../../types/users'; +import { IdentityProvider } from '../../types/types'; import Header from './Header'; import UsersList from './UsersList'; import { USERS_ACTIVITY_OPTIONS, USER_INACTIVITY_DAYS_THRESHOLD } from './constants'; @@ -41,17 +41,11 @@ import { UserActivity } from './types'; export default function UsersApp() { const [identityProviders, setIdentityProviders] = useState<IdentityProvider[]>([]); - - const [loading, setLoading] = useState(true); - const [paging, setPaging] = useState<Paging>(); - const [users, setUsers] = useState<User[]>([]); - + const [numberOfPages, setNumberOfPages] = useState<number>(1); const [search, setSearch] = useState(''); const [usersActivity, setUsersActivity] = useState<UserActivity>(UserActivity.AnyActivity); const [managed, setManaged] = useState<boolean | undefined>(undefined); - const manageProvider = useManageProvider(); - const usersActivityParams = useMemo(() => { const nowDate = now(); const nowDateMinus30Days = subDays(nowDate, USER_INACTIVITY_DAYS_THRESHOLD); @@ -76,39 +70,16 @@ export default function UsersApp() { } }, [usersActivity]); - const fetchUsers = useCallback(async () => { - setLoading(true); - try { - const { paging, users } = await searchUsers({ - q: search, - managed, - ...usersActivityParams, - }); - setPaging(paging); - setUsers(users); - } finally { - setLoading(false); - } - }, [search, managed, usersActivityParams]); + const { users, total, isLoading } = useUsersQueries( + { + q: search, + managed, + ...usersActivityParams, + }, + numberOfPages + ); - const fetchMoreUsers = useCallback(async () => { - if (!paging) { - return; - } - setLoading(true); - try { - const { paging: nextPage, users: nextUsers } = await searchUsers({ - q: search, - managed, - ...usersActivityParams, - p: paging.pageIndex + 1, - }); - setPaging(nextPage); - setUsers([...users, ...nextUsers]); - } finally { - setLoading(false); - } - }, [search, managed, usersActivityParams, paging, users]); + const manageProvider = useManageProvider(); useEffect(() => { (async () => { @@ -117,27 +88,29 @@ export default function UsersApp() { })(); }, []); - useEffect(() => { - fetchUsers(); - }, [fetchUsers]); - return ( <main className="page page-limited" id="users-page"> <Suggestions suggestions="users" /> <Helmet defer={false} title={translate('users.page')} /> - <Header onUpdateUsers={fetchUsers} manageProvider={manageProvider} /> + <Header manageProvider={manageProvider} /> {manageProvider === Provider.Github && <GitHubSynchronisationWarning short />} <div className="display-flex-justify-start big-spacer-bottom big-spacer-top"> <ManagedFilter manageProvider={manageProvider} - loading={loading} + loading={isLoading} managed={managed} - setManaged={setManaged} + setManaged={(m) => { + setManaged(m); + setNumberOfPages(1); + }} /> <SearchBox id="users-search" minLength={2} - onChange={(search: string) => setSearch(search)} + onChange={(search: string) => { + setSearch(search); + setNumberOfPages(1); + }} placeholder={translate('search.search_by_login_or_name')} value={search} /> @@ -145,9 +118,10 @@ export default function UsersApp() { <Select id="users-activity-filter" className="input-large" - isDisabled={loading} + isDisabled={isLoading} onChange={(userActivity: LabelValueSelectOption<UserActivity>) => { setUsersActivity(userActivity.value); + setNumberOfPages(1); }} options={USERS_ACTIVITY_OPTIONS} isSearchable={false} @@ -166,23 +140,20 @@ export default function UsersApp() { /> </div> </div> - <DeferredSpinner loading={loading}> + <DeferredSpinner loading={isLoading}> <UsersList identityProviders={identityProviders} - onUpdateUsers={fetchUsers} - updateTokensCount={fetchUsers} users={users} manageProvider={manageProvider} /> </DeferredSpinner> - {paging !== undefined && ( - <ListFooter - count={users.length} - loadMore={fetchMoreUsers} - ready={!loading} - total={paging.total} - /> - )} + + <ListFooter + count={users.length} + loadMore={() => setNumberOfPages((n) => n + 1)} + ready={!isLoading} + total={total} + /> </main> ); } diff --git a/server/sonar-web/src/main/js/apps/users/UsersList.tsx b/server/sonar-web/src/main/js/apps/users/UsersList.tsx index d1f3349c960..e3fed01d219 100644 --- a/server/sonar-web/src/main/js/apps/users/UsersList.tsx +++ b/server/sonar-web/src/main/js/apps/users/UsersList.tsx @@ -27,19 +27,11 @@ import UserListItem from './components/UserListItem'; interface Props { identityProviders: IdentityProvider[]; - onUpdateUsers: () => void; - updateTokensCount: (login: string, tokensCount: number) => void; users: User[]; manageProvider: string | undefined; } -export default function UsersList({ - identityProviders, - onUpdateUsers, - updateTokensCount, - users, - manageProvider, -}: Props) { +export default function UsersList({ identityProviders, users, manageProvider }: Props) { const userContext = React.useContext(CurrentUserContext); const currentUser = userContext?.currentUser; @@ -73,8 +65,6 @@ export default function UsersList({ )} isCurrentUser={isLoggedIn(currentUser) && currentUser.login === user.login} key={user.login} - onUpdateUsers={onUpdateUsers} - updateTokensCount={updateTokensCount} user={user} manageProvider={manageProvider} /> diff --git a/server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx b/server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx index 796d52abdf9..aa334af4bb4 100644 --- a/server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx @@ -19,108 +19,81 @@ */ import * as React from 'react'; import { FormattedMessage } from 'react-intl'; -import { deactivateUser } from '../../../api/users'; +import { useDeactivateUserMutation } from '../../../api/queries/users'; import DocLink from '../../../components/common/DocLink'; -import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons'; import Checkbox from '../../../components/controls/Checkbox'; import Modal from '../../../components/controls/Modal'; +import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons'; import { Alert } from '../../../components/ui/Alert'; import { translate, translateWithParameters } from '../../../helpers/l10n'; import { UserActive } from '../../../types/users'; export interface Props { onClose: () => void; - onUpdateUsers: () => void; user: UserActive; } -interface State { - submitting: boolean; - anonymize: boolean; -} - -export default class DeactivateForm extends React.PureComponent<Props, State> { - mounted = false; - state: State = { submitting: false, anonymize: false }; - - componentDidMount() { - this.mounted = true; - } +export default function DeactivateForm(props: Props) { + const { user } = props; + const [anonymize, setAnonymize] = React.useState(false); - componentWillUnmount() { - this.mounted = false; - } + const { mutate: deactivateUser, isLoading } = useDeactivateUserMutation(); - handleAnonymize = (checked: boolean) => { - this.setState({ anonymize: checked }); - }; - - handleDeactivate = (event: React.SyntheticEvent<HTMLFormElement>) => { + const handleDeactivate = (event: React.SyntheticEvent<HTMLFormElement>) => { event.preventDefault(); - this.setState({ submitting: true }); - deactivateUser({ login: this.props.user.login, anonymize: this.state.anonymize }).then( - () => { - this.props.onUpdateUsers(); - this.props.onClose(); - }, - () => { - if (this.mounted) { - this.setState({ submitting: false }); - } + deactivateUser( + { login: user.login, anonymize }, + { + onSuccess: props.onClose, } ); }; - render() { - const { user } = this.props; - const { submitting, anonymize } = this.state; - - const header = translate('users.deactivate_user'); - return ( - <Modal contentLabel={header} onRequestClose={this.props.onClose}> - <form autoComplete="off" id="deactivate-user-form" onSubmit={this.handleDeactivate}> - <header className="modal-head"> - <h2>{header}</h2> - </header> - <div className="modal-body display-flex-column"> - {translateWithParameters('users.deactivate_user.confirmation', user.name, user.login)} - <Checkbox - id="delete-user" - className="big-spacer-top" - checked={anonymize} - onCheck={this.handleAnonymize} - > - <label className="little-spacer-left" htmlFor="delete-user"> - {translate('users.delete_user')} - </label> - </Checkbox> - {anonymize && ( - <Alert variant="warning" className="big-spacer-top"> - <FormattedMessage - defaultMessage={translate('users.delete_user.help')} - id="delete-user-warning" - values={{ - link: ( - <DocLink to="/instance-administration/authentication/overview/"> - {translate('users.delete_user.help.link')} - </DocLink> - ), - }} - /> - </Alert> - )} - </div> - <footer className="modal-foot"> - {submitting && <i className="spinner spacer-right" />} - <SubmitButton className="js-confirm button-red" disabled={submitting}> - {translate('users.deactivate')} - </SubmitButton> - <ResetButtonLink className="js-modal-close" onClick={this.props.onClose}> - {translate('cancel')} - </ResetButtonLink> - </footer> - </form> - </Modal> - ); - } + const header = translate('users.deactivate_user'); + return ( + <Modal contentLabel={header} onRequestClose={props.onClose}> + <form autoComplete="off" id="deactivate-user-form" onSubmit={handleDeactivate}> + <header className="modal-head"> + <h2>{header}</h2> + </header> + <div className="modal-body display-flex-column"> + {translateWithParameters('users.deactivate_user.confirmation', user.name, user.login)} + <Checkbox + id="delete-user" + className="big-spacer-top" + checked={anonymize} + onCheck={(checked) => setAnonymize(checked)} + > + <label className="little-spacer-left" htmlFor="delete-user"> + {translate('users.delete_user')} + </label> + </Checkbox> + {anonymize && ( + <Alert variant="warning" className="big-spacer-top"> + <FormattedMessage + defaultMessage={translate('users.delete_user.help')} + id="delete-user-warning" + values={{ + link: ( + <DocLink to="/instance-administration/authentication/overview/"> + {translate('users.delete_user.help.link')} + </DocLink> + ), + }} + /> + </Alert> + )} + </div> + <footer className="modal-foot"> + {isLoading && <i className="spinner spacer-right" />} + <SubmitButton className="js-confirm button-red" disabled={isLoading}> + {translate('users.deactivate')} + </SubmitButton> + <ResetButtonLink className="js-modal-close" onClick={props.onClose}> + {translate('cancel')} + </ResetButtonLink> + </footer> + </form> + </Modal> + ); } 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 d590e7da07a..478b9b73376 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,114 +19,80 @@ */ import { find, without } from 'lodash'; import * as React from 'react'; -import { getUserGroups, UserGroup } from '../../../api/users'; +import { useInvalidateUsersList } from '../../../api/queries/users'; import { addUserToGroup, removeUserFromGroup } from '../../../api/user_groups'; -import { ResetButtonLink } from '../../../components/controls/buttons'; +import { UserGroup, getUserGroups } from '../../../api/users'; import Modal from '../../../components/controls/Modal'; import SelectList, { SelectListFilter, SelectListSearchParams, } from '../../../components/controls/SelectList'; +import { ResetButtonLink } from '../../../components/controls/buttons'; import { translate } from '../../../helpers/l10n'; import { User } from '../../../types/users'; interface Props { onClose: () => void; - onUpdateUsers: () => void; user: User; } -interface State { - needToReload: boolean; - lastSearchParams?: SelectListSearchParams; - groups: UserGroup[]; - groupsTotalCount?: number; - selectedGroups: string[]; -} - -export default class GroupsForm extends React.PureComponent<Props, State> { - mounted = false; - - constructor(props: Props) { - super(props); - - this.state = { - needToReload: false, - groups: [], - selectedGroups: [], - }; - } - - componentDidMount() { - this.mounted = true; - } +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[]>([]); - componentWillUnmount() { - this.mounted = false; - } + const invalidateUserList = useInvalidateUsersList(); - fetchUsers = (searchParams: SelectListSearchParams) => + const fetchUsers = (searchParams: SelectListSearchParams) => getUserGroups({ - login: this.props.user.login, + login: user.login, p: searchParams.page, ps: searchParams.pageSize, q: searchParams.query !== '' ? searchParams.query : undefined, selected: searchParams.filter, }).then((data) => { - if (this.mounted) { - this.setState((prevState) => { - const more = searchParams.page != null && searchParams.page > 1; - - const groups = more ? [...prevState.groups, ...data.groups] : data.groups; - const newSeletedGroups = data.groups.filter((gp) => gp.selected).map((gp) => gp.name); - const selectedGroups = more - ? [...prevState.selectedGroups, ...newSeletedGroups] - : newSeletedGroups; - - return { - lastSearchParams: searchParams, - needToReload: false, - groups, - groupsTotalCount: data.paging.total, - selectedGroups, - }; - }); - } + 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; + + setLastSearchParams(searchParams); + setNeedToReload(false); + setGroups(allGroups); + setGroupsTotalCount(data.paging.total); + setSelectedGroups(allSelectedGroups); }); - handleSelect = (name: string) => + const handleSelect = (name: string) => addUserToGroup({ name, - login: this.props.user.login, + login: user.login, }).then(() => { - if (this.mounted) { - this.setState((state: State) => ({ - needToReload: true, - selectedGroups: [...state.selectedGroups, name], - })); - } + setNeedToReload(true); + setSelectedGroups([...selectedGroups, name]); }); - handleUnselect = (name: string) => + const handleUnselect = (name: string) => removeUserFromGroup({ name, - login: this.props.user.login, + login: user.login, }).then(() => { - if (this.mounted) { - this.setState((state: State) => ({ - needToReload: true, - selectedGroups: without(state.selectedGroups, name), - })); - } + setNeedToReload(true); + setSelectedGroups(without(selectedGroups, name)); }); - handleClose = () => { - this.props.onUpdateUsers(); - this.props.onClose(); + const handleClose = () => { + invalidateUserList(); + props.onClose(); }; - renderElement = (name: string): React.ReactNode => { - const group = find(this.state.groups, { name }); + const renderElement = (name: string): React.ReactNode => { + const group = find(groups, { name }); return ( <div className="select-list-list-item"> {group === undefined ? ( @@ -142,37 +108,33 @@ export default class GroupsForm extends React.PureComponent<Props, State> { ); }; - render() { - const header = translate('users.update_groups'); + const header = translate('users.update_groups'); - return ( - <Modal contentLabel={header} onRequestClose={this.handleClose}> - <div className="modal-head"> - <h2>{header}</h2> - </div> + return ( + <Modal contentLabel={header} onRequestClose={handleClose}> + <div className="modal-head"> + <h2>{header}</h2> + </div> - <div className="modal-body modal-container"> - <SelectList - elements={this.state.groups.map((group) => group.name)} - elementsTotalCount={this.state.groupsTotalCount} - needToReload={ - this.state.needToReload && - this.state.lastSearchParams && - this.state.lastSearchParams.filter !== SelectListFilter.All - } - onSearch={this.fetchUsers} - onSelect={this.handleSelect} - onUnselect={this.handleUnselect} - renderElement={this.renderElement} - selectedElements={this.state.selectedGroups} - withPaging - /> - </div> + <div className="modal-body modal-container"> + <SelectList + elements={groups.map((group) => group.name)} + elementsTotalCount={groupsTotalCount} + needToReload={ + needToReload && lastSearchParams && lastSearchParams.filter !== SelectListFilter.All + } + onSearch={fetchUsers} + onSelect={handleSelect} + onUnselect={handleUnselect} + renderElement={renderElement} + selectedElements={selectedGroups} + withPaging + /> + </div> - <footer className="modal-foot"> - <ResetButtonLink onClick={this.handleClose}>{translate('done')}</ResetButtonLink> - </footer> - </Modal> - ); - } + <footer className="modal-foot"> + <ResetButtonLink onClick={handleClose}>{translate('done')}</ResetButtonLink> + </footer> + </Modal> + ); } diff --git a/server/sonar-web/src/main/js/apps/users/components/TokensFormModal.tsx b/server/sonar-web/src/main/js/apps/users/components/TokensFormModal.tsx index d924351573a..6d6f79f267e 100644 --- a/server/sonar-web/src/main/js/apps/users/components/TokensFormModal.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/TokensFormModal.tsx @@ -19,8 +19,9 @@ */ import * as React from 'react'; import { FormattedMessage } from 'react-intl'; -import { ResetButtonLink } from '../../../components/controls/buttons'; +import { useInvalidateUsersList } from '../../../api/queries/users'; import Modal from '../../../components/controls/Modal'; +import { ResetButtonLink } from '../../../components/controls/buttons'; import { translate } from '../../../helpers/l10n'; import { User } from '../../../types/users'; import TokensForm from './TokensForm'; @@ -28,12 +29,21 @@ import TokensForm from './TokensForm'; interface Props { user: User; onClose: () => void; - updateTokensCount: (login: string, tokensCount: number) => void; } export default function TokensFormModal(props: Props) { + const [hasTokenCountChanged, setHasTokenCountChanged] = React.useState(false); + const invalidateUserList = useInvalidateUsersList(); + + const handleClose = () => { + if (hasTokenCountChanged) { + invalidateUserList(); + } + props.onClose(); + }; + return ( - <Modal size="large" contentLabel={translate('users.tokens')} onRequestClose={props.onClose}> + <Modal size="large" contentLabel={translate('users.tokens')} onRequestClose={handleClose}> <header className="modal-head"> <h2> <FormattedMessage @@ -47,12 +57,12 @@ export default function TokensFormModal(props: Props) { <TokensForm deleteConfirmation="inline" login={props.user.login} - updateTokensCount={props.updateTokensCount} + updateTokensCount={() => setHasTokenCountChanged(true)} displayTokenTypeInput={false} /> </div> <footer className="modal-foot"> - <ResetButtonLink onClick={props.onClose}>{translate('done')}</ResetButtonLink> + <ResetButtonLink onClick={handleClose}>{translate('done')}</ResetButtonLink> </footer> </Modal> ); diff --git a/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx b/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx index 627c8b45cfa..e1872825607 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx @@ -23,67 +23,50 @@ import ActionsDropdown, { ActionsDropdownItem, } from '../../../components/controls/ActionsDropdown'; import { translate, translateWithParameters } from '../../../helpers/l10n'; -import { isUserActive, User } from '../../../types/users'; +import { User, isUserActive } from '../../../types/users'; import DeactivateForm from './DeactivateForm'; import PasswordForm from './PasswordForm'; import UserForm from './UserForm'; interface Props { isCurrentUser: boolean; - onUpdateUsers: () => void; user: User; manageProvider: string | undefined; } -interface State { - openForm?: string; -} - -export default class UserActions extends React.PureComponent<Props, State> { - state: State = {}; +export default function UserActions(props: Props) { + const { isCurrentUser, user, manageProvider } = props; - handleOpenDeactivateForm = () => { - this.setState({ openForm: 'deactivate' }); - }; - - handleOpenPasswordForm = () => { - this.setState({ openForm: 'password' }); - }; - - handleOpenUpdateForm = () => { - this.setState({ openForm: 'update' }); - }; - - handleCloseForm = () => { - this.setState({ openForm: undefined }); - }; + const [openForm, setOpenForm] = React.useState<string | undefined>(undefined); - isInstanceManaged = () => { - return this.props.manageProvider !== undefined; + const isInstanceManaged = () => { + return manageProvider !== undefined; }; - isUserLocal = () => { - return this.isInstanceManaged() && !this.props.user.managed; + const isUserLocal = () => { + return isInstanceManaged() && !user.managed; }; - isUserManaged = () => { - return this.isInstanceManaged() && this.props.user.managed; + const isUserManaged = () => { + return isInstanceManaged() && user.managed; }; - renderActions = () => { - const { user } = this.props; + if (isUserManaged()) { + return null; + } - return ( + return ( + <> <ActionsDropdown label={translateWithParameters('users.manage_user', user.login)}> - {!this.isInstanceManaged() && ( + {!isInstanceManaged() && ( <> - <ActionsDropdownItem className="js-user-update" onClick={this.handleOpenUpdateForm}> + <ActionsDropdownItem className="js-user-update" onClick={() => setOpenForm('update')}> {translate('update_details')} </ActionsDropdownItem> {user.local && ( <ActionsDropdownItem className="js-user-change-password" - onClick={this.handleOpenPasswordForm} + onClick={() => setOpenForm('password')} > {translate('my_profile.password.title')} </ActionsDropdownItem> @@ -91,45 +74,28 @@ export default class UserActions extends React.PureComponent<Props, State> { </> )} - {isUserActive(user) && !this.isInstanceManaged() && <ActionsDropdownDivider />} - {isUserActive(user) && (!this.isInstanceManaged() || this.isUserLocal()) && ( + {isUserActive(user) && !isInstanceManaged() && <ActionsDropdownDivider />} + {isUserActive(user) && (!isInstanceManaged() || isUserLocal()) && ( <ActionsDropdownItem className="js-user-deactivate" destructive - onClick={this.handleOpenDeactivateForm} + onClick={() => setOpenForm('deactivate')} > {translate('users.deactivate')} </ActionsDropdownItem> )} </ActionsDropdown> - ); - }; - - render() { - const { openForm } = this.state; - const { isCurrentUser, onUpdateUsers, user } = this.props; - - if (this.isUserManaged()) { - return null; - } - - return ( - <> - {this.renderActions()} - {openForm === 'deactivate' && isUserActive(user) && ( - <DeactivateForm - onClose={this.handleCloseForm} - onUpdateUsers={onUpdateUsers} - user={user} - /> - )} - {openForm === 'password' && ( - <PasswordForm isCurrentUser={isCurrentUser} onClose={this.handleCloseForm} user={user} /> - )} - {openForm === 'update' && ( - <UserForm onClose={this.handleCloseForm} onUpdateUsers={onUpdateUsers} user={user} /> - )} - </> - ); - } + {openForm === 'deactivate' && isUserActive(user) && ( + <DeactivateForm onClose={() => setOpenForm(undefined)} user={user} /> + )} + {openForm === 'password' && ( + <PasswordForm + isCurrentUser={isCurrentUser} + onClose={() => setOpenForm(undefined)} + user={user} + /> + )} + {openForm === 'update' && <UserForm onClose={() => setOpenForm(undefined)} user={user} />} + </> + ); } diff --git a/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx b/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx index 8896375624c..a1a4c11d7d4 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx @@ -18,9 +18,9 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { createUser, updateUser } from '../../../api/users'; -import { Button, ResetButtonLink, SubmitButton } from '../../../components/controls/buttons'; +import { useCreateUserMutation, useUpdateUserMutation } from '../../../api/queries/users'; import SimpleModal from '../../../components/controls/SimpleModal'; +import { Button, ResetButtonLink, SubmitButton } from '../../../components/controls/buttons'; import { Alert } from '../../../components/ui/Alert'; import MandatoryFieldMarker from '../../../components/ui/MandatoryFieldMarker'; import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsExplanation'; @@ -32,245 +32,196 @@ import UserScmAccountInput from './UserScmAccountInput'; export interface Props { onClose: () => void; - onUpdateUsers: () => void; user?: User; } -interface State { - email: string; - error?: string; - login: string; - name: string; - password: string; - scmAccounts: string[]; -} - -export default class UserForm extends React.PureComponent<Props, State> { - mounted = false; - - constructor(props: Props) { - super(props); - const { user } = props; - if (user) { - this.state = { - email: user.email || '', - login: user.login, - name: user.name || '', - password: '', - scmAccounts: user.scmAccounts || [], - }; - } else { - this.state = { - email: '', - login: '', - name: '', - password: '', - scmAccounts: [], - }; - } - } +export default function UserForm(props: Props) { + const { user } = props; - componentDidMount() { - this.mounted = true; - } + const { mutate: createUser } = useCreateUserMutation(); + const { mutate: updateUser } = useUpdateUserMutation(); - componentWillUnmount() { - this.mounted = false; - } + const [email, setEmail] = React.useState<string>(user?.email || ''); + const [login, setLogin] = React.useState<string>(user?.login || ''); + const [name, setName] = React.useState<string>(user?.name || ''); + const [password, setPassword] = React.useState<string>(''); + const [scmAccounts, setScmAccounts] = React.useState<string[]>(user?.scmAccounts || []); + const [error, setError] = React.useState<string | undefined>(undefined); - handleError = (response: Response) => { - if (!this.mounted || ![400, 500].includes(response.status)) { + const handleError = (response: Response) => { + if (![400, 500].includes(response.status)) { return throwGlobalError(response); - } else { - return parseError(response).then( - (errorMsg) => this.setState({ error: errorMsg }), - throwGlobalError - ); } + return parseError(response).then((errorMsg) => setError(errorMsg), throwGlobalError); }; - handleEmailChange = (event: React.SyntheticEvent<HTMLInputElement>) => - this.setState({ email: event.currentTarget.value }); - - handleLoginChange = (event: React.SyntheticEvent<HTMLInputElement>) => - this.setState({ login: event.currentTarget.value }); - - handleNameChange = (event: React.SyntheticEvent<HTMLInputElement>) => - this.setState({ name: event.currentTarget.value }); - - handlePasswordChange = (event: React.SyntheticEvent<HTMLInputElement>) => - this.setState({ password: event.currentTarget.value }); - - handleCreateUser = () => { - return createUser({ - email: this.state.email || undefined, - login: this.state.login, - name: this.state.name, - password: this.state.password, - scmAccount: this.state.scmAccounts, - }).then(() => { - this.props.onUpdateUsers(); - this.props.onClose(); - }, this.handleError); + const handleCreateUser = () => { + createUser( + { + email: email || undefined, + login, + name, + password, + scmAccount: scmAccounts, + }, + { onSuccess: props.onClose, onError: handleError } + ); }; - handleUpdateUser = () => { - const { user } = this.props; - return updateUser({ - email: user!.local ? this.state.email : undefined, - login: this.state.login, - name: user!.local ? this.state.name : undefined, - scmAccount: this.state.scmAccounts, - }).then(() => { - this.props.onUpdateUsers(); - this.props.onClose(); - }, this.handleError); + const handleUpdateUser = () => { + const { user } = props; + + updateUser( + { + email: user!.local ? email : undefined, + login, + name: user!.local ? name : undefined, + scmAccount: scmAccounts, + }, + { onSuccess: props.onClose, onError: handleError } + ); }; - handleAddScmAccount = () => { - this.setState(({ scmAccounts }) => ({ scmAccounts: scmAccounts.concat('') })); + const handleAddScmAccount = () => { + setScmAccounts((scmAccounts) => scmAccounts.concat('')); }; - handleUpdateScmAccount = (idx: number, scmAccount: string) => - this.setState(({ scmAccounts: oldScmAccounts }) => { - const scmAccounts = oldScmAccounts.slice(); - scmAccounts[idx] = scmAccount; - return { scmAccounts }; + const handleUpdateScmAccount = (idx: number, scmAccount: string) => { + setScmAccounts((scmAccounts) => { + const newScmAccounts = scmAccounts.slice(); + newScmAccounts[idx] = scmAccount; + return newScmAccounts; }); + }; - handleRemoveScmAccount = (idx: number) => - this.setState(({ scmAccounts }) => ({ - scmAccounts: scmAccounts.slice(0, idx).concat(scmAccounts.slice(idx + 1)), - })); + const handleRemoveScmAccount = (idx: number) => { + setScmAccounts((scmAccounts) => scmAccounts.slice(0, idx).concat(scmAccounts.slice(idx + 1))); + }; - render() { - const { user } = this.props; - const { error } = this.state; + const header = user ? translate('users.update_user') : translate('users.create_user'); - const header = user ? translate('users.update_user') : translate('users.create_user'); - return ( - <SimpleModal - header={header} - onClose={this.props.onClose} - onSubmit={user ? this.handleUpdateUser : this.handleCreateUser} - size="small" - > - {({ onCloseClick, onFormSubmit, submitting }) => ( - <form autoComplete="off" id="user-form" onSubmit={onFormSubmit}> - <header className="modal-head"> - <h2>{header}</h2> - </header> + return ( + <SimpleModal + header={header} + onClose={props.onClose} + onSubmit={user ? handleUpdateUser : handleCreateUser} + size="small" + > + {({ onCloseClick, onFormSubmit, submitting }) => ( + <form autoComplete="off" id="user-form" onSubmit={onFormSubmit}> + <header className="modal-head"> + <h2>{header}</h2> + </header> - <div className="modal-body modal-container"> - {error && <Alert variant="error">{error}</Alert>} + <div className="modal-body modal-container"> + {error && <Alert variant="error">{error}</Alert>} - {!error && user && !user.local && ( - <Alert variant="warning">{translate('users.cannot_update_delegated_user')}</Alert> - )} + {!error && user && !user.local && ( + <Alert variant="warning">{translate('users.cannot_update_delegated_user')}</Alert> + )} - <MandatoryFieldsExplanation className="modal-field" /> + <MandatoryFieldsExplanation className="modal-field" /> - {!user && ( - <div className="modal-field"> - <label htmlFor="create-user-login"> - {translate('login')} - <MandatoryFieldMarker /> - </label> - <input - autoComplete="off" - autoFocus - id="create-user-login" - maxLength={255} - minLength={3} - name="login" - onChange={this.handleLoginChange} - required - type="text" - value={this.state.login} - /> - <p className="note">{translateWithParameters('users.minimum_x_characters', 3)}</p> - </div> - )} + {!user && ( <div className="modal-field"> - <label htmlFor="create-user-name"> - {translate('name')} + <label htmlFor="create-user-login"> + {translate('login')} <MandatoryFieldMarker /> </label> <input autoComplete="off" - autoFocus={!!user} - disabled={user && !user.local} - id="create-user-name" - maxLength={200} - name="name" - onChange={this.handleNameChange} + autoFocus + id="create-user-login" + maxLength={255} + minLength={3} + name="login" + onChange={(e) => setLogin(e.currentTarget.value)} required type="text" - value={this.state.name} + value={login} /> + <p className="note">{translateWithParameters('users.minimum_x_characters', 3)}</p> </div> + )} + <div className="modal-field"> + <label htmlFor="create-user-name"> + {translate('name')} + <MandatoryFieldMarker /> + </label> + <input + autoComplete="off" + autoFocus={!!user} + disabled={user && !user.local} + id="create-user-name" + maxLength={200} + name="name" + onChange={(e) => setName(e.currentTarget.value)} + required + type="text" + value={name} + /> + </div> + <div className="modal-field"> + <label htmlFor="create-user-email">{translate('users.email')}</label> + <input + autoComplete="off" + disabled={user && !user.local} + id="create-user-email" + maxLength={100} + name="email" + onChange={(e) => setEmail(e.currentTarget.value)} + type="email" + value={email} + /> + </div> + {!user && ( <div className="modal-field"> - <label htmlFor="create-user-email">{translate('users.email')}</label> + <label htmlFor="create-user-password"> + {translate('password')} + <MandatoryFieldMarker /> + </label> <input autoComplete="off" - disabled={user && !user.local} - id="create-user-email" - maxLength={100} - name="email" - onChange={this.handleEmailChange} - type="email" - value={this.state.email} + id="create-user-password" + name="password" + onChange={(e) => setPassword(e.currentTarget.value)} + required + type="password" + value={password} /> </div> - {!user && ( - <div className="modal-field"> - <label htmlFor="create-user-password"> - {translate('password')} - <MandatoryFieldMarker /> - </label> - <input - autoComplete="off" - id="create-user-password" - name="password" - onChange={this.handlePasswordChange} - required - type="password" - value={this.state.password} + )} + <div className="modal-field"> + <fieldset> + <legend>{translate('my_profile.scm_accounts')}</legend> + {scmAccounts.map((scm, idx) => ( + <UserScmAccountInput + idx={idx} + key={idx} + onChange={handleUpdateScmAccount} + onRemove={handleRemoveScmAccount} + scmAccount={scm} /> + ))} + <div className="spacer-bottom"> + <Button className="js-scm-account-add" onClick={handleAddScmAccount}> + {translate('add_verb')} + </Button> </div> - )} - <div className="modal-field"> - <fieldset> - <legend>{translate('my_profile.scm_accounts')}</legend> - {this.state.scmAccounts.map((scm, idx) => ( - <UserScmAccountInput - idx={idx} - key={idx} - onChange={this.handleUpdateScmAccount} - onRemove={this.handleRemoveScmAccount} - scmAccount={scm} - /> - ))} - <div className="spacer-bottom"> - <Button className="js-scm-account-add" onClick={this.handleAddScmAccount}> - {translate('add_verb')} - </Button> - </div> - </fieldset> - <p className="note">{translate('user.login_or_email_used_as_scm_account')}</p> - </div> + </fieldset> + <p className="note">{translate('user.login_or_email_used_as_scm_account')}</p> </div> - - <footer className="modal-foot"> - {submitting && <i className="spinner spacer-right" />} - <SubmitButton disabled={submitting}> - {user ? translate('update_verb') : translate('create')} - </SubmitButton> - <ResetButtonLink onClick={onCloseClick}>{translate('cancel')}</ResetButtonLink> - </footer> - </form> - )} - </SimpleModal> - ); - } + </div> + + <footer className="modal-foot"> + {submitting && <i className="spinner spacer-right" />} + <SubmitButton disabled={submitting}> + {user ? translate('update_verb') : translate('create')} + </SubmitButton> + <ResetButtonLink onClick={onCloseClick}>{translate('cancel')}</ResetButtonLink> + </footer> + </form> + )} + </SimpleModal> + ); } diff --git a/server/sonar-web/src/main/js/apps/users/components/UserGroups.tsx b/server/sonar-web/src/main/js/apps/users/components/UserGroups.tsx index 73cfd23aff4..1b56b5e9764 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UserGroups.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/UserGroups.tsx @@ -26,71 +26,54 @@ import GroupsForm from './GroupsForm'; interface Props { groups: string[]; - onUpdateUsers: () => void; user: User; manageProvider: string | undefined; } -interface State { - openForm: boolean; - showMore: boolean; -} - const GROUPS_LIMIT = 3; -export default class UserGroups extends React.PureComponent<Props, State> { - state: State = { openForm: false, showMore: false }; - - handleOpenForm = () => this.setState({ openForm: true }); - handleCloseForm = () => this.setState({ openForm: false }); +export default function UserGroups(props: Props) { + const { groups, user, manageProvider } = props; - toggleShowMore = () => { - this.setState((state) => ({ showMore: !state.showMore })); - }; + const [openForm, setOpenForm] = React.useState(false); + const [showMore, setShowMore] = React.useState(false); - render() { - const { groups, user, manageProvider } = this.props; - const { showMore, openForm } = this.state; - const limit = groups.length > GROUPS_LIMIT ? GROUPS_LIMIT - 1 : GROUPS_LIMIT; - return ( - <ul> - {groups.slice(0, limit).map((group) => ( + const limit = groups.length > GROUPS_LIMIT ? GROUPS_LIMIT - 1 : GROUPS_LIMIT; + return ( + <ul> + {groups.slice(0, limit).map((group) => ( + <li className="little-spacer-bottom" key={group}> + {group} + </li> + ))} + {groups.length > GROUPS_LIMIT && + showMore && + groups.slice(limit).map((group) => ( <li className="little-spacer-bottom" key={group}> {group} </li> ))} - {groups.length > GROUPS_LIMIT && - showMore && - groups.slice(limit).map((group) => ( - <li className="little-spacer-bottom" key={group}> - {group} - </li> - ))} - <li className="little-spacer-bottom"> - {groups.length > GROUPS_LIMIT && !showMore && ( - <ButtonLink className="js-user-more-groups spacer-right" onClick={this.toggleShowMore}> - {translateWithParameters('more_x', groups.length - limit)} - </ButtonLink> - )} - {manageProvider === undefined && ( - <ButtonIcon - aria-label={translateWithParameters('users.update_users_groups', user.login)} - className="js-user-groups button-small" - onClick={this.handleOpenForm} - tooltip={translate('users.update_groups')} - > - <BulletListIcon /> - </ButtonIcon> - )} - </li> - {openForm && ( - <GroupsForm - onClose={this.handleCloseForm} - onUpdateUsers={this.props.onUpdateUsers} - user={user} - /> + <li className="little-spacer-bottom"> + {groups.length > GROUPS_LIMIT && !showMore && ( + <ButtonLink + className="js-user-more-groups spacer-right" + onClick={() => setShowMore(!showMore)} + > + {translateWithParameters('more_x', groups.length - limit)} + </ButtonLink> + )} + {manageProvider === undefined && ( + <ButtonIcon + aria-label={translateWithParameters('users.update_users_groups', user.login)} + className="js-user-groups button-small" + onClick={() => setOpenForm(true)} + tooltip={translate('users.update_groups')} + > + <BulletListIcon /> + </ButtonIcon> )} - </ul> - ); - } + </li> + {openForm && <GroupsForm onClose={() => setOpenForm(false)} user={user} />} + </ul> + ); } 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 cd6bdef46f0..d298d81b530 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 @@ -34,8 +34,6 @@ import UserScmAccounts from './UserScmAccounts'; export interface UserListItemProps { identityProvider?: IdentityProvider; isCurrentUser: boolean; - onUpdateUsers: () => void; - updateTokensCount: (login: string, tokensCount: number) => void; user: User; manageProvider: string | undefined; } @@ -43,14 +41,7 @@ export interface UserListItemProps { export default function UserListItem(props: UserListItemProps) { const [openTokenForm, setOpenTokenForm] = React.useState(false); - const { - identityProvider, - onUpdateUsers, - user, - manageProvider, - isCurrentUser, - updateTokensCount, - } = props; + const { identityProvider, user, manageProvider, isCurrentUser } = props; return ( <tr> @@ -79,12 +70,7 @@ export default function UserListItem(props: UserListItemProps) { <DateFromNow date={user.sonarLintLastConnectionDate} hourPrecision /> </td> <td className="thin nowrap text-middle"> - <UserGroups - groups={user.groups ?? []} - manageProvider={manageProvider} - onUpdateUsers={onUpdateUsers} - user={user} - /> + <UserGroups groups={user.groups ?? []} manageProvider={manageProvider} user={user} /> </td> <td className="thin nowrap text-middle"> {user.tokensCount} @@ -100,22 +86,11 @@ export default function UserListItem(props: UserListItemProps) { {(manageProvider === undefined || !user.managed) && ( <td className="thin nowrap text-right text-middle"> - <UserActions - isCurrentUser={isCurrentUser} - onUpdateUsers={onUpdateUsers} - user={user} - manageProvider={manageProvider} - /> + <UserActions isCurrentUser={isCurrentUser} user={user} manageProvider={manageProvider} /> </td> )} - {openTokenForm && ( - <TokensFormModal - onClose={() => setOpenTokenForm(false)} - updateTokensCount={updateTokensCount} - user={user} - /> - )} + {openTokenForm && <TokensFormModal onClose={() => setOpenTokenForm(false)} user={user} />} </tr> ); } |