diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b95e78772e219..91a0f2262e45a 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5699,6 +5699,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": [ @@ -22342,6 +22394,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 1a3f184e8d3a0..2938dcb9cf0d5 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5050,6 +5050,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"], @@ -20405,6 +20453,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 5b8036958c54f..33675a43892e5 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -7023,6 +7023,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 ef0e45eaeb055..214f93591329e 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2585,6 +2585,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 db91a9772b7c5..98a052076dfa2 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -4961,6 +4961,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 a038a74957f03..1e32449145235 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -9355,6 +9355,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 f82ef6ea146b6..a729d5f355146 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1280,6 +1280,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 ee88e6cdba8c8..7d6ac3c0c2eb5 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -15720,6 +15720,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, groups.id 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..b063f56beb4c1 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, groups.id 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 f8e7dd64b6a6d..0e3787e6954a4 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -174,6 +174,30 @@ func Users(query string) (database.GetUsersParams, []codersdk.ValidationError) { return filter, parser.Errors } +func Groups(query string) (string, []codersdk.ValidationError) { + // Always lowercase for all searches. + query = strings.ToLower(query) + values, errors := searchTerms(query, func(term string, values url.Values) error { + // Groups support free-text search only, so join bare terms into a + // single search value. Adding each term separately would make a + // multi-word query like "front end" look like a duplicate param and + // return a 400 instead of matching the name/display name substring. + if existing := values.Get("search"); existing != "" { + term = existing + " " + term + } + values.Set("search", term) + return nil + }) + if len(errors) > 0 { + return "", errors + } + + parser := httpapi.NewQueryParamParser() + search := parser.String(values, "", "search") + parser.ErrorExcessParams(values) + return search, parser.Errors +} + func Members(query string, organizationID uuid.UUID) (database.OrganizationMembersParams, []codersdk.ValidationError) { query = strings.TrimSpace(query) if query == "" { diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 76deab7398119..a8cc4db0a75a8 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -1707,3 +1707,78 @@ func TestSearchChats(t *testing.T) { }) } } + +func TestSearchGroups(t *testing.T) { + t.Parallel() + testCases := []struct { + Name string + Query string + Expected string + ExpectedErrorContains string + }{ + { + Name: "Empty", + Query: "", + Expected: "", + }, + { + Name: "SingleWord", + Query: "alpha", + Expected: "alpha", + }, + { + // Groups support free-text search, so an unquoted multi-word query + // is joined into a single search value instead of being rejected as + // a duplicate param. + Name: "MultiWord", + Query: "front end", + Expected: "front end", + }, + { + Name: "CaseInsensitive", + Query: "AlPhA", + Expected: "alpha", + }, + { + Name: "MultiWordCaseInsensitive", + Query: "Front End", + Expected: "front end", + }, + { + Name: "TrimsSurroundingSpaces", + Query: " alpha ", + Expected: "alpha", + }, + { + // Structured key:value queries are not supported for groups; the + // unrecognized key surfaces as an invalid query param. + Name: "StructuredKeyValueRejected", + Query: "name:alpha", + ExpectedErrorContains: "is not a valid query param", + }, + { + Name: "ExtraColon", + Query: "a:b:c", + ExpectedErrorContains: "can only contain 1 ':'", + }, + } + + for _, c := range testCases { + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + + search, errs := searchquery.Groups(c.Query) + if c.ExpectedErrorContains != "" { + require.True(t, len(errs) > 0, "expect some errors") + var s strings.Builder + for _, err := range errs { + _, _ = s.WriteString(fmt.Sprintf("%s: %s\n", err.Field, err.Detail)) + } + require.Contains(t, s.String(), c.ExpectedErrorContains) + } else { + require.Len(t, errs, 0, "expected no error") + require.Equal(t, c.Expected, search, "expected search value") + } + }) + } +} diff --git a/codersdk/groups.go b/codersdk/groups.go index a191b280e4790..0055e809ee42a 100644 --- a/codersdk/groups.go +++ b/codersdk/groups.go @@ -48,6 +48,29 @@ type GroupMembersResponse struct { Count int `json:"count"` } +type PaginatedGroupsResponse struct { + Groups []Group `json:"groups"` + Count int `json:"count"` +} + +// 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. +type PaginatedGroupsRequest struct { + SearchQuery string `json:"q,omitempty"` + Pagination +} + +func (req PaginatedGroupsRequest) asRequestOption() RequestOption { + return func(r *http.Request) { + q := r.URL.Query() + if req.SearchQuery != "" { + q.Set("q", req.SearchQuery) + } + r.URL.RawQuery = q.Encode() + } +} + func (g Group) IsEveryone() bool { return g.ID == g.OrganizationID } @@ -135,6 +158,31 @@ func (c *Client) GroupByOrgAndName(ctx context.Context, orgID uuid.UUID, name st return resp, json.NewDecoder(res.Body).Decode(&resp) } +// OrganizationGroupsPaginated lists filtered and paginated groups in an +// organization. Unlike Groups (GET /groups), which authorizes each group +// individually via its ACL, this endpoint requires organization-wide group +// read permission and does no per-group filtering. It is therefore not a +// drop-in replacement for Groups: callers without org-wide group read will +// receive an error rather than a filtered subset. +func (c *Client) OrganizationGroupsPaginated(ctx context.Context, orgID uuid.UUID, req PaginatedGroupsRequest) (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 88026e8599f6b..c3ef3bfe289a9 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -2234,6 +2234,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 + +```sh +# 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 cae7eb143c5dd..b3eb265ef6487 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -9445,6 +9445,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 d65dd1f6950e3..b1882f18158c7 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -530,6 +530,14 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { }) }) }) + 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("/organizations/{organization}/ai/spend", func(r chi.Router) { // AI cost controls are a paid feature (AI Governance add-on). r.Use( diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index 95b238f41af5e..26142304c6ee6 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") + search, 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: 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 { + 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..47ff9a447fafc 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,193 @@ 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. Names include mixed case, a pair + // that differs only by case, and one group with a distinct display name so + // ordering (LOWER(name)), the groups.id tiebreaker, and display-name search + // are all exercised. The org's implicit "Everyone" group also exists, so + // account for it in the expected counts. + type groupSpec struct { + name string + displayName string + } + specs := []groupSpec{ + {name: "alpha"}, + {name: "Bravo"}, + {name: "charlie"}, + {name: "Delta"}, + {name: "echo"}, + // "Dev" and "dev" collide once lowercased, forcing the groups.id + // tiebreaker to produce a deterministic order. + {name: "Dev"}, + {name: "dev"}, + {name: "zeta", displayName: "Frontend Squad"}, + } + for _, spec := range specs { + _, err := userAdminClient.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: spec.name, + DisplayName: spec.displayName, + }) + require.NoError(t, err) + } + + // The org's implicit "Everyone" group is included in the paginated results. + totalGroups := len(specs) + 1 + + // Add a known member to the "alpha" group so member hydration can be + // asserted below. + _, member := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + alpha, err := userAdminClient.GroupByOrgAndName(ctx, user.OrganizationID, "alpha") + require.NoError(t, err) + _, err = userAdminClient.PatchGroup(ctx, alpha.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{member.ID.String()}, + }) + require.NoError(t, err) + + t.Run("AllGroups", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{}) + require.NoError(t, err) + require.Equal(t, totalGroups, resp.Count) + require.Len(t, resp.Groups, totalGroups) + + // Verify deterministic ordering: lower(name) ascending, with ties + // broken by groups.id ascending. uuid string comparison matches + // Postgres' byte-wise uuid ordering. + for i := 1; i < len(resp.Groups); i++ { + prev, cur := resp.Groups[i-1], resp.Groups[i] + prevName, curName := strings.ToLower(prev.Name), strings.ToLower(cur.Name) + if prevName == curName { + require.Less(t, prev.ID.String(), cur.ID.String(), + "groups with equal lowercased names must be ordered by id") + } else { + require.Less(t, prevName, curName, + "groups must be ordered by lowercased name") + } + } + }) + + t.Run("MemberHydration", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + // The handler enriches each page group with its members and total + // count. Assert both so removing that logic would fail the test. + resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{ + SearchQuery: "alpha", + }) + require.NoError(t, err) + require.Len(t, resp.Groups, 1) + require.Equal(t, "alpha", resp.Groups[0].Name) + require.Equal(t, 1, resp.Groups[0].TotalMemberCount) + require.Len(t, resp.Groups[0].Members, 1) + require.Equal(t, member.ID, resp.Groups[0].Members[0].ID) + }) + + t.Run("Search", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{ + 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.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{ + SearchQuery: "does-not-exist", + }) + require.NoError(t, err) + require.Equal(t, 0, resp.Count) + require.Empty(t, resp.Groups) + }) + + t.Run("SearchSubstring", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + // A substring of the name matches, not just a prefix. + resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{ + SearchQuery: "harl", + }) + require.NoError(t, err) + require.Equal(t, 1, resp.Count) + require.Len(t, resp.Groups, 1) + require.Equal(t, "charlie", resp.Groups[0].Name) + }) + + t.Run("SearchCaseInsensitive", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + // An uppercase query matches both "Dev" and "dev" case-insensitively. + resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{ + SearchQuery: "DEV", + }) + require.NoError(t, err) + require.Equal(t, 2, resp.Count) + require.Len(t, resp.Groups, 2) + for _, g := range resp.Groups { + require.Equal(t, "dev", strings.ToLower(g.Name)) + } + // The case-only collision is ordered deterministically by id. + require.Less(t, resp.Groups[0].ID.String(), resp.Groups[1].ID.String()) + }) + + t.Run("SearchDisplayName", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + // Search matches the display name, not just the name. + resp, err := userAdminClient.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{ + SearchQuery: "squad", + }) + require.NoError(t, err) + require.Equal(t, 1, resp.Count) + require.Len(t, resp.Groups, 1) + require.Equal(t, "zeta", resp.Groups[0].Name) + }) + + 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.OrganizationGroupsPaginated(ctx, user.OrganizationID, codersdk.PaginatedGroupsRequest{ + 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 284edf485c402..9dcfcea37be30 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 8093330fc244a..d735738f724b3 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 2d3f42b2b37bd..c81ddd3fe0720 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 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} + /> + )}
; +} + +// 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 65d592a9efb99..5a1a407b00a61 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 598e1bfad1d73..502dbead7ec58 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 2668f25417fd2..af13fb5e5f9d9 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 (