addUserToGroup,
createGroup,
deleteGroup,
+ getUsersGroups,
getUsersInGroup,
removeUserFromGroup,
- searchUsersGroups,
updateGroup,
} from '../user_groups';
import { getIdentityProviders } from '../users';
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));
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 },
});
};
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;
* 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';
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}
/>
/>
</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>
</>
);
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' }),
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/),
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();
});
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 () => {
await user.click(await ui.localFilter.find());
});
- expect(ui.localGroupRowWithLocalBadge.get()).toBeInTheDocument();
+ expect(await ui.localGroupRowWithLocalBadge.find()).toBeInTheDocument();
expect(ui.managedGroupRow.query()).not.toBeInTheDocument();
});
});
expect(
- within(ui.githubManagedGroupRow.get()).getByRole('img', { name: 'github' }),
+ within(await ui.githubManagedGroupRow.find()).getByRole('img', { name: 'github' }),
).toBeInTheDocument();
});
});
* 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 }) => (
* 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';
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 =
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 }) => (
import GroupForm from './GroupForm';
interface HeaderProps {
- reload: () => void;
manageProvider?: string;
}
</Alert>
)}
</div>
- {createModal && (
- <GroupForm onClose={() => setCreateModal(false)} create reload={props.reload} />
- )}
+ {createModal && <GroupForm onClose={() => setCreateModal(false)} create />}
</>
);
}
interface Props {
groups: Group[];
- reload: () => void;
manageProvider: string | undefined;
}
</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>
* 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, {
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';
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;
};
</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">
</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>
--- /dev/null
+/*
+ * 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'] });
+ },
+ });
+}