From 94a8b446ce8a36f3410a4c6c92da737f93bce5f8 Mon Sep 17 00:00:00 2001 From: guillaume-peoch-sonarsource Date: Tue, 21 Nov 2023 16:05:27 +0100 Subject: [PATCH] SONAR-21086 Refactor using ReactQuery --- .../main/js/api/mocks/GroupsServiceMock.ts | 32 +++--- .../sonar-web/src/main/js/api/user_groups.ts | 2 +- .../src/main/js/apps/groups/GroupsApp.tsx | 77 ++++--------- .../js/apps/groups/__tests__/GroupsApp-it.tsx | 20 ++-- .../groups/components/DeleteGroupForm.tsx | 23 ++-- .../js/apps/groups/components/GroupForm.tsx | 56 ++++----- .../main/js/apps/groups/components/Header.tsx | 5 +- .../main/js/apps/groups/components/List.tsx | 8 +- .../js/apps/groups/components/ListItem.tsx | 26 ++--- .../sonar-web/src/main/js/queries/groups.ts | 108 ++++++++++++++++++ 10 files changed, 209 insertions(+), 148 deletions(-) create mode 100644 server/sonar-web/src/main/js/queries/groups.ts diff --git a/server/sonar-web/src/main/js/api/mocks/GroupsServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/GroupsServiceMock.ts index a1ca3a73498..d86e38376cf 100644 --- a/server/sonar-web/src/main/js/api/mocks/GroupsServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/GroupsServiceMock.ts @@ -39,9 +39,9 @@ import { addUserToGroup, createGroup, deleteGroup, + getUsersGroups, getUsersInGroup, removeUserFromGroup, - searchUsersGroups, updateGroup, } from '../user_groups'; import { getIdentityProviders } from '../users'; @@ -79,7 +79,7 @@ export default class GroupsServiceMock { jest.mocked(getSystemInfo).mockImplementation(this.handleGetSystemInfo); jest.mocked(getIdentityProviders).mockImplementation(this.handleGetIdentityProviders); - jest.mocked(searchUsersGroups).mockImplementation((p) => this.handleSearchUsersGroups(p)); + jest.mocked(getUsersGroups).mockImplementation((p) => this.handleSearchUsersGroups(p)); jest.mocked(createGroup).mockImplementation((g) => this.handleCreateGroup(g)); jest.mocked(deleteGroup).mockImplementation((g) => this.handleDeleteGroup(g)); jest.mocked(updateGroup).mockImplementation((g) => this.handleUpdateGroup(g)); @@ -154,20 +154,22 @@ export default class GroupsServiceMock { q?: string; selected?: string; }): Promise<{ paging: Paging; users: UserGroupMember[] }> => { + const users = this.users + .filter((u) => u.name.includes(data.q ?? '')) + .filter((u) => { + switch (data.selected) { + case 'selected': + return u.selected; + case 'deselected': + return !u.selected; + default: + return true; + } + }); + return this.reply({ - users: this.users - .filter((u) => u.name.includes(data.q ?? '')) - .filter((u) => { - switch (data.selected) { - case 'selected': - return u.selected; - case 'deselected': - return !u.selected; - default: - return true; - } - }), - paging: { ...this.paging }, + users, + paging: { ...this.paging, total: users.length }, }); }; diff --git a/server/sonar-web/src/main/js/api/user_groups.ts b/server/sonar-web/src/main/js/api/user_groups.ts index 247a2f259f9..61faf130605 100644 --- a/server/sonar-web/src/main/js/api/user_groups.ts +++ b/server/sonar-web/src/main/js/api/user_groups.ts @@ -21,7 +21,7 @@ import { throwGlobalError } from '../helpers/error'; import { getJSON, post, postJSON } from '../helpers/request'; import { Group, Paging, UserGroupMember } from '../types/types'; -export function searchUsersGroups(data: { +export function getUsersGroups(data: { f?: string; p?: number; ps?: number; diff --git a/server/sonar-web/src/main/js/apps/groups/GroupsApp.tsx b/server/sonar-web/src/main/js/apps/groups/GroupsApp.tsx index 5a18957633c..aa4f890f768 100644 --- a/server/sonar-web/src/main/js/apps/groups/GroupsApp.tsx +++ b/server/sonar-web/src/main/js/apps/groups/GroupsApp.tsx @@ -18,9 +18,8 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { useCallback, useEffect, useState } from 'react'; +import { useState } from 'react'; import { Helmet } from 'react-helmet-async'; -import { searchUsersGroups } from '../../api/user_groups'; import GitHubSynchronisationWarning from '../../app/components/GitHubSynchronisationWarning'; import ListFooter from '../../components/controls/ListFooter'; import { ManagedFilter } from '../../components/controls/ManagedFilter'; @@ -28,67 +27,37 @@ import SearchBox from '../../components/controls/SearchBox'; import Suggestions from '../../components/embed-docs-modal/Suggestions'; import { Provider, useManageProvider } from '../../components/hooks/useManageProvider'; import { translate } from '../../helpers/l10n'; -import { Group, Paging } from '../../types/types'; +import { useGroupsQueries } from '../../queries/groups'; import Header from './components/Header'; import List from './components/List'; import './groups.css'; export default function GroupsApp() { - const [loading, setLoading] = useState(true); - const [paging, setPaging] = useState(); + const [numberOfPages, setNumberOfPages] = useState(1); const [search, setSearch] = useState(''); - const [groups, setGroups] = useState([]); const [managed, setManaged] = useState(); 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]); + const { groups, total, isLoading } = useGroupsQueries( + { + q: search, + managed, + }, + numberOfPages, + ); return ( <>
-
+
{manageProvider === Provider.Github && }
@@ -101,19 +70,17 @@ export default function GroupsApp() { />
- + - {paging !== undefined && ( - - )} +
); diff --git a/server/sonar-web/src/main/js/apps/groups/__tests__/GroupsApp-it.tsx b/server/sonar-web/src/main/js/apps/groups/__tests__/GroupsApp-it.tsx index d6e591c004b..34263fe216e 100644 --- a/server/sonar-web/src/main/js/apps/groups/__tests__/GroupsApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/groups/__tests__/GroupsApp-it.tsx @@ -64,8 +64,8 @@ const ui = { membersDialog: byRole('dialog', { name: 'users.update' }), getMembers: () => within(ui.membersDialog.get()).getAllByRole('checkbox'), - managedGroupRow: byRole('row', { name: 'managed-group 1' }), - githubManagedGroupRow: byRole('row', { name: 'managed-group github 1' }), + managedGroupRow: byRole('row', { name: 'managed-group 3' }), + githubManagedGroupRow: byRole('row', { name: 'managed-group github 3' }), managedGroupEditMembersButton: byRole('button', { name: 'groups.users.edit.managed-group' }), managedGroupViewMembersButton: byRole('button', { name: 'groups.users.view.managed-group' }), @@ -75,13 +75,13 @@ const ui = { managedEditButton: byRole('button', { name: 'groups.edit.managed-group' }), - localGroupRow: byRole('row', { name: 'local-group 1' }), + localGroupRow: byRole('row', { name: 'local-group 3' }), localGroupEditMembersButton: byRole('button', { name: 'groups.users.edit.local-group' }), - localGroupRow2: byRole('row', { name: 'local-group 2 1 group 2 is loco!' }), - editedLocalGroupRow: byRole('row', { name: 'local-group 3 1 group 3 rocks!' }), + localGroupRow2: byRole('row', { name: 'local-group 2 3 group 2 is loco!' }), + editedLocalGroupRow: byRole('row', { name: 'local-group 3 3 group 3 rocks!' }), localEditButton: byRole('button', { name: 'groups.edit.local-group' }), localGroupRowWithLocalBadge: byRole('row', { - name: 'local-group local 1', + name: 'local-group local 3', }), githubProvisioningPending: byText(/synchronization_pending/), @@ -299,7 +299,7 @@ describe('in manage mode', () => { await act(async () => expect(await ui.localAndManagedFilter.find()).toBeInTheDocument()); - expect(ui.localGroupRowWithLocalBadge.get()).toBeInTheDocument(); + expect(await ui.localGroupRowWithLocalBadge.find()).toBeInTheDocument(); expect(ui.managedGroupRow.get()).toBeInTheDocument(); }); @@ -311,8 +311,8 @@ describe('in manage mode', () => { await user.click(await ui.managedFilter.find()); }); + expect(await ui.managedGroupRow.find()).toBeInTheDocument(); expect(ui.localGroupRow.query()).not.toBeInTheDocument(); - expect(ui.managedGroupRow.get()).toBeInTheDocument(); }); it('should render list of local groups', async () => { @@ -323,7 +323,7 @@ describe('in manage mode', () => { await user.click(await ui.localFilter.find()); }); - expect(ui.localGroupRowWithLocalBadge.get()).toBeInTheDocument(); + expect(await ui.localGroupRowWithLocalBadge.find()).toBeInTheDocument(); expect(ui.managedGroupRow.query()).not.toBeInTheDocument(); }); @@ -395,7 +395,7 @@ describe('in manage mode', () => { }); expect( - within(ui.githubManagedGroupRow.get()).getByRole('img', { name: 'github' }), + within(await ui.githubManagedGroupRow.find()).getByRole('img', { name: 'github' }), ).toBeInTheDocument(); }); }); diff --git a/server/sonar-web/src/main/js/apps/groups/components/DeleteGroupForm.tsx b/server/sonar-web/src/main/js/apps/groups/components/DeleteGroupForm.tsx index 3345f248d98..6b491854dbe 100644 --- a/server/sonar-web/src/main/js/apps/groups/components/DeleteGroupForm.tsx +++ b/server/sonar-web/src/main/js/apps/groups/components/DeleteGroupForm.tsx @@ -18,30 +18,33 @@ * 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 SimpleModal from '../../../components/controls/SimpleModal'; import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons'; import Spinner from '../../../components/ui/Spinner'; import { translate, translateWithParameters } from '../../../helpers/l10n'; +import { useDeleteGroupMutation } from '../../../queries/groups'; import { Group } from '../../../types/types'; interface Props { group: Group; onClose: () => void; - reload: () => void; } export default function DeleteGroupForm(props: Props) { - const header = translate('groups.delete_group'); - const { group, reload, onClose } = props; + const { group } = props; + + const { mutate: deleteGroup } = useDeleteGroupMutation(); - const onSubmit = useCallback(async () => { - await deleteGroup({ name: group.name }); - reload(); - onClose(); - }, [group, reload, onClose]); + const onSubmit = () => { + deleteGroup( + { name: group.name }, + { + onSuccess: props.onClose, + }, + ); + }; + const header = translate('groups.delete_group'); return ( {({ onCloseClick, onFormSubmit, submitting }) => ( 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 index b13b23bb7f2..84eff7ad012 100644 --- a/server/sonar-web/src/main/js/apps/groups/components/GroupForm.tsx +++ b/server/sonar-web/src/main/js/apps/groups/components/GroupForm.tsx @@ -18,8 +18,7 @@ * 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 { useState } from 'react'; import SimpleModal from '../../../components/controls/SimpleModal'; import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons'; import MandatoryFieldMarker from '../../../components/ui/MandatoryFieldMarker'; @@ -27,6 +26,7 @@ import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsEx import Spinner from '../../../components/ui/Spinner'; import { translate } from '../../../helpers/l10n'; import { omitNil } from '../../../helpers/request'; +import { useCreateGroupMutation, useUpdateGroupMutation } from '../../../queries/groups'; import { Group } from '../../../types/types'; type Props = @@ -34,52 +34,46 @@ 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 { group, create } = props; - const [name, setName] = useState(''); - const [description, setDescription] = useState(''); + const [name, setName] = useState(create ? '' : group.name); + const [description, setDescription] = useState(create ? '' : group.description ?? ''); - 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]); + const { mutate: createGroup } = useCreateGroupMutation(); + const { mutate: updateGroup } = useUpdateGroupMutation(); + + const handleCreateGroup = () => { + createGroup({ name, description }, { onSuccess: props.onClose }); + }; - useEffect(() => { - if (!create) { - setDescription(group.description ?? ''); - setName(group.name); + const handleUpdateGroup = () => { + if (!group) { + return; } - }, []); + updateGroup( + { + currentName: group.name, + description, + // pass `name` only if it has changed, otherwise the WS fails + ...omitNil({ name: name !== group.name ? name : undefined }), + }, + { onSuccess: props.onClose }, + ); + }; return ( {({ onCloseClick, onFormSubmit, submitting }) => ( 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 5f7f7d83c36..f285ff37c38 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 @@ -26,7 +26,6 @@ import { translate } from '../../../helpers/l10n'; import GroupForm from './GroupForm'; interface HeaderProps { - reload: () => void; manageProvider?: string; } @@ -68,9 +67,7 @@ export default function Header(props: HeaderProps) { )} - {createModal && ( - setCreateModal(false)} create reload={props.reload} /> - )} + {createModal && setCreateModal(false)} create />} ); } 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 b15de183e9c..b699b7eb70a 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,7 +25,6 @@ import ListItem from './ListItem'; interface Props { groups: Group[]; - reload: () => void; manageProvider: string | undefined; } @@ -49,12 +48,7 @@ export default function List(props: Props) { {sortBy(groups, (group) => group.name.toLowerCase()).map((group) => ( - + ))} 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 4c1ccea5f31..561c46536ce 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 @@ -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 { Spinner } from 'design-system'; import * as React from 'react'; import { useState } from 'react'; import ActionsDropdown, { @@ -26,6 +27,7 @@ import ActionsDropdown, { import { Provider } from '../../../components/hooks/useManageProvider'; import { translate, translateWithParameters } from '../../../helpers/l10n'; import { getBaseUrl } from '../../../helpers/system'; +import { useMembersCountQuery } from '../../../queries/groups'; import { Group } from '../../../types/types'; import DeleteGroupForm from './DeleteGroupForm'; import GroupForm from './GroupForm'; @@ -33,17 +35,18 @@ import Members from './Members'; export interface ListItemProps { group: Group; - reload: () => void; manageProvider: string | undefined; } export default function ListItem(props: ListItemProps) { const { manageProvider, group } = props; - const { name, managed, membersCount, description } = group; + const { name, managed, description } = group; const [groupToDelete, setGroupToDelete] = useState(); const [groupToEdit, setGroupToEdit] = useState(); + const { data: membersCount, isLoading, refetch } = useMembersCountQuery(group.name); + const isManaged = () => { return manageProvider !== undefined; }; @@ -73,8 +76,10 @@ export default function ListItem(props: ListItemProps) { - {membersCount} - + + {membersCount} + + @@ -107,19 +112,10 @@ export default function ListItem(props: ListItemProps) { )} {groupToDelete && ( - setGroupToDelete(undefined)} - /> + setGroupToDelete(undefined)} /> )} {groupToEdit && ( - setGroupToEdit(undefined)} - /> + setGroupToEdit(undefined)} /> )} diff --git a/server/sonar-web/src/main/js/queries/groups.ts b/server/sonar-web/src/main/js/queries/groups.ts new file mode 100644 index 00000000000..a7592593612 --- /dev/null +++ b/server/sonar-web/src/main/js/queries/groups.ts @@ -0,0 +1,108 @@ +/* + * 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, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; +import { range } from 'lodash'; +import { + createGroup, + deleteGroup, + getUsersGroups, + getUsersInGroup, + updateGroup, +} from '../api/user_groups'; +import { Group } from '../types/types'; + +const STALE_TIME = 4 * 60 * 1000; + +export function useGroupsQueries( + getParams: Omit[0], 'pageSize' | 'pageIndex'>, + numberOfPages: number, +) { + type QueryKey = [ + 'group', + 'list', + number, + Omit[0], 'pageSize' | 'pageIndex'>, + ]; + const results = useQueries({ + queries: range(1, numberOfPages + 1).map((page: number) => ({ + queryKey: ['group', 'list', page, getParams], + queryFn: ({ queryKey: [_u, _l, page, getParams] }: QueryFunctionContext) => + getUsersGroups({ ...getParams, p: page }), + staleTime: STALE_TIME, + })), + }); + + return results.reduce<{ groups: Group[]; total: number | undefined; isLoading: boolean }>( + (acc, { data, isLoading }) => ({ + groups: acc.groups.concat(data?.groups ?? []), + total: data?.paging.total, + isLoading: acc.isLoading || isLoading, + }), + { groups: [], total: 0, isLoading: false }, + ); +} + +export function useMembersCountQuery(name: string) { + return useQuery({ + queryKey: ['group', name, 'members', 'total'], + queryFn: () => getUsersInGroup({ name, ps: 1 }).then((r) => r.paging.total), + staleTime: STALE_TIME, + }); +} + +export function useCreateGroupMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: Parameters[0]) => createGroup(data), + onSuccess() { + queryClient.invalidateQueries({ queryKey: ['group', 'list'] }); + }, + }); +} + +export function useUpdateGroupMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: Parameters[0]) => updateGroup(data), + onSuccess() { + queryClient.invalidateQueries({ queryKey: ['group', 'list'] }); + }, + }); +} + +export function useDeleteGroupMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: Parameters[0]) => deleteGroup(data), + onSuccess() { + queryClient.invalidateQueries({ queryKey: ['group', 'list'] }); + }, + }); +} -- 2.39.5