Thanks to visit codestin.com
Credit goes to github.com

Skip to content

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

Closed
aqandrew wants to merge 23 commits into
mainfrom
aqandrew/devex-434-groups-search-pagination
Closed

feat: add server-side search and pagination for groups page#27271
aqandrew wants to merge 23 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

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Docs preview

Check off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here.

@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

@jeremyruppel jeremyruppel left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep review of the paginated groups endpoint. Clean PR overall: it faithfully mirrors the OrganizationMembers pagination pattern end to end, generalizes Filter cleanly with the presets.length > 0 guard, and the PageBoundaries test is genuinely good coverage.

A few things worth addressing, pinned inline: 6 P2, 4 P3, 3 nits across 13 comments. No P0/P1 and no security holes (auth chain is sound and fail-closed). The two most worthwhile are the non-deterministic pagination ordering and the React Query cache key that group mutations won't invalidate.

Generated by Coder Agents (deep-review), posted on behalf of @jeremyruppel. Severity scale P0 (highest) to P4 (lowest); event is COMMENT only, no approval implied.

Comment thread coderd/database/queries/groups.sql
Comment thread coderd/database/queries/groups.sql
Comment thread coderd/database/dbauthz/dbauthz.go
Comment thread enterprise/coderd/groups.go
Comment thread enterprise/coderd/groups.go
Comment thread codersdk/groups.go
Comment thread site/src/api/queries/groups.ts
Comment thread site/src/pages/GroupsPage/GroupsPageView.tsx
Comment thread site/src/components/Filter/GroupsFilter.tsx
Comment thread site/src/pages/GroupsPage/GroupsPageView.stories.tsx
Comment thread coderd/searchquery/search.go Outdated
Comment thread codersdk/groups.go
}

type PaginatedGroupsResponse struct {
Groups []Group `json:"groups"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you remove the other type then Groups just needs to be []string

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little confused by this recommendation. By the other type, do you mean the GroupsParams struct from your other comment? I would think Groups needs to remain []Group regardless, since GroupsPaginated needs to return groups, not just group names. Those group objects get passed into the table in GroupsPageView.tsx:

aqandrew added 9 commits July 20, 2026 22:05
…oups-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
…-pagination' into aqandrew/devex-434-groups-search-pagination
The name+id tiebreaker commit (7f6e800) 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.
…nGroupsPaginated

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).
…upsPaginated

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).
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).
@aqandrew
aqandrew requested a review from a team as a code owner July 24, 2026 01:01
@aqandrew
aqandrew requested review from cstyan and jeremyruppel July 24, 2026 01:09

@nickvigilante nickvigilante left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docs updates LGTM!

Comment thread coderd/searchquery/search_test.go
Comment thread enterprise/coderd/groups.go
@aqandrew

Copy link
Copy Markdown
Contributor Author

closing in favor of #27603 and #27604

@aqandrew aqandrew closed this Jul 28, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 28, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants