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
orgMembers := make([]database.OrganizationMember, 0)
for _, mem := range q.organizationMembers {
if arg.OrganizationID != uuid.Nil && mem.OrganizationID != arg.OrganizationID {
if mem.OrganizationID != arg.OrganizationID {
continue
}
@ -9606,7 +9606,7 @@ func (q *FakeQuerier) PaginatedOrganizationMembers(_ context.Context, arg databa
selectedMembers := make([]database.PaginatedOrganizationMembersRow, 0)
skippedMembers := 0
for _, organizationMember := range q.organizationMembers {
for _, organizationMember := range orgMembers {
if skippedMembers < int(arg.OffsetOpt) {
skippedMembers++
continue

View File

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

View File

@ -583,6 +583,24 @@ class ApiMethods {
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
*/

View File

@ -2,9 +2,12 @@ import { API } from "api/api";
import type {
CreateOrganizationRequest,
GroupSyncSettings,
PaginatedMembersRequest,
PaginatedMembersResponse,
RoleSyncSettings,
UpdateOrganizationRequest,
} from "api/typesGenerated";
import type { UsePaginatedQueryOptions } from "hooks/usePaginatedQuery";
import {
type OrganizationPermissionName,
type OrganizationPermissions,
@ -59,13 +62,45 @@ export const organizationMembersKey = (id: string) => [
"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) => {
return {
queryFn: () => API.getOrganizationMembers(id),
queryFn: () => API.getOrganizationPaginatedMembers(id, { limit: 0 }),
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) => {
return {
mutationFn: (userId: string) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,6 +18,7 @@ import {
MoreMenuTrigger,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";
import { PaginationContainer } from "components/PaginationWidget/PaginationContainer";
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
import { Stack } from "components/Stack/Stack";
import {
@ -29,6 +30,7 @@ import {
TableRow,
} from "components/Table/Table";
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
import type { PaginationResultInfo } from "hooks/usePaginatedQuery";
import { TriangleAlert } from "lucide-react";
import { UserGroupsCell } from "pages/UsersPage/UsersTable/UserGroupsCell";
import { type FC, useState } from "react";
@ -44,6 +46,9 @@ interface OrganizationMembersPageViewProps {
isUpdatingMemberRoles: boolean;
me: User;
members: Array<OrganizationMemberTableEntry> | undefined;
membersQuery: PaginationResultInfo & {
isPreviousData: boolean;
};
addMember: (user: User) => Promise<void>;
removeMember: (member: OrganizationMemberWithUserData) => void;
updateMemberRoles: (
@ -66,6 +71,7 @@ export const OrganizationMembersPageView: FC<
isAddingMember,
isUpdatingMemberRoles,
me,
membersQuery,
members,
addMember,
removeMember,
@ -92,7 +98,7 @@ export const OrganizationMembersPageView: FC<
</p>
</div>
)}
<PaginationContainer query={membersQuery} paginationUnitLabel="members">
<Table>
<TableHeader>
<TableRow>
@ -167,6 +173,7 @@ export const OrganizationMembersPageView: FC<
))}
</TableBody>
</Table>
</PaginationContainer>
</div>
</div>
);

View File

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