From c712b1b9e014e4d78afffcf07027edd6748dd7f5 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 15 Jul 2026 17:15:01 +0000 Subject: [PATCH 01/19] 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. --- coderd/apidoc/docs.go | 66 ++++++++++++++ coderd/apidoc/swagger.json | 62 +++++++++++++ coderd/database/dbauthz/dbauthz.go | 11 +++ coderd/database/dbauthz/dbauthz_test.go | 13 +++ coderd/database/dbmetrics/querymetrics.go | 8 ++ coderd/database/dbmock/dbmock.go | 15 +++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 86 ++++++++++++++++++ coderd/database/queries/groups.sql | 31 +++++++ coderd/searchquery/search.go | 26 ++++++ codersdk/groups.go | 25 +++++ docs/reference/api/enterprise.md | 70 ++++++++++++++ docs/reference/api/schemas.md | 45 +++++++++ enterprise/coderd/coderd.go | 8 ++ enterprise/coderd/groups.go | 83 +++++++++++++++++ enterprise/coderd/groups_test.go | 91 +++++++++++++++++++ site/src/api/api.ts | 21 +++++ site/src/api/queries/groups.ts | 33 +++++++ site/src/api/typesGenerated.ts | 6 ++ site/src/components/Filter/GroupsFilter.tsx | 21 +++++ site/src/pages/GroupsPage/GroupsPage.tsx | 21 +++-- .../GroupsPage/GroupsPageView.stories.tsx | 81 +++++++++++++---- site/src/pages/GroupsPage/GroupsPageView.tsx | 71 +++++++++------ 23 files changed, 845 insertions(+), 50 deletions(-) create mode 100644 site/src/components/Filter/GroupsFilter.tsx diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 5a0f622579131..e63bb73b88418 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5405,6 +5405,58 @@ const docTemplate = `{ ] } }, + "/api/v2/organizations/{organization}/paginated-groups": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get groups by organization (paginated)", + "operationId": "get-groups-by-organization-paginated", + "parameters": [ + { + "type": "string", + "description": "Organization ID or name", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query" + }, + { + "type": "integer", + "description": "Page limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.PaginatedGroupsResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/organizations/{organization}/paginated-members": { "get": { "produces": [ @@ -21752,6 +21804,20 @@ const docTemplate = `{ } } }, + "codersdk.PaginatedGroupsResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Group" + } + } + } + }, "codersdk.PaginatedMembersResponse": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7903886fdcbb8..42b79553ae8cf 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -4784,6 +4784,54 @@ ] } }, + "/api/v2/organizations/{organization}/paginated-groups": { + "get": { + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get groups by organization (paginated)", + "operationId": "get-groups-by-organization-paginated", + "parameters": [ + { + "type": "string", + "description": "Organization ID or name", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query" + }, + { + "type": "integer", + "description": "Page limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.PaginatedGroupsResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/organizations/{organization}/paginated-members": { "get": { "produces": ["application/json"], @@ -19856,6 +19904,20 @@ } } }, + "codersdk.PaginatedGroupsResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Group" + } + } + } + }, "codersdk.PaginatedMembersResponse": { "type": "object", "properties": { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index fd9e9d685b28b..03cfa386a5a3b 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -6896,6 +6896,17 @@ func (q *querier) OrganizationMembers(ctx context.Context, arg database.Organiza return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.OrganizationMembers)(ctx, arg) } +func (q *querier) PaginatedOrganizationGroups(ctx context.Context, arg database.PaginatedOrganizationGroupsParams) ([]database.PaginatedOrganizationGroupsRow, error) { + // Required to have permission to read all groups in the organization. This + // mirrors PaginatedOrganizationMembers: a single org-wide read check with no + // per-row post-filter, so that SQL LIMIT/OFFSET and COUNT(*) OVER() stay + // consistent across pages. + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceGroup.InOrg(arg.OrganizationID)); err != nil { + return nil, err + } + return q.db.PaginatedOrganizationGroups(ctx, arg) +} + func (q *querier) PaginatedOrganizationMembers(ctx context.Context, arg database.PaginatedOrganizationMembersParams) ([]database.PaginatedOrganizationMembersRow, error) { // Required to have permission to read all members in the organization if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOrganizationMember.InOrg(arg.OrganizationID)); err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 0b1c2b953be10..cc4eda9c54ca7 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2523,6 +2523,19 @@ func (s *MethodTestSuite) TestOrganization() { check.Args(arg).Asserts(mem, policy.ActionRead) })) + s.Run("PaginatedOrganizationGroups", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + o := testutil.Fake(s.T(), faker, database.Organization{}) + g := testutil.Fake(s.T(), faker, database.Group{OrganizationID: o.ID}) + arg := database.PaginatedOrganizationGroupsParams{OrganizationID: o.ID, LimitOpt: 0} + rows := []database.PaginatedOrganizationGroupsRow{{ + Group: g, + OrganizationName: o.Name, + OrganizationDisplayName: o.DisplayName, + Count: 1, + }} + dbm.EXPECT().PaginatedOrganizationGroups(gomock.Any(), arg).Return(rows, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceGroup.InOrg(o.ID), policy.ActionRead).Returns(rows) + })) s.Run("PaginatedOrganizationMembers", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { o := testutil.Fake(s.T(), faker, database.Organization{}) u := testutil.Fake(s.T(), faker, database.User{}) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 145d7ff5824b1..017b95c9333ee 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -4865,6 +4865,14 @@ func (m queryMetricsStore) OrganizationMembers(ctx context.Context, arg database return r0, r1 } +func (m queryMetricsStore) PaginatedOrganizationGroups(ctx context.Context, arg database.PaginatedOrganizationGroupsParams) ([]database.PaginatedOrganizationGroupsRow, error) { + start := time.Now() + r0, r1 := m.s.PaginatedOrganizationGroups(ctx, arg) + m.queryLatencies.WithLabelValues("PaginatedOrganizationGroups").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "PaginatedOrganizationGroups").Inc() + return r0, r1 +} + func (m queryMetricsStore) PaginatedOrganizationMembers(ctx context.Context, arg database.PaginatedOrganizationMembersParams) ([]database.PaginatedOrganizationMembersRow, error) { start := time.Now() r0, r1 := m.s.PaginatedOrganizationMembers(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index b2b7c28e02c7b..4a0b51914f782 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -9174,6 +9174,21 @@ func (mr *MockStoreMockRecorder) PGLocks(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PGLocks", reflect.TypeOf((*MockStore)(nil).PGLocks), ctx) } +// PaginatedOrganizationGroups mocks base method. +func (m *MockStore) PaginatedOrganizationGroups(ctx context.Context, arg database.PaginatedOrganizationGroupsParams) ([]database.PaginatedOrganizationGroupsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PaginatedOrganizationGroups", ctx, arg) + ret0, _ := ret[0].([]database.PaginatedOrganizationGroupsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PaginatedOrganizationGroups indicates an expected call of PaginatedOrganizationGroups. +func (mr *MockStoreMockRecorder) PaginatedOrganizationGroups(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaginatedOrganizationGroups", reflect.TypeOf((*MockStore)(nil).PaginatedOrganizationGroups), ctx, arg) +} + // PaginatedOrganizationMembers mocks base method. func (m *MockStore) PaginatedOrganizationMembers(ctx context.Context, arg database.PaginatedOrganizationMembersParams) ([]database.PaginatedOrganizationMembersRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index dbc70a813e7a2..612a794fc6ed9 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1214,6 +1214,7 @@ type sqlcQuerier interface { // - Use just 'user_id' to get all orgs a user is a member of // - Use both to get a specific org member row OrganizationMembers(ctx context.Context, arg OrganizationMembersParams) ([]OrganizationMembersRow, error) + PaginatedOrganizationGroups(ctx context.Context, arg PaginatedOrganizationGroupsParams) ([]PaginatedOrganizationGroupsRow, error) PaginatedOrganizationMembers(ctx context.Context, arg PaginatedOrganizationMembersParams) ([]PaginatedOrganizationMembersRow, error) // Under READ COMMITTED, concurrent pin operations for the same // owner may momentarily produce duplicate pin_order values because diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 574c803aa50c7..5e18f086a00c7 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -14860,6 +14860,92 @@ func (q *sqlQuerier) InsertMissingGroups(ctx context.Context, arg InsertMissingG return items, nil } +const paginatedOrganizationGroups = `-- name: PaginatedOrganizationGroups :many +SELECT + groups.id, groups.name, groups.organization_id, groups.avatar_url, groups.quota_allowance, groups.display_name, groups.source, groups.chat_spend_limit_micros, + organizations.name AS organization_name, + organizations.display_name AS organization_display_name, + COUNT(*) OVER() AS count +FROM + groups +INNER JOIN + organizations ON groups.organization_id = organizations.id +WHERE + true + AND CASE + WHEN $1:: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + groups.organization_id = $1 + ELSE true + END + -- Filter by group name or display name (substring, case-insensitive). + AND CASE WHEN $2 :: text != '' THEN ( + groups.name ILIKE concat('%', $2, '%') + OR groups.display_name ILIKE concat('%', $2, '%') + ) + ELSE true + END +ORDER BY + -- Deterministic and consistent ordering of all groups. This is to ensure consistent pagination. + LOWER(groups.name) ASC OFFSET $3 +LIMIT + -- A null limit means "no limit", so 0 means return all + NULLIF($4 :: int, 0) +` + +type PaginatedOrganizationGroupsParams struct { + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + Search string `db:"search" json:"search"` + OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` + LimitOpt int32 `db:"limit_opt" json:"limit_opt"` +} + +type PaginatedOrganizationGroupsRow struct { + Group Group `db:"group" json:"group"` + OrganizationName string `db:"organization_name" json:"organization_name"` + OrganizationDisplayName string `db:"organization_display_name" json:"organization_display_name"` + Count int64 `db:"count" json:"count"` +} + +func (q *sqlQuerier) PaginatedOrganizationGroups(ctx context.Context, arg PaginatedOrganizationGroupsParams) ([]PaginatedOrganizationGroupsRow, error) { + rows, err := q.db.QueryContext(ctx, paginatedOrganizationGroups, + arg.OrganizationID, + arg.Search, + arg.OffsetOpt, + arg.LimitOpt, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []PaginatedOrganizationGroupsRow + for rows.Next() { + var i PaginatedOrganizationGroupsRow + if err := rows.Scan( + &i.Group.ID, + &i.Group.Name, + &i.Group.OrganizationID, + &i.Group.AvatarURL, + &i.Group.QuotaAllowance, + &i.Group.DisplayName, + &i.Group.Source, + &i.Group.ChatSpendLimitMicros, + &i.OrganizationName, + &i.OrganizationDisplayName, + &i.Count, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateGroupByID = `-- name: UpdateGroupByID :one UPDATE groups diff --git a/coderd/database/queries/groups.sql b/coderd/database/queries/groups.sql index 39742d55350f1..ffbdff164f439 100644 --- a/coderd/database/queries/groups.sql +++ b/coderd/database/queries/groups.sql @@ -89,6 +89,37 @@ WHERE LIMIT NULLIF(@limit_opt :: int, 0) ; +-- name: PaginatedOrganizationGroups :many +SELECT + sqlc.embed(groups), + organizations.name AS organization_name, + organizations.display_name AS organization_display_name, + COUNT(*) OVER() AS count +FROM + groups +INNER JOIN + organizations ON groups.organization_id = organizations.id +WHERE + true + AND CASE + WHEN @organization_id:: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + groups.organization_id = @organization_id + ELSE true + END + -- Filter by group name or display name (substring, case-insensitive). + AND CASE WHEN @search :: text != '' THEN ( + groups.name ILIKE concat('%', @search, '%') + OR groups.display_name ILIKE concat('%', @search, '%') + ) + ELSE true + END +ORDER BY + -- Deterministic and consistent ordering of all groups. This is to ensure consistent pagination. + LOWER(groups.name) ASC OFFSET @offset_opt +LIMIT + -- A null limit means "no limit", so 0 means return all + NULLIF(@limit_opt :: int, 0); + -- name: InsertGroup :one INSERT INTO groups ( id, diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 20291c8033a7c..f34c4ec741bdb 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -174,6 +174,32 @@ func Users(query string) (database.GetUsersParams, []codersdk.ValidationError) { return filter, parser.Errors } +// GroupsParams holds the parsed filters for a groups search query. Groups +// only support free-text search against name and display name, so the query +// captures bare terms as Search and rejects any key:value filters. +type GroupsParams struct { + Search string +} + +func Groups(query string) (GroupsParams, []codersdk.ValidationError) { + // Always lowercase for all searches. + query = strings.ToLower(query) + values, errors := searchTerms(query, func(term string, values url.Values) error { + values.Add("search", term) + return nil + }) + if len(errors) > 0 { + return GroupsParams{}, errors + } + + parser := httpapi.NewQueryParamParser() + filter := GroupsParams{ + Search: parser.String(values, "", "search"), + } + parser.ErrorExcessParams(values) + return filter, parser.Errors +} + func Members(query string, organizationID uuid.UUID) (database.OrganizationMembersParams, []codersdk.ValidationError) { query = strings.TrimSpace(query) if query == "" { diff --git a/codersdk/groups.go b/codersdk/groups.go index a191b280e4790..9f98b787d4e00 100644 --- a/codersdk/groups.go +++ b/codersdk/groups.go @@ -48,6 +48,11 @@ type GroupMembersResponse struct { Count int `json:"count"` } +type PaginatedGroupsResponse struct { + Groups []Group `json:"groups"` + Count int `json:"count"` +} + func (g Group) IsEveryone() bool { return g.ID == g.OrganizationID } @@ -135,6 +140,26 @@ func (c *Client) GroupByOrgAndName(ctx context.Context, orgID uuid.UUID, name st return resp, json.NewDecoder(res.Body).Decode(&resp) } +// GroupsPaginated lists filtered and paginated groups in an organization. +func (c *Client) GroupsPaginated(ctx context.Context, orgID uuid.UUID, req UsersRequest) (PaginatedGroupsResponse, error) { + res, err := c.Request(ctx, http.MethodGet, + fmt.Sprintf("/api/v2/organizations/%s/paginated-groups", orgID.String()), + nil, + req.Pagination.asRequestOption(), + req.asRequestOption(), + ) + if err != nil { + return PaginatedGroupsResponse{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return PaginatedGroupsResponse{}, ReadBodyAsError(res) + } + var resp PaginatedGroupsResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + type GroupRequest struct { ExcludeMembers bool `json:"exclude_members"` } diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 34915c902b5e9..288ac1ff320f7 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -1999,6 +1999,76 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get groups by organization (paginated) + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/paginated-groups \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /api/v2/organizations/{organization}/paginated-groups` + +### Parameters + +| Name | In | Type | Required | Description | +|----------------|-------|---------|----------|-------------------------| +| `organization` | path | string | true | Organization ID or name | +| `q` | query | string | false | Search query | +| `limit` | query | integer | false | Page limit | +| `offset` | query | integer | false | Page offset | + +### Example responses + +> 200 Response + +```json +{ + "count": 0, + "groups": [ + { + "avatar_url": "http://example.com", + "display_name": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "members": [ + { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "is_service_account": true, + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + } + ], + "name": "string", + "organization_display_name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "organization_name": "string", + "quota_allowance": 0, + "source": "user", + "total_member_count": 0 + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.PaginatedGroupsResponse](schemas.md#codersdkpaginatedgroupsresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Serve provisioner daemon ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index fcbe267ec2036..270d02a9ed2ec 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -9260,6 +9260,51 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | ยป `[any property]` | array of string | false | | | | `organization_assign_default` | boolean | false | | Organization assign default will ensure the default org is always included for every user, regardless of their claims. This preserves legacy behavior. | +## codersdk.PaginatedGroupsResponse + +```json +{ + "count": 0, + "groups": [ + { + "avatar_url": "http://example.com", + "display_name": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "members": [ + { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "is_service_account": true, + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + } + ], + "name": "string", + "organization_display_name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "organization_name": "string", + "quota_allowance": 0, + "source": "user", + "total_member_count": 0 + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------|-------------------------------------------|----------|--------------|-------------| +| `count` | integer | false | | | +| `groups` | array of [codersdk.Group](#codersdkgroup) | false | | | + ## codersdk.PaginatedMembersResponse ```json diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 7d71e516a9e79..013d2aaa2cee0 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -512,6 +512,14 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Get("/members", api.groupMembersByOrganization) }) }) + r.Route("/organizations/{organization}/paginated-groups", func(r chi.Router) { + r.Use( + apiKeyMiddleware, + api.templateRBACEnabledMW, + httpmw.ExtractOrganizationParam(api.Database), + ) + r.Get("/", api.paginatedGroups) + }) r.Route("/provisionerkeys", func(r chi.Router) { r.Use( httpmw.ExtractProvisionerDaemonAuthenticated(httpmw.ExtractProvisionerAuthConfig{ diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index 95b238f41af5e..79fa41a44735c 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -547,6 +547,89 @@ func (api *API) groupsByOrganization(rw http.ResponseWriter, r *http.Request) { api.groups(rw, r) } +// @Summary Get groups by organization (paginated) +// @ID get-groups-by-organization-paginated +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param organization path string true "Organization ID or name" +// @Param q query string false "Search query" +// @Param limit query int false "Page limit" +// @Param offset query int false "Page offset" +// @Success 200 {object} codersdk.PaginatedGroupsResponse +// @Router /api/v2/organizations/{organization}/paginated-groups [get] +func (api *API) paginatedGroups(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + org := httpmw.OrganizationParam(r) + + filterQuery := r.URL.Query().Get("q") + groupFilter, filterErrs := searchquery.Groups(filterQuery) + if len(filterErrs) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid group search query.", + Validations: filterErrs, + }) + return + } + + paginationParams, ok := agpl.ParsePagination(rw, r) + if !ok { + return + } + + groups, err := api.Database.PaginatedOrganizationGroups(ctx, database.PaginatedOrganizationGroupsParams{ + OrganizationID: org.ID, + Search: groupFilter.Search, + // #nosec G115 - Pagination offsets are small and fit in int32 + OffsetOpt: int32(paginationParams.Offset), + // #nosec G115 - Pagination limits are small and fit in int32 + LimitOpt: int32(paginationParams.Limit), + }) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + httpapi.InternalServerError(rw, err) + return + } + + if len(groups) == 0 { + httpapi.Write(ctx, rw, http.StatusOK, codersdk.PaginatedGroupsResponse{ + Groups: []codersdk.Group{}, + Count: 0, + }) + return + } + + resp := codersdk.PaginatedGroupsResponse{ + Groups: make([]codersdk.Group, 0, len(groups)), + Count: int(groups[0].Count), + } + for _, group := range groups { + members, err := api.Database.GetGroupMembersByGroupID(ctx, database.GetGroupMembersByGroupIDParams{ + GroupID: group.Group.ID, + IncludeSystem: false, + }) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + memberCount, err := api.Database.GetGroupMembersCountByGroupID(ctx, database.GetGroupMembersCountByGroupIDParams{ + GroupID: group.Group.ID, + IncludeSystem: false, + }) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + resp.Groups = append(resp.Groups, db2sdk.Group(database.GetGroupsRow{ + Group: group.Group, + OrganizationName: group.OrganizationName, + OrganizationDisplayName: group.OrganizationDisplayName, + }, members, int(memberCount))) + } + + httpapi.Write(ctx, rw, http.StatusOK, resp) +} + // @Summary Get groups // @ID get-groups // @Security CoderSessionToken diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index 59335e91c5787..ca6c76d06e459 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "sort" + "strings" "testing" "time" @@ -1234,3 +1235,93 @@ func TestGetGroupMembersPagination(t *testing.T) { } coderdtest.UsersPagination(ctx, t, client, setup, fetch) } + +func TestPaginatedGroups(t *testing.T) { + t.Parallel() + + client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }}) + userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleUserAdmin()) + ctx := testutil.Context(t, testutil.WaitLong) + + // Create a deterministic set of groups. The org's implicit "Everyone" group + // also exists, so account for it in the expected counts. + names := []string{"alpha", "bravo", "charlie", "delta", "echo"} + for _, name := range names { + _, err := userAdminClient.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: name, + }) + require.NoError(t, err) + } + + // The org's implicit "Everyone" group is included in the paginated results. + totalGroups := len(names) + 1 + + t.Run("AllGroups", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + resp, err := userAdminClient.GroupsPaginated(ctx, user.OrganizationID, codersdk.UsersRequest{}) + require.NoError(t, err) + require.Equal(t, totalGroups, resp.Count) + require.Len(t, resp.Groups, totalGroups) + + // Verify deterministic ascending ordering by lower(name). + sorted := make([]string, len(resp.Groups)) + for i, g := range resp.Groups { + sorted[i] = strings.ToLower(g.Name) + } + require.IsIncreasing(t, sorted) + }) + + t.Run("Search", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + resp, err := userAdminClient.GroupsPaginated(ctx, user.OrganizationID, codersdk.UsersRequest{ + SearchQuery: "alpha", + }) + require.NoError(t, err) + require.Equal(t, 1, resp.Count) + require.Len(t, resp.Groups, 1) + require.Equal(t, "alpha", resp.Groups[0].Name) + }) + + t.Run("SearchNoResults", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + resp, err := userAdminClient.GroupsPaginated(ctx, user.OrganizationID, codersdk.UsersRequest{ + SearchQuery: "does-not-exist", + }) + require.NoError(t, err) + require.Equal(t, 0, resp.Count) + require.Empty(t, resp.Groups) + }) + + t.Run("PageBoundaries", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + // Page through the results two at a time and ensure the union covers + // every group exactly once, with a stable Count on each page. + seen := make(map[string]struct{}) + for offset := 0; offset < totalGroups; offset += 2 { + resp, err := userAdminClient.GroupsPaginated(ctx, user.OrganizationID, codersdk.UsersRequest{ + Pagination: codersdk.Pagination{Limit: 2, Offset: offset}, + }) + require.NoError(t, err) + require.Equal(t, totalGroups, resp.Count) + require.LessOrEqual(t, len(resp.Groups), 2) + for _, g := range resp.Groups { + _, dup := seen[g.Name] + require.False(t, dup, "group %q appeared on more than one page", g.Name) + seen[g.Name] = struct{}{} + } + } + require.Len(t, seen, totalGroups) + }) +} diff --git a/site/src/api/api.ts b/site/src/api/api.ts index b4efff76f0134..5b87f66d7d133 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -222,6 +222,11 @@ export type GroupMembersResponseWithAICostControl = Omit< "users" > & Readonly<{ users: readonly GroupMemberWithAICostControl[] }>; +export type PaginatedGroupsResponseWithAICostControl = Omit< + TypesGen.PaginatedGroupsResponse, + "groups" +> & + Readonly<{ groups: readonly GroupWithAICostControl[] }>; export function watchInboxNotifications( params?: WatchInboxNotificationsParams, @@ -2239,6 +2244,22 @@ class ApiMethods { return response.data; }; + /** + * @param organization Can be the organization's ID or name + * @param options Pagination and search options + */ + getOrganizationPaginatedGroups = async ( + organization: string, + options?: TypesGen.UsersRequest, + ): 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 d60766cefc04b..82e0197b984e0 100644 --- a/site/src/api/queries/groups.ts +++ b/site/src/api/queries/groups.ts @@ -3,6 +3,7 @@ import { API, type GroupMembersResponseWithAICostControl, type GroupWithAICostControl, + type PaginatedGroupsResponseWithAICostControl, } from "#/api/api"; import { isApiError } from "#/api/errors"; import type { @@ -41,6 +42,38 @@ export const groupsByOrganization = (organization: string) => { } satisfies UseQueryOptions; }; +const getPaginatedGroupsByOrganizationQueryKey = ( + organization: string, + req?: UsersRequest, +) => { + const base = ["organization", organization, "paginated-groups"]; + return req ? [...base, req] : base; +}; + +export function paginatedGroupsByOrganization( + organization: string, + searchParams: URLSearchParams, +): UsePaginatedQueryOptions< + PaginatedGroupsResponseWithAICostControl, + UsersRequest +> { + 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 da54ce2ce04b7..9acb64f6dbe28 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -6593,6 +6593,12 @@ export interface OrganizationSyncSettings { readonly organization_assign_default: boolean; } +// 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/GroupsFilter.tsx b/site/src/components/Filter/GroupsFilter.tsx new file mode 100644 index 0000000000000..20965dca7fc18 --- /dev/null +++ b/site/src/components/Filter/GroupsFilter.tsx @@ -0,0 +1,21 @@ +import type { FC } from "react"; +import { Filter, type useFilter } from "#/components/Filter/Filter"; + +interface GroupsFilterProps { + filter: ReturnType; + error?: unknown; +} + +// 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, error }) => { + return ( + + ); +}; diff --git a/site/src/pages/GroupsPage/GroupsPage.tsx b/site/src/pages/GroupsPage/GroupsPage.tsx index 13e64253431fa..376f3609dbc23 100644 --- a/site/src/pages/GroupsPage/GroupsPage.tsx +++ b/site/src/pages/GroupsPage/GroupsPage.tsx @@ -1,19 +1,21 @@ import { PlusIcon } from "lucide-react"; import { type FC, useEffect } from "react"; import { useQuery } from "react-query"; -import { Link as RouterLink } from "react-router"; +import { Link as RouterLink, useSearchParams } from "react-router"; import { toast } from "sonner"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; -import { groupsByOrganization } from "#/api/queries/groups"; +import { 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"; @@ -29,9 +31,14 @@ 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 permissionsQuery = useQuery({ ...organizationsPermissions([organization?.id ?? ""]), @@ -105,10 +112,12 @@ const GroupsPage: FC = () => { ); diff --git a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx index 8da7333352924..b4670d27453ef 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx @@ -1,12 +1,35 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, within } from "storybook/test"; import type { GroupAICostControl, GroupWithAICostControl } from "#/api/api"; +import { + mockInitialRenderResult, + mockSuccessResult, +} from "#/components/PaginationWidget/PaginationContainer.mocks"; +import type { UsePaginatedQueryResult } from "#/hooks/usePaginatedQuery"; import { MockGroup } from "#/testHelpers/entities"; import { GroupsPageView } 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; @@ -24,10 +47,11 @@ const aiGroup = ( ai_cost_control, }); +export const Default: Story = {}; + export const NotEnabled: Story = { args: { groups: [MockGroup], - canCreateGroup: true, groupsEnabled: false, }, }; @@ -35,15 +59,50 @@ export const NotEnabled: Story = { export const WithGroups: Story = { args: { groups: [MockGroup], - canCreateGroup: true, - groupsEnabled: true, + }, +}; + +// Multiple pages of results with the search field in use. +export const WithSearchAndPagination: Story = { + args: { + groups: [ + aiGroup("group-a", "Group A"), + aiGroup("group-b", "Group B"), + aiGroup("group-c", "Group C"), + ], + filterProps: { + filter: { + query: "group", + values: {}, + update: () => {}, + debounceUpdate: () => {}, + cancelDebounce: () => {}, + used: true, + }, + }, + groupsQuery: { + ...mockSuccessResult, + totalRecords: 60, + totalPages: 3, + 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: [ aiGroup("ai-unlimited", "Unlimited", { @@ -105,9 +164,8 @@ export const WithAIBudgets: Story = { export const WithAIBudgetsLoading: Story = { args: { groups: undefined, - canCreateGroup: true, - groupsEnabled: true, showAIBudget: true, + groupsQuery: mockInitialRenderResult as UsePaginatedQueryResult, }, }; @@ -115,8 +173,6 @@ export const WithAIBudgetsLoading: Story = { export const WithAIBudgetsSpendUnavailable: Story = { args: { groups: [aiGroup("ai-unavailable", "Spend unavailable")], - canCreateGroup: true, - groupsEnabled: true, showAIBudget: true, }, play: async ({ canvasElement }) => { @@ -131,8 +187,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 }) => { @@ -144,8 +198,6 @@ export const WithoutAIBudgetColumn: Story = { export const WithDisplayGroup: Story = { args: { groups: [{ ...MockGroup, name: "front-end" }], - canCreateGroup: true, - groupsEnabled: true, }, }; @@ -153,14 +205,11 @@ export const EmptyGroup: Story = { args: { groups: [], canCreateGroup: false, - groupsEnabled: true, }, }; export const EmptyGroupWithPermission: Story = { args: { groups: [], - canCreateGroup: true, - groupsEnabled: true, }, }; diff --git a/site/src/pages/GroupsPage/GroupsPageView.tsx b/site/src/pages/GroupsPage/GroupsPageView.tsx index 0c160d42b33bc..44a17ee532ab5 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,14 +27,19 @@ import { TableRowSkeleton, } from "#/components/TableLoader/TableLoader"; import { useClickableTableRow } from "#/hooks/useClickableTableRow"; +import type { PaginationResultInfo } from "#/hooks/usePaginatedQuery"; import { docs } from "#/utils/docs"; import { InfoIconTooltip } from "./InfoIconTooltip"; type GroupsPageViewProps = { - groups: GroupWithAICostControl[] | undefined; + groups: readonly GroupWithAICostControl[] | undefined; canCreateGroup: boolean; groupsEnabled: boolean; showAIBudget: boolean; + filterProps: { filter: ReturnType }; + groupsQuery: PaginationResultInfo & { + isPlaceholderData: boolean; + }; }; export const GroupsPageView: FC = ({ @@ -39,6 +47,8 @@ export const GroupsPageView: FC = ({ canCreateGroup, groupsEnabled, showAIBudget, + filterProps, + groupsQuery, }) => { if (!groupsEnabled) { return ( @@ -51,37 +61,42 @@ export const GroupsPageView: FC = ({ } return ( - - - - Name - - Users - - {showAIBudget && ( - -
- AI budget - -
-
- )} - -
-
- - - -
+
+ + + + + + Name + + Users + + {showAIBudget && ( + +
+ AI budget + +
+
+ )} + +
+
+ + + +
+
+
); }; interface GroupsTableBodyProps { - groups: GroupWithAICostControl[] | undefined; + groups: readonly GroupWithAICostControl[] | undefined; canCreateGroup: boolean; showAIBudget: boolean; } From 50991b199558986de8033402d7029ab54eb28b3c Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 15 Jul 2026 22:37:58 +0000 Subject: [PATCH 02/19] fix: hide Filter preset menu if no presets are provided --- site/src/components/Filter/Filter.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/site/src/components/Filter/Filter.tsx b/site/src/components/Filter/Filter.tsx index d194d0f237a0d..3f8972af69e6f 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} + /> + )}
Date: Wed, 15 Jul 2026 23:04:29 +0000 Subject: [PATCH 03/19] fix: move "Create group" button to GroupsPageView to match OrganizationMembersPageView --- site/src/pages/GroupsPage/GroupsPage.tsx | 13 +--------- site/src/pages/GroupsPage/GroupsPageView.tsx | 26 +++++++++++++++++++- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/site/src/pages/GroupsPage/GroupsPage.tsx b/site/src/pages/GroupsPage/GroupsPage.tsx index 376f3609dbc23..9cb4051e9fac0 100644 --- a/site/src/pages/GroupsPage/GroupsPage.tsx +++ b/site/src/pages/GroupsPage/GroupsPage.tsx @@ -1,12 +1,10 @@ -import { PlusIcon } from "lucide-react"; import { type FC, useEffect } from "react"; import { useQuery } from "react-query"; -import { Link as RouterLink, useSearchParams } from "react-router"; +import { useSearchParams } from "react-router"; import { toast } from "sonner"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; import { 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"; @@ -100,15 +98,6 @@ const GroupsPage: FC = () => { {showOrganizations ? "organization" : "deployment"}. - - {groupsEnabled && permissions.createGroup && ( - - )}
= ({ filterProps, groupsQuery, }) => { + const { organization } = useGroupsSettings(); + const permissionsQuery = useQuery({ + ...organizationsPermissions([organization?.id ?? ""]), + enabled: Boolean(organization), + }); + + // We can safely assume the organization is defined, since its non-nullness is + // already made in the GroupsPage parent component before GroupsPageView is rendered + const permissions = permissionsQuery.data?.[organization!.id]; + if (!groupsEnabled) { return ( = ({ return (
- +
+ + {groupsEnabled && permissions?.createGroup && ( + + )} +
+ From 3f71e1c54d15e76929b1122ae426bc02e555a7d3 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 15 Jul 2026 23:17:42 +0000 Subject: [PATCH 04/19] docs: regenerate enterprise API reference after merging main --- docs/reference/api/enterprise.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 5c284c9ddd969..d2c8805b2fec0 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -2003,7 +2003,7 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ### Code samples -```shell +```sh # Example request using curl curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/paginated-groups \ -H 'Accept: application/json' \ From 3d3e6745b0cc77a62ab82be0f4d450250d2134cb Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 15 Jul 2026 23:39:31 +0000 Subject: [PATCH 05/19] 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. --- site/src/pages/GroupsPage/GroupsPageView.tsx | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/site/src/pages/GroupsPage/GroupsPageView.tsx b/site/src/pages/GroupsPage/GroupsPageView.tsx index 03f1942a7ddad..e0fec08d1d595 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.tsx @@ -1,9 +1,7 @@ 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 { GroupWithAICostControl } from "#/api/api"; -import { organizationsPermissions } from "#/api/queries/organizations"; import { AIBudgetUsage } from "#/components/AIBudgetUsage/AIBudgetUsage"; import { Avatar } from "#/components/Avatar/Avatar"; import { AvatarData } from "#/components/Avatar/AvatarData"; @@ -30,7 +28,6 @@ import { } from "#/components/TableLoader/TableLoader"; import { useClickableTableRow } from "#/hooks/useClickableTableRow"; import type { PaginationResultInfo } from "#/hooks/usePaginatedQuery"; -import { useGroupsSettings } from "#/pages/GroupsPage/GroupsPageProvider"; import { docs } from "#/utils/docs"; import { InfoIconTooltip } from "./InfoIconTooltip"; @@ -53,16 +50,6 @@ export const GroupsPageView: FC = ({ filterProps, groupsQuery, }) => { - const { organization } = useGroupsSettings(); - const permissionsQuery = useQuery({ - ...organizationsPermissions([organization?.id ?? ""]), - enabled: Boolean(organization), - }); - - // We can safely assume the organization is defined, since its non-nullness is - // already made in the GroupsPage parent component before GroupsPageView is rendered - const permissions = permissionsQuery.data?.[organization!.id]; - if (!groupsEnabled) { return ( = ({
- {groupsEnabled && permissions?.createGroup && ( + {canCreateGroup && (
@@ -143,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 af3cd63ab9a45e6275f41ed5b2e2e2536f26cfb8 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Fri, 24 Jul 2026 00:36:17 +0000 Subject: [PATCH 18/19] refactor(site/src/components/Filter): drop unused GroupsFilter error prop --- site/src/components/Filter/GroupsFilter.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/site/src/components/Filter/GroupsFilter.tsx b/site/src/components/Filter/GroupsFilter.tsx index 20965dca7fc18..27aeb482b7bcc 100644 --- a/site/src/components/Filter/GroupsFilter.tsx +++ b/site/src/components/Filter/GroupsFilter.tsx @@ -3,18 +3,16 @@ import { Filter, type useFilter } from "#/components/Filter/Filter"; interface GroupsFilterProps { filter: ReturnType; - error?: unknown; } // 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, error }) => { +export const GroupsFilter: FC = ({ filter }) => { return ( ); From c498b254c52911272f8957067f03278a5c317afa Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Fri, 24 Jul 2026 00:36:49 +0000 Subject: [PATCH 19/19] refactor(site/src/pages/GroupsPage): use Array.from map callback in story --- site/src/pages/GroupsPage/GroupsPageView.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx index ec010928bb9e2..502dbead7ec58 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx @@ -68,7 +68,7 @@ const limit = totalRecords / totalPages; // Multiple pages of results with the search field in use. export const WithSearchAndPagination: Story = { args: { - groups: Array.from({ length: limit }).map((_, i) => + groups: Array.from({ length: limit }, (_, i) => aiGroup(`group-${i}`, `Group ${i}`), ), filterProps: {