feat: add pagination to the organizaton members table (#16870)

Closes
[coder/internal#344](https://github.com/coder/internal/issues/344)
This commit is contained in:
brettkolodny
2025-03-14 14:27:55 -04:00
committed by GitHub
parent 7ba4df1bc4
commit 1ec39f4c55
11 changed files with 172 additions and 100 deletions

View File

@ -9596,7 +9596,7 @@ func (q *FakeQuerier) PaginatedOrganizationMembers(_ context.Context, arg databa
// All of the members in the organization // All of the members in the organization
orgMembers := make([]database.OrganizationMember, 0) orgMembers := make([]database.OrganizationMember, 0)
for _, mem := range q.organizationMembers { for _, mem := range q.organizationMembers {
if arg.OrganizationID != uuid.Nil && mem.OrganizationID != arg.OrganizationID { if mem.OrganizationID != arg.OrganizationID {
continue continue
} }
@ -9606,7 +9606,7 @@ func (q *FakeQuerier) PaginatedOrganizationMembers(_ context.Context, arg databa
selectedMembers := make([]database.PaginatedOrganizationMembersRow, 0) selectedMembers := make([]database.PaginatedOrganizationMembersRow, 0)
skippedMembers := 0 skippedMembers := 0
for _, organizationMember := range q.organizationMembers { for _, organizationMember := range orgMembers {
if skippedMembers < int(arg.OffsetOpt) { if skippedMembers < int(arg.OffsetOpt) {
skippedMembers++ skippedMembers++
continue continue

View File

@ -82,14 +82,13 @@ type OrganizationMemberWithUserData struct {
} }
type PaginatedMembersRequest struct { type PaginatedMembersRequest struct {
OrganizationID uuid.UUID `table:"organization id" json:"organization_id" format:"uuid"` Limit int `json:"limit,omitempty"`
Limit int `json:"limit,omitempty"` Offset int `json:"offset,omitempty"`
Offset int `json:"offset,omitempty"`
} }
type PaginatedMembersResponse struct { type PaginatedMembersResponse struct {
Members []OrganizationMemberWithUserData Members []OrganizationMemberWithUserData `json:"members"`
Count int `json:"count"` Count int `json:"count"`
} }
type CreateOrganizationRequest struct { type CreateOrganizationRequest struct {

View File

@ -583,6 +583,24 @@ class ApiMethods {
return response.data; return response.data;
}; };
/**
* @param organization Can be the organization's ID or name
* @param options Pagination options
*/
getOrganizationPaginatedMembers = async (
organization: string,
options?: TypesGen.Pagination,
) => {
const url = getURLWithSearchParams(
`/api/v2/organizations/${organization}/paginated-members`,
options,
);
const response =
await this.axios.get<TypesGen.PaginatedMembersResponse>(url);
return response.data;
};
/** /**
* @param organization Can be the organization's ID or name * @param organization Can be the organization's ID or name
*/ */

View File

@ -2,9 +2,12 @@ import { API } from "api/api";
import type { import type {
CreateOrganizationRequest, CreateOrganizationRequest,
GroupSyncSettings, GroupSyncSettings,
PaginatedMembersRequest,
PaginatedMembersResponse,
RoleSyncSettings, RoleSyncSettings,
UpdateOrganizationRequest, UpdateOrganizationRequest,
} from "api/typesGenerated"; } from "api/typesGenerated";
import type { UsePaginatedQueryOptions } from "hooks/usePaginatedQuery";
import { import {
type OrganizationPermissionName, type OrganizationPermissionName,
type OrganizationPermissions, type OrganizationPermissions,
@ -59,13 +62,45 @@ export const organizationMembersKey = (id: string) => [
"members", "members",
]; ];
/**
* Creates a query configuration to fetch all members of an organization.
*
* Unlike the paginated version, this function sets the `limit` parameter to 0,
* which instructs the API to return all organization members in a single request
* without pagination.
*
* @param id - The unique identifier of the organization
* @returns A query configuration object for use with React Query
*
* @see paginatedOrganizationMembers - For fetching members with pagination support
*/
export const organizationMembers = (id: string) => { export const organizationMembers = (id: string) => {
return { return {
queryFn: () => API.getOrganizationMembers(id), queryFn: () => API.getOrganizationPaginatedMembers(id, { limit: 0 }),
queryKey: organizationMembersKey(id), queryKey: organizationMembersKey(id),
}; };
}; };
export const paginatedOrganizationMembers = (
id: string,
searchParams: URLSearchParams,
): UsePaginatedQueryOptions<
PaginatedMembersResponse,
PaginatedMembersRequest
> => {
return {
searchParams,
queryPayload: ({ limit, offset }) => {
return {
limit: limit,
offset: offset,
};
},
queryKey: ({ payload }) => [...organizationMembersKey(id), payload],
queryFn: ({ payload }) => API.getOrganizationPaginatedMembers(id, payload),
};
};
export const addOrganizationMember = (queryClient: QueryClient, id: string) => { export const addOrganizationMember = (queryClient: QueryClient, id: string) => {
return { return {
mutationFn: (userId: string) => { mutationFn: (userId: string) => {

View File

@ -1486,14 +1486,13 @@ export interface OrganizationSyncSettings {
// From codersdk/organizations.go // From codersdk/organizations.go
export interface PaginatedMembersRequest { export interface PaginatedMembersRequest {
readonly organization_id: string;
readonly limit?: number; readonly limit?: number;
readonly offset?: number; readonly offset?: number;
} }
// From codersdk/organizations.go // From codersdk/organizations.go
export interface PaginatedMembersResponse { export interface PaginatedMembersResponse {
readonly Members: readonly OrganizationMemberWithUserData[]; readonly members: readonly OrganizationMemberWithUserData[];
readonly count: number; readonly count: number;
} }

View File

@ -69,7 +69,6 @@ export const MemberAutocomplete: FC<MemberAutocompleteProps> = ({
}) => { }) => {
const [filter, setFilter] = useState<string>(); const [filter, setFilter] = useState<string>();
// Currently this queries all members, as there is no pagination.
const membersQuery = useQuery({ const membersQuery = useQuery({
...organizationMembers(organizationId), ...organizationMembers(organizationId),
enabled: filter !== undefined, enabled: filter !== undefined,
@ -80,7 +79,7 @@ export const MemberAutocomplete: FC<MemberAutocompleteProps> = ({
error={membersQuery.error} error={membersQuery.error}
isFetching={membersQuery.isFetching} isFetching={membersQuery.isFetching}
setFilter={setFilter} setFilter={setFilter}
users={membersQuery.data} users={membersQuery.data?.members}
{...props} {...props}
/> />
); );

View File

@ -38,8 +38,8 @@ beforeEach(() => {
const renderPage = async () => { const renderPage = async () => {
renderWithOrganizationSettingsLayout(<OrganizationMembersPage />, { renderWithOrganizationSettingsLayout(<OrganizationMembersPage />, {
route: `/organizations/${MockOrganization.name}/members`, route: `/organizations/${MockOrganization.name}/paginated-members`,
path: "/organizations/:organization/members", path: "/organizations/:organization/paginated-members",
}); });
await waitForLoaderToBeRemoved(); await waitForLoaderToBeRemoved();
}; };

View File

@ -3,7 +3,7 @@ import { getErrorMessage } from "api/errors";
import { groupsByUserIdInOrganization } from "api/queries/groups"; import { groupsByUserIdInOrganization } from "api/queries/groups";
import { import {
addOrganizationMember, addOrganizationMember,
organizationMembers, paginatedOrganizationMembers,
removeOrganizationMember, removeOrganizationMember,
updateOrganizationMemberRoles, updateOrganizationMemberRoles,
} from "api/queries/organizations"; } from "api/queries/organizations";
@ -14,12 +14,13 @@ import { EmptyState } from "components/EmptyState/EmptyState";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import { Stack } from "components/Stack/Stack"; import { Stack } from "components/Stack/Stack";
import { useAuthenticated } from "contexts/auth/RequireAuth"; import { useAuthenticated } from "contexts/auth/RequireAuth";
import { usePaginatedQuery } from "hooks/usePaginatedQuery";
import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout"; import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout";
import { RequirePermission } from "modules/permissions/RequirePermission"; import { RequirePermission } from "modules/permissions/RequirePermission";
import { type FC, useState } from "react"; import { type FC, useState } from "react";
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
import { useMutation, useQuery, useQueryClient } from "react-query"; import { useMutation, useQuery, useQueryClient } from "react-query";
import { useParams } from "react-router-dom"; import { useParams, useSearchParams } from "react-router-dom";
import { pageTitle } from "utils/page"; import { pageTitle } from "utils/page";
import { OrganizationMembersPageView } from "./OrganizationMembersPageView"; import { OrganizationMembersPageView } from "./OrganizationMembersPageView";
@ -30,17 +31,23 @@ const OrganizationMembersPage: FC = () => {
organization: string; organization: string;
}; };
const { organization, organizationPermissions } = useOrganizationSettings(); const { organization, organizationPermissions } = useOrganizationSettings();
const searchParamsResult = useSearchParams();
const membersQuery = useQuery(organizationMembers(organizationName));
const organizationRolesQuery = useQuery(organizationRoles(organizationName)); const organizationRolesQuery = useQuery(organizationRoles(organizationName));
const groupsByUserIdQuery = useQuery( const groupsByUserIdQuery = useQuery(
groupsByUserIdInOrganization(organizationName), groupsByUserIdInOrganization(organizationName),
); );
const members = membersQuery.data?.map((member) => { const membersQuery = usePaginatedQuery(
const groups = groupsByUserIdQuery.data?.get(member.user_id) ?? []; paginatedOrganizationMembers(organizationName, searchParamsResult[0]),
return { ...member, groups }; );
});
const members = membersQuery.data?.members.map(
(member: OrganizationMemberWithUserData) => {
const groups = groupsByUserIdQuery.data?.get(member.user_id) ?? [];
return { ...member, groups };
},
);
const addMemberMutation = useMutation( const addMemberMutation = useMutation(
addOrganizationMember(queryClient, organizationName), addOrganizationMember(queryClient, organizationName),
@ -95,6 +102,7 @@ const OrganizationMembersPage: FC = () => {
isUpdatingMemberRoles={updateMemberRolesMutation.isLoading} isUpdatingMemberRoles={updateMemberRolesMutation.isLoading}
me={me} me={me}
members={members} members={members}
membersQuery={membersQuery}
addMember={async (user: User) => { addMember={async (user: User) => {
await addMemberMutation.mutateAsync(user.id); await addMemberMutation.mutateAsync(user.id);
void membersQuery.refetch(); void membersQuery.refetch();

View File

@ -1,4 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import { mockSuccessResult } from "components/PaginationWidget/PaginationContainer.mocks";
import type { UsePaginatedQueryResult } from "hooks/usePaginatedQuery";
import { import {
MockOrganizationMember, MockOrganizationMember,
MockOrganizationMember2, MockOrganizationMember2,
@ -14,11 +16,16 @@ const meta: Meta<typeof OrganizationMembersPageView> = {
error: undefined, error: undefined,
isAddingMember: false, isAddingMember: false,
isUpdatingMemberRoles: false, isUpdatingMemberRoles: false,
canViewMembers: true,
me: MockUser, me: MockUser,
members: [ members: [
{ ...MockOrganizationMember, groups: [] }, { ...MockOrganizationMember, groups: [] },
{ ...MockOrganizationMember2, groups: [] }, { ...MockOrganizationMember2, groups: [] },
], ],
membersQuery: {
...mockSuccessResult,
totalRecords: 2,
} as UsePaginatedQueryResult,
addMember: () => Promise.resolve(), addMember: () => Promise.resolve(),
removeMember: () => Promise.resolve(), removeMember: () => Promise.resolve(),
updateMemberRoles: () => Promise.resolve(), updateMemberRoles: () => Promise.resolve(),

View File

@ -18,6 +18,7 @@ import {
MoreMenuTrigger, MoreMenuTrigger,
ThreeDotsButton, ThreeDotsButton,
} from "components/MoreMenu/MoreMenu"; } from "components/MoreMenu/MoreMenu";
import { PaginationContainer } from "components/PaginationWidget/PaginationContainer";
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader"; import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
import { Stack } from "components/Stack/Stack"; import { Stack } from "components/Stack/Stack";
import { import {
@ -29,6 +30,7 @@ import {
TableRow, TableRow,
} from "components/Table/Table"; } from "components/Table/Table";
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
import type { PaginationResultInfo } from "hooks/usePaginatedQuery";
import { TriangleAlert } from "lucide-react"; import { TriangleAlert } from "lucide-react";
import { UserGroupsCell } from "pages/UsersPage/UsersTable/UserGroupsCell"; import { UserGroupsCell } from "pages/UsersPage/UsersTable/UserGroupsCell";
import { type FC, useState } from "react"; import { type FC, useState } from "react";
@ -44,6 +46,9 @@ interface OrganizationMembersPageViewProps {
isUpdatingMemberRoles: boolean; isUpdatingMemberRoles: boolean;
me: User; me: User;
members: Array<OrganizationMemberTableEntry> | undefined; members: Array<OrganizationMemberTableEntry> | undefined;
membersQuery: PaginationResultInfo & {
isPreviousData: boolean;
};
addMember: (user: User) => Promise<void>; addMember: (user: User) => Promise<void>;
removeMember: (member: OrganizationMemberWithUserData) => void; removeMember: (member: OrganizationMemberWithUserData) => void;
updateMemberRoles: ( updateMemberRoles: (
@ -66,6 +71,7 @@ export const OrganizationMembersPageView: FC<
isAddingMember, isAddingMember,
isUpdatingMemberRoles, isUpdatingMemberRoles,
me, me,
membersQuery,
members, members,
addMember, addMember,
removeMember, removeMember,
@ -92,81 +98,82 @@ export const OrganizationMembersPageView: FC<
</p> </p>
</div> </div>
)} )}
<PaginationContainer query={membersQuery} paginationUnitLabel="members">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-2/6">User</TableHead> <TableHead className="w-2/6">User</TableHead>
<TableHead className="w-2/6"> <TableHead className="w-2/6">
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1} alignItems="center">
<span>Roles</span> <span>Roles</span>
<TableColumnHelpTooltip variant="roles" /> <TableColumnHelpTooltip variant="roles" />
</Stack> </Stack>
</TableHead> </TableHead>
<TableHead className="w-2/6"> <TableHead className="w-2/6">
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1} alignItems="center">
<span>Groups</span> <span>Groups</span>
<TableColumnHelpTooltip variant="groups" /> <TableColumnHelpTooltip variant="groups" />
</Stack> </Stack>
</TableHead> </TableHead>
<TableHead className="w-auto" /> <TableHead className="w-auto" />
</TableRow>
</TableHeader>
<TableBody>
{members?.map((member) => (
<TableRow key={member.user_id} className="align-baseline">
<TableCell>
<AvatarData
avatar={
<Avatar
fallback={member.username}
src={member.avatar_url}
/>
}
title={member.name || member.username}
subtitle={member.email}
/>
</TableCell>
<UserRoleCell
inheritedRoles={member.global_roles}
roles={member.roles}
allAvailableRoles={allAvailableRoles}
oidcRoleSyncEnabled={false}
isLoading={isUpdatingMemberRoles}
canEditUsers={canEditMembers}
onEditRoles={async (roles) => {
try {
await updateMemberRoles(member, roles);
displaySuccess("Roles updated successfully.");
} catch (error) {
displayError(
getErrorMessage(error, "Failed to update roles."),
);
}
}}
/>
<UserGroupsCell userGroups={member.groups} />
<TableCell>
{member.user_id !== me.id && canEditMembers && (
<MoreMenu>
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
danger
onClick={() => removeMember(member)}
>
Remove
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
)}
</TableCell>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {members?.map((member) => (
<TableRow key={member.user_id} className="align-baseline">
<TableCell>
<AvatarData
avatar={
<Avatar
fallback={member.username}
src={member.avatar_url}
/>
}
title={member.name || member.username}
subtitle={member.email}
/>
</TableCell>
<UserRoleCell
inheritedRoles={member.global_roles}
roles={member.roles}
allAvailableRoles={allAvailableRoles}
oidcRoleSyncEnabled={false}
isLoading={isUpdatingMemberRoles}
canEditUsers={canEditMembers}
onEditRoles={async (roles) => {
try {
await updateMemberRoles(member, roles);
displaySuccess("Roles updated successfully.");
} catch (error) {
displayError(
getErrorMessage(error, "Failed to update roles."),
);
}
}}
/>
<UserGroupsCell userGroups={member.groups} />
<TableCell>
{member.user_id !== me.id && canEditMembers && (
<MoreMenu>
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
danger
onClick={() => removeMember(member)}
>
Remove
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</PaginationContainer>
</div> </div>
</div> </div>
); );

View File

@ -64,11 +64,11 @@ export const handlers = [
M.MockOrganizationAuditorRole, M.MockOrganizationAuditorRole,
]); ]);
}), }),
http.get("/api/v2/organizations/:organizationId/members", () => { http.get("/api/v2/organizations/:organizationId/paginated-members", () => {
return HttpResponse.json([ return HttpResponse.json({
M.MockOrganizationMember, members: [M.MockOrganizationMember, M.MockOrganizationMember2],
M.MockOrganizationMember2, count: 2,
]); });
}), }),
http.delete( http.delete(
"/api/v2/organizations/:organizationId/members/:userId", "/api/v2/organizations/:organizationId/members/:userId",