From 33cf03a9e6316e69e53c3960d43dde522d17583f Mon Sep 17 00:00:00 2001 From: guillaume-peoch-sonarsource Date: Mon, 27 Mar 2023 10:06:34 +0200 Subject: [PATCH] SONAR-18888 UserApp and GroupApp refactorization --- .../src/main/js/api/mocks/UsersServiceMock.ts | 17 +- .../current-user/CurrentUserContext.ts | 12 +- .../main/js/apps/groups/components/App.tsx | 294 ------------------ .../{DeleteForm.tsx => DeleteGroupForm.tsx} | 15 +- .../main/js/apps/groups/components/Form.tsx | 119 ------- .../js/apps/groups/components/GroupForm.tsx | 136 ++++++++ .../js/apps/groups/components/GroupsApp.tsx | 118 +++++++ .../main/js/apps/groups/components/Header.tsx | 11 +- .../main/js/apps/groups/components/List.tsx | 8 +- .../js/apps/groups/components/ListItem.tsx | 33 +- .../components/__tests__/GroupsApp-it.tsx | 67 ++-- .../groups/components/__tests__/List-test.tsx | 10 +- .../components/__tests__/ListItem-test.tsx | 9 +- .../__snapshots__/List-test.tsx.snap | 12 +- .../src/main/js/apps/groups/routes.tsx | 2 +- .../src/main/js/apps/users/UsersApp.tsx | 236 +++++--------- .../src/main/js/apps/users/UsersList.tsx | 10 +- .../js/apps/users/__tests__/UsersApp-it.tsx | 45 ++- .../apps/users/__tests__/UsersList-test.tsx | 66 ---- .../__snapshots__/Header-test.tsx.snap | 29 -- .../__snapshots__/UsersList-test.tsx.snap | 80 ----- .../js/components/controls/ManagedFilter.tsx | 58 ++++ .../hooks/useManageProvider.ts} | 26 +- 23 files changed, 563 insertions(+), 850 deletions(-) delete mode 100644 server/sonar-web/src/main/js/apps/groups/components/App.tsx rename server/sonar-web/src/main/js/apps/groups/components/{DeleteForm.tsx => DeleteGroupForm.tsx} (82%) delete mode 100644 server/sonar-web/src/main/js/apps/groups/components/Form.tsx create mode 100644 server/sonar-web/src/main/js/apps/groups/components/GroupForm.tsx create mode 100644 server/sonar-web/src/main/js/apps/groups/components/GroupsApp.tsx delete mode 100644 server/sonar-web/src/main/js/apps/users/__tests__/UsersList-test.tsx delete mode 100644 server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/Header-test.tsx.snap delete mode 100644 server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersList-test.tsx.snap create mode 100644 server/sonar-web/src/main/js/components/controls/ManagedFilter.tsx rename server/sonar-web/src/main/js/{apps/users/__tests__/Header-test.tsx => components/hooks/useManageProvider.ts} (63%) 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( - undefined -); +export const CurrentUserContext = React.createContext({ + 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 ( - <> - - -
-
- -
- {manageProvider !== undefined && ( -
- { - if (filterOption === 'all') { - this.setState({ managed: undefined }); - } else { - this.setState({ managed: filterOption as boolean }); - } - }} - /> -
- )} - this.setState({ query: q })} - placeholder={translate('search.search_by_name')} - value={query} - /> -
- - {groups !== undefined && ( - this.setState({ groupToBeDeleted })} - onEdit={(editedGroup) => this.setState({ editedGroup })} - onEditMembers={this.refresh} - manageProvider={manageProvider} - /> - )} - - {groups !== undefined && paging !== undefined && ( - - )} - - {groupToBeDeleted && ( - this.setState({ groupToBeDeleted: undefined })} - onSubmit={this.handleDelete} - /> - )} - - {editedGroup && ( -
this.setState({ editedGroup: undefined })} - onSubmit={this.handleEdit} - /> - )} -
- - ); - } -} 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 similarity index 82% rename from server/sonar-web/src/main/js/apps/groups/components/DeleteForm.tsx rename to 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; + 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 ( - + {({ onCloseClick, onFormSubmit, submitting }) => (
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; -} - -interface State { - description: string; - name: string; -} - -export default class Form extends React.PureComponent { - 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) => { - this.setState({ description: event.currentTarget.value }); - }; - - handleNameChange = (event: React.SyntheticEvent) => { - this.setState({ name: event.currentTarget.value }); - }; - - render() { - return ( - - {({ onCloseClick, onFormSubmit, submitting }) => ( - -
-

{this.props.header}

-
- -
- -
- - -
-
- -