]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-21086 Refactor using ReactQuery
authorguillaume-peoch-sonarsource <guillaume.peoch@sonarsource.com>
Tue, 21 Nov 2023 15:05:27 +0000 (16:05 +0100)
committersonartech <sonartech@sonarsource.com>
Thu, 23 Nov 2023 20:02:58 +0000 (20:02 +0000)
server/sonar-web/src/main/js/api/mocks/GroupsServiceMock.ts
server/sonar-web/src/main/js/api/user_groups.ts
server/sonar-web/src/main/js/apps/groups/GroupsApp.tsx
server/sonar-web/src/main/js/apps/groups/__tests__/GroupsApp-it.tsx
server/sonar-web/src/main/js/apps/groups/components/DeleteGroupForm.tsx
server/sonar-web/src/main/js/apps/groups/components/GroupForm.tsx
server/sonar-web/src/main/js/apps/groups/components/Header.tsx
server/sonar-web/src/main/js/apps/groups/components/List.tsx
server/sonar-web/src/main/js/apps/groups/components/ListItem.tsx
server/sonar-web/src/main/js/queries/groups.ts [new file with mode: 0644]

index a1ca3a73498068ac56261c7af596a617b626c89a..d86e38376cf70e46a64af4da3fa51198e31908b4 100644 (file)
@@ -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 },
     });
   };
 
index 247a2f259f939f76ebaf9c48068d9050b3ccf22c..61faf130605ffd0e0ea8da1ba8e58cae684b8b22 100644 (file)
@@ -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;
index 5a18957633cf479747af5004ec459038da6e23c0..aa4f890f7688b1eb056a970952658ab24e2ab74c 100644 (file)
@@ -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<boolean>(true);
-  const [paging, setPaging] = useState<Paging>();
+  const [numberOfPages, setNumberOfPages] = useState<number>(1);
   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]);
+  const { groups, total, isLoading } = useGroupsQueries(
+    {
+      q: search,
+      managed,
+    },
+    numberOfPages,
+  );
 
   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} />
+        <Header manageProvider={manageProvider} />
         {manageProvider === Provider.Github && <GitHubSynchronisationWarning short />}
 
         <div className="display-flex-justify-start big-spacer-bottom big-spacer-top">
           <ManagedFilter
             manageProvider={manageProvider}
-            loading={loading}
+            loading={isLoading}
             managed={managed}
             setManaged={setManaged}
           />
@@ -101,19 +70,17 @@ export default function GroupsApp() {
           />
         </div>
 
-        <List groups={groups} reload={fetchGroups} manageProvider={manageProvider} />
+        <List groups={groups} manageProvider={manageProvider} />
 
-        {paging !== undefined && (
-          <div id="groups-list-footer">
-            <ListFooter
-              count={groups.length}
-              loading={loading}
-              loadMore={fetchMoreGroups}
-              ready={!loading}
-              total={paging.total}
-            />
-          </div>
-        )}
+        <div id="groups-list-footer">
+          <ListFooter
+            count={groups.length}
+            loading={isLoading}
+            loadMore={() => setNumberOfPages((n) => n + 1)}
+            ready={!isLoading}
+            total={total}
+          />
+        </div>
       </main>
     </>
   );
index d6e591c004b27b202cb6447d63f739eb548a386e..34263fe216e19f580cb5daa4edea68385b33eba6 100644 (file)
@@ -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();
     });
   });
index 3345f248d988dfdd08309ef77df4c687128968c9..6b491854dbefc6ffbcb409fc0168dea880680b35 100644 (file)
  * 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 (
     <SimpleModal header={header} onClose={props.onClose} onSubmit={onSubmit}>
       {({ onCloseClick, onFormSubmit, submitting }) => (
index b13b23bb7f2de48d1c5c28f44727ace365908976..84eff7ad0120069ce63426146e5fac263995ab02 100644 (file)
@@ -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<string>('');
-  const [description, setDescription] = useState<string>('');
+  const [name, setName] = useState<string>(create ? '' : group.name);
+  const [description, setDescription] = useState<string>(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 (
     <SimpleModal
       header={create ? translate('groups.create_group') : translate('groups.update_group')}
       onClose={props.onClose}
-      onSubmit={handleSubmit}
+      onSubmit={create ? handleCreateGroup : handleUpdateGroup}
       size="small"
     >
       {({ onCloseClick, onFormSubmit, submitting }) => (
index 5f7f7d83c361989ed2fdd3fc4c21f571345a810d..f285ff37c38d42e24132ca7903133bfc8866fb59 100644 (file)
@@ -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) {
           </Alert>
         )}
       </div>
-      {createModal && (
-        <GroupForm onClose={() => setCreateModal(false)} create reload={props.reload} />
-      )}
+      {createModal && <GroupForm onClose={() => setCreateModal(false)} create />}
     </>
   );
 }
index b15de183e9ca88856454b8843ad54ee6758d69e5..b699b7eb70a859715a3fa3cef226932ba326f5b9 100644 (file)
@@ -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) {
         </thead>
         <tbody>
           {sortBy(groups, (group) => group.name.toLowerCase()).map((group) => (
-            <ListItem
-              group={group}
-              key={group.name}
-              reload={props.reload}
-              manageProvider={manageProvider}
-            />
+            <ListItem group={group} key={group.name} manageProvider={manageProvider} />
           ))}
         </tbody>
       </table>
index 4c1ccea5f3158ea8221bd72b5b9ea87d07c3c3a6..561c46536ce42423ec919730ead5fb02bcd6bb52 100644 (file)
@@ -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<Group | undefined>();
   const [groupToEdit, setGroupToEdit] = useState<Group | undefined>();
 
+  const { data: membersCount, isLoading, refetch } = useMembersCountQuery(group.name);
+
   const isManaged = () => {
     return manageProvider !== undefined;
   };
@@ -73,8 +76,10 @@ export default function ListItem(props: ListItemProps) {
       </td>
 
       <td className="group-members display-flex-justify-end" headers="list-group-member">
-        <span>{membersCount}</span>
-        <Members group={group} onEdit={props.reload} isManaged={isManaged()} />
+        <Spinner loading={isLoading}>
+          <span>{membersCount}</span>
+        </Spinner>
+        <Members group={group} onEdit={refetch} isManaged={isManaged()} />
       </td>
 
       <td className="width-40" headers="list-group-description">
@@ -107,19 +112,10 @@ export default function ListItem(props: ListItemProps) {
           </ActionsDropdown>
         )}
         {groupToDelete && (
-          <DeleteGroupForm
-            group={groupToDelete}
-            reload={props.reload}
-            onClose={() => setGroupToDelete(undefined)}
-          />
+          <DeleteGroupForm group={groupToDelete} onClose={() => setGroupToDelete(undefined)} />
         )}
         {groupToEdit && (
-          <GroupForm
-            create={false}
-            group={groupToEdit}
-            reload={props.reload}
-            onClose={() => setGroupToEdit(undefined)}
-          />
+          <GroupForm create={false} group={groupToEdit} onClose={() => setGroupToEdit(undefined)} />
         )}
       </td>
     </tr>
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 (file)
index 0000000..a759259
--- /dev/null
@@ -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<Parameters<typeof getUsersGroups>[0], 'pageSize' | 'pageIndex'>,
+  numberOfPages: number,
+) {
+  type QueryKey = [
+    'group',
+    'list',
+    number,
+    Omit<Parameters<typeof getUsersGroups>[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<QueryKey>) =>
+        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<typeof createGroup>[0]) => createGroup(data),
+    onSuccess() {
+      queryClient.invalidateQueries({ queryKey: ['group', 'list'] });
+    },
+  });
+}
+
+export function useUpdateGroupMutation() {
+  const queryClient = useQueryClient();
+
+  return useMutation({
+    mutationFn: (data: Parameters<typeof updateGroup>[0]) => updateGroup(data),
+    onSuccess() {
+      queryClient.invalidateQueries({ queryKey: ['group', 'list'] });
+    },
+  });
+}
+
+export function useDeleteGroupMutation() {
+  const queryClient = useQueryClient();
+
+  return useMutation({
+    mutationFn: (data: Parameters<typeof deleteGroup>[0]) => deleteGroup(data),
+    onSuccess() {
+      queryClient.invalidateQueries({ queryKey: ['group', 'list'] });
+    },
+  });
+}