diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go
index 63ee1d0bd95e7..1ece2571f4960 100644
--- a/coderd/database/dbmem/dbmem.go
+++ b/coderd/database/dbmem/dbmem.go
@@ -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
diff --git a/codersdk/organizations.go b/codersdk/organizations.go
index e093f6f85594a..8a028d46e098c 100644
--- a/codersdk/organizations.go
+++ b/codersdk/organizations.go
@@ -82,14 +82,13 @@ 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"`
+ Limit int `json:"limit,omitempty"`
+ Offset int `json:"offset,omitempty"`
}
type PaginatedMembersResponse struct {
- Members []OrganizationMemberWithUserData
- Count int `json:"count"`
+ Members []OrganizationMemberWithUserData `json:"members"`
+ Count int `json:"count"`
}
type CreateOrganizationRequest struct {
diff --git a/site/src/api/api.ts b/site/src/api/api.ts
index 627ede80976c6..b6012335f93d8 100644
--- a/site/src/api/api.ts
+++ b/site/src/api/api.ts
@@ -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(url);
+
+ return response.data;
+ };
+
/**
* @param organization Can be the organization's ID or name
*/
diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts
index bca0bc6a72fff..2dc0402d75484 100644
--- a/site/src/api/queries/organizations.ts
+++ b/site/src/api/queries/organizations.ts
@@ -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) => {
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 6fdfb5ea9d9a1..cd993e61db94a 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -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;
}
diff --git a/site/src/components/UserAutocomplete/UserAutocomplete.tsx b/site/src/components/UserAutocomplete/UserAutocomplete.tsx
index f5bfd109c4a5c..e375116cd2d22 100644
--- a/site/src/components/UserAutocomplete/UserAutocomplete.tsx
+++ b/site/src/components/UserAutocomplete/UserAutocomplete.tsx
@@ -69,7 +69,6 @@ export const MemberAutocomplete: FC = ({
}) => {
const [filter, setFilter] = useState();
- // 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 = ({
error={membersQuery.error}
isFetching={membersQuery.isFetching}
setFilter={setFilter}
- users={membersQuery.data}
+ users={membersQuery.data?.members}
{...props}
/>
);
diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.test.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.test.tsx
index 1270f78484dc7..f828969238cec 100644
--- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.test.tsx
+++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.test.tsx
@@ -38,8 +38,8 @@ beforeEach(() => {
const renderPage = async () => {
renderWithOrganizationSettingsLayout(, {
- route: `/organizations/${MockOrganization.name}/members`,
- path: "/organizations/:organization/members",
+ route: `/organizations/${MockOrganization.name}/paginated-members`,
+ path: "/organizations/:organization/paginated-members",
});
await waitForLoaderToBeRemoved();
};
diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx
index ffa7b08b83742..5b566efa914aa 100644
--- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx
+++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx
@@ -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 groups = groupsByUserIdQuery.data?.get(member.user_id) ?? [];
- return { ...member, groups };
- });
+ 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();
diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx
index f3427bd58775d..1c2f2c6e804a3 100644
--- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx
+++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx
@@ -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 = {
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(),
diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx
index 6c85f57dd538d..adf5e3e566ffc 100644
--- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx
+++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx
@@ -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 | undefined;
+ membersQuery: PaginationResultInfo & {
+ isPreviousData: boolean;
+ };
addMember: (user: User) => Promise;
removeMember: (member: OrganizationMemberWithUserData) => void;
updateMemberRoles: (
@@ -66,6 +71,7 @@ export const OrganizationMembersPageView: FC<
isAddingMember,
isUpdatingMemberRoles,
me,
+ membersQuery,
members,
addMember,
removeMember,
@@ -92,81 +98,82 @@ export const OrganizationMembersPageView: FC<
)}
-
-
-
-
- User
-
-
- Roles
-
-
-
-
-
- Groups
-
-
-
-
-
-
-
- {members?.map((member) => (
-
-
-
- }
- title={member.name || member.username}
- subtitle={member.email}
- />
-
- {
- try {
- await updateMemberRoles(member, roles);
- displaySuccess("Roles updated successfully.");
- } catch (error) {
- displayError(
- getErrorMessage(error, "Failed to update roles."),
- );
- }
- }}
- />
-
-
- {member.user_id !== me.id && canEditMembers && (
-
-
-
-
-
- removeMember(member)}
- >
- Remove
-
-
-
- )}
-
+
+
+
+
+ User
+
+
+ Roles
+
+
+
+
+
+ Groups
+
+
+
+
- ))}
-
-
+
+
+ {members?.map((member) => (
+
+
+
+ }
+ title={member.name || member.username}
+ subtitle={member.email}
+ />
+
+ {
+ try {
+ await updateMemberRoles(member, roles);
+ displaySuccess("Roles updated successfully.");
+ } catch (error) {
+ displayError(
+ getErrorMessage(error, "Failed to update roles."),
+ );
+ }
+ }}
+ />
+
+
+ {member.user_id !== me.id && canEditMembers && (
+
+
+
+
+
+ removeMember(member)}
+ >
+ Remove
+
+
+
+ )}
+
+
+ ))}
+
+
+
);
diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts
index 7fbd14147af83..79bc116891bf9 100644
--- a/site/src/testHelpers/handlers.ts
+++ b/site/src/testHelpers/handlers.ts
@@ -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",