feat(site): add server-side search and pagination for groups page (#27994)

Repeat of #27604, which I had mistakenly manually merged into #27603,
instead of letting Graphite handle it after #27603 merges into main.
Reattempting with GitHub stacks now instead of Graphite!

See #27604 for details/review.
This commit is contained in:
Andrew Aquino
2026-08-10 13:23:14 -07:00
committed by GitHub
parent 6e07e2610f
commit 87fdd2b59b
7 changed files with 404 additions and 94 deletions
+16
View File
@@ -2230,6 +2230,22 @@ class ApiMethods {
};
};
/**
* @param organization Can be the organization's ID or name
* @param options Pagination and search options
*/
getOrganizationPaginatedGroups = async (
organization: string,
options?: TypesGen.PaginatedGroupsRequest,
): Promise<TypesGen.PaginatedGroupsResponse> => {
const url = getURLWithSearchParams(
`/api/v2/organizations/${organization}/paginated-groups`,
options,
);
const response = await this.axios.get(url);
return response.data;
};
/**
* @param organization Can be the organization's ID or name
*/
+60
View File
@@ -9,6 +9,8 @@ import type {
GroupMembersResponse,
GroupRequest,
OrganizationGroupsAISpend,
PaginatedGroupsRequest,
PaginatedGroupsResponse,
PatchGroupRequest,
UsersRequest,
} from "#/api/typesGenerated";
@@ -93,6 +95,37 @@ export const groupMembersAISpend = (
} satisfies UseQueryOptions<GroupMembersAISpend>;
};
const getPaginatedGroupsByOrganizationQueryKey = (
organization: string,
req?: PaginatedGroupsRequest,
) => {
// Nested under the org groups key so create/patch/delete invalidations,
// which target ["organization", org, "groups"], also cover this list.
const base = [...getGroupsByOrganizationQueryKey(organization), "paginated"];
return req ? [...base, req] : base;
};
export function paginatedGroupsByOrganization(
organization: string,
searchParams: URLSearchParams,
): UsePaginatedQueryOptions<PaginatedGroupsResponse, PaginatedGroupsRequest> {
return {
searchParams,
queryPayload: ({ limit, offset }) => {
return {
limit,
offset,
q: prepareQuery(searchParams.get("filter") ?? ""),
};
},
queryKey: ({ payload }) =>
getPaginatedGroupsByOrganizationQueryKey(organization, payload),
queryFn: ({ payload }) =>
API.getOrganizationPaginatedGroups(organization, payload),
};
}
const getRootGroupQueryKey = (organization: string, groupName: string) => [
"organization",
organization,
@@ -167,6 +200,33 @@ export function groupMembers(
};
}
export const getGroupMemberAvatarsQueryKey = (
organization: string,
groupName: string,
limit: number,
) => [...getGroupMembersQueryKey(organization, groupName), "avatars", limit];
/** Number of member avatars previewed per group row in list views. */
export const GROUP_MEMBER_AVATAR_LIMIT = 5;
/**
* A capped page of a group's members for avatar previews in list views. The
* paginated groups endpoint no longer returns rosters, so rows fetch a small
* preview lazily. Nests under the group members key so membership mutations
* invalidate it.
*/
export const groupMemberAvatars = (
organization: string,
groupName: string,
limit: number,
): UseQueryOptions<GroupMembersResponse> => {
return {
queryKey: getGroupMemberAvatarsQueryKey(organization, groupName, limit),
queryFn: ({ signal }) =>
API.getGroupMembers(organization, groupName, { limit }, signal),
};
};
export type GroupsByUserId = Readonly<Map<string, readonly Group[]>>;
export function groupsByUserId() {
+10 -8
View File
@@ -210,14 +210,16 @@ export const Filter: FC<FilterProps> = ({
</>
) : (
<>
<PresetMenu
value={filter.query}
onSelect={(query) => filter.update(query)}
presets={presets}
learnMoreLink={learnMoreLink}
learnMoreLabel2={learnMoreLabel2}
learnMoreLink2={learnMoreLink2}
/>
{presets.length > 0 && (
<PresetMenu
value={filter.query}
onSelect={(query) => filter.update(query)}
presets={presets}
learnMoreLink={learnMoreLink}
learnMoreLabel2={learnMoreLabel2}
learnMoreLink2={learnMoreLink2}
/>
)}
<div className="flex flex-col gap-2 w-full">
<SearchField
ref={textboxInputRef}
@@ -0,0 +1,19 @@
import type { FC } from "react";
import { Filter, type useFilter } from "#/components/Filter/Filter";
interface GroupsFilterProps {
filter: ReturnType<typeof useFilter>;
}
// GroupsFilter renders a search-only filter. Groups support free-text search
// against name and display name, so there are no presets or option menus.
export const GroupsFilter: FC<GroupsFilterProps> = ({ filter }) => {
return (
<Filter
presets={[]}
isLoading={false}
filter={filter}
optionsSkeleton={null}
/>
);
};
+16 -18
View File
@@ -1,16 +1,15 @@
import { PlusIcon } from "lucide-react";
import { type FC, useEffect } from "react";
import { useQuery } from "react-query";
import { Link as RouterLink } from "react-router";
import { useSearchParams } from "react-router";
import { toast } from "sonner";
import { getErrorDetail, getErrorMessage } from "#/api/errors";
import {
groupsByOrganization,
organizationGroupsAISpend,
paginatedGroupsByOrganization,
} from "#/api/queries/groups";
import { organizationsPermissions } from "#/api/queries/organizations";
import { Button } from "#/components/Button/Button";
import { EmptyState } from "#/components/EmptyState/EmptyState";
import { useFilter } from "#/components/Filter/Filter";
import { Loader } from "#/components/Loader/Loader";
import {
SettingsHeader,
@@ -18,6 +17,7 @@ import {
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { usePaginatedQuery } from "#/hooks/usePaginatedQuery";
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { pageTitle } from "#/utils/page";
@@ -29,17 +29,22 @@ const GroupsPage: FC = () => {
const { template_rbac: groupsEnabled, aibridge } = useFeatureVisibility();
const { organization, showOrganizations } = useGroupsSettings();
const aibridgeVisible = Boolean(aibridge);
const groupsQuery = useQuery({
...groupsByOrganization(organization?.name ?? ""),
enabled: Boolean(organization),
const [searchParams, setSearchParams] = useSearchParams();
const groupsQuery = usePaginatedQuery(
paginatedGroupsByOrganization(organization?.name ?? "", searchParams),
);
const filter = useFilter({
searchParams,
onSearchParamsChange: setSearchParams,
onUpdate: groupsQuery.goToFirstPage,
});
const groupIds = groupsQuery.data?.map((group) => group.id) ?? [];
const groupIds = groupsQuery.data?.groups.map((group) => group.id) ?? [];
const groupsSpendQuery = useQuery({
...organizationGroupsAISpend(organization?.name ?? "", groupIds),
enabled: aibridgeVisible && Boolean(organization) && groupIds.length > 0,
});
const groupsWithSpend = joinGroupsSpend(
groupsQuery.data,
groupsQuery.data?.groups,
groupsSpendQuery.data,
);
const permissionsQuery = useQuery({
@@ -113,15 +118,6 @@ const GroupsPage: FC = () => {
{showOrganizations ? "organization" : "deployment"}.
</SettingsHeaderDescription>
</SettingsHeader>
{groupsEnabled && permissions.createGroup && (
<Button asChild>
<RouterLink to="create">
<PlusIcon className="size-icon-sm" />
Create group
</RouterLink>
</Button>
)}
</div>
<GroupsPageView
@@ -130,6 +126,8 @@ const GroupsPage: FC = () => {
canCreateGroup={permissions.createGroup}
groupsEnabled={groupsEnabled}
showAIBudget={aibridgeVisible}
filterProps={{ filter }}
groupsQuery={groupsQuery}
permissions={authPermissions}
/>
</div>
@@ -1,12 +1,42 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ComponentProps } from "react";
import { expect, within } from "storybook/test";
import { MockGroup, MockPermissions } from "#/testHelpers/entities";
import {
GROUP_MEMBER_AVATAR_LIMIT,
getGroupMemberAvatarsQueryKey,
} from "#/api/queries/groups";
import { getDefaultFilterProps } from "#/components/Filter/storyHelpers";
import type { UsersFilter } from "#/components/Filter/UsersFilter";
import {
mockInitialRenderResult,
mockSuccessResult,
} from "#/components/PaginationWidget/PaginationContainer.mocks";
import type { UsePaginatedQueryResult } from "#/hooks/usePaginatedQuery";
import {
MockGroup,
MockOrganization,
MockPermissions,
MockUserMember,
MockUserOwner,
} from "#/testHelpers/entities";
import { GroupsPageView, type GroupWithSpend } from "./GroupsPageView";
type FilterProps = ComponentProps<typeof UsersFilter>;
const meta: Meta<typeof GroupsPageView> = {
title: "pages/OrganizationGroupsPage",
component: GroupsPageView,
args: {
canCreateGroup: true,
groupsEnabled: true,
filterProps: getDefaultFilterProps<FilterProps>({
values: {},
menus: {},
}),
groupsQuery: {
...mockSuccessResult,
totalRecords: 1,
} as UsePaginatedQueryResult,
permissions: MockPermissions,
},
};
@@ -19,17 +49,36 @@ const mockGroupWithSpend: GroupWithSpend = {
spend: undefined,
};
// AI-budget and pagination stories aren't about membership, so give their
// groups no members. Rows with a zero count skip the per-row avatar fetch.
const aiGroup = (id: string, name: string): GroupWithSpend => ({
...mockGroupWithSpend,
id,
name,
display_name: name,
total_member_count: 0,
});
// Seeds the per-row member avatar preview query for a group so the row renders
// avatars deterministically without hitting the network.
const seedAvatars = (
groupName: string,
users: ReadonlyArray<typeof MockUserOwner>,
totalCount: number,
) => ({
key: getGroupMemberAvatarsQueryKey(
MockOrganization.name,
groupName,
GROUP_MEMBER_AVATAR_LIMIT,
),
data: { users, count: totalCount },
});
export const Default: Story = {};
export const NotEnabled: Story = {
args: {
groups: [{ ...mockGroupWithSpend }],
canCreateGroup: true,
groups: [mockGroupWithSpend],
groupsEnabled: false,
},
play: async ({ canvasElement }) => {
@@ -59,16 +108,87 @@ export const NotEnabledWithoutLicenseAccess: Story = {
export const WithGroups: Story = {
args: {
groups: [{ ...mockGroupWithSpend }],
canCreateGroup: true,
groupsEnabled: true,
groups: [mockGroupWithSpend],
},
parameters: {
queries: [seedAvatars(MockGroup.name, [MockUserOwner, MockUserMember], 2)],
},
};
// A group with more members than fit in the preview: the row shows the capped
// avatars plus a "+N" badge derived from total_member_count.
export const WithMemberAvatars: Story = {
args: {
groups: [
{
...mockGroupWithSpend,
id: "with-members",
name: "with-members",
display_name: "With members",
total_member_count: 8,
},
],
},
parameters: {
queries: [
seedAvatars(
"with-members",
Array.from({ length: GROUP_MEMBER_AVATAR_LIMIT }, (_, i) => ({
...MockUserOwner,
id: `preview-${i}`,
username: `member-${i}`,
})),
8,
),
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByText("+3")).toBeInTheDocument();
await expect(canvas.getByText("8 members")).toBeInTheDocument();
},
};
const totalRecords = 15;
const totalPages = 3;
const limit = totalRecords / totalPages;
// Multiple pages of results with the search field in use.
export const WithSearchAndPagination: Story = {
args: {
groups: Array.from({ length: limit }, (_, i) =>
aiGroup(`group-${i}`, `Group ${i}`),
),
filterProps: getDefaultFilterProps<FilterProps>({
query: "group",
values: {},
menus: {},
used: true,
}),
groupsQuery: {
...mockSuccessResult,
totalRecords,
totalPages,
limit,
hasNextPage: true,
} as UsePaginatedQueryResult,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByLabelText("Filter")).toHaveValue("group");
},
};
// Groups still loading: the pagination + table render their loading states.
export const Loading: Story = {
args: {
groups: undefined,
groupsQuery: mockInitialRenderResult as UsePaginatedQueryResult,
},
};
export const WithAIBudgets: Story = {
args: {
canCreateGroup: true,
groupsEnabled: true,
showAIBudget: true,
groups: [
{
@@ -165,9 +285,8 @@ export const WithAIBudgets: Story = {
export const WithAIBudgetsLoading: Story = {
args: {
groups: undefined,
canCreateGroup: true,
groupsEnabled: true,
showAIBudget: true,
groupsQuery: mockInitialRenderResult as UsePaginatedQueryResult,
},
};
@@ -211,8 +330,6 @@ export const WithAIBudgetsSpendError: Story = {
export const WithAIBudgetsSpendUnavailable: Story = {
args: {
groups: [aiGroup("ai-unavailable", "Spend unavailable")],
canCreateGroup: true,
groupsEnabled: true,
showAIBudget: true,
},
play: async ({ canvasElement }) => {
@@ -227,8 +344,6 @@ export const WithAIBudgetsSpendUnavailable: Story = {
export const WithoutAIBudgetColumn: Story = {
args: {
groups: [aiGroup("ai-hidden", "No AI column")],
canCreateGroup: true,
groupsEnabled: true,
showAIBudget: false,
},
play: async ({ canvasElement }) => {
@@ -240,8 +355,9 @@ export const WithoutAIBudgetColumn: Story = {
export const WithDisplayGroup: Story = {
args: {
groups: [{ ...mockGroupWithSpend, name: "front-end" }],
canCreateGroup: true,
groupsEnabled: true,
},
parameters: {
queries: [seedAvatars("front-end", [MockUserOwner, MockUserMember], 2)],
},
};
@@ -249,14 +365,32 @@ export const EmptyGroup: Story = {
args: {
groups: [],
canCreateGroup: false,
groupsEnabled: true,
},
};
export const EmptyGroupWithPermission: Story = {
args: {
groups: [],
canCreateGroup: true,
groupsEnabled: true,
},
};
// A search that matches nothing shows filter-aware copy, not the
// create-first-group empty state.
export const NoSearchResults: Story = {
args: {
groups: [],
filterProps: getDefaultFilterProps<FilterProps>({
query: "nomatch",
values: {},
menus: {},
used: true,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByText("No groups match your search"),
).toBeInTheDocument();
expect(canvas.queryByText("No groups yet")).not.toBeInTheDocument();
},
};
+130 -49
View File
@@ -1,13 +1,25 @@
import { ChevronRightIcon, PlusIcon } from "lucide-react";
import type { FC } from "react";
import { useQuery } from "react-query";
import { Link as RouterLink, useNavigate } from "react-router";
import type { Group, OrganizationGroupsAISpend } from "#/api/typesGenerated";
import {
GROUP_MEMBER_AVATAR_LIMIT,
groupMemberAvatars,
} from "#/api/queries/groups";
import type {
OrganizationGroupsAISpend,
PaginatedGroup,
} from "#/api/typesGenerated";
import { AIBudgetUsage } from "#/components/AIBudgetUsage/AIBudgetUsage";
import { Avatar } from "#/components/Avatar/Avatar";
import { AvatarData } from "#/components/Avatar/AvatarData";
import { AvatarDataSkeleton } from "#/components/Avatar/AvatarDataSkeleton";
import { Badge } from "#/components/Badge/Badge";
import { Button } from "#/components/Button/Button";
import { EmptyState } from "#/components/EmptyState/EmptyState";
import type { useFilter } from "#/components/Filter/Filter";
import { GroupsFilter } from "#/components/Filter/GroupsFilter";
import { PaginationContainer } from "#/components/PaginationWidget/PaginationContainer";
import { PaywallPremium } from "#/components/Paywall/PaywallPremium";
import { Skeleton } from "#/components/Skeleton/Skeleton";
import {
@@ -24,6 +36,7 @@ import {
TableRowSkeleton,
} from "#/components/TableLoader/TableLoader";
import { useClickableTableRow } from "#/hooks/useClickableTableRow";
import type { PaginationResultInfo } from "#/hooks/usePaginatedQuery";
import type { Permissions } from "#/modules/permissions";
import { docs } from "#/utils/docs";
import { SpendEstimateDocsLink } from "./AICostControl";
@@ -31,13 +44,16 @@ import { StatusIconTooltip } from "./StatusIconTooltip";
const EM_DASH = "\u2014";
export type GroupWithSpend = Group & {
// Stable keys for the avatar loading skeletons (indexes would trip lint).
const AVATAR_SKELETON_KEYS = ["a", "b", "c", "d", "e"];
export type GroupWithSpend = PaginatedGroup & {
readonly spend: OrganizationGroupsAISpend["groups"][number] | undefined;
};
/** Attach each group's spend, when present, so rows get a single object. */
export const joinGroupsSpend = (
groups: Group[] | undefined,
groups: readonly PaginatedGroup[] | undefined,
groupsSpend: OrganizationGroupsAISpend | undefined,
): GroupWithSpend[] | undefined => {
if (groups === undefined) {
@@ -59,6 +75,10 @@ type GroupsPageViewProps = {
canCreateGroup: boolean;
groupsEnabled: boolean;
showAIBudget: boolean;
filterProps: { filter: ReturnType<typeof useFilter> };
groupsQuery: PaginationResultInfo & {
isPlaceholderData: boolean;
};
permissions: Permissions;
};
@@ -68,6 +88,8 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
canCreateGroup,
groupsEnabled,
showAIBudget,
filterProps,
groupsQuery,
permissions,
}) => {
if (!groupsEnabled) {
@@ -82,46 +104,64 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
}
return (
<Table aria-label="Groups">
<TableHeader>
<TableRow>
<TableHead className="w-2/5">Name</TableHead>
<TableHead className={showAIBudget ? "w-1/5" : "w-3/5"}>
Users
</TableHead>
{showAIBudget && (
<TableHead className="w-2/5">
<div className="flex items-center gap-1">
AI spend
{spendError ? (
<StatusIconTooltip
kind="warning"
message="AI spend couldn't be loaded, so budgets aren't shown."
/>
) : (
<StatusIconTooltip
message={
<>
Approximate AI spend compared to the group's AI budget
for the active period. <SpendEstimateDocsLink />
</>
}
/>
)}
</div>
</TableHead>
)}
<TableHead className="w-auto" />
</TableRow>
</TableHeader>
<TableBody>
<GroupsTableBody
groups={groups}
canCreateGroup={canCreateGroup}
showAIBudget={showAIBudget}
/>
</TableBody>
</Table>
<div className="flex flex-col gap-4">
<div className="flex flex-row justify-between">
<GroupsFilter {...filterProps} />
{canCreateGroup && (
<Button asChild>
<RouterLink to="create">
<PlusIcon className="size-icon-sm" />
Create group
</RouterLink>
</Button>
)}
</div>
<PaginationContainer query={groupsQuery} paginationUnitLabel="groups">
<Table aria-label="Groups">
<TableHeader>
<TableRow>
<TableHead className="w-2/5">Name</TableHead>
<TableHead className={showAIBudget ? "w-1/5" : "w-3/5"}>
Users
</TableHead>
{showAIBudget && (
<TableHead className="w-2/5">
<div className="flex items-center gap-1">
AI spend
{spendError ? (
<StatusIconTooltip
kind="warning"
message="AI spend couldn't be loaded, so budgets aren't shown."
/>
) : (
<StatusIconTooltip
message={
<>
Approximate AI spend compared to the group's AI
budget for the active period.{" "}
<SpendEstimateDocsLink />
</>
}
/>
)}
</div>
</TableHead>
)}
<TableHead className="w-auto" />
</TableRow>
</TableHeader>
<TableBody>
<GroupsTableBody
groups={groups}
canCreateGroup={canCreateGroup}
showAIBudget={showAIBudget}
filterUsed={filterProps.filter.used}
/>
</TableBody>
</Table>
</PaginationContainer>
</div>
);
};
@@ -129,17 +169,33 @@ interface GroupsTableBodyProps {
groups: GroupWithSpend[] | undefined;
canCreateGroup: boolean;
showAIBudget: boolean;
filterUsed: boolean;
}
const GroupsTableBody: FC<GroupsTableBodyProps> = ({
groups,
canCreateGroup,
showAIBudget,
filterUsed,
}) => {
if (groups === undefined) {
return <TableLoader showAIBudget={showAIBudget} />;
}
if (groups.length === 0) {
// When a search returned no matches, don't nudge the user to create a
// first group; the org may already have groups that simply don't match.
if (filterUsed) {
return (
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message="No groups match your search"
description="Try a different search term."
/>
</TableCell>
</TableRow>
);
}
return (
<TableEmpty
message="No groups yet"
@@ -180,8 +236,23 @@ const GroupRow: FC<GroupRowProps> = ({ group, showAIBudget }) => {
const rowProps = useClickableTableRow({
onClick: () => navigate(group.name),
});
const memberAvatars = group.members.slice(0, 5);
const remainingAvatars = group.members.length - memberAvatars.length;
// The list endpoint returns only total_member_count, so fetch a small
// avatar preview per visible row instead of a full roster.
const membersQuery = useQuery({
...groupMemberAvatars(
group.organization_name,
group.name,
GROUP_MEMBER_AVATAR_LIMIT,
),
enabled: group.total_member_count > 0,
});
const memberAvatars = membersQuery.data?.users ?? [];
const remainingAvatars = group.total_member_count - memberAvatars.length;
const skeletonCount = Math.min(
group.total_member_count,
GROUP_MEMBER_AVATAR_LIMIT,
);
return (
<TableRow data-testid={`group-${group.id}`} {...rowProps}>
@@ -196,12 +267,24 @@ const GroupRow: FC<GroupRowProps> = ({ group, showAIBudget }) => {
/>
}
title={group.display_name || group.name}
subtitle={`${group.members.length} members`}
subtitle={`${group.total_member_count} members`}
/>
</TableCell>
<TableCell>
{group.members.length > 0 ? (
{group.total_member_count === 0 || membersQuery.isError ? (
EM_DASH
) : membersQuery.isLoading ? (
<div className="flex items-center gap-2">
{AVATAR_SKELETON_KEYS.slice(0, skeletonCount).map((key) => (
<Skeleton
key={key}
variant="circular"
className="size-[--avatar-default]"
/>
))}
</div>
) : (
<div className="flex items-center gap-2">
{memberAvatars.map((member) => (
<Avatar
@@ -216,8 +299,6 @@ const GroupRow: FC<GroupRowProps> = ({ group, showAIBudget }) => {
</Badge>
)}
</div>
) : (
EM_DASH
)}
</TableCell>