mirror of
https://github.com/grafana/grafana.git
synced 2024-12-28 01:41:24 -06:00
Team: Support sort
query param for teams search endpoint (#75622)
* Teams: Implement backend sorting * Add docs * Make name ordering case insensitive * lint * Fix no lowercasing on memberCount * Add test to double check the filters or correctly OrderBy
This commit is contained in:
parent
a2964731eb
commit
6ffd4a23de
@ -33,7 +33,7 @@ Access to these API endpoints is restricted as follows:
|
|||||||
|
|
||||||
## Team Search With Paging
|
## Team Search With Paging
|
||||||
|
|
||||||
`GET /api/teams/search?perpage=50&page=1&query=myteam`
|
`GET /api/teams/search?perpage=50&page=1&query=myteam&sort=memberCount-desc`
|
||||||
|
|
||||||
or
|
or
|
||||||
|
|
||||||
@ -87,6 +87,8 @@ The `totalCount` field in the response can be used for pagination of the teams l
|
|||||||
|
|
||||||
The `query` parameter is optional and it will return results where the query value is contained in the `name` field. Query values with spaces need to be URL encoded e.g. `query=my%20team`.
|
The `query` parameter is optional and it will return results where the query value is contained in the `name` field. Query values with spaces need to be URL encoded e.g. `query=my%20team`.
|
||||||
|
|
||||||
|
The `sort` param is an optional comma separated list of options to order the search result. Accepted values for the sort filter are: ` name-asc`, `name-desc`, `email-asc`, `email-desc`, `memberCount-asc`, `memberCount-desc`. By default, if `sort` is not specified, the teams list will be ordered by `name` in ascending order.
|
||||||
|
|
||||||
### Using the name parameter
|
### Using the name parameter
|
||||||
|
|
||||||
The `name` parameter returns a single team if the parameter matches the `name` field.
|
The `name` parameter returns a single team if the parameter matches the `name` field.
|
||||||
@ -94,6 +96,7 @@ The `name` parameter returns a single team if the parameter matches the `name` f
|
|||||||
#### Status Codes:
|
#### Status Codes:
|
||||||
|
|
||||||
- **200** - Ok
|
- **200** - Ok
|
||||||
|
- **400** - Bad Request
|
||||||
- **401** - Unauthorized
|
- **401** - Unauthorized
|
||||||
- **403** - Permission denied
|
- **403** - Permission denied
|
||||||
- **404** - Team not found (if searching by name)
|
- **404** - Team not found (if searching by name)
|
||||||
|
@ -11,6 +11,7 @@ import (
|
|||||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||||
"github.com/grafana/grafana/pkg/services/team"
|
"github.com/grafana/grafana/pkg/services/team"
|
||||||
|
"github.com/grafana/grafana/pkg/services/team/sortopts"
|
||||||
"github.com/grafana/grafana/pkg/util"
|
"github.com/grafana/grafana/pkg/util"
|
||||||
"github.com/grafana/grafana/pkg/web"
|
"github.com/grafana/grafana/pkg/web"
|
||||||
)
|
)
|
||||||
@ -146,6 +147,11 @@ func (hs *HTTPServer) SearchTeams(c *contextmodel.ReqContext) response.Response
|
|||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sortOpts, err := sortopts.ParseSortQueryParam(c.Query("sort"))
|
||||||
|
if err != nil {
|
||||||
|
return response.Err(err)
|
||||||
|
}
|
||||||
|
|
||||||
query := team.SearchTeamsQuery{
|
query := team.SearchTeamsQuery{
|
||||||
OrgID: c.SignedInUser.GetOrgID(),
|
OrgID: c.SignedInUser.GetOrgID(),
|
||||||
Query: c.Query("query"),
|
Query: c.Query("query"),
|
||||||
@ -154,6 +160,7 @@ func (hs *HTTPServer) SearchTeams(c *contextmodel.ReqContext) response.Response
|
|||||||
Limit: perPage,
|
Limit: perPage,
|
||||||
SignedInUser: c.SignedInUser,
|
SignedInUser: c.SignedInUser,
|
||||||
HiddenUsers: hs.Cfg.HiddenUsers,
|
HiddenUsers: hs.Cfg.HiddenUsers,
|
||||||
|
SortOpts: sortOpts,
|
||||||
}
|
}
|
||||||
|
|
||||||
queryResult, err := hs.teamService.SearchTeams(c.Req.Context(), &query)
|
queryResult, err := hs.teamService.SearchTeams(c.Req.Context(), &query)
|
||||||
|
@ -65,7 +65,7 @@ func newTimeSortOption(field string, desc bool, index int) model.SortOption {
|
|||||||
return model.SortOption{
|
return model.SortOption{
|
||||||
Name: fmt.Sprintf("%v-%v", field, direction),
|
Name: fmt.Sprintf("%v-%v", field, direction),
|
||||||
DisplayName: fmt.Sprintf("%v (%v)", cases.Title(language.Und).String(field), description),
|
DisplayName: fmt.Sprintf("%v (%v)", cases.Title(language.Und).String(field), description),
|
||||||
Description: fmt.Sprintf("Sort %v in an alphabetically %vending order", field, direction),
|
Description: fmt.Sprintf("Sort %v by time in an %vending order", field, direction),
|
||||||
Index: index,
|
Index: index,
|
||||||
Filter: []model.SortOptionFilter{Sorter{Field: field, Descending: desc}},
|
Filter: []model.SortOptionFilter{Sorter{Field: field, Descending: desc}},
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/kinds/team"
|
"github.com/grafana/grafana/pkg/kinds/team"
|
||||||
"github.com/grafana/grafana/pkg/services/auth/identity"
|
"github.com/grafana/grafana/pkg/services/auth/identity"
|
||||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||||
|
"github.com/grafana/grafana/pkg/services/search/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Typed errors
|
// Typed errors
|
||||||
@ -90,6 +91,7 @@ type SearchTeamsQuery struct {
|
|||||||
Limit int
|
Limit int
|
||||||
Page int
|
Page int
|
||||||
OrgID int64 `xorm:"org_id"`
|
OrgID int64 `xorm:"org_id"`
|
||||||
|
SortOpts []model.SortOption
|
||||||
SignedInUser identity.Requester
|
SignedInUser identity.Requester
|
||||||
HiddenUsers map[string]struct{}
|
HiddenUsers map[string]struct{}
|
||||||
}
|
}
|
||||||
|
99
pkg/services/team/sortopts/sortopts.go
Normal file
99
pkg/services/team/sortopts/sortopts.go
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
package sortopts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/services/search/model"
|
||||||
|
"github.com/grafana/grafana/pkg/util/errutil"
|
||||||
|
"golang.org/x/text/cases"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// SortOptionsByQueryParam is a map to translate the "sort" query param values to SortOption(s)
|
||||||
|
SortOptionsByQueryParam = map[string]model.SortOption{
|
||||||
|
"name-asc": newSortOption("name", false, true, 0), // Lower case the name ordering
|
||||||
|
"name-desc": newSortOption("name", true, true, 0),
|
||||||
|
"email-asc": newSortOption("email", false, false, 1), // Not to slow down the request let's not lower case the email ordering
|
||||||
|
"email-desc": newSortOption("email", true, false, 1),
|
||||||
|
"memberCount-asc": newIntSortOption("member_count", false, 2),
|
||||||
|
"memberCount-desc": newIntSortOption("member_count", true, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorUnknownSortingOption = errutil.BadRequest("unknown sorting option")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Sorter struct {
|
||||||
|
Field string
|
||||||
|
LowerCase bool
|
||||||
|
Descending bool
|
||||||
|
WithTableName bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Sorter) OrderBy() string {
|
||||||
|
orderBy := "team."
|
||||||
|
if !s.WithTableName {
|
||||||
|
orderBy = ""
|
||||||
|
}
|
||||||
|
orderBy += s.Field
|
||||||
|
if s.LowerCase {
|
||||||
|
orderBy = fmt.Sprintf("LOWER(%v)", orderBy)
|
||||||
|
}
|
||||||
|
if s.Descending {
|
||||||
|
return orderBy + " DESC"
|
||||||
|
}
|
||||||
|
return orderBy + " ASC"
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSortOption(field string, desc bool, lowerCase bool, index int) model.SortOption {
|
||||||
|
direction := "asc"
|
||||||
|
description := ("A-Z")
|
||||||
|
if desc {
|
||||||
|
direction = "desc"
|
||||||
|
description = ("Z-A")
|
||||||
|
}
|
||||||
|
return model.SortOption{
|
||||||
|
Name: fmt.Sprintf("%v-%v", field, direction),
|
||||||
|
DisplayName: fmt.Sprintf("%v (%v)", cases.Title(language.Und).String(field), description),
|
||||||
|
Description: fmt.Sprintf("Sort %v in an alphabetically %vending order", field, direction),
|
||||||
|
Index: index,
|
||||||
|
Filter: []model.SortOptionFilter{Sorter{Field: field, LowerCase: lowerCase, Descending: desc, WithTableName: true}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newIntSortOption(field string, desc bool, index int) model.SortOption {
|
||||||
|
direction := "asc"
|
||||||
|
description := ("Fewest-Most")
|
||||||
|
if desc {
|
||||||
|
direction = "desc"
|
||||||
|
description = ("Most-Fewest")
|
||||||
|
}
|
||||||
|
return model.SortOption{
|
||||||
|
Name: fmt.Sprintf("%v-%v", field, direction),
|
||||||
|
DisplayName: fmt.Sprintf("%v (%v)", cases.Title(language.Und).String(field), description),
|
||||||
|
Description: fmt.Sprintf("Sort %v in a numerically %vending order", field, direction),
|
||||||
|
Index: index,
|
||||||
|
Filter: []model.SortOptionFilter{Sorter{Field: field, LowerCase: false, Descending: desc, WithTableName: false}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSortQueryParam parses the "sort" query param and returns an ordered list of SortOption(s)
|
||||||
|
func ParseSortQueryParam(param string) ([]model.SortOption, error) {
|
||||||
|
opts := []model.SortOption{}
|
||||||
|
if param != "" {
|
||||||
|
optsStr := strings.Split(param, ",")
|
||||||
|
for i := range optsStr {
|
||||||
|
if opt, ok := SortOptionsByQueryParam[optsStr[i]]; !ok {
|
||||||
|
return nil, ErrorUnknownSortingOption.Errorf("%v option unknown", optsStr[i])
|
||||||
|
} else {
|
||||||
|
opts = append(opts, opt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(opts, func(i, j int) bool {
|
||||||
|
return opts[i].Index < opts[j].Index || (opts[i].Index == opts[j].Index && opts[i].Name < opts[j].Name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return opts, nil
|
||||||
|
}
|
74
pkg/services/team/sortopts/sortopts_test.go
Normal file
74
pkg/services/team/sortopts/sortopts_test.go
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
package sortopts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSorter_Filters(t *testing.T) {
|
||||||
|
require.Equal(t, SortOptionsByQueryParam["name-asc"].Filter[0].OrderBy(), "LOWER(team.name) ASC")
|
||||||
|
require.Equal(t, SortOptionsByQueryParam["name-desc"].Filter[0].OrderBy(), "LOWER(team.name) DESC")
|
||||||
|
require.Equal(t, SortOptionsByQueryParam["email-asc"].Filter[0].OrderBy(), "team.email ASC")
|
||||||
|
require.Equal(t, SortOptionsByQueryParam["email-desc"].Filter[0].OrderBy(), "team.email DESC")
|
||||||
|
require.Equal(t, SortOptionsByQueryParam["memberCount-asc"].Filter[0].OrderBy(), "member_count ASC")
|
||||||
|
require.Equal(t, SortOptionsByQueryParam["memberCount-desc"].Filter[0].OrderBy(), "member_count DESC")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSorter_OrderBy(t *testing.T) {
|
||||||
|
type fields struct {
|
||||||
|
Field string
|
||||||
|
LowerCase bool
|
||||||
|
Descending bool
|
||||||
|
WithTableName bool
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fields fields
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "team.email case sensitive desc",
|
||||||
|
fields: fields{
|
||||||
|
Field: "email",
|
||||||
|
LowerCase: false,
|
||||||
|
Descending: true,
|
||||||
|
WithTableName: true,
|
||||||
|
},
|
||||||
|
want: "team.email DESC",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "member_count sensitive desc",
|
||||||
|
fields: fields{
|
||||||
|
Field: "member_count",
|
||||||
|
LowerCase: false,
|
||||||
|
Descending: true,
|
||||||
|
WithTableName: false,
|
||||||
|
},
|
||||||
|
want: "member_count DESC",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "team.name case insensitive asc",
|
||||||
|
fields: fields{
|
||||||
|
Field: "name",
|
||||||
|
LowerCase: true,
|
||||||
|
Descending: false,
|
||||||
|
WithTableName: true,
|
||||||
|
},
|
||||||
|
want: "LOWER(team.name) ASC",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
s := Sorter{
|
||||||
|
Field: tt.fields.Field,
|
||||||
|
LowerCase: tt.fields.LowerCase,
|
||||||
|
Descending: tt.fields.Descending,
|
||||||
|
WithTableName: tt.fields.WithTableName,
|
||||||
|
}
|
||||||
|
|
||||||
|
got := s.OrderBy()
|
||||||
|
require.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -215,7 +215,17 @@ func (ss *xormStore) Search(ctx context.Context, query *team.SearchTeamsQuery) (
|
|||||||
sql.WriteString(` and` + acFilter.Where)
|
sql.WriteString(` and` + acFilter.Where)
|
||||||
params = append(params, acFilter.Args...)
|
params = append(params, acFilter.Args...)
|
||||||
|
|
||||||
sql.WriteString(` order by team.name asc`)
|
if len(query.SortOpts) > 0 {
|
||||||
|
orderBy := ` order by `
|
||||||
|
for i := range query.SortOpts {
|
||||||
|
for j := range query.SortOpts[i].Filter {
|
||||||
|
orderBy += query.SortOpts[i].Filter[j].OrderBy() + ","
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sql.WriteString(orderBy[:len(orderBy)-1])
|
||||||
|
} else {
|
||||||
|
sql.WriteString(` order by team.name asc`)
|
||||||
|
}
|
||||||
|
|
||||||
if query.Limit != 0 {
|
if query.Limit != 0 {
|
||||||
offset := query.Limit * (query.Page - 1)
|
offset := query.Limit * (query.Page - 1)
|
||||||
|
@ -20,6 +20,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||||
"github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest"
|
"github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest"
|
||||||
"github.com/grafana/grafana/pkg/services/team"
|
"github.com/grafana/grafana/pkg/services/team"
|
||||||
|
"github.com/grafana/grafana/pkg/services/team/sortopts"
|
||||||
"github.com/grafana/grafana/pkg/services/user"
|
"github.com/grafana/grafana/pkg/services/user"
|
||||||
"github.com/grafana/grafana/pkg/services/user/userimpl"
|
"github.com/grafana/grafana/pkg/services/user/userimpl"
|
||||||
)
|
)
|
||||||
@ -233,6 +234,40 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) {
|
|||||||
require.Equal(t, len(query2Result.Teams), 2)
|
require.Equal(t, len(query2Result.Teams), 2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("Should be able to sort teams by descending member count order", func(t *testing.T) {
|
||||||
|
sortOpts, err := sortopts.ParseSortQueryParam("memberCount-desc")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Add a team member
|
||||||
|
err = teamSvc.AddTeamMember(userIds[0], testOrgID, team2.ID, false, 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() {
|
||||||
|
err := teamSvc.RemoveTeamMember(context.Background(),
|
||||||
|
&team.RemoveTeamMemberCommand{OrgID: testOrgID, UserID: userIds[0], TeamID: team2.ID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}()
|
||||||
|
|
||||||
|
query := &team.SearchTeamsQuery{OrgID: testOrgID, SortOpts: sortOpts, SignedInUser: testUser}
|
||||||
|
queryResult, err := teamSvc.SearchTeams(context.Background(), query)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, len(queryResult.Teams), 2)
|
||||||
|
require.EqualValues(t, queryResult.TotalCount, 2)
|
||||||
|
require.Greater(t, queryResult.Teams[0].MemberCount, queryResult.Teams[1].MemberCount)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Should be able to sort teams by descending name order", func(t *testing.T) {
|
||||||
|
sortOpts, err := sortopts.ParseSortQueryParam("name-desc")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
query := &team.SearchTeamsQuery{OrgID: testOrgID, SortOpts: sortOpts, SignedInUser: testUser}
|
||||||
|
queryResult, err := teamSvc.SearchTeams(context.Background(), query)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, len(queryResult.Teams), 2)
|
||||||
|
require.EqualValues(t, queryResult.TotalCount, 2)
|
||||||
|
require.Equal(t, queryResult.Teams[0].Name, team2.Name)
|
||||||
|
require.Equal(t, queryResult.Teams[1].Name, team1.Name)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("Should be able to return all teams a user is member of", func(t *testing.T) {
|
t.Run("Should be able to return all teams a user is member of", func(t *testing.T) {
|
||||||
sqlStore = db.InitTestDB(t)
|
sqlStore = db.InitTestDB(t)
|
||||||
setup()
|
setup()
|
||||||
|
Loading…
Reference in New Issue
Block a user