diff options
author | guillaume-peoch-sonarsource <guillaume.peoch@sonarsource.com> | 2023-03-27 10:06:34 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2023-03-30 20:03:07 +0000 |
commit | 33cf03a9e6316e69e53c3960d43dde522d17583f (patch) | |
tree | 39639d5abf37cf5a3a72380620e15b49917f7010 /server/sonar-web/src/main/js | |
parent | 4726b0404d6e7b101f5d808c9ee3707ad1e03494 (diff) | |
download | sonarqube-33cf03a9e6316e69e53c3960d43dde522d17583f.tar.gz sonarqube-33cf03a9e6316e69e53c3960d43dde522d17583f.zip |
SONAR-18888 UserApp and GroupApp refactorization
Diffstat (limited to 'server/sonar-web/src/main/js')
23 files changed, 563 insertions, 850 deletions
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 c22c47bff13..efa961414bc 100644 --- a/server/sonar-web/src/main/js/api/mocks/UsersServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/UsersServiceMock.ts @@ -41,7 +41,6 @@ const DEFAULT_USERS = [ export default class UsersServiceMock { isManaged = true; users = cloneDeep(DEFAULT_USERS); - constructor() { jest.mocked(getSystemInfo).mockImplementation(this.handleGetSystemInfo); jest.mocked(getIdentityProviders).mockImplementation(this.handleGetIdentityProviders); @@ -54,12 +53,22 @@ export default class UsersServiceMock { } handleSearchUsers = (data: any): Promise<{ paging: Paging; users: User[] }> => { - const paging = { + let paging = { pageIndex: 1, - pageSize: 100, - total: 0, + pageSize: 2, + total: 6, }; + if (data.p !== undefined && data.p !== paging.pageIndex) { + paging = { pageIndex: 2, pageSize: 2, total: 6 }; + const users = [ + mockUser({ name: `local-user ${this.users.length + 4}` }), + mockUser({ name: `local-user ${this.users.length + 5}` }), + ]; + + return this.reply({ paging, users }); + } + if (this.isManaged) { if (data.managed === undefined) { return this.reply({ paging, users: this.users }); diff --git a/server/sonar-web/src/main/js/app/components/current-user/CurrentUserContext.ts b/server/sonar-web/src/main/js/app/components/current-user/CurrentUserContext.ts index 3ab9c00fa6c..3bc8792dc04 100644 --- a/server/sonar-web/src/main/js/app/components/current-user/CurrentUserContext.ts +++ b/server/sonar-web/src/main/js/app/components/current-user/CurrentUserContext.ts @@ -17,6 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { noop } from 'lodash'; import * as React from 'react'; import { CurrentUser, HomePage, NoticeType } from '../../../types/users'; @@ -26,6 +27,11 @@ export interface CurrentUserContextInterface { updateDismissedNotices: (key: NoticeType, value: boolean) => void; } -export const CurrentUserContext = React.createContext<CurrentUserContextInterface | undefined>( - undefined -); +export const CurrentUserContext = React.createContext<CurrentUserContextInterface>({ + currentUser: { + isLoggedIn: false, + dismissedNotices: {}, + }, + updateCurrentUserHomepage: noop, + updateDismissedNotices: noop, +}); diff --git a/server/sonar-web/src/main/js/apps/groups/components/App.tsx b/server/sonar-web/src/main/js/apps/groups/components/App.tsx deleted file mode 100644 index 4652527c324..00000000000 --- a/server/sonar-web/src/main/js/apps/groups/components/App.tsx +++ /dev/null @@ -1,294 +0,0 @@ -/* - * 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 { omit } from 'lodash'; -import * as React from 'react'; -import { Helmet } from 'react-helmet-async'; -import { getSystemInfo } from '../../../api/system'; -import { createGroup, deleteGroup, searchUsersGroups, updateGroup } from '../../../api/user_groups'; -import ButtonToggle from '../../../components/controls/ButtonToggle'; -import ListFooter from '../../../components/controls/ListFooter'; -import SearchBox from '../../../components/controls/SearchBox'; -import Suggestions from '../../../components/embed-docs-modal/Suggestions'; -import { translate } from '../../../helpers/l10n'; -import { omitNil } from '../../../helpers/request'; -import { Group, Paging, SysInfoCluster } from '../../../types/types'; -import '../groups.css'; -import DeleteForm from './DeleteForm'; -import Form from './Form'; -import Header from './Header'; -import List from './List'; - -interface State { - groups?: Group[]; - editedGroup?: Group; - groupToBeDeleted?: Group; - loading: boolean; - paging?: Paging; - query: string; - manageProvider?: string; - managed: boolean | undefined; -} - -export default class App extends React.PureComponent<{}, State> { - mounted = false; - state: State = { - loading: true, - query: '', - managed: undefined, - paging: { pageIndex: 1, pageSize: 100, total: 1000 }, - }; - - componentDidMount() { - this.mounted = true; - this.fetchGroups(); - this.fetchManageInstance(); - } - - componentDidUpdate(_prevProps: {}, prevState: State) { - if (prevState.query !== this.state.query || prevState.managed !== this.state.managed) { - this.fetchGroups(); - } - if (prevState !== undefined && prevState.paging?.pageIndex !== this.state.paging?.pageIndex) { - this.fetchMoreGroups(); - } - } - - componentWillUnmount() { - this.mounted = false; - } - - async fetchManageInstance() { - const info = (await getSystemInfo()) as SysInfoCluster; - if (this.mounted) { - this.setState({ - manageProvider: info.System['External Users and Groups Provisioning'], - }); - } - } - - stopLoading = () => { - if (this.mounted) { - this.setState({ loading: false }); - } - }; - - fetchGroups = async () => { - const { query: q, managed } = this.state; - this.setState({ loading: true }); - try { - const { groups, paging } = await searchUsersGroups({ - q, - managed, - }); - if (this.mounted) { - this.setState({ groups, loading: false, paging }); - } - } catch { - this.stopLoading(); - } - }; - - fetchMoreGroups = async () => { - const { query: q, managed, paging: currentPaging } = this.state; - if (currentPaging && currentPaging.total > currentPaging.pageIndex * currentPaging.pageSize) { - try { - const { groups, paging } = await searchUsersGroups({ - p: currentPaging.pageIndex, - q, - managed, - }); - if (this.mounted) { - this.setState(({ groups: existingGroups = [] }) => ({ - groups: [...existingGroups, ...groups], - loading: false, - paging, - })); - } - } catch { - this.stopLoading(); - } - } - }; - - refresh = async () => { - const { paging } = this.state; - - await this.fetchGroups(); - - // reload all pages in order - if (paging && paging.pageIndex > 1) { - for (let p = 1; p < paging.pageIndex; p++) { - // eslint-disable-next-line no-await-in-loop - await this.fetchMoreGroups(); // This is a intentional promise chain - } - } - }; - - handleCreate = async (data: { description: string; name: string }) => { - await createGroup({ ...data }); - - await this.refresh(); - }; - - handleDelete = async () => { - const { groupToBeDeleted } = this.state; - - if (!groupToBeDeleted) { - return; - } - - await deleteGroup({ name: groupToBeDeleted.name }); - - await this.refresh(); - - if (this.mounted) { - this.setState({ groupToBeDeleted: undefined }); - } - }; - - handleEdit = async ({ name, description }: { name?: string; description: string }) => { - const { editedGroup } = this.state; - - if (!editedGroup) { - return; - } - - const data = { - currentName: editedGroup.name, - description, - // pass `name` only if it has changed, otherwise the WS fails - ...omitNil({ name: name !== editedGroup.name ? name : undefined }), - }; - - await updateGroup(data); - - if (this.mounted) { - this.setState(({ groups = [] }: State) => ({ - editedGroup: undefined, - groups: groups.map((group) => - group.name === editedGroup.name - ? { - ...group, - ...omit(data, ['currentName']), - } - : group - ), - })); - } - }; - - render() { - const { - editedGroup, - groupToBeDeleted, - groups, - loading, - paging, - query, - manageProvider, - managed, - } = this.state; - - return ( - <> - <Suggestions suggestions="user_groups" /> - <Helmet defer={false} title={translate('user_groups.page')} /> - <main className="page page-limited" id="groups-page"> - <Header onCreate={this.handleCreate} manageProvider={manageProvider} /> - - <div className="display-flex-justify-start big-spacer-bottom big-spacer-top"> - {manageProvider !== undefined && ( - <div className="big-spacer-right"> - <ButtonToggle - value={managed === undefined ? 'all' : managed} - disabled={loading} - options={[ - { label: translate('all'), value: 'all' }, - { label: translate('managed'), value: true }, - { label: translate('local'), value: false }, - ]} - onCheck={(filterOption) => { - if (filterOption === 'all') { - this.setState({ managed: undefined }); - } else { - this.setState({ managed: filterOption as boolean }); - } - }} - /> - </div> - )} - <SearchBox - className="big-spacer-bottom" - id="groups-search" - minLength={2} - onChange={(q) => this.setState({ query: q })} - placeholder={translate('search.search_by_name')} - value={query} - /> - </div> - - {groups !== undefined && ( - <List - groups={groups} - onDelete={(groupToBeDeleted) => this.setState({ groupToBeDeleted })} - onEdit={(editedGroup) => this.setState({ editedGroup })} - onEditMembers={this.refresh} - manageProvider={manageProvider} - /> - )} - - {groups !== undefined && paging !== undefined && ( - <div id="groups-list-footer"> - <ListFooter - count={groups.length} - loading={loading} - loadMore={() => { - if (paging.total > paging.pageIndex * paging.pageSize) { - this.setState({ paging: { ...paging, pageIndex: paging.pageIndex + 1 } }); - } - }} - ready={!loading} - total={paging.total} - /> - </div> - )} - - {groupToBeDeleted && ( - <DeleteForm - group={groupToBeDeleted} - onClose={() => this.setState({ groupToBeDeleted: undefined })} - onSubmit={this.handleDelete} - /> - )} - - {editedGroup && ( - <Form - confirmButtonText={translate('update_verb')} - group={editedGroup} - header={translate('groups.update_group')} - onClose={() => this.setState({ editedGroup: undefined })} - onSubmit={this.handleEdit} - /> - )} - </main> - </> - ); - } -} diff --git a/server/sonar-web/src/main/js/apps/groups/components/DeleteForm.tsx b/server/sonar-web/src/main/js/apps/groups/components/DeleteGroupForm.tsx index 99ee7d09e24..fd716931b76 100644 --- a/server/sonar-web/src/main/js/apps/groups/components/DeleteForm.tsx +++ b/server/sonar-web/src/main/js/apps/groups/components/DeleteGroupForm.tsx @@ -18,6 +18,8 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; +import { useCallback } from 'react'; +import { deleteGroup } from '../../../api/user_groups'; import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons'; import SimpleModal from '../../../components/controls/SimpleModal'; import DeferredSpinner from '../../../components/ui/DeferredSpinner'; @@ -27,14 +29,21 @@ import { Group } from '../../../types/types'; interface Props { group: Group; onClose: () => void; - onSubmit: () => Promise<void>; + reload: () => void; } -export default function DeleteForm({ group, onClose, onSubmit }: Props) { +export default function DeleteGroupForm(props: Props) { const header = translate('groups.delete_group'); + const { group, reload, onClose } = props; + + const onSubmit = useCallback(async () => { + await deleteGroup({ name: group.name }); + reload(); + onClose(); + }, [group, reload, onClose]); return ( - <SimpleModal header={header} onClose={onClose} onSubmit={onSubmit}> + <SimpleModal header={header} onClose={props.onClose} onSubmit={onSubmit}> {({ onCloseClick, onFormSubmit, submitting }) => ( <form onSubmit={onFormSubmit}> <header className="modal-head"> diff --git a/server/sonar-web/src/main/js/apps/groups/components/Form.tsx b/server/sonar-web/src/main/js/apps/groups/components/Form.tsx deleted file mode 100644 index 91ec41a5d3e..00000000000 --- a/server/sonar-web/src/main/js/apps/groups/components/Form.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/* - * 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 * as React from 'react'; -import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons'; -import SimpleModal from '../../../components/controls/SimpleModal'; -import DeferredSpinner from '../../../components/ui/DeferredSpinner'; -import MandatoryFieldMarker from '../../../components/ui/MandatoryFieldMarker'; -import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsExplanation'; -import { translate } from '../../../helpers/l10n'; -import { Group } from '../../../types/types'; - -interface Props { - confirmButtonText: string; - group?: Group; - header: string; - onClose: () => void; - onSubmit: (data: { description: string; name: string }) => Promise<void>; -} - -interface State { - description: string; - name: string; -} - -export default class Form extends React.PureComponent<Props, State> { - constructor(props: Props) { - super(props); - this.state = { - description: (props.group && props.group.description) || '', - name: (props.group && props.group.name) || '', - }; - } - - handleSubmit = () => { - return this.props - .onSubmit({ description: this.state.description, name: this.state.name }) - .then(this.props.onClose); - }; - - handleDescriptionChange = (event: React.SyntheticEvent<HTMLTextAreaElement>) => { - this.setState({ description: event.currentTarget.value }); - }; - - handleNameChange = (event: React.SyntheticEvent<HTMLInputElement>) => { - this.setState({ name: event.currentTarget.value }); - }; - - render() { - return ( - <SimpleModal - header={this.props.header} - onClose={this.props.onClose} - onSubmit={this.handleSubmit} - size="small" - > - {({ onCloseClick, onFormSubmit, submitting }) => ( - <form onSubmit={onFormSubmit}> - <header className="modal-head"> - <h2>{this.props.header}</h2> - </header> - - <div className="modal-body"> - <MandatoryFieldsExplanation className="modal-field" /> - <div className="modal-field"> - <label htmlFor="create-group-name"> - {translate('name')} - <MandatoryFieldMarker /> - </label> - <input - autoFocus={true} - id="create-group-name" - maxLength={255} - name="name" - onChange={this.handleNameChange} - required={true} - size={50} - type="text" - value={this.state.name} - /> - </div> - <div className="modal-field"> - <label htmlFor="create-group-description">{translate('description')}</label> - <textarea - id="create-group-description" - name="description" - onChange={this.handleDescriptionChange} - value={this.state.description} - /> - </div> - </div> - - <footer className="modal-foot"> - <DeferredSpinner className="spacer-right" loading={submitting} /> - <SubmitButton disabled={submitting}>{this.props.confirmButtonText}</SubmitButton> - <ResetButtonLink onClick={onCloseClick}>{translate('cancel')}</ResetButtonLink> - </footer> - </form> - )} - </SimpleModal> - ); - } -} diff --git a/server/sonar-web/src/main/js/apps/groups/components/GroupForm.tsx b/server/sonar-web/src/main/js/apps/groups/components/GroupForm.tsx new file mode 100644 index 00000000000..ddeee0ee24c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/groups/components/GroupForm.tsx @@ -0,0 +1,136 @@ +/* + * 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 * as React from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { createGroup, updateGroup } from '../../../api/user_groups'; +import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons'; +import SimpleModal from '../../../components/controls/SimpleModal'; +import DeferredSpinner from '../../../components/ui/DeferredSpinner'; +import MandatoryFieldMarker from '../../../components/ui/MandatoryFieldMarker'; +import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsExplanation'; +import { translate } from '../../../helpers/l10n'; +import { omitNil } from '../../../helpers/request'; +import { Group } from '../../../types/types'; + +type Props = + | { + create: true; + group?: undefined; + onClose: () => void; + reload: () => void; + } + | { + create: false; + group: Group; + onClose: () => void; + reload: () => void; + }; + +export default function GroupForm(props: Props) { + const { group, create, reload, onClose } = props; + + const [name, setName] = useState<string>(''); + const [description, setDescription] = useState<string>(''); + + const handleSubmit = useCallback(async () => { + try { + if (create) { + await createGroup({ name, description }); + } else { + const data = { + currentName: group.name, + description, + // pass `name` only if it has changed, otherwise the WS fails + ...omitNil({ name: name !== group.name ? name : undefined }), + }; + await updateGroup(data); + } + } finally { + reload(); + onClose(); + } + }, [name, description, group, create, reload, onClose]); + + useEffect(() => { + if (!create) { + setDescription(group.description ?? ''); + setName(group.name); + } + }, []); + + return ( + <SimpleModal + header={create ? translate('groups.create_group') : translate('groups.update_group')} + onClose={props.onClose} + onSubmit={handleSubmit} + size="small" + > + {({ onCloseClick, onFormSubmit, submitting }) => ( + <form onSubmit={onFormSubmit}> + <header className="modal-head"> + <h2>{create ? translate('groups.create_group') : translate('groups.update_group')}</h2> + </header> + + <div className="modal-body"> + <MandatoryFieldsExplanation className="modal-field" /> + <div className="modal-field"> + <label htmlFor="create-group-name"> + {translate('name')} + <MandatoryFieldMarker /> + </label> + <input + autoFocus={true} + id="create-group-name" + maxLength={255} + name="name" + onChange={(event: React.SyntheticEvent<HTMLInputElement>) => { + setName(event.currentTarget.value); + }} + required={true} + size={50} + type="text" + value={name} + /> + </div> + <div className="modal-field"> + <label htmlFor="create-group-description">{translate('description')}</label> + <textarea + id="create-group-description" + name="description" + onChange={(event: React.SyntheticEvent<HTMLTextAreaElement>) => { + setDescription(event.currentTarget.value); + }} + value={description} + /> + </div> + </div> + + <footer className="modal-foot"> + <DeferredSpinner className="spacer-right" loading={submitting} /> + <SubmitButton disabled={submitting}> + {create ? translate('create') : translate('update_verb')} + </SubmitButton> + <ResetButtonLink onClick={onCloseClick}>{translate('cancel')}</ResetButtonLink> + </footer> + </form> + )} + </SimpleModal> + ); +} diff --git a/server/sonar-web/src/main/js/apps/groups/components/GroupsApp.tsx b/server/sonar-web/src/main/js/apps/groups/components/GroupsApp.tsx new file mode 100644 index 00000000000..3e72b40977e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/groups/components/GroupsApp.tsx @@ -0,0 +1,118 @@ +/* + * 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 * as React from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { Helmet } from 'react-helmet-async'; +import { searchUsersGroups } from '../../../api/user_groups'; +import ListFooter from '../../../components/controls/ListFooter'; +import { ManagedFilter } from '../../../components/controls/ManagedFilter'; +import SearchBox from '../../../components/controls/SearchBox'; +import Suggestions from '../../../components/embed-docs-modal/Suggestions'; +import { useManageProvider } from '../../../components/hooks/useManageProvider'; +import { translate } from '../../../helpers/l10n'; +import { Group, Paging } from '../../../types/types'; +import '../groups.css'; +import Header from './Header'; +import List from './List'; + +export default function App() { + const [loading, setLoading] = useState<boolean>(true); + const [paging, setPaging] = useState<Paging>(); + const [search, setSearch] = useState<string>(''); + const [groups, setGroups] = useState<Group[]>([]); + const [managed, setManaged] = useState<boolean | undefined>(); + const manageProvider = useManageProvider(); + + const fetchGroups = useCallback(async () => { + setLoading(true); + try { + const { groups, paging } = await searchUsersGroups({ + q: search, + managed, + }); + setGroups(groups); + setPaging(paging); + } finally { + setLoading(false); + } + }, [search, managed]); + + const fetchMoreGroups = useCallback(async () => { + if (!paging) { + return; + } + setLoading(true); + try { + const { groups: nextGroups, paging: nextPage } = await searchUsersGroups({ + q: search, + managed, + p: paging.pageIndex + 1, + }); + setPaging(nextPage); + setGroups([...groups, ...nextGroups]); + } finally { + setLoading(false); + } + }, [groups, search, managed, paging]); + + useEffect(() => { + fetchGroups(); + }, [search, managed]); + + return ( + <> + <Suggestions suggestions="user_groups" /> + <Helmet defer={false} title={translate('user_groups.page')} /> + <main className="page page-limited" id="groups-page"> + <Header reload={fetchGroups} manageProvider={manageProvider} /> + + <div className="display-flex-justify-start big-spacer-bottom big-spacer-top"> + <ManagedFilter + manageProvider={manageProvider} + loading={loading} + managed={managed} + setManaged={setManaged} + /> + <SearchBox + id="groups-search" + minLength={2} + onChange={(q) => setSearch(q)} + placeholder={translate('search.search_by_name')} + value={search} + /> + </div> + + <List groups={groups} reload={fetchGroups} manageProvider={manageProvider} /> + + {paging !== undefined && ( + <div id="groups-list-footer"> + <ListFooter + count={groups.length} + loading={loading} + loadMore={fetchMoreGroups} + ready={!loading} + total={paging.total} + /> + </div> + )} + </main> + </> + ); +} diff --git a/server/sonar-web/src/main/js/apps/groups/components/Header.tsx b/server/sonar-web/src/main/js/apps/groups/components/Header.tsx index 8a681c42b5e..bbfd8a1a4c9 100644 --- a/server/sonar-web/src/main/js/apps/groups/components/Header.tsx +++ b/server/sonar-web/src/main/js/apps/groups/components/Header.tsx @@ -23,10 +23,10 @@ import DocLink from '../../../components/common/DocLink'; import { Button } from '../../../components/controls/buttons'; import { Alert } from '../../../components/ui/Alert'; import { translate } from '../../../helpers/l10n'; -import Form from './Form'; +import GroupForm from './GroupForm'; interface HeaderProps { - onCreate: (data: { description: string; name: string }) => Promise<void>; + reload: () => void; manageProvider?: string; } @@ -69,12 +69,7 @@ export default function Header(props: HeaderProps) { )} </div> {createModal && ( - <Form - confirmButtonText={translate('create')} - header={translate('groups.create_group')} - onClose={() => setCreateModal(false)} - onSubmit={props.onCreate} - /> + <GroupForm onClose={() => setCreateModal(false)} create={true} reload={props.reload} /> )} </> ); diff --git a/server/sonar-web/src/main/js/apps/groups/components/List.tsx b/server/sonar-web/src/main/js/apps/groups/components/List.tsx index 2a98562c17e..b15de183e9c 100644 --- a/server/sonar-web/src/main/js/apps/groups/components/List.tsx +++ b/server/sonar-web/src/main/js/apps/groups/components/List.tsx @@ -25,9 +25,7 @@ import ListItem from './ListItem'; interface Props { groups: Group[]; - onDelete: (group: Group) => void; - onEdit: (group: Group) => void; - onEditMembers: () => void; + reload: () => void; manageProvider: string | undefined; } @@ -54,9 +52,7 @@ export default function List(props: Props) { <ListItem group={group} key={group.name} - onDelete={props.onDelete} - onEdit={props.onEdit} - onEditMembers={props.onEditMembers} + reload={props.reload} manageProvider={manageProvider} /> ))} diff --git a/server/sonar-web/src/main/js/apps/groups/components/ListItem.tsx b/server/sonar-web/src/main/js/apps/groups/components/ListItem.tsx index 58a9d324e81..93acc5ca969 100644 --- a/server/sonar-web/src/main/js/apps/groups/components/ListItem.tsx +++ b/server/sonar-web/src/main/js/apps/groups/components/ListItem.tsx @@ -19,19 +19,20 @@ */ import classNames from 'classnames'; import * as React from 'react'; +import { useState } from 'react'; import ActionsDropdown, { ActionsDropdownDivider, ActionsDropdownItem, } from '../../../components/controls/ActionsDropdown'; import { translate, translateWithParameters } from '../../../helpers/l10n'; import { Group } from '../../../types/types'; +import DeleteGroupForm from './DeleteGroupForm'; import EditMembers from './EditMembers'; +import GroupForm from './GroupForm'; export interface ListItemProps { group: Group; - onDelete: (group: Group) => void; - onEdit: (group: Group) => void; - onEditMembers: () => void; + reload: () => void; manageProvider: string | undefined; } @@ -39,6 +40,9 @@ export default function ListItem(props: ListItemProps) { const { manageProvider, group } = props; const { name, managed, membersCount, description } = group; + const [groupToDelete, setGroupToDelete] = useState<Group | undefined>(); + const [groupToEdit, setGroupToEdit] = useState<Group | undefined>(); + const isManaged = () => { return manageProvider !== undefined; }; @@ -61,9 +65,7 @@ export default function ListItem(props: ListItemProps) { > {membersCount} </span> - {!group.default && !isManaged() && ( - <EditMembers group={group} onEdit={props.onEditMembers} /> - )} + {!group.default && !isManaged() && <EditMembers group={group} onEdit={props.reload} />} </td> <td className="width-40" headers="list-group-description"> @@ -77,7 +79,7 @@ export default function ListItem(props: ListItemProps) { <> <ActionsDropdownItem className="js-group-update" - onClick={() => props.onEdit(group)} + onClick={() => setGroupToEdit(group)} > {translate('update_details')} </ActionsDropdownItem> @@ -88,13 +90,28 @@ export default function ListItem(props: ListItemProps) { <ActionsDropdownItem className="js-group-delete" destructive={true} - onClick={() => props.onDelete(group)} + onClick={() => setGroupToDelete(group)} > {translate('delete')} </ActionsDropdownItem> )} </ActionsDropdown> )} + {groupToDelete && ( + <DeleteGroupForm + group={groupToDelete} + reload={props.reload} + onClose={() => setGroupToDelete(undefined)} + /> + )} + {groupToEdit && ( + <GroupForm + create={false} + group={groupToEdit} + reload={props.reload} + onClose={() => setGroupToEdit(undefined)} + /> + )} </td> </tr> ); diff --git a/server/sonar-web/src/main/js/apps/groups/components/__tests__/GroupsApp-it.tsx b/server/sonar-web/src/main/js/apps/groups/components/__tests__/GroupsApp-it.tsx index 1af97ddd5ed..cd66848bd95 100644 --- a/server/sonar-web/src/main/js/apps/groups/components/__tests__/GroupsApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/groups/components/__tests__/GroupsApp-it.tsx @@ -25,7 +25,7 @@ import { act } from 'react-dom/test-utils'; import { byRole, byText } from 'testing-library-selector'; import GroupsServiceMock from '../../../../api/mocks/GroupsServiceMock'; import { renderApp } from '../../../../helpers/testReactTestingUtils'; -import App from '../App'; +import App from '../GroupsApp'; jest.mock('../../../../api/users'); jest.mock('../../../../api/system'); @@ -89,14 +89,15 @@ describe('in non managed mode', () => { renderGroupsApp(); expect(await ui.description.find()).toBeInTheDocument(); + await act(async () => { + await user.click(ui.createGroupButton.get()); + }); - await user.click(ui.createGroupButton.get()); - expect(ui.createGroupDialog.get()).toBeInTheDocument(); - - await user.type(ui.nameInput.get(), 'local-group 2'); - await user.type(ui.descriptionInput.get(), 'group 2 is loco!'); + expect(await ui.createGroupDialog.find()).toBeInTheDocument(); await act(async () => { + await user.type(ui.nameInput.get(), 'local-group 2'); + await user.type(ui.descriptionInput.get(), 'group 2 is loco!'); await user.click(ui.createGroupDialogButton.get()); }); @@ -107,8 +108,10 @@ describe('in non managed mode', () => { const user = userEvent.setup(); renderGroupsApp(); - await user.click(await ui.localEditButton.find()); - await user.click(await ui.deleteButton.find()); + await act(async () => { + await user.click(await ui.localEditButton.find()); + await user.click(await ui.deleteButton.find()); + }); expect(await ui.deleteDialog.find()).toBeInTheDocument(); await act(async () => { @@ -123,15 +126,19 @@ describe('in non managed mode', () => { const user = userEvent.setup(); renderGroupsApp(); - await user.click(await ui.localEditButton.find()); - await user.click(await ui.updateButton.find()); + await act(async () => { + await user.click(await ui.localEditButton.find()); + await user.click(await ui.updateButton.find()); + }); expect(ui.updateDialog.get()).toBeInTheDocument(); - await user.clear(ui.nameInput.get()); - await user.type(ui.nameInput.get(), 'local-group 3'); - await user.clear(ui.descriptionInput.get()); - await user.type(ui.descriptionInput.get(), 'group 3 rocks!'); + await act(async () => { + await user.clear(ui.nameInput.get()); + await user.type(ui.nameInput.get(), 'local-group 3'); + await user.clear(ui.descriptionInput.get()); + await user.type(ui.descriptionInput.get(), 'group 3 rocks!'); + }); expect(ui.updateDialog.get()).toBeInTheDocument(); @@ -150,7 +157,10 @@ describe('in non managed mode', () => { expect(await ui.localGroupRow.find()).toBeInTheDocument(); expect(await ui.localGroupEditMembersButton.find()).toBeInTheDocument(); - await user.click(ui.localGroupEditMembersButton.get()); + await act(async () => { + await user.click(ui.localGroupEditMembersButton.get()); + }); + expect(await ui.membersDialog.find()).toBeInTheDocument(); }); @@ -161,7 +171,9 @@ describe('in non managed mode', () => { expect(await ui.localGroupRow.find()).toBeInTheDocument(); expect(ui.managedGroupRow.get()).toBeInTheDocument(); - await user.type(await ui.searchInput.find(), 'local'); + await act(async () => { + await user.type(await ui.searchInput.find(), 'local'); + }); expect(await ui.localGroupRow.find()).toBeInTheDocument(); expect(ui.managedGroupRow.query()).not.toBeInTheDocument(); @@ -171,9 +183,12 @@ describe('in non managed mode', () => { const user = userEvent.setup(); renderGroupsApp(); + expect(await ui.localGroupRow.find()).toBeInTheDocument(); expect(await screen.findAllByRole('row')).toHaveLength(3); - await user.click(await ui.showMore.find()); + await act(async () => { + await user.click(await ui.showMore.find()); + }); expect(await screen.findAllByRole('row')).toHaveLength(5); }); @@ -197,11 +212,15 @@ describe('in manage mode', () => { expect(await ui.localGroupRowWithLocalBadge.find()).toBeInTheDocument(); - await user.click(await ui.localFilter.find()); - await user.click(await ui.localEditButton.find()); + await act(async () => { + await user.click(await ui.localFilter.find()); + await user.click(await ui.localEditButton.find()); + }); expect(ui.updateButton.query()).not.toBeInTheDocument(); - await user.click(await ui.deleteButton.find()); + await act(async () => { + await user.click(await ui.deleteButton.find()); + }); expect(await ui.deleteDialog.find()).toBeInTheDocument(); await act(async () => { @@ -232,7 +251,9 @@ describe('in manage mode', () => { const user = userEvent.setup(); renderGroupsApp(); - await user.click(await ui.managedFilter.find()); + await act(async () => { + await user.click(await ui.managedFilter.find()); + }); expect(ui.localGroupRow.query()).not.toBeInTheDocument(); expect(ui.managedGroupRow.get()).toBeInTheDocument(); @@ -242,7 +263,9 @@ describe('in manage mode', () => { const user = userEvent.setup(); renderGroupsApp(); - await user.click(await ui.localFilter.find()); + await act(async () => { + await user.click(await ui.localFilter.find()); + }); expect(ui.localGroupRowWithLocalBadge.get()).toBeInTheDocument(); expect(ui.managedGroupRow.query()).not.toBeInTheDocument(); diff --git a/server/sonar-web/src/main/js/apps/groups/components/__tests__/List-test.tsx b/server/sonar-web/src/main/js/apps/groups/components/__tests__/List-test.tsx index 44607b3ab11..d40207a484a 100644 --- a/server/sonar-web/src/main/js/apps/groups/components/__tests__/List-test.tsx +++ b/server/sonar-web/src/main/js/apps/groups/components/__tests__/List-test.tsx @@ -32,13 +32,5 @@ function shallowRender() { mockGroup({ name: 'foo', description: 'foobar', membersCount: 0, default: false }), mockGroup({ name: 'bar', description: 'barbar', membersCount: 1, default: false }), ]; - return shallow( - <List - groups={groups} - onDelete={jest.fn()} - onEdit={jest.fn()} - onEditMembers={jest.fn()} - manageProvider={undefined} - /> - ); + return shallow(<List groups={groups} manageProvider={undefined} reload={jest.fn()} />); } diff --git a/server/sonar-web/src/main/js/apps/groups/components/__tests__/ListItem-test.tsx b/server/sonar-web/src/main/js/apps/groups/components/__tests__/ListItem-test.tsx index 206257c15cd..e51bc5eabd9 100644 --- a/server/sonar-web/src/main/js/apps/groups/components/__tests__/ListItem-test.tsx +++ b/server/sonar-web/src/main/js/apps/groups/components/__tests__/ListItem-test.tsx @@ -29,13 +29,6 @@ it('should render correctly', () => { function shallowRender(overrides: Partial<ListItemProps> = {}) { return shallow( - <ListItem - group={mockGroup()} - onDelete={jest.fn()} - onEdit={jest.fn()} - onEditMembers={jest.fn()} - manageProvider={undefined} - {...overrides} - /> + <ListItem group={mockGroup()} reload={jest.fn()} manageProvider={undefined} {...overrides} /> ); } diff --git a/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/List-test.tsx.snap b/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/List-test.tsx.snap index f9d93c9e591..f79f1f69b91 100644 --- a/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/List-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/List-test.tsx.snap @@ -46,9 +46,7 @@ exports[`should render 1`] = ` } } key="bar" - onDelete={[MockFunction]} - onEdit={[MockFunction]} - onEditMembers={[MockFunction]} + reload={[MockFunction]} /> <ListItem group={ @@ -61,9 +59,7 @@ exports[`should render 1`] = ` } } key="foo" - onDelete={[MockFunction]} - onEdit={[MockFunction]} - onEditMembers={[MockFunction]} + reload={[MockFunction]} /> <ListItem group={ @@ -76,9 +72,7 @@ exports[`should render 1`] = ` } } key="sonar-users" - onDelete={[MockFunction]} - onEdit={[MockFunction]} - onEditMembers={[MockFunction]} + reload={[MockFunction]} /> </tbody> </table> diff --git a/server/sonar-web/src/main/js/apps/groups/routes.tsx b/server/sonar-web/src/main/js/apps/groups/routes.tsx index d6da3b58c94..04bebdb5d64 100644 --- a/server/sonar-web/src/main/js/apps/groups/routes.tsx +++ b/server/sonar-web/src/main/js/apps/groups/routes.tsx @@ -19,7 +19,7 @@ */ import React from 'react'; import { Route } from 'react-router-dom'; -import App from './components/App'; +import App from './components/GroupsApp'; const routes = () => <Route path="groups" element={<App />} />; 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 36b1daac07c..7cec61d1f3b 100644 --- a/server/sonar-web/src/main/js/apps/users/UsersApp.tsx +++ b/server/sonar-web/src/main/js/apps/users/UsersApp.tsx @@ -17,180 +17,110 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import * as React from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Helmet } from 'react-helmet-async'; -import { getSystemInfo } from '../../api/system'; import { getIdentityProviders, searchUsers } from '../../api/users'; -import withCurrentUserContext from '../../app/components/current-user/withCurrentUserContext'; -import ButtonToggle from '../../components/controls/ButtonToggle'; import ListFooter from '../../components/controls/ListFooter'; +import { ManagedFilter } from '../../components/controls/ManagedFilter'; import SearchBox from '../../components/controls/SearchBox'; import Suggestions from '../../components/embed-docs-modal/Suggestions'; -import { Location, Router, withRouter } from '../../components/hoc/withRouter'; +import { useManageProvider } from '../../components/hooks/useManageProvider'; +import DeferredSpinner from '../../components/ui/DeferredSpinner'; import { translate } from '../../helpers/l10n'; -import { IdentityProvider, Paging, SysInfoCluster } from '../../types/types'; -import { CurrentUser, User } from '../../types/users'; +import { IdentityProvider, Paging } from '../../types/types'; +import { User } from '../../types/users'; import Header from './Header'; import UsersList from './UsersList'; -import { parseQuery, Query, serializeQuery } from './utils'; -interface Props { - currentUser: CurrentUser; - location: Location; - router: Router; -} - -interface State { - identityProviders: IdentityProvider[]; - manageProvider?: string; - loading: boolean; - paging?: Paging; - users: User[]; -} +export default function UsersApp() { + const [identityProviders, setIdentityProviders] = useState<IdentityProvider[]>([]); -export class UsersApp extends React.PureComponent<Props, State> { - mounted = false; - state: State = { identityProviders: [], loading: true, users: [] }; + const [loading, setLoading] = useState(true); + const [paging, setPaging] = useState<Paging>(); + const [users, setUsers] = useState<User[]>([]); - componentDidMount() { - this.mounted = true; - this.fetchIdentityProviders(); - this.fetchManageInstance(); - this.fetchUsers(); - } + const [search, setSearch] = useState(''); + const [managed, setManaged] = useState<boolean | undefined>(undefined); - componentDidUpdate(prevProps: Props) { - if ( - prevProps.location.query.search !== this.props.location.query.search || - prevProps.location.query.managed !== this.props.location.query.managed - ) { - this.fetchUsers(); - } - } - - componentWillUnmount() { - this.mounted = false; - } + const manageProvider = useManageProvider(); - finishLoading = () => { - if (this.mounted) { - this.setState({ loading: false }); + const fetchUsers = useCallback(async () => { + setLoading(true); + try { + const { paging, users } = await searchUsers({ q: search, managed }); + setPaging(paging); + setUsers(users); + } finally { + setLoading(false); } - }; + }, [search, managed]); - async fetchManageInstance() { - const info = (await getSystemInfo()) as SysInfoCluster; - if (this.mounted) { - this.setState({ - manageProvider: info.System['External Users and Groups Provisioning'], - }); + const fetchMoreUsers = useCallback(async () => { + if (!paging) { + return; } - } - - fetchIdentityProviders = () => - getIdentityProviders().then(({ identityProviders }) => { - if (this.mounted) { - this.setState({ identityProviders }); - } - }); - - fetchUsers = () => { - const { search, managed } = parseQuery(this.props.location.query); - this.setState({ loading: true }); - searchUsers({ - q: search, - managed, - }).then(({ paging, users }) => { - if (this.mounted) { - this.setState({ loading: false, paging, users }); - } - }, this.finishLoading); - }; - - fetchMoreUsers = () => { - const { paging } = this.state; - if (paging) { - const { search, managed } = parseQuery(this.props.location.query); - this.setState({ loading: true }); - searchUsers({ - p: paging.pageIndex + 1, + setLoading(true); + try { + const { paging: nextPage, users: nextUsers } = await searchUsers({ q: search, managed, - }).then(({ paging, users }) => { - if (this.mounted) { - this.setState((state) => ({ loading: false, users: [...state.users, ...users], paging })); - } - }, this.finishLoading); + p: paging.pageIndex + 1, + }); + setPaging(nextPage); + setUsers([...users, ...nextUsers]); + } finally { + setLoading(false); } - }; - - updateQuery = (newQuery: Partial<Query>) => { - const query = serializeQuery({ ...parseQuery(this.props.location.query), ...newQuery }); - this.props.router.push({ ...this.props.location, query }); - }; - - updateTokensCount = (login: string, tokensCount: number) => { - this.setState((state) => ({ - users: state.users.map((user) => (user.login === login ? { ...user, tokensCount } : user)), - })); - }; - - render() { - const { search, managed } = parseQuery(this.props.location.query); - const { loading, paging, users, manageProvider } = this.state; - - return ( - <main className="page page-limited" id="users-page"> - <Suggestions suggestions="users" /> - <Helmet defer={false} title={translate('users.page')} /> - <Header onUpdateUsers={this.fetchUsers} manageProvider={manageProvider} /> - <div className="display-flex-justify-start big-spacer-bottom big-spacer-top"> - {manageProvider !== undefined && ( - <div className="big-spacer-right"> - <ButtonToggle - value={managed === undefined ? 'all' : managed} - disabled={loading} - options={[ - { label: translate('all'), value: 'all' }, - { label: translate('managed'), value: true }, - { label: translate('local'), value: false }, - ]} - onCheck={(filterOption) => { - if (filterOption === 'all') { - this.updateQuery({ managed: undefined }); - } else { - this.updateQuery({ managed: filterOption as boolean }); - } - }} - /> - </div> - )} - <SearchBox - id="users-search" - onChange={(search: string) => this.updateQuery({ search })} - placeholder={translate('search.search_by_login_or_name')} - value={search} - /> - </div> + }, [search, managed, paging, users]); + + useEffect(() => { + (async () => { + const { identityProviders } = await getIdentityProviders(); + setIdentityProviders(identityProviders); + })(); + }, []); + + useEffect(() => { + fetchUsers(); + }, [search, managed]); + + return ( + <main className="page page-limited" id="users-page"> + <Suggestions suggestions="users" /> + <Helmet defer={false} title={translate('users.page')} /> + <Header onUpdateUsers={fetchUsers} manageProvider={manageProvider} /> + <div className="display-flex-justify-start big-spacer-bottom big-spacer-top"> + <ManagedFilter + manageProvider={manageProvider} + loading={loading} + managed={managed} + setManaged={setManaged} + /> + <SearchBox + id="users-search" + minLength={2} + onChange={(search: string) => setSearch(search)} + placeholder={translate('search.search_by_login_or_name')} + value={search} + /> + </div> + <DeferredSpinner loading={loading}> <UsersList - currentUser={this.props.currentUser} - identityProviders={this.state.identityProviders} - onUpdateUsers={this.fetchUsers} - updateTokensCount={this.updateTokensCount} + identityProviders={identityProviders} + onUpdateUsers={fetchUsers} + updateTokensCount={fetchUsers} users={users} manageProvider={manageProvider} /> - {paging !== undefined && ( - <ListFooter - count={users.length} - loadMore={this.fetchMoreUsers} - ready={!loading} - total={paging.total} - /> - )} - </main> - ); - } + </DeferredSpinner> + {paging !== undefined && ( + <ListFooter + count={users.length} + loadMore={fetchMoreUsers} + ready={!loading} + total={paging.total} + /> + )} + </main> + ); } - -export default withRouter(withCurrentUserContext(UsersApp)); 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 d0c4e130803..8d20e705bbe 100644 --- a/server/sonar-web/src/main/js/apps/users/UsersList.tsx +++ b/server/sonar-web/src/main/js/apps/users/UsersList.tsx @@ -18,13 +18,13 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; +import { CurrentUserContext } from '../../app/components/current-user/CurrentUserContext'; import { translate } from '../../helpers/l10n'; import { IdentityProvider } from '../../types/types'; -import { User } from '../../types/users'; +import { isLoggedIn, User } from '../../types/users'; import UserListItem from './components/UserListItem'; interface Props { - currentUser: { isLoggedIn: boolean; login?: string }; identityProviders: IdentityProvider[]; onUpdateUsers: () => void; updateTokensCount: (login: string, tokensCount: number) => void; @@ -33,13 +33,15 @@ interface Props { } export default function UsersList({ - currentUser, identityProviders, onUpdateUsers, updateTokensCount, users, manageProvider, }: Props) { + const userContext = React.useContext(CurrentUserContext); + const currentUser = userContext?.currentUser; + return ( <div className="boxed-group boxed-group-inner"> <table className="data zebra" id="users-list"> @@ -62,7 +64,7 @@ export default function UsersList({ identityProvider={identityProviders.find( (provider) => user.externalProvider === provider.key )} - isCurrentUser={currentUser.isLoggedIn && currentUser.login === user.login} + isCurrentUser={isLoggedIn(currentUser) && currentUser.login === user.login} key={user.login} onUpdateUsers={onUpdateUsers} updateTokensCount={updateTokensCount} 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 4661ee408db..23fce172487 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 @@ -18,6 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { act, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { byLabelText, byRole, byText } from 'testing-library-selector'; @@ -37,6 +38,7 @@ const ui = { allFilter: byRole('button', { name: 'all' }), managedFilter: byRole('button', { name: 'managed' }), localFilter: byRole('button', { name: 'local' }), + showMore: byRole('button', { name: 'show_more' }), aliceRow: byRole('row', { name: 'AM Alice Merveille alice.merveille never' }), aliceRowWithLocalBadge: byRole('row', { name: 'AM Alice Merveille alice.merveille local never', @@ -99,22 +101,37 @@ describe('in non managed mode', () => { renderUsersApp(); expect(await ui.aliceUpdateGroupButton.find()).toBeInTheDocument(); - expect(await ui.bobUpdateGroupButton.find()).toBeInTheDocument(); + expect(ui.bobUpdateGroupButton.get()).toBeInTheDocument(); }); it('should be able to update / change password / deactivate a user', async () => { renderUsersApp(); expect(await ui.aliceUpdateButton.find()).toBeInTheDocument(); - expect(await ui.bobUpdateButton.find()).toBeInTheDocument(); + expect(ui.bobUpdateButton.get()).toBeInTheDocument(); }); it('should render all users', async () => { renderUsersApp(); + expect(await ui.aliceRow.find()).toBeInTheDocument(); + expect(ui.bobRow.get()).toBeInTheDocument(); expect(ui.aliceRowWithLocalBadge.query()).not.toBeInTheDocument(); + }); + + it('should be able load more users', async () => { + const user = userEvent.setup(); + renderUsersApp(); + expect(await ui.aliceRow.find()).toBeInTheDocument(); - expect(await ui.bobRow.find()).toBeInTheDocument(); + expect(ui.bobRow.get()).toBeInTheDocument(); + expect(screen.getAllByRole('row')).toHaveLength(4); + + await act(async () => { + await user.click(await ui.showMore.find()); + }); + + expect(screen.getAllByRole('row')).toHaveLength(6); }); }); @@ -125,8 +142,9 @@ describe('in manage mode', () => { it('should not be able to create a user"', async () => { renderUsersApp(); - expect(await ui.createUserButton.get()).toBeDisabled(); + expect(await ui.infoManageMode.find()).toBeInTheDocument(); + expect(ui.createUserButton.get()).toBeDisabled(); }); it("should not be able to add/remove a user's group", async () => { @@ -134,8 +152,7 @@ describe('in manage mode', () => { expect(await ui.aliceRowWithLocalBadge.find()).toBeInTheDocument(); expect(ui.aliceUpdateGroupButton.query()).not.toBeInTheDocument(); - - expect(await ui.bobRow.find()).toBeInTheDocument(); + expect(ui.bobRow.get()).toBeInTheDocument(); expect(ui.bobUpdateGroupButton.query()).not.toBeInTheDocument(); }); @@ -152,7 +169,7 @@ describe('in manage mode', () => { expect(await ui.aliceRowWithLocalBadge.find()).toBeInTheDocument(); await user.click(ui.aliceUpdateButton.get()); - expect(await ui.alicedDeactivateButton.get()).toBeInTheDocument(); + expect(await ui.alicedDeactivateButton.find()).toBeInTheDocument(); }); it('should render list of all users', async () => { @@ -168,19 +185,25 @@ describe('in manage mode', () => { const user = userEvent.setup(); renderUsersApp(); - await user.click(await ui.managedFilter.find()); + expect(await ui.aliceRowWithLocalBadge.find()).toBeInTheDocument(); + + await act(async () => { + await user.click(await ui.managedFilter.find()); + }); + expect(await ui.bobRow.find()).toBeInTheDocument(); expect(ui.aliceRowWithLocalBadge.query()).not.toBeInTheDocument(); - expect(ui.bobRow.get()).toBeInTheDocument(); }); it('should render list of local users', async () => { const user = userEvent.setup(); renderUsersApp(); - await user.click(await ui.localFilter.find()); + await act(async () => { + await user.click(await ui.localFilter.find()); + }); - expect(ui.aliceRowWithLocalBadge.get()).toBeInTheDocument(); + expect(await ui.aliceRowWithLocalBadge.find()).toBeInTheDocument(); expect(ui.bobRow.query()).not.toBeInTheDocument(); }); }); diff --git a/server/sonar-web/src/main/js/apps/users/__tests__/UsersList-test.tsx b/server/sonar-web/src/main/js/apps/users/__tests__/UsersList-test.tsx deleted file mode 100644 index 51b3d06e945..00000000000 --- a/server/sonar-web/src/main/js/apps/users/__tests__/UsersList-test.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/* - * 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 { shallow } from 'enzyme'; -import * as React from 'react'; -import UsersList from '../UsersList'; - -const users = [ - { - login: 'luke', - name: 'Luke', - active: true, - scmAccounts: [], - local: false, - managed: false, - }, - { - login: 'obi', - name: 'One', - active: true, - scmAccounts: [], - local: false, - managed: false, - }, -]; - -it('should render correctly', () => { - expect(getWrapper()).toMatchSnapshot(); -}); - -function getWrapper(props = {}) { - return shallow( - <UsersList - currentUser={{ isLoggedIn: true, login: 'luke' }} - identityProviders={[ - { - backgroundColor: 'blue', - iconPath: 'icon/path', - key: 'foo', - name: 'Foo Provider', - }, - ]} - onUpdateUsers={jest.fn()} - updateTokensCount={jest.fn()} - users={users} - manageProvider={undefined} - {...props} - /> - ); -} diff --git a/server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/Header-test.tsx.snap b/server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/Header-test.tsx.snap deleted file mode 100644 index 88dc40cb905..00000000000 --- a/server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/Header-test.tsx.snap +++ /dev/null @@ -1,29 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should render correctly 1`] = ` -<div - className="page-header null-spacer-bottom" -> - <h2 - className="page-title" - > - users.page - </h2> - <div - className="page-actions" - > - <Button - disabled={false} - id="users-create" - onClick={[Function]} - > - users.create_user - </Button> - </div> - <p - className="page-description" - > - users.page.description - </p> -</div> -`; diff --git a/server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersList-test.tsx.snap b/server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersList-test.tsx.snap deleted file mode 100644 index 64566ee533b..00000000000 --- a/server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersList-test.tsx.snap +++ /dev/null @@ -1,80 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should render correctly 1`] = ` -<div - className="boxed-group boxed-group-inner" -> - <table - className="data zebra" - id="users-list" - > - <thead> - <tr> - <th /> - <th - className="nowrap" - /> - <th - className="nowrap" - > - my_profile.scm_accounts - </th> - <th - className="nowrap" - > - users.last_connection - </th> - <th - className="nowrap" - > - my_profile.groups - </th> - <th - className="nowrap" - > - users.tokens - </th> - <th - className="nowrap" - > - - </th> - </tr> - </thead> - <tbody> - <UserListItem - isCurrentUser={true} - key="luke" - onUpdateUsers={[MockFunction]} - updateTokensCount={[MockFunction]} - user={ - { - "active": true, - "local": false, - "login": "luke", - "managed": false, - "name": "Luke", - "scmAccounts": [], - } - } - /> - <UserListItem - isCurrentUser={false} - key="obi" - onUpdateUsers={[MockFunction]} - updateTokensCount={[MockFunction]} - user={ - { - "active": true, - "local": false, - "login": "obi", - "managed": false, - "name": "One", - "scmAccounts": [], - } - } - /> - </tbody> - </table> -</div> -`; diff --git a/server/sonar-web/src/main/js/components/controls/ManagedFilter.tsx b/server/sonar-web/src/main/js/components/controls/ManagedFilter.tsx new file mode 100644 index 00000000000..846d5959dcd --- /dev/null +++ b/server/sonar-web/src/main/js/components/controls/ManagedFilter.tsx @@ -0,0 +1,58 @@ +/* + * 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 * as React from 'react'; +import { translate } from '../../helpers/l10n'; +import ButtonToggle from './ButtonToggle'; + +interface ManagedFilterProps { + manageProvider: string | undefined; + loading: boolean; + managed: boolean | undefined; + setManaged: (managed: boolean | undefined) => void; +} + +export function ManagedFilter(props: ManagedFilterProps) { + const { manageProvider, loading, managed } = props; + + if (manageProvider === undefined) { + return null; + } + + return ( + <div className="big-spacer-right"> + <ButtonToggle + value={managed === undefined ? 'all' : managed} + disabled={loading} + options={[ + { label: translate('all'), value: 'all' }, + { label: translate('managed'), value: true }, + { label: translate('local'), value: false }, + ]} + onCheck={(filterOption) => { + if (filterOption === 'all') { + props.setManaged(undefined); + } else { + props.setManaged(filterOption as boolean); + } + }} + /> + </div> + ); +} diff --git a/server/sonar-web/src/main/js/apps/users/__tests__/Header-test.tsx b/server/sonar-web/src/main/js/components/hooks/useManageProvider.ts index cab66f21b2d..f27b576924c 100644 --- a/server/sonar-web/src/main/js/apps/users/__tests__/Header-test.tsx +++ b/server/sonar-web/src/main/js/components/hooks/useManageProvider.ts @@ -17,21 +17,21 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { shallow } from 'enzyme'; + import * as React from 'react'; -import { click } from '../../../helpers/testUtils'; -import Header from '../Header'; +import { useEffect } from 'react'; +import { getSystemInfo } from '../../api/system'; +import { SysInfoCluster } from '../../types/types'; -it('should render correctly', () => { - expect(getWrapper()).toMatchSnapshot(); -}); +export function useManageProvider(): string | undefined { + const [manageProvider, setManageProvider] = React.useState<string | undefined>(); -it('should open the user creation form', () => { - const wrapper = getWrapper(); - click(wrapper.find('#users-create')); - expect(wrapper.find('UserForm').exists()).toBe(true); -}); + useEffect(() => { + (async () => { + const info = (await getSystemInfo()) as SysInfoCluster; + setManageProvider(info.System['External Users and Groups Provisioning']); + })(); + }, []); -function getWrapper(props = {}) { - return shallow(<Header onUpdateUsers={jest.fn()} {...props} />); + return manageProvider; } |