diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 484fbb7aa4c..078da917beb 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -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 => { + 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 */ diff --git a/site/src/api/queries/groups.ts b/site/src/api/queries/groups.ts index 6e714f92c79..8360b268f34 100644 --- a/site/src/api/queries/groups.ts +++ b/site/src/api/queries/groups.ts @@ -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; }; +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 { + 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 => { + return { + queryKey: getGroupMemberAvatarsQueryKey(organization, groupName, limit), + queryFn: ({ signal }) => + API.getGroupMembers(organization, groupName, { limit }, signal), + }; +}; + export type GroupsByUserId = Readonly>; export function groupsByUserId() { diff --git a/site/src/components/Filter/Filter.tsx b/site/src/components/Filter/Filter.tsx index d194d0f237a..3f8972af69e 100644 --- a/site/src/components/Filter/Filter.tsx +++ b/site/src/components/Filter/Filter.tsx @@ -210,14 +210,16 @@ export const Filter: FC = ({ ) : ( <> - filter.update(query)} - presets={presets} - learnMoreLink={learnMoreLink} - learnMoreLabel2={learnMoreLabel2} - learnMoreLink2={learnMoreLink2} - /> + {presets.length > 0 && ( + filter.update(query)} + presets={presets} + learnMoreLink={learnMoreLink} + learnMoreLabel2={learnMoreLabel2} + learnMoreLink2={learnMoreLink2} + /> + )}
; +} + +// 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 = ({ filter }) => { + return ( + + ); +}; diff --git a/site/src/pages/GroupsPage/GroupsPage.tsx b/site/src/pages/GroupsPage/GroupsPage.tsx index 0f783091065..5dd5846fb2c 100644 --- a/site/src/pages/GroupsPage/GroupsPage.tsx +++ b/site/src/pages/GroupsPage/GroupsPage.tsx @@ -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"}. - - {groupsEnabled && permissions.createGroup && ( - - )}
{ canCreateGroup={permissions.createGroup} groupsEnabled={groupsEnabled} showAIBudget={aibridgeVisible} + filterProps={{ filter }} + groupsQuery={groupsQuery} permissions={authPermissions} /> diff --git a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx index 9d74dddaab8..a9b6e21bef1 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx @@ -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; + const meta: Meta = { title: "pages/OrganizationGroupsPage", component: GroupsPageView, args: { + canCreateGroup: true, + groupsEnabled: true, + filterProps: getDefaultFilterProps({ + 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, + 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({ + 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({ + 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(); }, }; diff --git a/site/src/pages/GroupsPage/GroupsPageView.tsx b/site/src/pages/GroupsPage/GroupsPageView.tsx index 2a2bbe3ef55..74db1ce8eb9 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.tsx @@ -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 }; + groupsQuery: PaginationResultInfo & { + isPlaceholderData: boolean; + }; permissions: Permissions; }; @@ -68,6 +88,8 @@ export const GroupsPageView: FC = ({ canCreateGroup, groupsEnabled, showAIBudget, + filterProps, + groupsQuery, permissions, }) => { if (!groupsEnabled) { @@ -82,46 +104,64 @@ export const GroupsPageView: FC = ({ } return ( - - - - Name - - Users - - {showAIBudget && ( - -
- AI spend - {spendError ? ( - - ) : ( - - Approximate AI spend compared to the group's AI budget - for the active period. - - } - /> - )} -
-
- )} - -
-
- - - -
+
+
+ + {canCreateGroup && ( + + )} +
+ + + + + + Name + + Users + + {showAIBudget && ( + +
+ AI spend + {spendError ? ( + + ) : ( + + Approximate AI spend compared to the group's AI + budget for the active period.{" "} + + + } + /> + )} +
+
+ )} + +
+
+ + + +
+
+
); }; @@ -129,17 +169,33 @@ interface GroupsTableBodyProps { groups: GroupWithSpend[] | undefined; canCreateGroup: boolean; showAIBudget: boolean; + filterUsed: boolean; } const GroupsTableBody: FC = ({ groups, canCreateGroup, showAIBudget, + filterUsed, }) => { if (groups === undefined) { return ; } 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 ( + + + + + + ); + } return ( = ({ 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 ( @@ -196,12 +267,24 @@ const GroupRow: FC = ({ group, showAIBudget }) => { /> } title={group.display_name || group.name} - subtitle={`${group.members.length} members`} + subtitle={`${group.total_member_count} members`} /> - {group.members.length > 0 ? ( + {group.total_member_count === 0 || membersQuery.isError ? ( + EM_DASH + ) : membersQuery.isLoading ? ( +
+ {AVATAR_SKELETON_KEYS.slice(0, skeletonCount).map((key) => ( + + ))} +
+ ) : (
{memberAvatars.map((member) => ( = ({ group, showAIBudget }) => { )}
- ) : ( - EM_DASH )}