Skip to content

feat: add server-side search and pagination for groups page#27271

Open
aqandrew wants to merge 7 commits into
mainfrom
aqandrew/devex-434-groups-search-pagination
Open

feat: add server-side search and pagination for groups page#27271
aqandrew wants to merge 7 commits into
mainfrom
aqandrew/devex-434-groups-search-pagination

Conversation

@aqandrew

@aqandrew aqandrew commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

URL path: /organizations/:organization/groups

The main purpose of this PR is to add a search field and pagination controls to the groups page (DEVEX-434). To be able to add pagination, Coder Agents created a new paginated API endpoint to use when querying groups. All of the Go/SQL code is agent-generated--I gave it as close a look as I could as a frontend developer but I would appreciate some extra scrutiny there. 😅 I can split this into smaller PRs if it's easier

I also moved the "Create group" button down from GroupsPage into GroupsPageView, to keep visually consistent with the "Add users" button on the members page.

image

See Storybook for what pagination looks like:

image

agent output below:

Summary

Adds a server-side, searchable, paginated groups endpoint and wires the
organization Groups page to it. This replaces the previous non-paginated
groupsByOrganization fetch and matches the established organization Members
page pattern: a container wires useSearchParams + usePaginatedQuery +
useFilter, and a presentational view renders <Filter> +
<PaginationContainer> purely from props.

Resolves DEVEX-434.

Changes

Database (OSS coderd/database/)

  • New PaginatedOrganizationGroups query: reuses the GetGroups org/search
    filters and adds COUNT(*) OVER(), deterministic ORDER BY LOWER(name),
    and OFFSET/LIMIT. Regenerated querier/mock/metrics.
  • dbauthz wrapper authorizes policy.ActionRead on
    rbac.ResourceGroup.InOrg(orgID) once, with no per-row post-filter,
    mirroring PaginatedOrganizationMembers. This is required for SQL
    LIMIT/OFFSET + COUNT(*) OVER() to stay consistent, and is consistent with
    the frontend already gating the whole page on the org-level viewGroups
    permission. The legacy GetGroups post-filter is left untouched.
  • Added the dbauthz method-coverage test entry.

codersdk (codersdk/groups.go)

  • PaginatedGroupsResponse { Groups []Group; Count int }
  • GroupsPaginated(ctx, orgID, UsersRequest) hitting
    /api/v2/organizations/{org}/paginated-groups.

searchquery (coderd/searchquery/search.go)

  • Minimal Groups(q) parser (free-text Search only, rejects key:value
    filters via ErrorExcessParams).

Enterprise (enterprise/coderd/)

  • paginatedGroups handler enriching each page group with members + total
    count, and the new route registered as a sibling of the existing groups
    routes so it inherits the same license/entitlement middleware.

Frontend (site/src/)

  • getOrganizationPaginatedGroups API + augmented response type keeping the
    optional ai_cost_control field.
  • paginatedGroupsByOrganization paginated query.
  • GroupsFilter search-only filter wrapper.
  • GroupsPage container now uses usePaginatedQuery + useFilter;
    GroupsPageView renders the filter above the table inside a
    PaginationContainer.
  • Updated stories (hoisted shared args, added search/multi-page and loading
    stories).

Out of scope

  • AI cost control / budgets (unchanged; per-group shape mirrored).
  • The legacy GET /groups endpoint and groupsByUserIdInOrganization stay on
    the non-paginated query.

Verification

  • make gen, make fmt, make lint (via pre-commit) clean.
  • go test ./enterprise/coderd/ -run TestPaginatedGroups (search filtering,
    page boundaries, Count) passes.
  • dbauthz method suite passes.
  • tsc clean; biome lint clean.
  • Storybook: GroupsPageView stories (12) pass, including the search-field
    and pagination-widget assertions.
Implementation plan (DEVEX-434)

Server-side search + pagination for the Groups page (DEVEX-434)

Context

The organization Groups list page (GroupsPage.tsx) currently fetches all
groups for an org in one non-paginated request (groupsByOrganization → plain
useQueryGET /api/v2/organizations/{org}/groups → bare []Group). The
page already carries two placeholders for this work: a // TODO add search/filtering here comment and a <div>TODO filter</div>.

The goal is to add a search box + pagination, matching the organization
Members page
(OrganizationMembersPage.tsx / OrganizationMembersPageView.tsx),
which is the established pattern: a container wires useSearchParams +
usePaginatedQuery + useFilter, and a presentational view renders
<Filter> + <PaginationContainer> purely from props. Because the view stays
presentational, the new UI is fully renderable in GroupsPageView.stories.tsx
with static mocks (this was the user's original question — yes, once the filter

  • pagination live in the view and the stories pass the new props).

This requires a new server-side paginated groups endpoint, since the
current /groups response has no count and can't feed usePaginatedQuery.

Key design decision (please confirm at approval)

Authorization semantics change, mirroring the Members precedent. The
existing GetGroups applies RBAC as a Go post-filter
(fetchWithPostFilter in dbauthz.go:3868): the DB returns rows, then groups
the caller can't read are dropped. This is fundamentally incompatible with SQL
LIMIT/OFFSET + COUNT(*) OVER() — the count would over-count and pages would
have holes.

PaginatedOrganizationMembers solves this by requiring a single org-wide
ActionRead check up front and doing no per-row filtering
(dbauthz.go:6899). We will do the same for groups: the new paginated query's
dbauthz wrapper authorizes policy.ActionRead on
rbac.ResourceGroup.InOrg(orgID) once, with no post-filter. This is consistent
with the frontend already gating the whole Groups page on the org-level
viewGroups permission. The legacy /groups endpoint and its post-filter are
left untouched (CLI and other consumers keep using it).

Out of scope

  • AI cost control / budgets. The current list endpoint does not embed
    ai_cost_control (db2sdk.Group doesn't set it; the frontend
    GroupWithAICostControl.ai_cost_control is optional and undefined in
    practice, pending AIGOV-290). The new endpoint mirrors the existing per-group
    shape. No budget search/sort.
  • The legacy GET /groups endpoint and groupsByUserIdInOrganization (the
    members page's user→groups mapper) stay on the non-paginated query.

Implementation

1. Database — new paginated query (OSS coderd/database/)

  • coderd/database/queries/groups.sql: add a new query
    PaginatedOrganizationGroups :many (separate from GetGroups to avoid
    changing every GetGroups caller's row type). Reuse GetGroups's filter
    clauses for @organization_id and @search (ILIKE on name/display_name,
    already supported), and add what GetGroups lacks:

    • COUNT(*) OVER() AS count
    • deterministic ORDER BY LOWER(groups.name) ASC
    • OFFSET @offset_opt
    • LIMIT NULLIF(@limit_opt :: int, 0)

    Model it on PaginatedOrganizationMembers
    (coderd/database/queries/organizationmembers.sql:84).

  • Run make gen to regenerate queries.sql.go, querier.go, the gomock
    (dbmock), and dbmetrics wrappers.

  • coderd/database/dbauthz/dbauthz.go: hand-add the wrapper for the new
    method with the single org-wide authorize check (see design decision). Place
    it next to GetGroups (~3868) / model on PaginatedOrganizationMembers
    (~6899).

  • coderd/database/dbmem/dbmem.go: hand-add an in-memory implementation
    matching the SQL (filter by org + search, sort by lower(name), apply
    offset/limit, set Count to the pre-limit total).

  • coderd/database/dbauthz/dbauthz_test.go: add a test entry for the new
    method (the dbauthz suite asserts every store method is covered).

2. codersdk (OSS codersdk/groups.go)

  • Add response type PaginatedGroupsResponse { Groups []Group; Count int }
    (mirrors PaginatedMembersResponse in codersdk/organizations.go:102 and the
    existing GroupMembersResponse).
  • Add client method GroupsPaginated(ctx, orgID uuid.UUID, req UsersRequest)
    hitting /api/v2/organizations/{org}/paginated-groups, using
    req.Pagination.asRequestOption() + req.asRequestOption() for
    after_id/limit/offset/q. Reuse the existing UsersRequest
    (codersdk/users.go:27, embeds Pagination, has q) — groups need only
    free-text search, no extra filter fields. Model on
    OrganizationMembersPaginated (codersdk/users.go:825).

3. Backend handler (ENTERPRISE enterprise/coderd/)

Groups handlers live in enterprise, not OSS.

  • enterprise/coderd/groups.go: add handler paginatedGroups, modeled on
    coderd/members.go:276 (paginatedMembers) and the existing groups
    handler in this file:
    • parse q into a search string (add a minimal searchquery.Groups(q) in
      coderd/searchquery/search.go returning {Search string} + validation via
      ErrorExcessParams, following searchquery.Users; groups have no
      key:value filters, so it just captures bare terms as Search).
    • agpl.ParsePagination(rw, r) for limit/offset (enterprise imports the OSS
      ParsePagination).
    • call api.Database.PaginatedOrganizationGroups(...).
    • for the page's groups, enrich each with members + total count exactly as
      the existing groups handler does (GetGroupMembersByGroupID +
      GetGroupMembersCountByGroupID + db2sdk.Group). Bounded to page size, so
      the N+1 is acceptable.
    • return codersdk.PaginatedGroupsResponse{Groups, Count} (Count from the
      first row's count, 0/empty slice when no rows), with swagger
      annotations.
  • enterprise/coderd/coderd.go: register the route as a sibling of the
    existing groups routes so it inherits the same license/entitlement
    middleware, e.g. GET /organizations/{organization}/paginated-groups
    (near lines 498-513). Confirm chi placement doesn't collide with
    /{groupName}.

4. Frontend query + API (site/src/api/)

  • make gen regenerates site/src/api/typesGenerated.ts with
    PaginatedGroupsResponse.
  • site/src/api/api.ts: add getOrganizationPaginatedGroups(organization, options?: TypesGen.UsersRequest) using getURLWithSearchParams
    PaginatedGroupsResponse. Add an augmented response type mirroring
    GroupMembersResponseWithAICostControl so .groups is typed
    GroupWithAICostControl[] (keeps the optional ai_cost_control field the
    view already reads). Model on getOrganizationPaginatedMembers (api.ts:763).
  • site/src/api/queries/groups.ts: add
    paginatedGroupsByOrganization(organization, searchParams) returning
    UsePaginatedQueryOptions<PaginatedGroupsResponse..., UsersRequest>, copied
    from groupMembers (lines 96-119) / paginatedOrganizationMembers:
    queryPayload: ({limit, offset}) => ({limit, offset, q: prepareQuery(searchParams.get("filter") ?? "")}). Leave groupsByOrganization
    in place for other callers.

5. Frontend container + view + filter (site/src/pages/GroupsPage/)

  • GroupsPage.tsx: replace useQuery(groupsByOrganization(...)) with
    const [searchParams, setSearchParams] = useSearchParams();,
    const groupsQuery = usePaginatedQuery(paginatedGroupsByOrganization(org.name, searchParams));, and const filter = useFilter({ searchParams, onSearchParamsChange: setSearchParams, onUpdate: groupsQuery.goToFirstPage });. Remove the <div>TODO filter</div> placeholder and the TODO comment.
    Pass filterProps={{ filter }}, groupsQuery, and groups: groupsQuery.data?.groups down to the view. Keep the existing error toasts.
  • GroupsPageView.tsx: add props filterProps: { filter: ReturnType<typeof useFilter> } and groupsQuery: PaginationResultInfo & { isPlaceholderData: boolean }. Render a search-only filter above the table
    and wrap the table in <PaginationContainer query={groupsQuery} paginationUnitLabel="groups">, mirroring OrganizationMembersPageView.tsx.
    For the filter, use the base Filter (components/Filter/Filter.tsx) with
    presets={[]} and no options/menus (a thin GroupsFilter wrapper, since no
    existing page renders a menu-less Filter yet) — it renders just the
    SearchField.
  • Keep groupsEnabled (paywall) and showAIBudget behavior as-is.

6. Stories (site/src/pages/GroupsPage/GroupsPageView.stories.tsx)

  • Adopt the OrganizationMembersPageView.stories.tsx pattern: hoist shared args
    into meta.args, supply the new required props there:
    • filterProps: { filter: { query: "", values: {}, update() {}, debounceUpdate() {}, cancelDebounce() {}, used: false } }
    • groupsQuery: { ...mockSuccessResult, totalRecords: N } as UsePaginatedQueryResult (from
      components/PaginationWidget/PaginationContainer.mocks).
  • Every existing story (WithGroups, WithAIBudgets, EmptyGroup, etc.) must
    now inherit/override these; the WithAIBudgets play assertions stay valid.
  • Add stories exercising the new UI: a populated search + multi-page pagination
    (e.g. totalRecords > page size, hasNextPage: true), and a loading state
    (mockInitialRenderResult). Optionally an empty Default: Story = {} to
    match the reference file.

Verification

  1. make gen (twice if audit/lint complains), then make lint and
    make fmt — clean.
  2. Backend: make test RUN=TestPaginatedGroups (new test in
    enterprise/coderd/groups_test.go covering search filtering, page
    boundaries, and Count) plus the dbauthz suite for the new method. Uses
    coderdenttest since groups are enterprise.
  3. Frontend types: pnpm --dir site check / tsc clean;
    mcp__typescript-language-server__diagnostics on the changed files.
  4. Storybook (per MCP workflow): run-story-tests for the GroupsPageView
    stories (assert the search field + pagination widget render, existing
    play assertions pass), then preview-stories and surface the URLs.
  5. End-to-end: ./scripts/develop.sh, open the org Groups page, type in the
    search box (verify the ?filter= URL param + narrowed results), and page
    through when there are more groups than the page size.

Note: the plan referenced hand-editing coderd/database/dbmem/dbmem.go, but
dbmem has since been removed from the repo (commit 3c2f3d640b), so that
step was obsolete and skipped; tests run against a real database. The plan's
GroupsPageView.tsx TODO placeholders were also already gone.

The endpoint uses an org-wide ActionRead authorization check (no post-filter)
to keep SQL LIMIT/OFFSET + COUNT(*) OVER() consistent, mirroring
PaginatedOrganizationMembers. See the PR summary for the full breakdown.

Generated with Coder Agents.

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.
@linear-code

linear-code Bot commented Jul 15, 2026

Copy link
Copy Markdown

DEVEX-434

@github-actions

Copy link
Copy Markdown

Docs preview

📖 View docs preview for docs/reference/api/enterprise.md

@aqandrew aqandrew changed the title feat: add server-side search and pagination for the Groups page feat(site): add server-side search and pagination for the Groups page Jul 15, 2026
@aqandrew aqandrew changed the title feat(site): add server-side search and pagination for the Groups page feat: add server-side search and pagination for the Groups page Jul 15, 2026
@aqandrew aqandrew changed the title feat: add server-side search and pagination for the Groups page feat: add server-side search and pagination for groups page Jul 15, 2026
@aqandrew aqandrew marked this pull request as draft July 15, 2026 22:37
aqandrew added 6 commits July 15, 2026 22:37
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.
@aqandrew aqandrew marked this pull request as ready for review July 16, 2026 01:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant