From cee12ef254227c07535f9d8599b90a61bfb04efc Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Tue, 28 Jul 2026 17:06:59 +0000 Subject: [PATCH 1/4] feat: add server-side search and pagination for the Groups page Add a paginated, server-side searchable groups endpoint and wire the organization Groups page to it, matching the organization Members page pattern (useSearchParams + usePaginatedQuery + useFilter with a presentational view rendering Filter + PaginationContainer). - database: new PaginatedOrganizationGroups query with COUNT(*) OVER(), deterministic ORDER BY LOWER(name), OFFSET/LIMIT. dbauthz wrapper does a single org-wide ActionRead check (no post-filter) to keep LIMIT/OFFSET and the count consistent, mirroring PaginatedOrganizationMembers. - codersdk: PaginatedGroupsResponse + GroupsPaginated client method. - searchquery: minimal Groups(q) parser for free-text search. - enterprise: paginatedGroups handler + /organizations/{organization}/paginated-groups route. - frontend: getOrganizationPaginatedGroups API, paginatedGroupsByOrganization query, GroupsFilter (search-only), GroupsPageView filter + pagination, updated stories. The legacy /groups endpoint and its post-filter are left untouched. Generated with Coder Agents. fix: hide Filter preset menu if no presets are provided fix: move "Create group" button to GroupsPageView to match OrganizationMembersPageView Merge remote-tracking branch 'origin/main' into aqandrew/devex-434-groups-search-pagination docs: regenerate enterprise API reference after merging main fix(site/src/pages/GroupsPage): keep GroupsPageView presentational Gate the Create group button on the canCreateGroup prop the container already passes, instead of re-deriving permissions via useGroupsSettings + organizationsPermissions inside the view. Calling useGroupsSettings coupled the presentational view to GroupsPageContext, which threw "useGroupsSettings should be used inside of GroupsPageContext" in Storybook. test: set correct pagination number labels for WithSearchAndPagination story refactor: replace GroupsParams struct with string fix: order PaginatedOrganizationGroups by name, then id style: add spaces around cast operators Merge remote-tracking branch 'origin/main' into aqandrew/devex-434-groups-search-pagination # Conflicts: # site/src/api/api.ts # site/src/api/queries/groups.ts # site/src/pages/GroupsPage/GroupsPage.tsx # site/src/pages/GroupsPage/GroupsPageView.stories.tsx # site/src/pages/GroupsPage/GroupsPageView.tsx Merge remote-tracking branch 'origin/aqandrew/devex-434-groups-search-pagination' into aqandrew/devex-434-groups-search-pagination fix(coderd/database): restore OFFSET in PaginatedOrganizationGroups The name+id tiebreaker commit (7f6e8005f5) accidentally dropped the OFFSET @offset_opt clause when it was collapsed onto the ORDER BY line, which disabled offset pagination and removed OffsetOpt from the generated params. Restore it alongside the id tiebreaker and regenerate. refactor(codersdk): add PaginatedGroupsRequest and rename OrganizationGroupsPaginated Replace UsersRequest with a dedicated PaginatedGroupsRequest (pagination + search only) so the paginated groups client no longer advertises key:value filters the endpoint rejects with a 400. Rename GroupsPaginated to OrganizationGroupsPaginated for parity with OrganizationMembersPaginated and the DB/frontend names. Addresses review comment (codersdk/groups.go). docs(codersdk): document org-wide read requirement on OrganizationGroupsPaginated The paginated endpoint requires organization-wide group read and is not a drop-in for Groups (GET /groups), which authorizes per-group. Document this on the published SDK method so consumers do not assume equivalent behavior. Addresses review comment (dbauthz.go). fix(coderd/searchquery): support multi-word group search Bare search terms were added as separate 'search' values, so a multi-word query like 'front end' tripped the duplicate-param check and returned a 400 instead of a substring match the SQL supports. Concatenate bare terms into a single search value. Addresses review comment (searchquery/search.go). fix(enterprise/coderd): drop dead sql.ErrNoRows check in paginatedGroups PaginatedOrganizationGroups is a :many query that returns an empty slice, never sql.ErrNoRows, so the errors.Is guard was dead code. The empty result is already handled by the len(groups) == 0 branch. Addresses review comment (groups.go). test(enterprise/coderd): assert paginated group member hydration test(coderd): strengthen paginated groups query and search coverage feat(site/src/pages/GroupsPage): show filter-aware empty state refactor(site/src/components/Filter): drop unused GroupsFilter error prop refactor(site/src/pages/GroupsPage): use Array.from map callback in story Merge branch 'main' into aqandrew/devex-434-groups-search-pagination --- site/src/api/api.ts | 16 +++ site/src/api/queries/groups.ts | 33 +++++ site/src/api/typesGenerated.ts | 16 +++ site/src/components/Filter/Filter.tsx | 18 +-- site/src/components/Filter/GroupsFilter.tsx | 19 +++ site/src/pages/GroupsPage/GroupsPage.tsx | 34 +++--- .../GroupsPage/GroupsPageView.stories.tsx | 113 +++++++++++++++--- site/src/pages/GroupsPage/GroupsPageView.tsx | 111 +++++++++++------ 8 files changed, 282 insertions(+), 78 deletions(-) create mode 100644 site/src/components/Filter/GroupsFilter.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 284edf485c4..9dcfcea37be 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2212,6 +2212,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 8093330fc24..d735738f724 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"; @@ -74,6 +76,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, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 2d3f42b2b37..c81ddd3fe07 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -6978,6 +6978,22 @@ export interface OrganizationSyncSettings { readonly organization_assign_default: boolean; } +// From codersdk/groups.go +/** + * PaginatedGroupsRequest are the filters for a paginated groups request. + * Groups only support free-text search, so unlike UsersRequest it exposes no + * key:value filters that the endpoint would reject. + */ +export interface PaginatedGroupsRequest extends Pagination { + readonly q?: string; +} + +// From codersdk/groups.go +export interface PaginatedGroupsResponse { + readonly groups: readonly Group[]; + readonly count: number; +} + // From codersdk/organizations.go export interface PaginatedMembersRequest { readonly limit?: number; 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 65d592a9efb..5a1a407b00a 100644 --- a/site/src/pages/GroupsPage/GroupsPage.tsx +++ b/site/src/pages/GroupsPage/GroupsPage.tsx @@ -1,22 +1,22 @@ -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, SettingsHeaderDescription, SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; +import { usePaginatedQuery } from "#/hooks/usePaginatedQuery"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; @@ -32,17 +32,22 @@ const GroupsPage: FC = () => { // the cost-control feature is stable. const aibridgeVisible = Boolean(aibridge) && experiments.includes("ai-gateway-cost-control"); - 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({ @@ -116,15 +121,6 @@ const GroupsPage: FC = () => { {showOrganizations ? "organization" : "deployment"}. - - {groupsEnabled && permissions.createGroup && ( - - )}
{ canCreateGroup={permissions.createGroup} groupsEnabled={groupsEnabled} showAIBudget={aibridgeVisible} + filterProps={{ filter }} + groupsQuery={groupsQuery} /> ); diff --git a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx index 598e1bfad1d..502dbead7ec 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx @@ -1,11 +1,34 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, within } from "storybook/test"; +import { + mockInitialRenderResult, + mockSuccessResult, +} from "#/components/PaginationWidget/PaginationContainer.mocks"; +import type { UsePaginatedQueryResult } from "#/hooks/usePaginatedQuery"; import { MockGroup } from "#/testHelpers/entities"; import { GroupsPageView, type GroupWithSpend } from "./GroupsPageView"; const meta: Meta = { title: "pages/OrganizationGroupsPage", component: GroupsPageView, + args: { + canCreateGroup: true, + groupsEnabled: true, + filterProps: { + filter: { + query: "", + values: {}, + update: () => {}, + debounceUpdate: () => {}, + cancelDebounce: () => {}, + used: false, + }, + }, + groupsQuery: { + ...mockSuccessResult, + totalRecords: 1, + } as UsePaginatedQueryResult, + }, }; export default meta; @@ -23,26 +46,65 @@ const aiGroup = (id: string, name: string): GroupWithSpend => ({ display_name: name, }); +export const Default: Story = {}; + export const NotEnabled: Story = { args: { - groups: [{ ...mockGroupWithSpend }], - canCreateGroup: true, + groups: [mockGroupWithSpend], groupsEnabled: false, }, }; export const WithGroups: Story = { args: { - groups: [{ ...mockGroupWithSpend }], - canCreateGroup: true, - groupsEnabled: true, + groups: [mockGroupWithSpend], + }, +}; + +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: { + filter: { + query: "group", + values: {}, + update: () => {}, + debounceUpdate: () => {}, + cancelDebounce: () => {}, + 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: [ { @@ -132,9 +194,8 @@ export const WithAIBudgets: Story = { export const WithAIBudgetsLoading: Story = { args: { groups: undefined, - canCreateGroup: true, - groupsEnabled: true, showAIBudget: true, + groupsQuery: mockInitialRenderResult as UsePaginatedQueryResult, }, }; @@ -178,8 +239,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 }) => { @@ -194,8 +253,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 }) => { @@ -207,8 +264,6 @@ export const WithoutAIBudgetColumn: Story = { export const WithDisplayGroup: Story = { args: { groups: [{ ...mockGroupWithSpend, name: "front-end" }], - canCreateGroup: true, - groupsEnabled: true, }, }; @@ -216,14 +271,36 @@ 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: { + filter: { + query: "nomatch", + values: {}, + update: () => {}, + debounceUpdate: () => {}, + cancelDebounce: () => {}, + 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 2668f25417f..af13fb5e5f9 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.tsx @@ -9,6 +9,9 @@ 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 +27,7 @@ import { TableRowSkeleton, } from "#/components/TableLoader/TableLoader"; import { useClickableTableRow } from "#/hooks/useClickableTableRow"; +import type { PaginationResultInfo } from "#/hooks/usePaginatedQuery"; import { docs } from "#/utils/docs"; import { StatusIconTooltip } from "./StatusIconTooltip"; @@ -35,7 +39,7 @@ export type GroupWithSpend = Group & { /** Attach each group's spend, when present, so rows get a single object. */ export const joinGroupsSpend = ( - groups: Group[] | undefined, + groups: readonly Group[] | undefined, groupsSpend: OrganizationGroupsAISpend | undefined, ): GroupWithSpend[] | undefined => { if (groups === undefined) { @@ -57,6 +61,10 @@ type GroupsPageViewProps = { canCreateGroup: boolean; groupsEnabled: boolean; showAIBudget: boolean; + filterProps: { filter: ReturnType }; + groupsQuery: PaginationResultInfo & { + isPlaceholderData: boolean; + }; }; export const GroupsPageView: FC = ({ @@ -65,6 +73,8 @@ export const GroupsPageView: FC = ({ canCreateGroup, groupsEnabled, showAIBudget, + filterProps, + groupsQuery, }) => { if (!groupsEnabled) { return ( @@ -77,39 +87,56 @@ export const GroupsPageView: FC = ({ } return ( - - - - Name - - Users - - {showAIBudget && ( - -
- AI budget - {spendError ? ( - - ) : ( - - )} -
-
- )} - -
-
- - - -
+
+
+ + {canCreateGroup && ( + + )} +
+ + + + + + Name + + Users + + {showAIBudget && ( + +
+ AI budget + {spendError ? ( + + ) : ( + + )} +
+
+ )} + +
+
+ + + +
+
+
); }; @@ -117,17 +144,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 ( From 27b2c78ba021863de1dc1ae1ae086edbfd3efb89 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 29 Jul 2026 19:25:51 +0000 Subject: [PATCH 2/4] refactor: mock UsersFilter props with getDefaultFilterProps --- .../GroupsPage/GroupsPageView.stories.tsx | 51 ++++++++----------- 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx index 502dbead7ec..63ccbae522f 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx @@ -1,5 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ComponentProps } from "react"; import { expect, within } from "storybook/test"; +import { getDefaultFilterProps } from "#/components/Filter/storyHelpers"; +import type { UsersFilter } from "#/components/Filter/UsersFilter"; import { mockInitialRenderResult, mockSuccessResult, @@ -8,22 +11,18 @@ import type { UsePaginatedQueryResult } from "#/hooks/usePaginatedQuery"; import { MockGroup } 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: { - filter: { - query: "", - values: {}, - update: () => {}, - debounceUpdate: () => {}, - cancelDebounce: () => {}, - used: false, - }, - }, + filterProps: getDefaultFilterProps({ + values: {}, + menus: {}, + }), groupsQuery: { ...mockSuccessResult, totalRecords: 1, @@ -71,16 +70,12 @@ export const WithSearchAndPagination: Story = { groups: Array.from({ length: limit }, (_, i) => aiGroup(`group-${i}`, `Group ${i}`), ), - filterProps: { - filter: { - query: "group", - values: {}, - update: () => {}, - debounceUpdate: () => {}, - cancelDebounce: () => {}, - used: true, - }, - }, + filterProps: getDefaultFilterProps({ + query: "group", + values: {}, + menus: {}, + used: true, + }), groupsQuery: { ...mockSuccessResult, totalRecords, @@ -285,16 +280,12 @@ export const EmptyGroupWithPermission: Story = { export const NoSearchResults: Story = { args: { groups: [], - filterProps: { - filter: { - query: "nomatch", - values: {}, - update: () => {}, - debounceUpdate: () => {}, - cancelDebounce: () => {}, - used: true, - }, - }, + filterProps: getDefaultFilterProps({ + query: "nomatch", + values: {}, + menus: {}, + used: true, + }), }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); From 015c9b4d22dc16666827cf7e5da55e8690ac3de6 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 6 Aug 2026 18:10:04 +0000 Subject: [PATCH 3/4] feat(site/src/pages/GroupsPage): lazily fetch group member avatars per row The paginated groups endpoint no longer returns member rosters, so each group row now uses total_member_count for the count and lazily fetches a capped avatar preview via the group members endpoint. Handles loading (skeletons), error and empty (em dash), and success (avatars plus a +N badge) states. Adds a shared groupMemberAvatars query nested under the group members key, and Storybook coverage seeding the preview query. --- site/src/api/queries/groups.ts | 27 +++++++ .../GroupsPage/GroupsPageView.stories.tsx | 70 ++++++++++++++++++- site/src/pages/GroupsPage/GroupsPageView.tsx | 45 ++++++++++-- 3 files changed, 135 insertions(+), 7 deletions(-) diff --git a/site/src/api/queries/groups.ts b/site/src/api/queries/groups.ts index 1bac478e4ff..8360b268f34 100644 --- a/site/src/api/queries/groups.ts +++ b/site/src/api/queries/groups.ts @@ -200,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/pages/GroupsPage/GroupsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx index af5dafbd3c3..9dd3e9e402b 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx @@ -1,6 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { ComponentProps } from "react"; import { expect, within } from "storybook/test"; +import { + GROUP_MEMBER_AVATAR_LIMIT, + getGroupMemberAvatarsQueryKey, +} from "#/api/queries/groups"; import { getDefaultFilterProps } from "#/components/Filter/storyHelpers"; import type { UsersFilter } from "#/components/Filter/UsersFilter"; import { @@ -8,7 +12,12 @@ import { mockSuccessResult, } from "#/components/PaginationWidget/PaginationContainer.mocks"; import type { UsePaginatedQueryResult } from "#/hooks/usePaginatedQuery"; -import { MockGroup } from "#/testHelpers/entities"; +import { + MockGroup, + MockOrganization, + MockUserMember, + MockUserOwner, +} from "#/testHelpers/entities"; import { GroupsPageView, type GroupWithSpend } from "./GroupsPageView"; type FilterProps = ComponentProps; @@ -38,11 +47,30 @@ 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, + members: [], + 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 = {}; @@ -58,6 +86,43 @@ export const WithGroups: Story = { args: { 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; @@ -267,6 +332,9 @@ export const WithDisplayGroup: Story = { args: { groups: [{ ...mockGroupWithSpend, name: "front-end" }], }, + parameters: { + queries: [seedAvatars("front-end", [MockUserOwner, MockUserMember], 2)], + }, }; export const EmptyGroup: Story = { diff --git a/site/src/pages/GroupsPage/GroupsPageView.tsx b/site/src/pages/GroupsPage/GroupsPageView.tsx index fd67173a23b..68b24eb9ee9 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.tsx @@ -1,6 +1,11 @@ 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 { + GROUP_MEMBER_AVATAR_LIMIT, + groupMemberAvatars, +} from "#/api/queries/groups"; import type { Group, OrganizationGroupsAISpend } from "#/api/typesGenerated"; import { AIBudgetUsage } from "#/components/AIBudgetUsage/AIBudgetUsage"; import { Avatar } from "#/components/Avatar/Avatar"; @@ -35,6 +40,9 @@ import { StatusIconTooltip } from "./StatusIconTooltip"; const EM_DASH = "\u2014"; +// Stable keys for the avatar loading skeletons (indexes would trip lint). +const AVATAR_SKELETON_KEYS = ["a", "b", "c", "d", "e"]; + export type GroupWithSpend = Group & { readonly spend: OrganizationGroupsAISpend["groups"][number] | undefined; }; @@ -220,8 +228,23 @@ const GroupRow: FC = ({ 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 ( @@ -236,12 +259,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 )}
From cff663474bf39290a5bd2bef13869d4251792ded Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 6 Aug 2026 18:46:29 +0000 Subject: [PATCH 4/4] refactor(site/src/pages/GroupsPage): consume slim PaginatedGroup type The paginated groups endpoint now returns codersdk.PaginatedGroup, which omits the member roster. Point GroupWithSpend and joinGroupsSpend at the generated PaginatedGroup type and drop the obsolete members field from the story fixtures. --- site/src/pages/GroupsPage/GroupsPageView.stories.tsx | 1 - site/src/pages/GroupsPage/GroupsPageView.tsx | 9 ++++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx index 9dd3e9e402b..1accbbe8caf 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx @@ -54,7 +54,6 @@ const aiGroup = (id: string, name: string): GroupWithSpend => ({ id, name, display_name: name, - members: [], total_member_count: 0, }); diff --git a/site/src/pages/GroupsPage/GroupsPageView.tsx b/site/src/pages/GroupsPage/GroupsPageView.tsx index 68b24eb9ee9..3369776c3e7 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.tsx @@ -6,7 +6,10 @@ import { GROUP_MEMBER_AVATAR_LIMIT, groupMemberAvatars, } from "#/api/queries/groups"; -import type { Group, OrganizationGroupsAISpend } from "#/api/typesGenerated"; +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"; @@ -43,13 +46,13 @@ const EM_DASH = "\u2014"; // Stable keys for the avatar loading skeletons (indexes would trip lint). const AVATAR_SKELETON_KEYS = ["a", "b", "c", "d", "e"]; -export type GroupWithSpend = Group & { +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: readonly Group[] | undefined, + groups: readonly PaginatedGroup[] | undefined, groupsSpend: OrganizationGroupsAISpend | undefined, ): GroupWithSpend[] | undefined => { if (groups === undefined) {