feat: add filtering and pagination to group members page (#23392)

Makes use of the new group members endpoint added in
https://github.com/coder/coder/pull/23067
This commit is contained in:
Asher
2026-03-21 16:58:08 -08:00
committed by GitHub
parent ecc28a6650
commit 4b707515c0
9 changed files with 298 additions and 115 deletions
+20 -1
View File
@@ -29,6 +29,7 @@ import type {
DeleteExternalAuthByIDResponse,
DynamicParametersRequest,
PostWorkspaceUsageRequest,
UsersRequest,
} from "./typesGenerated";
import * as TypesGen from "./typesGenerated";
@@ -2134,10 +2135,28 @@ class ApiMethods {
getGroup = async (
organization: string,
groupName: string,
req: TypesGen.GroupRequest,
signal?: AbortSignal,
): Promise<TypesGen.Group> => {
const response = await this.axios.get(
const url = getURLWithSearchParams(
`/api/v2/organizations/${organization}/groups/${groupName}`,
req,
);
const response = await this.axios.get(url, { signal });
return response.data;
};
getGroupMembers = async (
organization: string,
groupName: string,
filter?: UsersRequest,
signal?: AbortSignal,
): Promise<TypesGen.GroupMembersResponse> => {
const url = getURLWithSearchParams(
`/api/v2/organizations/${organization}/groups/${groupName}/members`,
filter,
);
const response = await this.axios.get(url.toString(), { signal });
return response.data;
};
+54 -5
View File
@@ -2,9 +2,14 @@ import { API } from "api/api";
import type {
CreateGroupRequest,
Group,
GroupMembersResponse,
GroupRequest,
PatchGroupRequest,
UsersRequest,
} from "api/typesGenerated";
import type { UsePaginatedQueryOptions } from "hooks/usePaginatedQuery";
import type { QueryClient, UseQueryOptions } from "react-query";
import { prepareQuery } from "utils/filters";
type GroupSortOrder = "asc" | "desc";
@@ -31,20 +36,64 @@ export const groupsByOrganization = (organization: string) => {
} satisfies UseQueryOptions<Group[]>;
};
export const getGroupQueryKey = (organization: string, groupName: string) => [
const getRootGroupQueryKey = (organization: string, groupName: string) => [
"organization",
organization,
"group",
groupName,
];
export const group = (organization: string, groupName: string) => {
export const getGroupQueryKey = (
organization: string,
groupName: string,
req: GroupRequest,
) => {
const base = getRootGroupQueryKey(organization, groupName);
return [...base, req];
};
export const group = (
organization: string,
groupName: string,
req: GroupRequest,
): UseQueryOptions<Group> => {
return {
queryKey: getGroupQueryKey(organization, groupName),
queryFn: () => API.getGroup(organization, groupName),
queryKey: getGroupQueryKey(organization, groupName, req),
queryFn: ({ signal }) => API.getGroup(organization, groupName, req, signal),
};
};
export const getGroupMembersQueryKey = (
organization: string,
groupName: string,
req?: UsersRequest,
) => {
const base = [...getRootGroupQueryKey(organization, groupName), "members"];
return req ? [...base, req] : base;
};
export function groupMembers(
organization: string,
groupName: string,
searchParams: URLSearchParams,
): UsePaginatedQueryOptions<GroupMembersResponse, UsersRequest> {
return {
searchParams,
queryPayload: ({ limit, offset }) => {
return {
limit,
offset,
q: prepareQuery(searchParams.get("filter") ?? ""),
};
},
queryKey: ({ payload }) =>
getGroupMembersQueryKey(organization, groupName, payload),
queryFn: ({ payload, signal }) =>
API.getGroupMembers(organization, groupName, payload, signal),
};
}
export type GroupsByUserId = Readonly<Map<string, readonly Group[]>>;
export function groupsByUserId() {
@@ -183,7 +232,7 @@ const invalidateGroup = (
queryKey: getGroupsByOrganizationQueryKey(organization),
}),
queryClient.invalidateQueries({
queryKey: getGroupQueryKey(organization, groupName),
queryKey: getRootGroupQueryKey(organization, groupName),
}),
]);
@@ -57,8 +57,8 @@ const PRESET_FILTERS = [
interface UsersFilterProps {
filter: ReturnType<typeof useFilter>;
error?: unknown;
menus: {
status: StatusFilterMenu;
menus?: {
status?: StatusFilterMenu;
};
}
@@ -69,11 +69,11 @@ export const UsersFilter: FC<UsersFilterProps> = ({ filter, error, menus }) => {
learnMoreLink={docs("/admin/users#user-filtering")}
learnMoreLabel2="User status"
learnMoreLink2={docs("/admin/users#user-status")}
isLoading={menus.status.isInitializing}
isLoading={menus?.status?.isInitializing ?? false}
filter={filter}
error={error}
options={<StatusMenu {...menus.status} />}
optionsSkeleton={<MenuSkeleton />}
options={menus?.status && <StatusMenu {...menus.status} />}
optionsSkeleton={menus?.status && <MenuSkeleton />}
/>
);
};
+67 -80
View File
@@ -16,7 +16,9 @@ import {
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { EmptyState } from "components/EmptyState/EmptyState";
import { UsersFilter } from "components/Filter/UsersFilter";
import { LastSeen } from "components/LastSeen/LastSeen";
import { PaginationContainer } from "components/PaginationWidget/PaginationContainer";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack";
import {
@@ -27,10 +29,6 @@ import {
TableHeader,
TableRow,
} from "components/Table/Table";
import {
PaginationStatus,
TableToolbar,
} from "components/TableToolbar/TableToolbar";
import { MemberAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
import { EllipsisVertical, UserPlusIcon } from "lucide-react";
import { isEveryoneGroup } from "modules/groups";
@@ -43,9 +41,11 @@ import type { GroupPageOutletContext } from "./GroupPage";
const GroupMembersPage: FC = () => {
const {
group: groupData,
members,
organization,
permissions,
groupQuery,
membersQuery,
filterProps,
} = useOutletContext<GroupPageOutletContext>();
const queryClient = useQueryClient();
const addMemberMutation = useMutation(addMember(queryClient, organization));
@@ -57,87 +57,74 @@ const GroupMembersPage: FC = () => {
return (
<div className="flex flex-col w-full gap-1 pb-8">
{canUpdateGroup && groupData && !isEveryoneGroup(groupData) && (
<AddGroupMember
isLoading={addMemberMutation.isPending}
organizationId={groupData.organization_id}
onSubmit={async (member, reset) => {
try {
await addMemberMutation.mutateAsync({
groupId,
userId: member.user_id,
});
reset();
await groupQuery.refetch();
} catch (error) {
toast.error(getErrorMessage(error, "Failed to add member."), {
description: getErrorDetail(error),
});
}
}}
/>
)}
<TableToolbar>
<PaginationStatus
isLoading={false}
showing={groupData?.members.length ?? 0}
total={groupData?.members.length ?? 0}
label="members"
/>
</TableToolbar>
<div className="flex flex-row justify-between">
<UsersFilter {...filterProps} />
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-2/5">User</TableHead>
<TableHead className="w-3/5">Status</TableHead>
<TableHead className="w-auto" />
</TableRow>
</TableHeader>
{canUpdateGroup && groupData && !isEveryoneGroup(groupData) && (
<AddGroupMember
isLoading={addMemberMutation.isPending}
organizationId={groupData.organization_id}
onSubmit={async (member, reset) => {
try {
await addMemberMutation.mutateAsync({
groupId,
userId: member.user_id,
});
reset();
} catch (error) {
toast.error(getErrorMessage(error, "Failed to add member."), {
description: getErrorDetail(error),
});
}
}}
/>
)}
</div>
<TableBody>
{groupData?.members.length === 0 ? (
<PaginationContainer query={membersQuery} paginationUnitLabel="members">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message="No members yet"
description="Add a member using the controls above"
/>
</TableCell>
<TableHead className="w-2/5">User</TableHead>
<TableHead className="w-3/5">Status</TableHead>
<TableHead className="w-auto" />
</TableRow>
) : (
groupData?.members.map((member) => (
<GroupMemberRow
member={member}
group={groupData}
key={member.id}
canUpdate={canUpdateGroup}
onRemove={async () => {
const mutation = removeMemberMutation.mutateAsync(
{
</TableHeader>
<TableBody>
{members.length === 0 ? (
<TableRow>
<TableCell colSpan={999}>
<EmptyState message="No members found" />
</TableCell>
</TableRow>
) : (
members.map((member) => (
<GroupMemberRow
member={member}
group={groupData}
key={member.id}
canUpdate={canUpdateGroup}
onRemove={async () => {
const mutation = removeMemberMutation.mutateAsync({
groupId: groupData.id,
userId: member.id,
},
{
onSuccess: () => {
groupQuery.refetch();
},
},
);
toast.promise(mutation, {
loading: `Removing member "${member.username}" from "${groupData.name}"...`,
success: `Member "${member.username}" has been removed from "${groupData.name}" successfully.`,
error: (error) => ({
message: `Failed to remove member "${member.username}" from "${groupData.name}".`,
description: getErrorDetail(error),
}),
});
}}
/>
))
)}
</TableBody>
</Table>
});
toast.promise(mutation, {
loading: `Removing member "${member.username}" from "${groupData.name}"...`,
success: `Member "${member.username}" has been removed from "${groupData.name}" successfully.`,
error: (error) => ({
message: `Failed to remove member "${member.username}" from "${groupData.name}".`,
description: getErrorDetail(error),
}),
});
}}
/>
))
)}
</TableBody>
</Table>
</PaginationContainer>
</div>
);
};
+87 -14
View File
@@ -1,12 +1,17 @@
import {
MockDefaultOrganization,
MockGroup,
MockGroupWithoutMembers,
MockOrganizationMember,
MockOrganizationMember2,
} from "testHelpers/entities";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import { getGroupQueryKey, groupPermissionsKey } from "api/queries/groups";
import {
getGroupMembersQueryKey,
getGroupQueryKey,
groupPermissionsKey,
} from "api/queries/groups";
import { organizationMembersKey } from "api/queries/organizations";
import { spyOn, userEvent, within } from "storybook/test";
import {
@@ -24,7 +29,7 @@ const meta: Meta<typeof GroupPage> = {
location: {
pathParams: {
organization: MockDefaultOrganization.name,
groupName: MockGroup.name,
groupName: MockGroupWithoutMembers.name,
},
},
routing: reactRouterOutlet(
@@ -36,12 +41,31 @@ const meta: Meta<typeof GroupPage> = {
};
const groupQuery = (data: unknown) => ({
key: getGroupQueryKey(MockDefaultOrganization.name, MockGroup.name),
key: getGroupQueryKey(
MockDefaultOrganization.name,
MockGroupWithoutMembers.name,
{
exclude_members: true,
},
),
data,
});
const groupMembersQuery = (data: unknown) => ({
key: getGroupMembersQueryKey(
MockDefaultOrganization.name,
MockGroupWithoutMembers.name,
{
limit: 25,
offset: 0,
q: "",
},
),
data,
});
const permissionsQuery = (data: unknown, id?: string) => ({
key: groupPermissionsKey(id ?? MockGroup.id),
key: groupPermissionsKey(id ?? MockGroupWithoutMembers.id),
data,
});
@@ -55,27 +79,62 @@ type Story = StoryObj<typeof GroupPage>;
export const LoadingGroup: Story = {
parameters: {
queries: [groupQuery(null), permissionsQuery({})],
queries: [groupQuery(null), groupMembersQuery(null), permissionsQuery({})],
},
};
export const LoadingGroupMembers: Story = {
parameters: {
queries: [
groupQuery(MockGroupWithoutMembers),
groupMembersQuery(null),
permissionsQuery({}),
],
},
};
export const GroupError: Story = {
beforeEach: () => {
spyOn(API, "getGroup").mockRejectedValue(new Error("test group error"));
spyOn(API, "getGroupMembers").mockResolvedValue({
users: [],
count: 0,
});
spyOn(API, "checkAuthorization").mockResolvedValue({});
},
};
export const GroupMembersError: Story = {
beforeEach: () => {
spyOn(API, "getGroup").mockResolvedValue(MockGroupWithoutMembers);
spyOn(API, "getGroupMembers").mockRejectedValue(
new Error("test group members error"),
);
spyOn(API, "checkAuthorization").mockResolvedValue({});
},
};
export const LoadingPermissions: Story = {
parameters: {
queries: [groupQuery(MockGroup), permissionsQuery(null)],
queries: [
groupQuery(MockGroupWithoutMembers),
groupMembersQuery({
users: MockGroup.members,
count: MockGroup.members.length,
}),
permissionsQuery(null),
],
},
};
export const NoUpdatePermission: Story = {
parameters: {
queries: [
groupQuery(MockGroup),
groupQuery(MockGroupWithoutMembers),
groupMembersQuery({
users: MockGroup.members,
count: MockGroup.members.length,
}),
permissionsQuery({ canUpdateGroup: false }),
],
},
@@ -85,10 +144,14 @@ export const EveryoneGroup: Story = {
parameters: {
queries: [
groupQuery({
...MockGroup,
...MockGroupWithoutMembers,
// The everyone group has the same ID as the organization.
id: MockDefaultOrganization.id,
}),
groupMembersQuery({
users: MockGroup.members,
count: MockGroup.members.length,
}),
permissionsQuery({ canUpdateGroup: true }, MockDefaultOrganization.id),
],
},
@@ -96,7 +159,7 @@ export const EveryoneGroup: Story = {
export const MembersError: Story = {
beforeEach() {
spyOn(API, "getGroup").mockResolvedValue(MockGroup);
spyOn(API, "getGroup").mockResolvedValue(MockGroupWithoutMembers);
spyOn(API, "checkAuthorization").mockResolvedValue({
canUpdateGroup: true,
});
@@ -104,6 +167,14 @@ export const MembersError: Story = {
new Error("test members error"),
);
},
parameters: {
queries: [
groupMembersQuery({
users: MockGroup.members,
count: MockGroup.members.length,
}),
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
@@ -115,10 +186,8 @@ export const MembersError: Story = {
export const NoMembers: Story = {
parameters: {
queries: [
groupQuery({
...MockGroup,
members: [],
}),
groupQuery(MockGroupWithoutMembers),
groupMembersQuery({ users: [], count: 0 }),
permissionsQuery({ canUpdateGroup: true }),
membersQuery({ members: [] }),
],
@@ -134,7 +203,11 @@ export const NoMembers: Story = {
export const FiltersByMembers: Story = {
parameters: {
queries: [
groupQuery(MockGroup),
groupQuery(MockGroupWithoutMembers),
groupMembersQuery({
users: MockGroup.members,
count: MockGroup.members.length,
}),
permissionsQuery({ canUpdateGroup: true }),
membersQuery({
members: [MockOrganizationMember, MockOrganizationMember2],
+49 -8
View File
@@ -1,28 +1,46 @@
import { getErrorDetail, getErrorMessage } from "api/errors";
import { deleteGroup, group, groupPermissions } from "api/queries/groups";
import type { Group } from "api/typesGenerated";
import {
deleteGroup,
group,
groupMembers,
groupPermissions,
} from "api/queries/groups";
import type { Group, ReducedUser } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Button } from "components/Button/Button";
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
import { useFilter } from "components/Filter/Filter";
import type { UsersFilter } from "components/Filter/UsersFilter";
import { Loader } from "components/Loader/Loader";
import type { PaginationResult } from "components/PaginationWidget/PaginationContainer";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "components/SettingsHeader/SettingsHeader";
import { TabLink, Tabs, TabsList } from "components/Tabs/Tabs";
import { usePaginatedQuery } from "hooks/usePaginatedQuery";
import { TrashIcon } from "lucide-react";
import { type FC, useState } from "react";
import { type ComponentProps, type FC, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { Outlet, useLocation, useNavigate, useParams } from "react-router";
import {
Outlet,
useLocation,
useNavigate,
useParams,
useSearchParams,
} from "react-router";
import { toast } from "sonner";
import { pageTitle } from "utils/page";
export type GroupPageOutletContext = {
group: Group;
members: readonly ReducedUser[];
permissions: { canUpdateGroup: boolean };
organization: string;
groupQuery: ReturnType<typeof useQuery>;
membersQuery: PaginationResult;
filterProps: ComponentProps<typeof UsersFilter>;
};
const GroupPage: FC = () => {
@@ -33,7 +51,19 @@ const GroupPage: FC = () => {
const location = useLocation();
const queryClient = useQueryClient();
const navigate = useNavigate();
const groupQuery = useQuery(group(organization, groupName));
const [searchParams, setSearchParams] = useSearchParams();
const groupQuery = useQuery(
group(organization, groupName, { exclude_members: true }),
);
const membersQuery = usePaginatedQuery(
groupMembers(organization, groupName, searchParams),
);
const useFilterResult = useFilter({
searchParams,
onSearchParamsChange: setSearchParams,
onUpdate: membersQuery.goToFirstPage,
});
const groupData = groupQuery.data;
const { data: permissions } = useQuery({
...groupPermissions(groupData?.id ?? ""),
@@ -43,7 +73,12 @@ const GroupPage: FC = () => {
deleteGroup(queryClient, organization),
);
const [isDeletingGroup, setIsDeletingGroup] = useState(false);
const isLoading = groupQuery.isLoading || !groupData || !permissions;
const isLoading =
groupQuery.isLoading ||
!groupData ||
!permissions ||
membersQuery.isLoading ||
!membersQuery.data;
const canUpdateGroup = permissions ? permissions.canUpdateGroup : false;
const title = (
@@ -52,8 +87,9 @@ const GroupPage: FC = () => {
</title>
);
if (groupQuery.error) {
return <ErrorAlert error={groupQuery.error} />;
const error = groupQuery.error || membersQuery.error;
if (error) {
return <ErrorAlert error={error} />;
}
if (isLoading) {
@@ -115,9 +151,14 @@ const GroupPage: FC = () => {
context={
{
group: groupData,
members: membersQuery.data?.users || [],
permissions: { canUpdateGroup },
organization,
groupQuery,
membersQuery,
filterProps: {
filter: useFilterResult,
},
} satisfies GroupPageOutletContext
}
/>
+1 -1
View File
@@ -15,6 +15,7 @@ import type { User } from "api/typesGenerated";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
import { useFilter } from "components/Filter/Filter";
import { useStatusFilterMenu } from "components/Filter/UsersFilter";
import { isNonInitialPage } from "components/PaginationWidget/utils";
import { useAuthenticated } from "hooks";
import { usePaginatedQuery } from "hooks/usePaginatedQuery";
@@ -26,7 +27,6 @@ import { toast } from "sonner";
import { pageTitle } from "utils/page";
import { generateRandomString } from "utils/random";
import { ResetPasswordDialog } from "./ResetPasswordDialog";
import { useStatusFilterMenu } from "./UsersFilter";
import { UsersPageView } from "./UsersPageView";
type UserPageProps = {
+1 -1
View File
@@ -1,6 +1,7 @@
import type { GroupsByUserId } from "api/queries/groups";
import type * as TypesGen from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { UsersFilter } from "components/Filter/UsersFilter";
import {
PaginationContainer,
type PaginationResult,
@@ -13,7 +14,6 @@ import {
import { UserPlusIcon } from "lucide-react";
import type { ComponentProps, FC } from "react";
import { Link as RouterLink } from "react-router";
import { UsersFilter } from "./UsersFilter";
import { UsersTable } from "./UsersTable/UsersTable";
interface UsersPageViewProps {
+14
View File
@@ -2980,6 +2980,20 @@ export const MockGroup: TypesGen.Group = {
total_member_count: 2,
};
export const MockGroupWithoutMembers: TypesGen.Group = {
id: "fbd2116a-8961-4954-87ae-e4575bd29ce0",
name: "Front-End",
display_name: "Front-End",
avatar_url: "https://example.com",
organization_id: MockOrganization.id,
organization_name: MockOrganization.name,
organization_display_name: MockOrganization.display_name,
members: [],
quota_allowance: 5,
source: "user",
total_member_count: 2,
};
export const MockGroup2: TypesGen.Group = {
id: "13de3eb4-9b4f-49e7-b0f8-0c3728a0d2e2",
name: "developer",