From 4b707515c04f19e2d783446bbb99b7bd3d204c17 Mon Sep 17 00:00:00 2001 From: Asher Date: Sat, 21 Mar 2026 16:58:08 -0800 Subject: [PATCH] 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 --- site/src/api/api.ts | 21 ++- site/src/api/queries/groups.ts | 59 ++++++- .../Filter}/UsersFilter.tsx | 10 +- .../src/pages/GroupsPage/GroupMembersPage.tsx | 147 ++++++++---------- .../pages/GroupsPage/GroupPage.stories.tsx | 101 ++++++++++-- site/src/pages/GroupsPage/GroupPage.tsx | 57 ++++++- site/src/pages/UsersPage/UsersPage.tsx | 2 +- site/src/pages/UsersPage/UsersPageView.tsx | 2 +- site/src/testHelpers/entities.ts | 14 ++ 9 files changed, 298 insertions(+), 115 deletions(-) rename site/src/{pages/UsersPage => components/Filter}/UsersFilter.tsx (90%) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index bd124236aa..bb8e955a92 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -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 => { - 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 => { + const url = getURLWithSearchParams( + `/api/v2/organizations/${organization}/groups/${groupName}/members`, + filter, + ); + const response = await this.axios.get(url.toString(), { signal }); return response.data; }; diff --git a/site/src/api/queries/groups.ts b/site/src/api/queries/groups.ts index 4f5d7bc4c3..516fb20b6f 100644 --- a/site/src/api/queries/groups.ts +++ b/site/src/api/queries/groups.ts @@ -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; }; -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 => { 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 { + 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>; export function groupsByUserId() { @@ -183,7 +232,7 @@ const invalidateGroup = ( queryKey: getGroupsByOrganizationQueryKey(organization), }), queryClient.invalidateQueries({ - queryKey: getGroupQueryKey(organization, groupName), + queryKey: getRootGroupQueryKey(organization, groupName), }), ]); diff --git a/site/src/pages/UsersPage/UsersFilter.tsx b/site/src/components/Filter/UsersFilter.tsx similarity index 90% rename from site/src/pages/UsersPage/UsersFilter.tsx rename to site/src/components/Filter/UsersFilter.tsx index 782ba3de50..abbc1757bf 100644 --- a/site/src/pages/UsersPage/UsersFilter.tsx +++ b/site/src/components/Filter/UsersFilter.tsx @@ -57,8 +57,8 @@ const PRESET_FILTERS = [ interface UsersFilterProps { filter: ReturnType; error?: unknown; - menus: { - status: StatusFilterMenu; + menus?: { + status?: StatusFilterMenu; }; } @@ -69,11 +69,11 @@ export const UsersFilter: FC = ({ 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={} - optionsSkeleton={} + options={menus?.status && } + optionsSkeleton={menus?.status && } /> ); }; diff --git a/site/src/pages/GroupsPage/GroupMembersPage.tsx b/site/src/pages/GroupsPage/GroupMembersPage.tsx index 61f56b3c28..f968bb790e 100644 --- a/site/src/pages/GroupsPage/GroupMembersPage.tsx +++ b/site/src/pages/GroupsPage/GroupMembersPage.tsx @@ -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(); const queryClient = useQueryClient(); const addMemberMutation = useMutation(addMember(queryClient, organization)); @@ -57,87 +57,74 @@ const GroupMembersPage: FC = () => { return (
- {canUpdateGroup && groupData && !isEveryoneGroup(groupData) && ( - { - 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), - }); - } - }} - /> - )} - - - +
+ - - - - User - Status - - - + {canUpdateGroup && groupData && !isEveryoneGroup(groupData) && ( + { + try { + await addMemberMutation.mutateAsync({ + groupId, + userId: member.user_id, + }); + reset(); + } catch (error) { + toast.error(getErrorMessage(error, "Failed to add member."), { + description: getErrorDetail(error), + }); + } + }} + /> + )} + - - {groupData?.members.length === 0 ? ( + +
+ - - - + User + Status + - ) : ( - groupData?.members.map((member) => ( - { - const mutation = removeMemberMutation.mutateAsync( - { + + + + {members.length === 0 ? ( + + + + + + ) : ( + members.map((member) => ( + { + 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), - }), - }); - }} - /> - )) - )} - -
+ }); + 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), + }), + }); + }} + /> + )) + )} + + +
); }; diff --git a/site/src/pages/GroupsPage/GroupPage.stories.tsx b/site/src/pages/GroupsPage/GroupPage.stories.tsx index a0448ec2e4..3db55c4a3c 100644 --- a/site/src/pages/GroupsPage/GroupPage.stories.tsx +++ b/site/src/pages/GroupsPage/GroupPage.stories.tsx @@ -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 = { location: { pathParams: { organization: MockDefaultOrganization.name, - groupName: MockGroup.name, + groupName: MockGroupWithoutMembers.name, }, }, routing: reactRouterOutlet( @@ -36,12 +41,31 @@ const meta: Meta = { }; 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; 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], diff --git a/site/src/pages/GroupsPage/GroupPage.tsx b/site/src/pages/GroupsPage/GroupPage.tsx index a608ba6a19..d6b931c151 100644 --- a/site/src/pages/GroupsPage/GroupPage.tsx +++ b/site/src/pages/GroupsPage/GroupPage.tsx @@ -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; + membersQuery: PaginationResult; + filterProps: ComponentProps; }; 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 = () => { ); - if (groupQuery.error) { - return ; + const error = groupQuery.error || membersQuery.error; + if (error) { + return ; } 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 } /> diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 8cddfa5878..7acb230991 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -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 = { diff --git a/site/src/pages/UsersPage/UsersPageView.tsx b/site/src/pages/UsersPage/UsersPageView.tsx index e97eb36714..7916a1d768 100644 --- a/site/src/pages/UsersPage/UsersPageView.tsx +++ b/site/src/pages/UsersPage/UsersPageView.tsx @@ -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 { diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 3ccb5f0a24..1d30491ca0 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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",